summaryrefslogtreecommitdiff
path: root/platform-paper
diff options
context:
space:
mode:
Diffstat (limited to 'platform-paper')
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt5
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PerPlayerWorkQueue.kt54
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PluginCoroutineScope.kt13
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/ReplyCommand.kt16
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommand.kt14
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerPresenceListener.kt3
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/PerPlayerWorkQueueTest.kt106
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommandTest.kt5
8 files changed, 199 insertions, 17 deletions
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt
index d51a254..3041759 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt
@@ -44,6 +44,10 @@ class LunaticChat :
// Read by commands that must not block the tick thread.
lateinit var pluginScope: PluginCoroutineScope
private set
+
+ /** Serializes each player's outgoing messages so they arrive in the order they were sent. */
+ lateinit var deliveryQueue: PerPlayerWorkQueue
+ private set
private var updateChecker: UpdateChecker? = null
private val updateAvailable = AtomicBoolean(false)
@@ -64,6 +68,7 @@ class LunaticChat :
// Initialize plugin coroutine scope
pluginScope = PluginCoroutineScope(logger)
+ deliveryQueue = PerPlayerWorkQueue(pluginScope.scope)
// Initialize all services
serviceInitializer =
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PerPlayerWorkQueue.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PerPlayerWorkQueue.kt
new file mode 100644
index 0000000..392eea8
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PerPlayerWorkQueue.kt
@@ -0,0 +1,54 @@
+package dev.m1sk9.lunaticChat.paper
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.SendChannel
+import kotlinx.coroutines.launch
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Runs work submitted for a player one item at a time, in the order it was submitted.
+ *
+ * Launching a coroutine per message would let a fast one overtake a slow one - a cached romaji
+ * conversion finishing ahead of an uncached one sent before it - so a player's messages could
+ * appear out of order to themselves and to their recipient. A queue per player keeps each player's
+ * messages ordered while still letting different players proceed independently.
+ *
+ * [submit] must be called from one thread per player (the server's command thread), since that is
+ * what makes "the order it was submitted" well defined.
+ */
+class PerPlayerWorkQueue(
+ private val scope: CoroutineScope,
+) {
+ private val queues = ConcurrentHashMap<UUID, SendChannel<suspend () -> Unit>>()
+
+ /**
+ * Queues [work] behind anything already pending for [playerId].
+ */
+ fun submit(
+ playerId: UUID,
+ work: suspend () -> Unit,
+ ) {
+ queues.computeIfAbsent(playerId) { startWorker() }.trySend(work)
+ }
+
+ /**
+ * Drops the player's queue once they can no longer send anything. Work already queued still
+ * runs; without this the map and its worker coroutines would grow for the life of the server.
+ */
+ fun release(playerId: UUID) {
+ queues.remove(playerId)?.close()
+ }
+
+ private fun startWorker(): SendChannel<suspend () -> Unit> {
+ // Unlimited so that submit never suspends or drops work on the caller's thread.
+ val channel = Channel<suspend () -> Unit>(Channel.UNLIMITED)
+ scope.launch {
+ for (work in channel) {
+ work()
+ }
+ }
+ return channel
+ }
+}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PluginCoroutineScope.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PluginCoroutineScope.kt
index f67d262..22ff0dc 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PluginCoroutineScope.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PluginCoroutineScope.kt
@@ -1,9 +1,11 @@
package dev.m1sk9.lunaticChat.paper
+import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
+import java.util.logging.Level
import java.util.logging.Logger
/**
@@ -28,7 +30,16 @@ class PluginCoroutineScope(
private val logger: Logger,
) {
private val job = SupervisorJob()
- val scope = CoroutineScope(Dispatchers.Default + job)
+
+ // Without this, a coroutine that throws reports to the JVM default handler and never reaches
+ // the plugin's log - and callers that dispatch work and return immediately have no other way
+ // to learn that it failed.
+ private val errorHandler =
+ CoroutineExceptionHandler { _, throwable ->
+ logger.log(Level.SEVERE, "Unhandled exception in a plugin coroutine", throwable)
+ }
+
+ val scope = CoroutineScope(Dispatchers.Default + job + errorHandler)
/**
* Cancels all coroutines in this scope.
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/ReplyCommand.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/ReplyCommand.kt
index c530ce9..8f676b3 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/ReplyCommand.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/ReplyCommand.kt
@@ -5,6 +5,7 @@ import com.mojang.brigadier.builder.LiteralArgumentBuilder
import dev.m1sk9.lunaticChat.engine.command.CommandResult
import dev.m1sk9.lunaticChat.engine.permission.LunaticChatPermissionNode
import dev.m1sk9.lunaticChat.paper.LunaticChat
+import dev.m1sk9.lunaticChat.paper.PerPlayerWorkQueue
import dev.m1sk9.lunaticChat.paper.chat.handler.DirectMessageHandler
import dev.m1sk9.lunaticChat.paper.chat.handler.ReplyTarget
import dev.m1sk9.lunaticChat.paper.command.annotation.Command
@@ -16,8 +17,6 @@ import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager
import dev.m1sk9.lunaticChat.paper.velocity.CrossServerDirectMessageManager
import io.papermc.paper.command.brigadier.CommandSourceStack
import io.papermc.paper.command.brigadier.Commands
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
import org.bukkit.Bukkit
@Command(
@@ -32,9 +31,10 @@ class ReplyCommand(
private val dmHandler: DirectMessageHandler,
override val languageManager: LanguageManager,
private val crossServerDirectMessageManager: CrossServerDirectMessageManager? = null,
- // Delivery is dispatched here rather than run inline: romaji conversion can reach the Google
- // IME API, and a command executor runs on the tick thread.
- private val scope: CoroutineScope = plugin.pluginScope.scope,
+ // Delivery is queued rather than run inline: romaji conversion can reach the Google IME API,
+ // and a command executor runs on the tick thread. Queueing per sender keeps their messages in
+ // the order they typed them.
+ private val deliveryQueue: PerPlayerWorkQueue = plugin.deliveryQueue,
) : LunaticCommand(plugin) {
override val description: String
get() = languageManager.getMessage("commandDescription.reply")
@@ -70,14 +70,16 @@ class ReplyCommand(
val recipient =
Bukkit.getPlayer(target.uuid)
?: return fail("directMessage.replyTargetNotFound")
- scope.launch { dmHandler.sendDirectMessage(sender, recipient, message) }
+ deliveryQueue.submit(sender.uniqueId) { dmHandler.sendDirectMessage(sender, recipient, message) }
CommandResult.Success
}
is ReplyTarget.Remote -> {
val manager =
crossServerDirectMessageManager
?: return fail("directMessage.replyTargetNotFound")
- scope.launch { manager.sendCrossServerMessage(sender, target.playerName, target.serverName, message) }
+ deliveryQueue.submit(sender.uniqueId) {
+ manager.sendCrossServerMessage(sender, target.playerName, target.serverName, message)
+ }
CommandResult.Success
}
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommand.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommand.kt
index bd98f22..f19afed 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommand.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommand.kt
@@ -7,6 +7,7 @@ import com.mojang.brigadier.suggestion.SuggestionsBuilder
import dev.m1sk9.lunaticChat.engine.command.CommandResult
import dev.m1sk9.lunaticChat.engine.permission.LunaticChatPermissionNode
import dev.m1sk9.lunaticChat.paper.LunaticChat
+import dev.m1sk9.lunaticChat.paper.PerPlayerWorkQueue
import dev.m1sk9.lunaticChat.paper.chat.handler.DirectMessageHandler
import dev.m1sk9.lunaticChat.paper.command.annotation.Command
import dev.m1sk9.lunaticChat.paper.command.annotation.Permission
@@ -18,8 +19,6 @@ import dev.m1sk9.lunaticChat.paper.velocity.CrossServerDirectMessageManager
import dev.m1sk9.lunaticChat.paper.velocity.RemotePlayerRegistry
import io.papermc.paper.command.brigadier.CommandSourceStack
import io.papermc.paper.command.brigadier.Commands
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
import org.bukkit.Bukkit
import org.bukkit.entity.Player
import java.util.concurrent.CompletableFuture
@@ -39,9 +38,10 @@ class TellCommand(
private val crossServerDirectMessageManager: CrossServerDirectMessageManager? = null,
private val remotePlayerRegistry: RemotePlayerRegistry? = null,
private val localServerName: String = "",
- // Delivery is dispatched here rather than run inline: romaji conversion can reach the Google
- // IME API, and a command executor runs on the tick thread.
- private val scope: CoroutineScope = plugin.pluginScope.scope,
+ // Delivery is queued rather than run inline: romaji conversion can reach the Google IME API,
+ // and a command executor runs on the tick thread. Queueing per sender keeps their messages in
+ // the order they typed them.
+ private val deliveryQueue: PerPlayerWorkQueue = plugin.deliveryQueue,
) : LunaticCommand(plugin) {
override val description: String
get() = languageManager.getMessage("commandDescription.tell")
@@ -104,7 +104,7 @@ class TellCommand(
) {
return fail("directMessage.yourself")
}
- scope.launch { manager.sendCrossServerMessage(sender, name, server, message) }
+ deliveryQueue.submit(sender.uniqueId) { manager.sendCrossServerMessage(sender, name, server, message) }
return CommandResult.Success
}
@@ -116,7 +116,7 @@ class TellCommand(
return fail("directMessage.yourself")
}
- scope.launch { directMessageHandler.sendDirectMessage(sender, recipient, message) }
+ deliveryQueue.submit(sender.uniqueId) { directMessageHandler.sendDirectMessage(sender, recipient, message) }
return CommandResult.Success
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerPresenceListener.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerPresenceListener.kt
index 6418f6f..1e3fde9 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerPresenceListener.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerPresenceListener.kt
@@ -96,5 +96,8 @@ class PlayerPresenceListener(
// 3. Trigger async save of player settings
playerSettingsManager.queueSave()
+
+ // 4. Drop their delivery queue; anything already queued still runs
+ lunaticChat.deliveryQueue.release(playerId)
}
}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/PerPlayerWorkQueueTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/PerPlayerWorkQueueTest.kt
new file mode 100644
index 0000000..17341ca
--- /dev/null
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/PerPlayerWorkQueueTest.kt
@@ -0,0 +1,106 @@
+package dev.m1sk9.lunaticChat.paper
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import java.util.UUID
+import java.util.concurrent.ConcurrentLinkedQueue
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class PerPlayerWorkQueueTest {
+ private val alice = UUID.fromString("00000001-0000-0000-0000-000000000000")
+ private val bob = UUID.fromString("00000002-0000-0000-0000-000000000000")
+
+ private suspend fun awaitSize(
+ completed: ConcurrentLinkedQueue<String>,
+ expected: Int,
+ ) = withTimeout(5_000) {
+ while (completed.size < expected) {
+ delay(5)
+ }
+ }
+
+ @Test
+ fun `a player's work runs in submission order even when later work is faster`() =
+ runBlocking {
+ val queue = PerPlayerWorkQueue(CoroutineScope(Dispatchers.Default))
+ val completed = ConcurrentLinkedQueue<String>()
+
+ // The regression this guards: a cached conversion overtaking an uncached one sent
+ // before it, so the player sees their messages out of order.
+ queue.submit(alice) {
+ delay(200)
+ completed.add("slow-first")
+ }
+ queue.submit(alice) { completed.add("fast-second") }
+
+ awaitSize(completed, 2)
+ assertEquals(listOf("slow-first", "fast-second"), completed.toList())
+ }
+
+ @Test
+ fun `one player's slow work does not hold up another player`() =
+ runBlocking {
+ val queue = PerPlayerWorkQueue(CoroutineScope(Dispatchers.Default))
+ val completed = ConcurrentLinkedQueue<String>()
+
+ queue.submit(alice) {
+ delay(1_000)
+ completed.add("alice")
+ }
+ queue.submit(bob) { completed.add("bob") }
+
+ awaitSize(completed, 1)
+ assertEquals(listOf("bob"), completed.toList())
+ }
+
+ @Test
+ fun `submit does not block the caller`() {
+ val queue = PerPlayerWorkQueue(CoroutineScope(Dispatchers.Default))
+ val started = ConcurrentLinkedQueue<String>()
+
+ queue.submit(alice) {
+ delay(2_000)
+ started.add("done")
+ }
+
+ // submit returns without waiting for the work; the point of queueing off the tick thread.
+ assertTrue(started.isEmpty())
+ }
+
+ @Test
+ fun `work already queued still runs after the player is released`() =
+ runBlocking {
+ val queue = PerPlayerWorkQueue(CoroutineScope(Dispatchers.Default))
+ val completed = ConcurrentLinkedQueue<String>()
+
+ queue.submit(alice) {
+ delay(50)
+ completed.add("in-flight")
+ }
+ queue.release(alice)
+
+ awaitSize(completed, 1)
+ assertEquals(listOf("in-flight"), completed.toList())
+ }
+
+ @Test
+ fun `a released player gets a fresh queue if they come back`() =
+ runBlocking {
+ val queue = PerPlayerWorkQueue(CoroutineScope(Dispatchers.Default))
+ val completed = ConcurrentLinkedQueue<String>()
+
+ queue.submit(alice) { completed.add("before") }
+ awaitSize(completed, 1)
+ queue.release(alice)
+
+ queue.submit(alice) { completed.add("after") }
+
+ awaitSize(completed, 2)
+ assertEquals(listOf("before", "after"), completed.toList())
+ }
+}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommandTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommandTest.kt
index cea9d6f..36b6ff7 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommandTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommandTest.kt
@@ -2,6 +2,7 @@ package dev.m1sk9.lunaticChat.paper.command.impl
import dev.m1sk9.lunaticChat.engine.command.CommandResult
import dev.m1sk9.lunaticChat.paper.LunaticChat
+import dev.m1sk9.lunaticChat.paper.PerPlayerWorkQueue
import dev.m1sk9.lunaticChat.paper.TestUtils
import dev.m1sk9.lunaticChat.paper.chat.handler.DirectMessageHandler
import dev.m1sk9.lunaticChat.paper.command.core.CommandContext
@@ -20,7 +21,7 @@ class TellCommandTest {
private fun createCommand(
crossServerManager: CrossServerDirectMessageManager? = null,
localServerName: String = "lobby",
- // Unconfined runs the dispatched delivery inline, so a test can assert on it right after
+ // Unconfined runs the queued delivery inline, so a test can assert on it right after
// execute() returns.
scope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined),
): TellDeps {
@@ -42,7 +43,7 @@ class TellCommandTest {
crossServerManager,
null,
localServerName,
- scope,
+ PerPlayerWorkQueue(scope),
)
return TellDeps(command, ctx, dmHandler, crossServerManager, sender)
}