summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSho Sakuma <me@m1sk9.dev>2026-08-05 04:30:07 +0900
committerGitHub <noreply@github.com>2026-08-05 04:30:07 +0900
commit82a592fe744ab4172c8f86fd2a71ea540575174a (patch)
treeb683bdb24d1a2ce8746f4fef7cd6b6b1159438ce
parent7c544224291e8ed7e23dcf97ea3cc6f42588c338 (diff)
parentadbcb0d59dfc96050cac9d578b632b5fab38acbb (diff)
downloadLunaticChat-82a592fe744ab4172c8f86fd2a71ea540575174a.tar.gz
LunaticChat-82a592fe744ab4172c8f86fd2a71ea540575174a.tar.bz2
LunaticChat-82a592fe744ab4172c8f86fd2a71ea540575174a.zip
Merge pull request #265 from m1sk9/refactor/post-merge-cleanup
refactor: fix a delivery-queue regression and lift durability into the storage layer
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt11
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PerPlayerWorkQueue.kt82
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceContainer.kt7
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt82
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/StoppableService.kt16
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelManager.kt22
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMembershipManager.kt12
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMessageLogger.kt5
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelStorage.kt44
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt7
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt9
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/ReplyCommand.kt3
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommand.kt3
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommand.kt5
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelSwitchCommand.kt3
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt10
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LenientBoolean.kt14
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCache.kt31
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionTimeoutException.kt15
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/GoogleIMEClient.kt10
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverter.kt15
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/SpyPermissionManager.kt19
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerSettingsManager.kt24
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/YamlPlayerSettingsStorage.kt36
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/storage/AsyncScheduler.kt15
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/storage/AtomicWrite.kt (renamed from platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/AtomicWrite.kt)4
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/storage/DebouncedSaver.kt (renamed from platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/DebouncedSaver.kt)23
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/storage/FileStore.kt60
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt60
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/VelocityConnectionManager.kt6
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/PerPlayerWorkQueueTest.kt71
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/ServiceShutdownTest.kt67
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt27
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMembershipManagerTest.kt4
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelStorageTest.kt49
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandlerTest.kt33
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommandTest.kt20
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt9
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCacheTest.kt20
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverterTest.kt44
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerSettingsManagerTest.kt16
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/settings/YamlPlayerSettingsStorageTest.kt109
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/storage/AtomicWriteTest.kt (renamed from platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/AtomicWriteTest.kt)18
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/storage/DebouncedSaverTest.kt61
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManagerTest.kt14
45 files changed, 830 insertions, 385 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 3f9cb76..85d87cd 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
@@ -41,11 +41,14 @@ class LunaticChat :
private lateinit var configuration: LunaticChatConfiguration
private lateinit var serviceInitializer: ServiceInitializer
- // Read by commands that must not block the tick thread.
- lateinit var pluginScope: PluginCoroutineScope
- private set
+ private lateinit var pluginScope: PluginCoroutineScope
- /** Serializes each player's outgoing messages so they arrive in the order they were sent. */
+ /**
+ * Serializes each player's outgoing messages so they arrive in the order they were sent.
+ *
+ * Commands submit delivery here rather than running it inline: romaji conversion can reach the
+ * Google IME API, and a command executor runs on the tick thread.
+ */
lateinit var deliveryQueue: PerPlayerWorkQueue
private set
private var updateChecker: UpdateChecker? = null
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
index f5e4a28..5973d64 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PerPlayerWorkQueue.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PerPlayerWorkQueue.kt
@@ -2,6 +2,7 @@ package dev.m1sk9.lunaticChat.paper
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.isActive
@@ -26,10 +27,27 @@ class PerPlayerWorkQueue(
private val scope: CoroutineScope,
private val logger: Logger,
) {
- private val queues = ConcurrentHashMap<UUID, SendChannel<suspend () -> Unit>>()
+ private val queues = ConcurrentHashMap<UUID, Worker>()
+
+ private companion object {
+ /**
+ * Bounded so a player cannot make the server hold work it will never get through.
+ *
+ * An item takes up to the conversion timeout to drain, which is far slower than a player can
+ * send; an unbounded queue therefore grew for as long as a macro ran, each item holding its
+ * sender and recipient alive and arriving minutes after it was typed. Refusing the overflow
+ * is visible to the player, unlike delivering it late.
+ */
+ const val CAPACITY = 8
+ }
+
+ private class Worker(
+ val channel: SendChannel<suspend () -> Unit>,
+ val job: Job,
+ )
/**
- * Queues [work] behind anything already pending for [playerId].
+ * Queues [work] behind anything already pending for [playerId], unless their queue is full.
*/
fun submit(
playerId: UUID,
@@ -38,40 +56,52 @@ class PerPlayerWorkQueue(
// Checked rather than left to trySend: cancelling the scope kills the worker coroutines but
// does not close their channels, so after shutdown trySend would keep reporting success for
// work nothing will ever read.
- val accepted = scope.isActive && queues.computeIfAbsent(playerId) { startWorker() }.trySend(work).isSuccess
+ val accepted =
+ scope.isActive &&
+ queues
+ .computeIfAbsent(playerId) { startWorker() }
+ .channel
+ .trySend(work)
+ .isSuccess
if (!accepted) {
- // Reachable once the scope is cancelled at shutdown, or if the player's queue is
- // released in the same tick as their command. Dropping a message in silence is worse
- // than saying so.
- logger.warning("Discarded queued work for player $playerId: their queue is closed")
+ // Reachable when the player is sending faster than delivery drains, once the scope is
+ // cancelled at shutdown, or if their queue is released in the same tick as their command.
+ // Dropping a message in silence is worse than saying so.
+ logger.warning("Discarded queued work for player $playerId: their queue is full or closed")
}
}
/**
- * 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.
+ * Drops the player's queue once they can no longer send anything.
+ *
+ * Anything still pending is abandoned rather than delivered: it was addressed to or sent by a
+ * player who has left, so finishing it would spend a conversion round trip per item to write to
+ * nobody, while holding both players alive until the backlog drained.
*/
fun release(playerId: UUID) {
- queues.remove(playerId)?.close()
+ queues.remove(playerId)?.let {
+ it.channel.close()
+ it.job.cancel()
+ }
}
- 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) {
- // One failed message must not end the loop. If it did, the channel would stay in
- // `queues` with nothing reading it, so every later message from this player would
- // be buffered and never delivered - with no way to recover short of reconnecting.
- try {
- work()
- } catch (e: CancellationException) {
- throw e
- } catch (e: Throwable) {
- logger.log(Level.SEVERE, "Queued work failed", e)
+ private fun startWorker(): Worker {
+ val channel = Channel<suspend () -> Unit>(CAPACITY)
+ val job =
+ scope.launch {
+ for (work in channel) {
+ // One failed message must not end the loop. If it did, the channel would stay in
+ // `queues` with nothing reading it, so every later message from this player would
+ // be buffered and never delivered - with no way to recover short of reconnecting.
+ try {
+ work()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Throwable) {
+ logger.log(Level.SEVERE, "Queued work failed", e)
+ }
}
}
- }
- return channel
+ return Worker(channel, job)
}
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceContainer.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceContainer.kt
index 48f6e37..13cb31b 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceContainer.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceContainer.kt
@@ -51,4 +51,11 @@ data class ServiceContainer(
val crossServerChatManager: CrossServerChatManager? = null,
val crossServerDirectMessageManager: CrossServerDirectMessageManager? = null,
val remotePlayerRegistry: RemotePlayerRegistry? = null,
+ /**
+ * The services with teardown, in the order it must happen.
+ *
+ * Built as the services are, so a service that needs stopping is stopped because it was
+ * registered where it was created - not because someone remembered to extend a second list.
+ */
+ val stoppables: List<StoppableService> = emptyList(),
)
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt
index bbfaab2..cac49c7 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt
@@ -14,6 +14,8 @@ import dev.m1sk9.lunaticChat.paper.converter.RomanjiConverter
import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager
import dev.m1sk9.lunaticChat.paper.settings.PlayerSettingsManager
import dev.m1sk9.lunaticChat.paper.settings.YamlPlayerSettingsStorage
+import dev.m1sk9.lunaticChat.paper.storage.AsyncScheduler
+import dev.m1sk9.lunaticChat.paper.storage.FileStore
import dev.m1sk9.lunaticChat.paper.velocity.CrossServerChatManager
import dev.m1sk9.lunaticChat.paper.velocity.CrossServerDirectMessageManager
import dev.m1sk9.lunaticChat.paper.velocity.RemotePlayerRegistry
@@ -54,6 +56,14 @@ class ServiceInitializer(
) {
private val handshakeCompleted = AtomicBoolean(false)
+ private val asyncScheduler =
+ AsyncScheduler { delaySeconds, task ->
+ plugin.server.asyncScheduler.runDelayed(plugin, { task() }, delaySeconds, TimeUnit.SECONDS)
+ }
+
+ /** A store for [relativePath] under the plugin's data folder, with its own debounced saver. */
+ private fun fileStore(relativePath: String) = FileStore(plugin.dataFolder.resolve(relativePath).toPath(), asyncScheduler, logger)
+
private companion object {
/** Matches the value documented in config.yml. */
const val DEFAULT_CACHE_SAVE_INTERVAL_SECONDS = 300L
@@ -119,26 +129,19 @@ class ServiceInitializer(
}
// 7. Initialize cross-server chat manager (optional)
+ //
+ // Gated on velocityManager alone: it is non-null only when velocityIntegration.enabled, so
+ // testing that flag again here would let the two conditions disagree.
val crossServerManager =
- if (configuration.features.velocityIntegration.enabled &&
- configuration.features.velocityIntegration.crossServerGlobalChat &&
- velocityManager != null
- ) {
- initializeCrossServerChatManager(velocityManager)
- } else {
- null
- }
+ velocityManager
+ ?.takeIf { configuration.features.velocityIntegration.crossServerGlobalChat }
+ ?.let { initializeCrossServerChatManager(it) }
// 8. Initialize cross-server direct message manager and presence registry (optional)
val crossServerDirectMessage =
- if (configuration.features.velocityIntegration.enabled &&
- configuration.features.velocityIntegration.crossServerDirectMessage &&
- velocityManager != null
- ) {
- initializeCrossServerDirectMessage(velocityManager, directMessageHandler, languageManager)
- } else {
- null
- }
+ velocityManager
+ ?.takeIf { configuration.features.velocityIntegration.crossServerDirectMessage }
+ ?.let { initializeCrossServerDirectMessage(it, directMessageHandler, languageManager) }
return ServiceContainer(
languageManager = languageManager,
@@ -155,6 +158,16 @@ class ServiceInitializer(
crossServerChatManager = crossServerManager,
crossServerDirectMessageManager = crossServerDirectMessage?.first,
remotePlayerRegistry = crossServerDirectMessage?.second,
+ // Ordered: player-visible state is persisted first, then the log is flushed, and the
+ // proxy connection is closed last so a relay in flight still has somewhere to go.
+ stoppables =
+ listOfNotNull(
+ playerSettingsManager,
+ japaneseConversion?.second,
+ channelComponents?.channelManager,
+ channelComponents?.channelMessageLogger,
+ velocityManager,
+ ),
)
}
@@ -163,11 +176,9 @@ class ServiceInitializer(
* This is always needed for features like DM notifications.
*/
private fun initializePlayerSettingsManager(): PlayerSettingsManager {
- val settingsFile = plugin.dataFolder.resolve(configuration.userSettingsFilePath).toPath()
val storage =
YamlPlayerSettingsStorage(
- settingsFile = settingsFile,
- saver = DebouncedSaver(plugin),
+ store = fileStore(configuration.userSettingsFilePath),
logger = logger,
)
@@ -190,7 +201,7 @@ class ServiceInitializer(
// Initialize conversion cache
val cache =
ConversionCache(
- cacheFile = plugin.dataFolder.resolve(configuration.features.japaneseConversion.cache.filePath).toPath(),
+ store = fileStore(configuration.features.japaneseConversion.cache.filePath),
maxEntries = configuration.features.japaneseConversion.cache.maxEntries,
logger = logger,
)
@@ -223,11 +234,9 @@ class ServiceInitializer(
settingsManager: PlayerSettingsManager,
languageManager: LanguageManager,
): ChannelComponents {
- val channelsFile = plugin.dataFolder.resolve("channels.json").toPath()
val storage =
ChannelStorage(
- channelsFile = channelsFile,
- saver = DebouncedSaver(plugin),
+ store = fileStore("channels.json"),
logger = logger,
)
@@ -453,24 +462,15 @@ class ServiceInitializer(
* Performs shutdown tasks, including saving all caches to disk.
*/
fun shutdown(services: ServiceContainer) {
- shutdownStep("save player settings") { services.playerSettingsManager.saveToDisk() }
- shutdownStep("save the conversion cache") { services.conversionCache?.saveToDisk() }
- shutdownStep("save channel data") { services.channelManager?.saveToDisk() }
- shutdownStep("shut down the channel message logger") { services.channelMessageLogger?.shutdown() }
- shutdownStep("shut down the Velocity connection") { services.velocityConnectionManager?.shutdown() }
- }
-
- // The steps are independent, so one that throws must not skip the ones after it - which is what
- // an exception escaping onDisable would do, leaving the log flusher and the Velocity connection
- // to be torn down by the server instead.
- private fun shutdownStep(
- what: String,
- step: () -> Unit,
- ) {
- try {
- step()
- } catch (e: Exception) {
- logger.log(Level.SEVERE, "Failed to $what during shutdown", e)
+ // The services are independent, so one that throws must not skip the ones after it - which is
+ // what an exception escaping onDisable would do, leaving the log flusher and the Velocity
+ // connection to be torn down by the server instead.
+ services.stoppables.forEach { service ->
+ try {
+ service.stop()
+ } catch (e: Exception) {
+ logger.log(Level.SEVERE, "Failed to stop ${service::class.simpleName} during shutdown", e)
+ }
}
}
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/StoppableService.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/StoppableService.kt
new file mode 100644
index 0000000..7ebd25f
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/StoppableService.kt
@@ -0,0 +1,16 @@
+package dev.m1sk9.lunaticChat.paper
+
+/**
+ * A service with work to finish before the server stops - a cache to flush, a connection to close.
+ *
+ * Implementing this is how a service gets torn down: [ServiceInitializer] registers each one as it
+ * builds it, so shutdown follows from construction rather than from a second hand-maintained list
+ * that a new service is silently missing from.
+ *
+ * The five services that had teardown before this spelled it `saveToDisk()` three times and
+ * `shutdown()` twice, so nothing but a reader could tell they were the same obligation.
+ */
+interface StoppableService {
+ /** Finishes outstanding work. Called once, on the shutdown path, off the tick thread. */
+ fun stop()
+}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelManager.kt
index 663984c..2feb87e 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelManager.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelManager.kt
@@ -13,6 +13,7 @@ import dev.m1sk9.lunaticChat.engine.exception.ChannelNoOwnerPermissionException
import dev.m1sk9.lunaticChat.engine.exception.ChannelNotFoundException
import dev.m1sk9.lunaticChat.engine.exception.ChannelPlayerAlreadyBannedException
import dev.m1sk9.lunaticChat.engine.exception.ChannelPlayerNotBannedException
+import dev.m1sk9.lunaticChat.paper.StoppableService
import dev.m1sk9.lunaticChat.paper.config.key.ChannelChatFeatureConfig
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
@@ -24,7 +25,7 @@ class ChannelManager(
private val storage: ChannelStorage,
private val logger: Logger,
private val config: ChannelChatFeatureConfig,
-) {
+) : StoppableService {
private val channelsCache = ConcurrentHashMap<String, Channel>()
private val membersCache = ConcurrentHashMap<String, CopyOnWriteArrayList<ChannelMember>>()
private val activeChannels = ConcurrentHashMap<UUID, String>()
@@ -464,6 +465,8 @@ class ChannelManager(
* Saves the current state of channels and members to storage synchronously.
* Should only br called during server shutdown.
*/
+ override fun stop() = saveToDisk()
+
fun saveToDisk() {
storage.saveToDisk(snapshot())
}
@@ -542,11 +545,18 @@ class ChannelManager(
playerId: UUID,
channelId: String?,
) {
- if (channelId == null) {
- activeChannels.remove(playerId)
- } else {
- activeChannels[playerId] = channelId
- }
+ // Returning early when nothing moved matters on the quit path, which clears the active
+ // channel for every player whether or not they had one: a snapshot copies all three caches
+ // and stringifies every active channel's UUID, and a mass disconnect would pay that once per
+ // player in a single tick.
+ val changed =
+ if (channelId == null) {
+ activeChannels.remove(playerId) != null
+ } else {
+ activeChannels.put(playerId, channelId) != channelId
+ }
+ if (!changed) return
+
saveToStorage()
}
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMembershipManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMembershipManager.kt
index 6ce2672..72a016d 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMembershipManager.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMembershipManager.kt
@@ -176,13 +176,7 @@ class ChannelMembershipManager(
// Check if player has reached max membership limit (0 means unlimited)
if (config.maxMembershipPerPlayer > 0) {
- val playerChannelCount =
- getPlayerChannels(playerId)
- .getOrElse {
- return Result.failure(
- ChannelRuntimeException("Failed to get player channels for $playerId", it),
- )
- }.size
+ val playerChannelCount = getPlayerChannels(playerId).size
if (playerChannelCount >= config.maxMembershipPerPlayer) {
return Result.failure(
@@ -333,7 +327,7 @@ class ChannelMembershipManager(
* Gets all channels where the player is a member.
*
* @param playerId The UUID of the player.
- * @return Result containing a list of channel IDs where the player is a member.
+ * @return The channel IDs where the player is a member.
*/
- fun getPlayerChannels(playerId: UUID): Result<List<String>> = Result.success(channelManager.channelIdsOf(playerId))
+ fun getPlayerChannels(playerId: UUID): List<String> = channelManager.channelIdsOf(playerId)
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMessageLogger.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMessageLogger.kt
index a100d93..885f9ce 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMessageLogger.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMessageLogger.kt
@@ -1,6 +1,7 @@
package dev.m1sk9.lunaticChat.paper.chat.channel
import dev.m1sk9.lunaticChat.engine.chat.channel.ChannelMessageLogEntry
+import dev.m1sk9.lunaticChat.paper.StoppableService
import io.ktor.util.logging.Logger
import io.papermc.paper.threadedregions.scheduler.ScheduledTask
import kotlinx.serialization.encodeToString
@@ -38,7 +39,7 @@ class ChannelMessageLogger(
private val logger: Logger,
private val maxFileSizeBytes: Long,
private val retentionDays: Int,
-) {
+) : StoppableService {
private val pendingEntries = ConcurrentLinkedQueue<ChannelMessageLogEntry>()
private val json = Json { encodeDefaults = true }
private var flushTask: ScheduledTask? = null
@@ -161,7 +162,7 @@ class ChannelMessageLogger(
* Shuts down the logger by cancelling scheduled tasks and flushing pending entries.
* Should be called during plugin shutdown.
*/
- fun shutdown() {
+ override fun stop() {
// Cancel scheduled tasks
flushTask?.cancel()
cleanupTask?.cancel()
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelStorage.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelStorage.kt
index 4fb18e8..fd18b9d 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelStorage.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelStorage.kt
@@ -3,24 +3,18 @@ package dev.m1sk9.lunaticChat.paper.chat.channel
import dev.m1sk9.lunaticChat.engine.chat.channel.ChannelData
import dev.m1sk9.lunaticChat.engine.exception.ChannelStorageLoadException
import dev.m1sk9.lunaticChat.engine.exception.ChannelStorageSaveException
-import dev.m1sk9.lunaticChat.paper.DebouncedSaver
-import dev.m1sk9.lunaticChat.paper.writeTextAtomically
+import dev.m1sk9.lunaticChat.paper.storage.FileStore
import kotlinx.serialization.json.Json
-import java.nio.file.Path
import java.util.logging.Logger
-import kotlin.io.path.bufferedReader
-import kotlin.io.path.exists
/**
* Manages the storage of channel data on disk.
*
- * @property channelsFile The path to the file where channel data is stored.
- * @property saver Coalesces bursts of save requests into one asynchronous write.
+ * @property store The file channel data is read from and written to.
* @property logger The logger for logging messages.
*/
class ChannelStorage(
- private val channelsFile: Path,
- private val saver: DebouncedSaver,
+ private val store: FileStore,
private val logger: Logger,
) {
private val json =
@@ -36,22 +30,19 @@ class ChannelStorage(
* @throws ChannelStorageLoadException if there is an error loading the data.
*/
fun loadFromDisk(): ChannelData {
- if (!channelsFile.exists()) {
- logger.warning("Channel storage not found, will create a new one.")
- return ChannelData()
- }
+ val jsonContent =
+ store.read() ?: run {
+ logger.warning("Channel storage not found, will create a new one.")
+ return ChannelData()
+ }
return try {
- val jsonContent =
- channelsFile.bufferedReader().use {
- it.readText()
- }
json.decodeFromString(ChannelData.serializer(), jsonContent).also {
- logger.info("Successfully loaded channels from ${channelsFile.fileName}.")
+ logger.info("Successfully loaded channels from ${store.name}.")
}
} catch (e: Exception) {
throw ChannelStorageLoadException(
- "Failed to load channels from ${channelsFile.fileName}: ${e.message}",
+ "Failed to load channels from ${store.name}: ${e.message}",
e,
)
}
@@ -65,12 +56,11 @@ class ChannelStorage(
*/
fun saveToDisk(data: ChannelData) {
try {
- val jsonContent = json.encodeToString(ChannelData.serializer(), data)
- channelsFile.writeTextAtomically(jsonContent)
- logger.fine("Successfully saved channels from ${channelsFile.fileName}.")
+ store.write(json.encodeToString(ChannelData.serializer(), data))
+ logger.fine("Successfully saved channels from ${store.name}.")
} catch (e: Exception) {
throw ChannelStorageSaveException(
- "Failed to save channels to ${channelsFile.fileName}: ${e.message}",
+ "Failed to save channels to ${store.name}: ${e.message}",
e,
)
}
@@ -84,12 +74,6 @@ class ChannelStorage(
* write instead of one of each per change.
*/
fun queueAsyncSave(data: () -> ChannelData) {
- saver.request {
- try {
- saveToDisk(data())
- } catch (e: ChannelStorageSaveException) {
- logger.severe("Error saving channel data asynchronously: ${e.message}")
- }
- }
+ store.queueWrite { json.encodeToString(ChannelData.serializer(), data()) }
}
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt
index 4da0c1a..0155a33 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt
@@ -41,10 +41,11 @@ class ChannelMessageHandler(
player.playMessageSendNotification()
}
- // Send to spy players (exclude sender and channel members)
- val memberIds = context.members.map { it.playerId }.toSet()
+ // Send to spy players (exclude sender and channel members). The member set is built lazily
+ // because notifySpies only consults exclude when a spy is actually online.
+ val memberIds by lazy { context.members.mapTo(HashSet()) { it.playerId } }
SpyPermissionManager.notifySpies(
- noticeText = languageManager.getMessage("general.spyMessage"),
+ noticeText = { languageManager.getMessage("general.spyMessage") },
exclude = { it.uniqueId == playerId || it.uniqueId in memberIds },
) { formattedMessage }
context.members.forEach { member ->
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt
index 2033661..7eb077a 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt
@@ -156,8 +156,10 @@ class DirectMessageHandler(
/**
* Handles the sender-side display of an outgoing cross-server direct message.
- * Applies romaji conversion, shows the message to the sender, notifies spies,
- * and records the remote reply target.
+ * Applies romaji conversion, shows the message to the sender and notifies spies.
+ *
+ * The reply target is recorded by the caller via [recordRemoteRecipient] before the delivery is
+ * queued, for the same reason as [sendDirectMessage]: /reply reads it on the command thread.
*
* @return the message body to relay (romaji-converted if applicable), since the
* receiving server has no access to the sender's settings.
@@ -184,7 +186,6 @@ class DirectMessageHandler(
?.playMessageSendNotification()
}
- recordRemoteRecipient(sender, targetName, targetServerName)
return displayMessage
}
@@ -230,7 +231,7 @@ class DirectMessageHandler(
rawMessage: String,
) {
SpyPermissionManager.notifySpies(
- noticeText = languageManager.getMessage("general.spyMessage"),
+ noticeText = { languageManager.getMessage("general.spyMessage") },
exclude = { it.name == senderName || it.name == recipientName },
) {
formatMessage(format, senderName, recipientName, rawMessage, replyTo = senderName)
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 df5b2a9..e999a60 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
@@ -31,9 +31,6 @@ class ReplyCommand(
private val dmHandler: DirectMessageHandler,
override val languageManager: LanguageManager,
private val crossServerDirectMessageManager: CrossServerDirectMessageManager? = null,
- // 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
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 5e6e2c7..82c1eef 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
@@ -38,9 +38,6 @@ class TellCommand(
private val crossServerDirectMessageManager: CrossServerDirectMessageManager? = null,
private val remotePlayerRegistry: RemotePlayerRegistry? = null,
private val localServerName: String = "",
- // 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
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommand.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommand.kt
index 78871b7..ae2ebfe 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommand.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommand.kt
@@ -49,10 +49,7 @@ class ChannelStatusCommand(
val activeChannel = activeChannelId?.let { channelManager.getChannel(it).getOrNull() }
// Get all player's channels
- val playerChannelIds =
- membershipManager.getPlayerChannels(sender.uniqueId).getOrElse {
- return fail("channel.status.error")
- }
+ val playerChannelIds = membershipManager.getPlayerChannels(sender.uniqueId)
// Display header
sender.sendMessage(
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelSwitchCommand.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelSwitchCommand.kt
index 2f71b7d..c558e31 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelSwitchCommand.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelSwitchCommand.kt
@@ -38,8 +38,7 @@ class ChannelSwitchCommand(
// Tab completion: suggest channels the player is a member of
val sender = ctx.source.executor
if (sender is org.bukkit.entity.Player) {
- val playerChannels = membershipManager.getPlayerChannels(sender.uniqueId).getOrNull() ?: emptyList()
- playerChannels.forEach { channelId ->
+ membershipManager.getPlayerChannels(sender.uniqueId).forEach { channelId ->
builder.suggest(channelId)
}
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt
index 2ce24b6..e01942d 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt
@@ -22,6 +22,10 @@ import java.util.logging.Logger
class ConfigManager(
private val logger: Logger,
) {
+ private companion object {
+ const val UNREADABLE = "config.yml could not be read"
+ }
+
private val yaml =
Yaml(
configuration =
@@ -44,7 +48,7 @@ class ConfigManager(
// Editors that write a UTF-8 BOM would otherwise leave it on the first key, which
// strictMode = false then drops as an unknown setting without a word.
yaml.parseToYamlNode(contents.removePrefix("\uFEFF"))
- } catch (e: EmptyYamlDocumentException) {
+ } catch (_: EmptyYamlDocumentException) {
// A file that only holds comments is a valid way of saying "use the defaults", so it
// is not reported as a failure the operator has to act on.
return LunaticChatConfiguration()
@@ -67,13 +71,13 @@ class ConfigManager(
val setting = e.path.settingKeys()
val remaining =
document.without(setting)
- ?: return allDefaults("config.yml could not be read", e)
+ ?: return allDefaults(UNREADABLE, e)
logger.warning("${setting.joinToString(".")} in config.yml fell back to its default: ${e.message}")
document = remaining
} catch (e: Exception) {
// A serializer can fail without kaml turning it into a YamlException, and there is
// no path to prune a single setting by without one.
- return allDefaults("config.yml could not be read", e)
+ return allDefaults(UNREADABLE, e)
}
}
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LenientBoolean.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LenientBoolean.kt
index 3c93fb8..72c1395 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LenientBoolean.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LenientBoolean.kt
@@ -4,7 +4,6 @@ import com.charleskorn.kaml.YamlException
import com.charleskorn.kaml.YamlInput
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
-import kotlinx.serialization.SerializationException
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
@@ -31,18 +30,15 @@ object LenientBooleanSerializer : KSerializer<Boolean> {
override val descriptor = PrimitiveSerialDescriptor("LenientBoolean", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): Boolean {
- // Captured before decoding: ConfigManager prunes the offending setting by path, and only a
- // YamlException carries one. A plain SerializationException would escape it and cost the
- // operator the whole file.
- val path = (decoder as? YamlInput)?.node?.path
+ // Captured before decoding: ConfigManager prunes the offending setting by the path its
+ // exception carries, and only a YamlException carries one. Throwing without a path would
+ // cost the operator every other setting in the file.
+ val path = (decoder as YamlInput).node.path
val raw = decoder.decodeString()
return when (raw.lowercase()) {
in trueWords -> true
in falseWords -> false
- else -> {
- val reason = "expected true/false, yes/no or on/off but found '$raw'"
- throw path?.let { YamlException(reason, it) } ?: SerializationException(reason)
- }
+ else -> throw YamlException("expected true/false, yes/no or on/off but found '$raw'", path)
}
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCache.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCache.kt
index cba62af..7be97d8 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCache.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCache.kt
@@ -1,19 +1,17 @@
package dev.m1sk9.lunaticChat.paper.converter
-import dev.m1sk9.lunaticChat.paper.writeTextAtomically
+import dev.m1sk9.lunaticChat.paper.StoppableService
+import dev.m1sk9.lunaticChat.paper.storage.FileStore
import kotlinx.serialization.json.Json
-import java.nio.file.Path
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.logging.Logger
-import kotlin.io.path.bufferedReader
-import kotlin.io.path.exists
class ConversionCache(
- private val cacheFile: Path,
+ private val store: FileStore,
private val maxEntries: Int = 500,
private val logger: Logger,
-) {
+) : StoppableService {
private val conversionMemoryCache = ConcurrentHashMap<String, String>()
private val dirty = AtomicBoolean(false)
@@ -26,14 +24,14 @@ class ConversionCache(
* If the cache file does not exist or version is incompatible, initializes it with an empty cache.
*/
fun loadFromDisk() {
- if (!cacheFile.exists()) {
- logger.info("Cache file not found, initializing new cache file at: $cacheFile")
- initializeEmptyCache()
- return
- }
+ val jsonBuffer =
+ store.read() ?: run {
+ logger.info("Cache file not found, initializing new cache file at: ${store.name}")
+ initializeEmptyCache()
+ return
+ }
try {
- val jsonBuffer = cacheFile.bufferedReader().use { it.readText() }
val cacheData = Json.decodeFromString<CacheData>(jsonBuffer)
if (cacheData.version != CACHE_VERSION) {
@@ -53,8 +51,7 @@ class ConversionCache(
private fun initializeEmptyCache() {
val emptyData = CacheData(version = CACHE_VERSION, entries = emptyMap())
- val jsonBuffer = Json.encodeToString(CacheData.serializer(), emptyData)
- cacheFile.writeTextAtomically(jsonBuffer)
+ store.write(Json.encodeToString(CacheData.serializer(), emptyData))
}
/**
@@ -101,8 +98,7 @@ class ConversionCache(
version = CACHE_VERSION,
entries = conversionMemoryCache.toMap(),
)
- val jsonBuffer = Json.encodeToString(CacheData.serializer(), data)
- cacheFile.writeTextAtomically(jsonBuffer)
+ store.write(Json.encodeToString(CacheData.serializer(), data))
logger.info("Saved ${conversionMemoryCache.size} cache entries to disk.")
} catch (e: Exception) {
dirty.set(true)
@@ -110,6 +106,9 @@ class ConversionCache(
}
}
+ /** Flushes on the shutdown path; the periodic task calls [saveToDisk] directly. */
+ override fun stop() = saveToDisk()
+
// FIXME: ConcurrentHashMap keys are unordered, so evicting "oldest" entries
// actually evicts random entries. Consider using LinkedHashMap with access-order
// or implement proper LRU cache with timestamp tracking.
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionTimeoutException.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionTimeoutException.kt
new file mode 100644
index 0000000..eafca63
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionTimeoutException.kt
@@ -0,0 +1,15 @@
+package dev.m1sk9.lunaticChat.paper.converter
+
+import kotlin.time.Duration
+
+/**
+ * The Google IME request did not answer in time.
+ *
+ * Deliberately not a CancellationException: callers degrade a failed conversion to hiragana but must
+ * rethrow cancellation, and reporting a timeout through the cancellation channel made an ordinary
+ * slow request look like plugin shutdown - killing the sender's delivery queue for the rest of the
+ * session.
+ */
+class ConversionTimeoutException(
+ timeout: Duration,
+) : Exception("Google IME did not answer within $timeout")
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/GoogleIMEClient.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/GoogleIMEClient.kt
index 0689ad4..463a3c3 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/GoogleIMEClient.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/GoogleIMEClient.kt
@@ -5,7 +5,7 @@ import io.ktor.client.call.body
import io.ktor.client.request.get
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
-import kotlinx.coroutines.withTimeout
+import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
@@ -18,7 +18,11 @@ class GoogleIMEClient(
) {
suspend fun convert(input: String): String =
withContext(Dispatchers.IO) {
- withTimeout(timeout) {
+ // withTimeoutOrNull rather than withTimeout: the latter reports a timeout as a
+ // CancellationException, which callers must rethrow rather than degrade. A slow request
+ // then reached the delivery queue looking like shutdown and ended the worker, so the
+ // sender's later messages were buffered and never delivered.
+ withTimeoutOrNull(timeout) {
val res =
httpClient.get("https://www.google.com/transliterate") {
url {
@@ -33,7 +37,7 @@ class GoogleIMEClient(
val jsonData = res.body<String>()
parseResponse(jsonData)
- }
+ } ?: throw ConversionTimeoutException(timeout)
}
private fun parseResponse(data: String): String {
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverter.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverter.kt
index 100aeb3..e1a11f8 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverter.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverter.kt
@@ -53,7 +53,7 @@ class RomanjiConverter(
coroutineScope {
words
.distinct()
- .map { word -> async { word to limiter.withPermit { convertWord(word) } } }
+ .map { word -> async { word to convertWord(word) } }
.awaitAll()
}.toMap()
@@ -81,14 +81,25 @@ class RomanjiConverter(
val hiragana = KanaConverter.toHiragana(word)
// Step 2: Hiragana -> Kanji/Kana
+ //
+ // The permit covers only the request. Holding it across the cache lookup above made cached
+ // words queue behind in-flight requests for a permit they never needed, so a message whose
+ // every word was cached could still run out of the caller's budget and go out unconverted.
val converted =
try {
- apiClient.convert(hiragana)
+ limiter.withPermit { apiClient.convert(hiragana) }
} catch (e: CancellationException) {
// Not an API failure: the caller's timeout fired. Caching the hiragana here would
// pin every word of the message to its unconverted form for good, because the words
// are converted concurrently and the timeout cancels all of them at once.
throw e
+ } catch (e: ConversionTimeoutException) {
+ // Returned without caching: a timeout says the request was slow, not that the word
+ // has no conversion, so recording the hiragana would pin it for the life of the
+ // cache over one slow reply. A hard API failure is different - the fallback is
+ // cached there deliberately.
+ logger.warning("Timed out converting $hiragana, leaving it uncached: ${e.message}")
+ return hiragana
} catch (e: Exception) {
logger.warning("Failed to convert $hiragana: ${e.message}")
hiragana // Use hiragana if API fails
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/SpyPermissionManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/SpyPermissionManager.kt
index 2e50081..20dd819 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/SpyPermissionManager.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/SpyPermissionManager.kt
@@ -20,30 +20,29 @@ object SpyPermissionManager : Listener {
private val directMessageSpyPlayers: ConcurrentHashMap<UUID, Player> = ConcurrentHashMap()
/**
- * Gets all players with spy permission as a map of UUID to Player.
- */
- fun getDirectMessageSpyPlayers(): Map<UUID, Player> = directMessageSpyPlayers.toMap()
-
- /**
* Sends a copy of a message to every online spy that [exclude] does not reject.
*
- * [message] is only invoked when someone will actually read the result, and the "you are
- * seeing this because you have spy permission" hover is built once for the whole audience
- * rather than per recipient. Spies are rare, so both matter on the message path.
+ * [noticeText], [exclude] and [message] are only invoked when someone will actually read the
+ * result, and the "you are seeing this because you have spy permission" hover is built once for
+ * the whole audience rather than per recipient. Spies are rare, so all of that matters on the
+ * message path - callers otherwise pay a translation lookup, and channel chat an O(members) set,
+ * for an audience that is usually empty.
*
* @param noticeText Text shown on hover, explaining why the reader is seeing the message
* @param exclude Rejects players who are party to the message already
* @param message Builds the message body
*/
fun notifySpies(
- noticeText: String,
+ noticeText: () -> String,
exclude: (Player) -> Boolean,
message: () -> Component,
) {
+ if (directMessageSpyPlayers.isEmpty()) return
+
val spies = directMessageSpyPlayers.values.filter { it.isOnline && !exclude(it) }
if (spies.isEmpty()) return
- val withNotice = message().hoverEvent(HoverEvent.showText(Component.text(noticeText)))
+ val withNotice = message().hoverEvent(HoverEvent.showText(Component.text(noticeText())))
spies.forEach { it.sendMessage(withNotice) }
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerSettingsManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerSettingsManager.kt
index 110390d..2821f6f 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerSettingsManager.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerSettingsManager.kt
@@ -2,8 +2,10 @@ package dev.m1sk9.lunaticChat.paper.settings
import dev.m1sk9.lunaticChat.engine.settings.PlayerChatSettings
import dev.m1sk9.lunaticChat.engine.settings.PlayerSettingsData
+import dev.m1sk9.lunaticChat.paper.StoppableService
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicBoolean
import java.util.logging.Logger
/**
@@ -16,8 +18,9 @@ import java.util.logging.Logger
class PlayerSettingsManager(
private val storage: YamlPlayerSettingsStorage,
private val logger: Logger,
-) {
+) : StoppableService {
private val settings = ConcurrentHashMap<UUID, PlayerChatSettings>()
+ private val dirty = AtomicBoolean(false)
// Written back unchanged: nothing migrates on it yet, but rewriting the file must not
// silently relabel a schema this build does not understand.
@@ -61,17 +64,22 @@ class PlayerSettingsManager(
*/
fun updateSettings(settings: PlayerChatSettings) {
this.settings[settings.uuid] = settings
+ dirty.set(true)
storage.queueAsyncSave(::snapshot)
logger.fine("Updated settings for player ${settings.uuid}")
}
/**
- * Queues a debounced asynchronous save without changing any setting.
+ * Queues a debounced asynchronous save, unless nothing has changed since the last write.
*
* Used where the caller wants what is already in memory flushed soon - a player leaving, say -
- * rather than paying for a write it does not need.
+ * rather than paying for a write it does not need. [updateSettings] is the only thing that
+ * changes a setting, and it queues its own save, so a quit almost always has nothing to persist:
+ * without the guard every quit re-serialized every player ever stored in the file to write bytes
+ * identical to the ones already there.
*/
fun queueSave() {
+ if (!dirty.get()) return
storage.queueAsyncSave(::snapshot)
}
@@ -85,11 +93,17 @@ class PlayerSettingsManager(
storage.saveToDisk(snapshot())
}
- private fun snapshot(): PlayerSettingsData =
- PlayerSettingsData(
+ override fun stop() = saveToDisk()
+
+ private fun snapshot(): PlayerSettingsData {
+ // Cleared where the snapshot is taken rather than after the write: a change made while the
+ // write is in flight must leave the flag set so the next queueSave still fires.
+ dirty.set(false)
+ return PlayerSettingsData(
version = schemaVersion,
japaneseConversion = settings.mapValues { it.value.japaneseConversionEnabled },
directMessageNotification = settings.mapValues { it.value.directMessageNotificationEnabled },
channelMessageNotification = settings.mapValues { it.value.channelMessageNotificationEnabled },
)
+ }
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/YamlPlayerSettingsStorage.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/YamlPlayerSettingsStorage.kt
index 6e28f6f..86c1a94 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/YamlPlayerSettingsStorage.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/YamlPlayerSettingsStorage.kt
@@ -2,24 +2,17 @@ package dev.m1sk9.lunaticChat.paper.settings
import com.charleskorn.kaml.Yaml
import dev.m1sk9.lunaticChat.engine.settings.PlayerSettingsData
-import dev.m1sk9.lunaticChat.paper.DebouncedSaver
-import dev.m1sk9.lunaticChat.paper.writeTextAtomically
-import java.nio.file.Path
+import dev.m1sk9.lunaticChat.paper.storage.FileStore
import java.util.logging.Logger
-import kotlin.io.path.bufferedReader
-import kotlin.io.path.exists
/**
- * Handles YAML file I/O operations for player settings.
- * Provides async save with debouncing.
+ * Handles YAML serialization for player settings.
*
- * @property settingsFile The path to the YAML settings file
- * @property saver Coalesces bursts of save requests into one asynchronous write
+ * @property store The file settings are read from and written to
* @property logger The logger for logging operations
*/
class YamlPlayerSettingsStorage(
- private val settingsFile: Path,
- private val saver: DebouncedSaver,
+ private val store: FileStore,
private val logger: Logger,
) {
private val yaml = Yaml.default
@@ -31,13 +24,13 @@ class YamlPlayerSettingsStorage(
* @return The loaded settings or empty settings if file doesn't exist
*/
fun loadFromDisk(): PlayerSettingsData {
- if (!settingsFile.exists()) {
- logger.info("Settings file not found, will create on first save")
- return PlayerSettingsData()
- }
+ val yamlContent =
+ store.read() ?: run {
+ logger.info("Settings file not found, will create on first save")
+ return PlayerSettingsData()
+ }
return try {
- val yamlContent = settingsFile.bufferedReader().use { it.readText() }
yaml.decodeFromString(PlayerSettingsData.serializer(), yamlContent)
} catch (e: Exception) {
logger.severe("Failed to load settings file: ${e.message}")
@@ -50,15 +43,11 @@ class YamlPlayerSettingsStorage(
* Saves player settings to the YAML file synchronously.
* This should only be called from async context or during shutdown.
*
- * A failed write leaves the previous file untouched: loading falls back to empty settings when
- * the YAML does not parse, so a torn file would silently discard every player's settings.
- *
* @param data The settings data to save
*/
fun saveToDisk(data: PlayerSettingsData) {
try {
- val yamlContent = yaml.encodeToString(PlayerSettingsData.serializer(), data)
- settingsFile.writeTextAtomically(yamlContent)
+ store.write(yaml.encodeToString(PlayerSettingsData.serializer(), data))
logger.fine("Saved player settings to disk")
} catch (e: Exception) {
logger.severe("Failed to save settings: ${e.message}")
@@ -66,14 +55,13 @@ class YamlPlayerSettingsStorage(
}
/**
- * Queues an async save operation with 5-second debouncing.
- * Multiple save requests within 5 seconds are batched into a single save.
+ * Queues a debounced asynchronous save.
*
* @param data Supplies the settings to write. It is called when the write runs rather than
* when it is queued, so the batched write persists every change made during the delay - not
* just the one that started it.
*/
fun queueAsyncSave(data: () -> PlayerSettingsData) {
- saver.request { saveToDisk(data()) }
+ store.queueWrite { yaml.encodeToString(PlayerSettingsData.serializer(), data()) }
}
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/storage/AsyncScheduler.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/storage/AsyncScheduler.kt
new file mode 100644
index 0000000..e458008
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/storage/AsyncScheduler.kt
@@ -0,0 +1,15 @@
+package dev.m1sk9.lunaticChat.paper.storage
+
+/**
+ * Runs a task off the tick thread after a delay.
+ *
+ * An interface rather than the Bukkit scheduler directly so that persistence can be exercised
+ * without a running server: the debounce is a rule about when writes happen, and asserting it
+ * through a mocked plugin proved nothing about the rule.
+ */
+fun interface AsyncScheduler {
+ fun runDelayed(
+ delaySeconds: Long,
+ task: () -> Unit,
+ )
+}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/AtomicWrite.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/storage/AtomicWrite.kt
index c3cd28b..3029598 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/AtomicWrite.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/storage/AtomicWrite.kt
@@ -1,4 +1,4 @@
-package dev.m1sk9.lunaticChat.paper
+package dev.m1sk9.lunaticChat.paper.storage
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
@@ -15,7 +15,7 @@ import kotlin.io.path.writeText
* reason: a fixed sibling would only move the interleaving from the destination to the temporary
* file, and the losing move would then fail with it already gone.
*/
-fun Path.writeTextAtomically(content: String) {
+internal fun Path.writeTextAtomically(content: String) {
val temporaryFile = Files.createTempFile(parent, fileName.toString(), ".tmp")
try {
temporaryFile.writeText(content)
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/DebouncedSaver.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/storage/DebouncedSaver.kt
index bc31785..5057575 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/DebouncedSaver.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/storage/DebouncedSaver.kt
@@ -1,7 +1,5 @@
-package dev.m1sk9.lunaticChat.paper
+package dev.m1sk9.lunaticChat.paper.storage
-import org.bukkit.plugin.java.JavaPlugin
-import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
/**
@@ -10,9 +8,13 @@ import java.util.concurrent.atomic.AtomicBoolean
* The first [request] after an idle period schedules the write [delaySeconds] later; requests
* arriving before it fires are absorbed by it, so a player toggling a setting repeatedly costs one
* file write rather than one per toggle.
+ *
+ * A request arriving while a write is pending is dropped rather than queued, so one saver serves
+ * exactly one file - sharing it would silently lose the other file's save. [FileStore] owns one
+ * each so that rule cannot be broken by wiring.
*/
class DebouncedSaver(
- private val plugin: JavaPlugin,
+ private val scheduler: AsyncScheduler,
private val delaySeconds: Long = 5,
) {
private val pending = AtomicBoolean(false)
@@ -23,14 +25,9 @@ class DebouncedSaver(
fun request(save: () -> Unit) {
if (!pending.compareAndSet(false, true)) return
- plugin.server.asyncScheduler.runDelayed(
- plugin,
- {
- pending.set(false)
- save()
- },
- delaySeconds,
- TimeUnit.SECONDS,
- )
+ scheduler.runDelayed(delaySeconds) {
+ pending.set(false)
+ save()
+ }
}
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/storage/FileStore.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/storage/FileStore.kt
new file mode 100644
index 0000000..f57cfde
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/storage/FileStore.kt
@@ -0,0 +1,60 @@
+package dev.m1sk9.lunaticChat.paper.storage
+
+import java.nio.file.Path
+import java.util.logging.Level
+import java.util.logging.Logger
+import kotlin.io.path.bufferedReader
+import kotlin.io.path.exists
+
+/**
+ * One persisted file, written whole and written atomically.
+ *
+ * Durability lives here rather than at each write site: a file added later is written atomically
+ * because it is a [FileStore], not because its author remembered to reach for the right helper.
+ *
+ * Each store also owns its own [DebouncedSaver] rather than accepting one, because a saver drops a
+ * request while a write is pending and so serves exactly one file. That rule used to hold only
+ * because the wiring happened to construct a separate saver per file.
+ *
+ * Decoding is left to the caller: the stores differ in what an unreadable file means - channels fail
+ * loudly, settings fall back to empty - and that is a policy the file cannot know.
+ */
+class FileStore(
+ private val file: Path,
+ scheduler: AsyncScheduler,
+ private val logger: Logger,
+ debounceSeconds: Long = 5,
+) {
+ private val saver = DebouncedSaver(scheduler, debounceSeconds)
+
+ /** The file's name, for log messages that tell the operator which file went wrong. */
+ val name: String get() = file.fileName.toString()
+
+ /** Reads the file, or returns null when it is not there yet. */
+ fun read(): String? {
+ if (!file.exists()) return null
+ return file.bufferedReader().use { it.readText() }
+ }
+
+ /** Replaces the file with [contents] so nothing ever reads a half-written file. */
+ fun write(contents: String) = file.writeTextAtomically(contents)
+
+ /**
+ * Queues a debounced asynchronous write.
+ *
+ * A failure is reported rather than thrown: there is no caller left to hand it to by the time the
+ * write runs, and because the write is atomic the previous file is still intact, so the next save
+ * simply tries again.
+ *
+ * @param contents Supplies what to write. It is called when the write runs rather than when it is
+ * queued, so a burst of changes costs one snapshot and one file write instead of one of each.
+ */
+ fun queueWrite(contents: () -> String) =
+ saver.request {
+ try {
+ write(contents())
+ } catch (e: Exception) {
+ logger.log(Level.SEVERE, "Failed to save $name", e)
+ }
+ }
+}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt
index 8328ad8..574cb30 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt
@@ -7,7 +7,6 @@ import dev.m1sk9.lunaticChat.paper.chat.handler.DirectMessageHandler
import dev.m1sk9.lunaticChat.paper.config.LunaticChatConfiguration
import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager
import dev.m1sk9.lunaticChat.paper.i18n.MessageFormatter
-import kotlinx.coroutines.CancellationException
import org.bukkit.entity.Player
import org.bukkit.plugin.Plugin
import java.util.UUID
@@ -40,6 +39,11 @@ class CrossServerDirectMessageManager(
* goes through may wait on the Google IME API. The reply target is recorded by the command
* before the work is queued; the sender-side display and the spy notification are handled by
* [DirectMessageHandler], and the (possibly romaji-converted) body is what gets relayed.
+ *
+ * Failures are deliberately not caught here. The delivery queue is the error boundary for
+ * queued work and already reports them without stopping the sender's later messages; a second
+ * boundary underneath it only obscured where failures on this path are handled - and made an
+ * ordinary cancellation at shutdown look like a delivery failure.
*/
suspend fun sendCrossServerMessage(
sender: Player,
@@ -47,40 +51,32 @@ class CrossServerDirectMessageManager(
targetServerName: String,
message: String,
) {
- try {
- val messageId = UUID.randomUUID().toString()
- processedMessages.markProcessed(messageId)
+ val messageId = UUID.randomUUID().toString()
+ processedMessages.markProcessed(messageId)
- val relayedMessage =
- directMessageHandler.handleOutgoingCrossServerMessage(
- sender = sender,
- targetName = targetName,
- targetServerName = targetServerName,
- message = message,
- )
+ val relayedMessage =
+ directMessageHandler.handleOutgoingCrossServerMessage(
+ sender = sender,
+ targetName = targetName,
+ targetServerName = targetServerName,
+ message = message,
+ )
- val relay =
- PluginMessage.DirectMessageRelay(
- messageId = messageId,
- sourceServerName = configuration.features.velocityIntegration.serverName,
- senderId = sender.uniqueId.toString(),
- senderName = sender.name,
- targetServerName = targetServerName,
- targetName = targetName,
- message = relayedMessage,
- )
+ val relay =
+ PluginMessage.DirectMessageRelay(
+ messageId = messageId,
+ sourceServerName = configuration.features.velocityIntegration.serverName,
+ senderId = sender.uniqueId.toString(),
+ senderName = sender.name,
+ targetServerName = targetServerName,
+ targetName = targetName,
+ message = relayedMessage,
+ )
- sender.sendPluginMessage(plugin, PluginMessageChannel.ID, PluginMessageCodec.encode(relay))
- logger.fine {
- "Sent direct message to Velocity: messageId=$messageId, " +
- "target=$targetName@$targetServerName"
- }
- } catch (e: CancellationException) {
- // Shutdown cancelling the delivery queue is not a delivery failure, and reporting it as
- // SEVERE while carrying on past the cancellation would be wrong twice over.
- throw e
- } catch (e: Exception) {
- logger.log(Level.SEVERE, "Failed to send cross-server direct message", e)
+ sender.sendPluginMessage(plugin, PluginMessageChannel.ID, PluginMessageCodec.encode(relay))
+ logger.fine {
+ "Sent direct message to Velocity: messageId=$messageId, " +
+ "target=$targetName@$targetServerName"
}
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/VelocityConnectionManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/VelocityConnectionManager.kt
index bdedb6f..85e2827 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/VelocityConnectionManager.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/VelocityConnectionManager.kt
@@ -4,6 +4,7 @@ import dev.m1sk9.lunaticChat.engine.protocol.PluginMessage
import dev.m1sk9.lunaticChat.engine.protocol.PluginMessageChannel
import dev.m1sk9.lunaticChat.engine.protocol.PluginMessageCodec
import dev.m1sk9.lunaticChat.engine.protocol.ProtocolVersion
+import dev.m1sk9.lunaticChat.paper.StoppableService
import org.bukkit.entity.Player
import org.bukkit.plugin.Plugin
import org.bukkit.plugin.messaging.PluginMessageListener
@@ -20,7 +21,8 @@ class VelocityConnectionManager(
private var crossServerChatManager: CrossServerChatManager? = null,
private var crossServerDirectMessageManager: CrossServerDirectMessageManager? = null,
private var remotePlayerRegistry: RemotePlayerRegistry? = null,
-) : PluginMessageListener {
+) : StoppableService,
+ PluginMessageListener {
companion object {
private val CHANNEL = PluginMessageChannel.ID
private const val HANDSHAKE_TIMEOUT_SECONDS = 5L
@@ -306,7 +308,7 @@ class VelocityConnectionManager(
/**
* Shutdown
*/
- fun shutdown() {
+ override fun stop() {
plugin.server.messenger.unregisterOutgoingPluginChannel(plugin, CHANNEL)
plugin.server.messenger.unregisterIncomingPluginChannel(plugin, CHANNEL)
logger.info("Velocity integration channel unregistered")
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
index 2d96cba..d9919d4 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/PerPlayerWorkQueueTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/PerPlayerWorkQueueTest.kt
@@ -1,6 +1,5 @@
package dev.m1sk9.lunaticChat.paper
-import dev.m1sk9.lunaticChat.paper.TestUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
@@ -17,6 +16,11 @@ class PerPlayerWorkQueueTest {
private val alice = UUID.fromString("00000001-0000-0000-0000-000000000000")
private val bob = UUID.fromString("00000002-0000-0000-0000-000000000000")
+ private fun createQueue(
+ logger: TestUtils.TestLogger = TestUtils.TestLogger(),
+ scope: CoroutineScope = CoroutineScope(Dispatchers.Default),
+ ) = PerPlayerWorkQueue(scope, logger)
+
private suspend fun awaitSize(
completed: ConcurrentLinkedQueue<String>,
expected: Int,
@@ -29,7 +33,7 @@ class PerPlayerWorkQueueTest {
@Test
fun `a player's work runs in submission order even when later work is faster`() =
runBlocking {
- val queue = PerPlayerWorkQueue(CoroutineScope(Dispatchers.Default), TestUtils.TestLogger())
+ val queue = createQueue()
val completed = ConcurrentLinkedQueue<String>()
// The regression this guards: a cached conversion overtaking an uncached one sent
@@ -47,7 +51,7 @@ class PerPlayerWorkQueueTest {
@Test
fun `one player's slow work does not hold up another player`() =
runBlocking {
- val queue = PerPlayerWorkQueue(CoroutineScope(Dispatchers.Default), TestUtils.TestLogger())
+ val queue = createQueue()
val completed = ConcurrentLinkedQueue<String>()
queue.submit(alice) {
@@ -62,7 +66,7 @@ class PerPlayerWorkQueueTest {
@Test
fun `submit does not block the caller`() {
- val queue = PerPlayerWorkQueue(CoroutineScope(Dispatchers.Default), TestUtils.TestLogger())
+ val queue = createQueue()
val started = ConcurrentLinkedQueue<String>()
queue.submit(alice) {
@@ -75,25 +79,28 @@ class PerPlayerWorkQueueTest {
}
@Test
- fun `work already queued still runs after the player is released`() =
+ fun `work still queued when the player is released is abandoned`() =
runBlocking {
- val queue = PerPlayerWorkQueue(CoroutineScope(Dispatchers.Default), TestUtils.TestLogger())
+ val queue = createQueue()
val completed = ConcurrentLinkedQueue<String>()
+ // Delivering it would spend a conversion round trip to write to a player who has left,
+ // and hold both them and their recipient alive until the backlog drained.
queue.submit(alice) {
- delay(50)
- completed.add("in-flight")
+ delay(5_000)
+ completed.add("first")
}
+ queue.submit(alice) { completed.add("behind-it") }
queue.release(alice)
- awaitSize(completed, 1)
- assertEquals(listOf("in-flight"), completed.toList())
+ delay(200)
+ assertTrue(completed.isEmpty())
}
@Test
fun `a released player gets a fresh queue if they come back`() =
runBlocking {
- val queue = PerPlayerWorkQueue(CoroutineScope(Dispatchers.Default), TestUtils.TestLogger())
+ val queue = createQueue()
val completed = ConcurrentLinkedQueue<String>()
queue.submit(alice) { completed.add("before") }
@@ -107,9 +114,10 @@ class PerPlayerWorkQueueTest {
}
@Test
- fun `a failed item does not stop the player's later work`() =
+ fun `a failed item is reported and does not stop the player's later work`() =
runBlocking {
- val queue = PerPlayerWorkQueue(CoroutineScope(Dispatchers.Default), TestUtils.TestLogger())
+ val logger = TestUtils.TestLogger()
+ val queue = createQueue(logger)
val completed = ConcurrentLinkedQueue<String>()
// Without a guard around each item, the throw would end the consumer loop while its
@@ -120,19 +128,6 @@ class PerPlayerWorkQueueTest {
awaitSize(completed, 1)
assertEquals(listOf("after-failure"), completed.toList())
- }
-
- @Test
- fun `a failed item is reported rather than swallowed`() =
- runBlocking {
- val logger = TestUtils.TestLogger()
- val queue = PerPlayerWorkQueue(CoroutineScope(Dispatchers.Default), logger)
- val completed = ConcurrentLinkedQueue<String>()
-
- queue.submit(alice) { error("delivery blew up") }
- queue.submit(alice) { completed.add("done") }
- awaitSize(completed, 1)
-
assertTrue(logger.severeMessages.any { it.contains("Queued work failed") })
}
@@ -140,13 +135,33 @@ class PerPlayerWorkQueueTest {
fun `work submitted after shutdown is reported rather than silently dropped`() {
val logger = TestUtils.TestLogger()
val scope = CoroutineScope(Dispatchers.Default)
- val queue = PerPlayerWorkQueue(scope, logger)
+ val queue = createQueue(logger, scope)
val completed = ConcurrentLinkedQueue<String>()
scope.cancel()
queue.submit(alice) { completed.add("never") }
assertTrue(completed.isEmpty())
- assertTrue(logger.warningMessages.any { it.contains("their queue is closed") })
+ assertTrue(logger.warningMessages.any { it.contains("full or closed") })
}
+
+ @Test
+ fun `a player who outruns delivery is refused rather than queued without limit`() =
+ runBlocking {
+ val logger = TestUtils.TestLogger()
+ val queue = createQueue(logger)
+ val completed = ConcurrentLinkedQueue<String>()
+
+ // The first item occupies the worker, so everything after it fills the buffer. An
+ // unbounded queue accepted all of them and delivered them minutes later.
+ repeat(64) { index ->
+ queue.submit(alice) {
+ delay(5_000)
+ completed.add("item-$index")
+ }
+ }
+
+ assertTrue(logger.warningMessages.any { it.contains("full or closed") })
+ assertTrue(completed.isEmpty())
+ }
}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/ServiceShutdownTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/ServiceShutdownTest.kt
new file mode 100644
index 0000000..58c1e30
--- /dev/null
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/ServiceShutdownTest.kt
@@ -0,0 +1,67 @@
+package dev.m1sk9.lunaticChat.paper
+
+import io.mockk.mockk
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class ServiceShutdownTest {
+ private class RecordingService(
+ private val name: String,
+ private val stopped: MutableList<String>,
+ private val failing: Boolean = false,
+ ) : StoppableService {
+ override fun stop() {
+ stopped.add(name)
+ if (failing) error("$name could not be stopped")
+ }
+ }
+
+ private fun shutdown(stoppables: List<StoppableService>): TestUtils.TestLogger {
+ val logger = TestUtils.TestLogger()
+ val initializer = ServiceInitializer(mockk(relaxed = true), TestUtils.createTestConfiguration(), lazy { mockk() }, logger)
+ initializer.shutdown(
+ ServiceContainer(
+ languageManager = mockk(),
+ playerSettingsManager = mockk(),
+ directMessageHandler = mockk(),
+ stoppables = stoppables,
+ ),
+ )
+ return logger
+ }
+
+ @Test
+ fun `every service is stopped, in the order it was registered`() {
+ val stopped = mutableListOf<String>()
+
+ shutdown(
+ listOf(
+ RecordingService("settings", stopped),
+ RecordingService("cache", stopped),
+ RecordingService("velocity", stopped),
+ ),
+ )
+
+ assertEquals(listOf("settings", "cache", "velocity"), stopped)
+ }
+
+ @Test
+ fun `a service that fails to stop is reported and does not skip the rest`() {
+ val stopped = mutableListOf<String>()
+
+ // An exception escaping onDisable left the log flusher and the proxy connection to be torn
+ // down by the server instead of by us.
+ val logger =
+ shutdown(
+ listOf(
+ RecordingService("settings", stopped, failing = true),
+ RecordingService("logger", stopped),
+ RecordingService("velocity", stopped),
+ ),
+ )
+
+ assertEquals(listOf("settings", "logger", "velocity"), stopped)
+ assertTrue(logger.severeMessages.any { it.contains("Failed to stop") })
+ }
+}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt
index 4bca21f..420391f 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt
@@ -13,6 +13,7 @@ import dev.m1sk9.lunaticChat.paper.config.key.MessageFormatConfig
import dev.m1sk9.lunaticChat.paper.config.key.QuickRepliesFeatureConfig
import dev.m1sk9.lunaticChat.paper.config.key.VelocityIntegrationConfig
import dev.m1sk9.lunaticChat.paper.i18n.Language
+import dev.m1sk9.lunaticChat.paper.storage.AsyncScheduler
import io.mockk.mockk
import org.bukkit.entity.Player
import org.bukkit.plugin.java.JavaPlugin
@@ -222,4 +223,30 @@ object TestUtils {
throw AssertionError("Expected list to contain at least one matching item")
}
}
+
+ /**
+ * An [AsyncScheduler] that holds the work until a test releases it.
+ *
+ * Lets a test assert what the debounce actually does - that the snapshot is taken when the write
+ * runs, not when it was queued - which mocking the saver could only assume.
+ */
+ class ManualScheduler : AsyncScheduler {
+ private val pending = mutableListOf<() -> Unit>()
+
+ val pendingCount: Int get() = pending.size
+
+ override fun runDelayed(
+ delaySeconds: Long,
+ task: () -> Unit,
+ ) {
+ pending.add(task)
+ }
+
+ /** Runs everything scheduled so far, as the server's async scheduler eventually would. */
+ fun runPending() {
+ val due = pending.toList()
+ pending.clear()
+ due.forEach { it() }
+ }
+ }
}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMembershipManagerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMembershipManagerTest.kt
index 02c4d1a..f0c41c9 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMembershipManagerTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMembershipManagerTest.kt
@@ -382,7 +382,7 @@ class ChannelMembershipManagerTest {
channelManager.setPlayerChannel(playerId, null)
membership.joinChannel(playerId, "ch2")
- val channels = membership.getPlayerChannels(playerId).getOrThrow()
+ val channels = membership.getPlayerChannels(playerId)
assertEquals(2, channels.size)
assertTrue(channels.contains("ch1"))
assertTrue(channels.contains("ch2"))
@@ -495,6 +495,6 @@ class ChannelMembershipManagerTest {
channelManager.deleteChannel("drop-ch", ownerId)
- assertEquals(listOf("keep-ch"), membership.getPlayerChannels(playerId).getOrThrow())
+ assertEquals(listOf("keep-ch"), membership.getPlayerChannels(playerId))
}
}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelStorageTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelStorageTest.kt
index 2211eef..d35f9c8 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelStorageTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelStorageTest.kt
@@ -5,12 +5,9 @@ import dev.m1sk9.lunaticChat.engine.chat.channel.ChannelData
import dev.m1sk9.lunaticChat.engine.chat.channel.ChannelMember
import dev.m1sk9.lunaticChat.engine.chat.channel.ChannelRole
import dev.m1sk9.lunaticChat.engine.exception.ChannelStorageLoadException
-import dev.m1sk9.lunaticChat.paper.DebouncedSaver
import dev.m1sk9.lunaticChat.paper.TestUtils
-import io.mockk.mockk
-import io.mockk.slot
-import io.mockk.verify
-import java.nio.file.Files
+import dev.m1sk9.lunaticChat.paper.storage.FileStore
+import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import java.util.UUID
import kotlin.io.path.listDirectoryEntries
@@ -42,19 +39,19 @@ class ChannelStorageTest {
activeChannels = mapOf(owner.toString() to "general"),
)
- private fun withStorage(block: (ChannelStorage, Path) -> Unit) {
- val directory = Files.createTempDirectory("channel-storage-test")
- try {
- val channelsFile = directory.resolve("channels.json")
- block(ChannelStorage(channelsFile, mockk(relaxed = true), TestUtils.TestLogger()), channelsFile)
- } finally {
- directory.toFile().deleteRecursively()
- }
+ @TempDir
+ lateinit var directory: Path
+
+ private fun withStorage(block: (ChannelStorage, Path, TestUtils.ManualScheduler) -> Unit) {
+ val channelsFile = directory.resolve("channels.json")
+ val scheduler = TestUtils.ManualScheduler()
+ val store = FileStore(channelsFile, scheduler, TestUtils.TestLogger())
+ block(ChannelStorage(store, TestUtils.TestLogger()), channelsFile, scheduler)
}
@Test
fun `saved data is loaded back unchanged`() =
- withStorage { storage, _ ->
+ withStorage { storage, _, _ ->
val data = sampleData()
storage.saveToDisk(data)
@@ -64,7 +61,7 @@ class ChannelStorageTest {
@Test
fun `saving leaves no temporary file beside the channel file`() =
- withStorage { storage, channelsFile ->
+ withStorage { storage, channelsFile, _ ->
storage.saveToDisk(sampleData())
assertEquals(listOf(channelsFile), channelsFile.parent.listDirectoryEntries())
@@ -72,13 +69,13 @@ class ChannelStorageTest {
@Test
fun `loading a missing file yields empty data rather than failing`() =
- withStorage { storage, _ ->
+ withStorage { storage, _, _ ->
assertEquals(ChannelData(), storage.loadFromDisk())
}
@Test
fun `loading an unparseable file fails loudly`() =
- withStorage { storage, channelsFile ->
+ withStorage { storage, channelsFile, _ ->
channelsFile.writeText("{ this is not json")
assertFailsWith<ChannelStorageLoadException> { storage.loadFromDisk() }
@@ -86,20 +83,15 @@ class ChannelStorageTest {
@Test
fun `unknown fields in the file are ignored`() =
- withStorage { storage, channelsFile ->
+ withStorage { storage, channelsFile, _ ->
channelsFile.writeText("""{"version":1,"channels":{},"members":{},"activeChannels":{},"future":true}""")
assertEquals(ChannelData(), storage.loadFromDisk())
}
@Test
- fun `a queued save reads the data when the write runs, not when it is queued`() {
- val directory = Files.createTempDirectory("channel-storage-test")
- try {
- val channelsFile = directory.resolve("channels.json")
- val saver = mockk<DebouncedSaver>(relaxed = true)
- val storage = ChannelStorage(channelsFile, saver, TestUtils.TestLogger())
- val queued = slot<() -> Unit>()
+ fun `a queued save reads the data when the write runs, not when it is queued`() =
+ withStorage { storage, _, scheduler ->
var supplied = false
storage.queueAsyncSave {
@@ -107,15 +99,12 @@ class ChannelStorageTest {
sampleData()
}
- verify { saver.request(capture(queued)) }
+ assertEquals(1, scheduler.pendingCount)
assertTrue(!supplied, "the snapshot must not be taken while queueing")
- queued.captured.invoke()
+ scheduler.runPending()
assertTrue(supplied)
assertEquals(sampleData(), storage.loadFromDisk())
- } finally {
- directory.toFile().deleteRecursively()
}
- }
}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandlerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandlerTest.kt
index a2ba325..1edef92 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandlerTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandlerTest.kt
@@ -24,8 +24,6 @@ import kotlin.test.assertTrue
* Validates message handling with dependency injection and conversion features.
*/
class DirectMessageHandlerTest {
- private fun <T> sync(block: suspend () -> T): T = runBlocking { block() }
-
private fun createHandler(
configuration: dev.m1sk9.lunaticChat.paper.config.LunaticChatConfiguration? = null,
settingsManager: PlayerSettingsManager? = null,
@@ -43,7 +41,7 @@ class DirectMessageHandlerTest {
val sender = TestUtils.createMockPlayer()
val recipient = TestUtils.createMockPlayer()
- val result = sync { handler.sendDirectMessage(sender, recipient, "Test message") }
+ val result = runBlocking { handler.sendDirectMessage(sender, recipient, "Test message") }
assertTrue(result)
}
@@ -65,7 +63,7 @@ class DirectMessageHandlerTest {
val sender = TestUtils.createMockPlayer(name = "Alice")
val recipient = TestUtils.createMockPlayer(name = "Bob")
- val result = sync { handler.sendDirectMessage(sender, recipient, "Test") }
+ val result = runBlocking { handler.sendDirectMessage(sender, recipient, "Test") }
assertTrue(result)
}
@@ -84,7 +82,7 @@ class DirectMessageHandlerTest {
val sender = TestUtils.createMockPlayer()
val recipient = TestUtils.createMockPlayer()
- val result = sync { handler.sendDirectMessage(sender, recipient, "konnichiwa") }
+ val result = runBlocking { handler.sendDirectMessage(sender, recipient, "konnichiwa") }
assertTrue(result)
}
@@ -110,7 +108,7 @@ class DirectMessageHandlerTest {
val recipient = TestUtils.createMockPlayer()
// Should not throw exception and should complete quickly (within timeout)
- val result = sync { handler.sendDirectMessage(sender, recipient, "konnichiwa") }
+ val result = runBlocking { handler.sendDirectMessage(sender, recipient, "konnichiwa") }
assertTrue(result)
}
@@ -125,7 +123,7 @@ class DirectMessageHandlerTest {
// This validates Issue #1 refactoring - ConfigManager DI
val sender = TestUtils.createMockPlayer()
val recipient = TestUtils.createMockPlayer()
- val result = sync { handler.sendDirectMessage(sender, recipient, "Test") }
+ val result = runBlocking { handler.sendDirectMessage(sender, recipient, "Test") }
assertTrue(result)
}
@@ -139,15 +137,24 @@ class DirectMessageHandlerTest {
}
@Test
- fun `handleOutgoingCrossServerMessage records a remote reply target and returns the body`() {
+ fun `handleOutgoingCrossServerMessage returns the body to relay`() {
+ val handler = createHandler()
+ val sender = TestUtils.createMockPlayer(name = "Alice")
+
+ val relayed = runBlocking { handler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi") }
+
+ assertEquals("hi", relayed)
+ }
+
+ @Test
+ fun `recordRemoteRecipient makes a remote player the reply target`() {
val handler = createHandler()
val registry = RemotePlayerRegistry(localServerName = "lobby")
registry.replaceAll(listOf(PresenceEntry("Bob", "survival")))
handler.remotePlayerRegistry = registry
val sender = TestUtils.createMockPlayer(name = "Alice")
- val relayed = sync { handler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi") }
- assertEquals("hi", relayed)
+ handler.recordRemoteRecipient(sender, "Bob", "survival")
val target = handler.getReplyTarget(sender)
assertIs<ReplyTarget.Remote>(target)
@@ -177,7 +184,7 @@ class DirectMessageHandlerTest {
handler.remotePlayerRegistry = RemotePlayerRegistry(localServerName = "lobby") // empty roster
val sender = TestUtils.createMockPlayer(name = "Alice")
- sync { handler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi") }
+ handler.recordRemoteRecipient(sender, "Bob", "survival")
assertNull(handler.getReplyTarget(sender))
}
@@ -209,7 +216,7 @@ class DirectMessageHandlerTest {
handler.remotePlayerRegistry = registry
val sender = TestUtils.createMockPlayer(name = "Alice")
- sync { handler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi") }
+ handler.recordRemoteRecipient(sender, "Bob", "survival")
handler.clearPlayer(sender)
assertNull(handler.getReplyTarget(sender))
@@ -228,7 +235,7 @@ class DirectMessageHandlerTest {
val sender = TestUtils.createMockPlayer()
val recipient = TestUtils.createMockPlayer()
- val result = sync { handler.sendDirectMessage(sender, recipient, "Test") }
+ val result = runBlocking { handler.sendDirectMessage(sender, recipient, "Test") }
assertTrue(result)
}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommandTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommandTest.kt
index 301160b..bbcfafb 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommandTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommandTest.kt
@@ -1,7 +1,6 @@
package dev.m1sk9.lunaticChat.paper.command.impl.lc.channel
import dev.m1sk9.lunaticChat.engine.command.CommandResult
-import dev.m1sk9.lunaticChat.engine.exception.ChannelNotFoundException
import dev.m1sk9.lunaticChat.paper.LunaticChat
import dev.m1sk9.lunaticChat.paper.TestUtils
import dev.m1sk9.lunaticChat.paper.chat.channel.ChannelManager
@@ -64,7 +63,7 @@ class ChannelStatusCommandTest {
every { deps.channelManager.getPlayerChannel(testUUID) } returns channelId
every { deps.channelManager.getChannel(channelId) } returns Result.success(channel)
every { deps.channelManager.getChannelMembers(channelId) } returns Result.success(members)
- every { deps.membershipManager.getPlayerChannels(testUUID) } returns Result.success(listOf(channelId))
+ every { deps.membershipManager.getPlayerChannels(testUUID) } returns listOf(channelId)
mockkStatic(Bukkit::class)
try {
@@ -83,7 +82,7 @@ class ChannelStatusCommandTest {
val deps = createDependencies()
every { deps.channelManager.getPlayerChannel(testUUID) } returns null
- every { deps.membershipManager.getPlayerChannels(testUUID) } returns Result.success(emptyList())
+ every { deps.membershipManager.getPlayerChannels(testUUID) } returns emptyList()
mockkStatic(Bukkit::class)
try {
@@ -113,7 +112,7 @@ class ChannelStatusCommandTest {
Result.success(
listOf(TestUtils.createTestChannelMember(playerId = testUUID)),
)
- every { deps.membershipManager.getPlayerChannels(testUUID) } returns Result.success(channelIds)
+ every { deps.membershipManager.getPlayerChannels(testUUID) } returns channelIds
mockkStatic(Bukkit::class)
try {
@@ -126,17 +125,4 @@ class ChannelStatusCommandTest {
unmockkStatic(Bukkit::class)
}
}
-
- @Test
- fun `execute should return Failure on error`() {
- val deps = createDependencies()
-
- every { deps.channelManager.getPlayerChannel(testUUID) } returns null
- every { deps.membershipManager.getPlayerChannels(testUUID) } returns
- Result.failure(ChannelNotFoundException("error"))
-
- val result = deps.command.execute(deps.ctx)
-
- assertIs<CommandResult.Failure>(result)
- }
}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt
index d415e97..fc7afc9 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt
@@ -24,15 +24,6 @@ class ConfigManagerTest {
}.bufferedReader().use { it.readText() }
@Test
- fun `the bundled config parses`() {
- val config = load(bundledConfig)
-
- assertFalse(config.debug)
- assertEquals("player-settings.yaml", config.userSettingsFilePath)
- assertEquals(Language.EN, config.language)
- }
-
- @Test
fun `the bundled config agrees with the declared defaults`() {
// The data class is meant to be the single source of every default. If config.yml ships a
// different value for a key, one of the two is lying to the operator.
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCacheTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCacheTest.kt
index b76c050..5041550 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCacheTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCacheTest.kt
@@ -1,6 +1,8 @@
package dev.m1sk9.lunaticChat.paper.converter
import dev.m1sk9.lunaticChat.paper.TestUtils
+import dev.m1sk9.lunaticChat.paper.storage.FileStore
+import org.junit.jupiter.api.io.TempDir
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.listDirectoryEntries
@@ -13,19 +15,19 @@ import kotlin.test.assertNull
import kotlin.test.assertTrue
class ConversionCacheTest {
- private fun withCacheFile(block: (Path) -> Unit) {
- val directory = Files.createTempDirectory("conversion-cache-test")
- try {
- block(directory.resolve("cache.json"))
- } finally {
- directory.toFile().deleteRecursively()
- }
- }
+ @TempDir
+ lateinit var directory: Path
+
+ private fun withCacheFile(block: (Path) -> Unit) = block(directory.resolve("cache.json"))
private fun createCache(
cacheFile: Path,
maxEntries: Int = 500,
- ) = ConversionCache(cacheFile, maxEntries, TestUtils.TestLogger())
+ ) = ConversionCache(
+ FileStore(cacheFile, TestUtils.ManualScheduler(), TestUtils.TestLogger()),
+ maxEntries,
+ TestUtils.TestLogger(),
+ )
@Test
fun `entries survive a save and reload`() =
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverterTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverterTest.kt
index c8fe680..c8314d7 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverterTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverterTest.kt
@@ -7,13 +7,16 @@ import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertIsNot
import kotlin.test.assertNull
+import kotlin.time.Duration.Companion.seconds
/**
* Tests for RomanjiConverter.
@@ -291,6 +294,47 @@ class RomanjiConverterTest {
// ===== API error handling =====
@Test
+ fun `a conversion timeout is not cancellation`() {
+ // Reported through the cancellation channel, a slow request reached the delivery queue
+ // looking like shutdown and ended the worker, so the sender's later messages were buffered
+ // and never delivered.
+ assertIsNot<CancellationException>(ConversionTimeoutException(1.seconds))
+ }
+
+ @Test
+ fun `an API timeout degrades to hiragana without caching it`() =
+ runBlocking {
+ val (converter, cache, apiClient) = createConverter()
+ coEvery { apiClient.convert("おはよう") } throws ConversionTimeoutException(1.seconds)
+
+ val result = converter.convert("ohayou")
+
+ assertEquals("おはよう", result)
+ // A slow reply says nothing about the word, so caching the hiragana would pin it to its
+ // unconverted form for the life of the cache.
+ verify(exactly = 0) { cache.put(any(), any()) }
+ }
+
+ @Test
+ fun `a word that timed out is converted on the next attempt`() =
+ runBlocking {
+ // A cache that actually remembers, unlike the shared fixture whose get() always returns
+ // null - which would let this pass even if the timeout had been cached.
+ val entries = mutableMapOf<String, String>()
+ val cache = mockk<ConversionCache>(relaxed = true)
+ every { cache.get(any()) } answers { entries[firstArg()] }
+ every { cache.put(any(), any()) } answers { entries[firstArg()] = secondArg() }
+ val apiClient = mockk<GoogleIMEClient>(relaxed = true)
+ val converter = RomanjiConverter(cache, apiClient, TestUtils.TestLogger())
+
+ coEvery { apiClient.convert("おはよう") } throws ConversionTimeoutException(1.seconds)
+ converter.convert("ohayou")
+ coEvery { apiClient.convert("おはよう") } returns "おはよう御座います"
+
+ assertEquals("おはよう御座います", converter.convert("ohayou"))
+ }
+
+ @Test
fun `API failure should fallback to hiragana`() =
runBlocking {
val (converter, cache, apiClient) = createConverter()
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerSettingsManagerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerSettingsManagerTest.kt
index 3c34295..db59cf0 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerSettingsManagerTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerSettingsManagerTest.kt
@@ -4,6 +4,7 @@ import dev.m1sk9.lunaticChat.engine.settings.PlayerChatSettings
import dev.m1sk9.lunaticChat.engine.settings.PlayerSettingsData
import dev.m1sk9.lunaticChat.paper.TestUtils
import dev.m1sk9.lunaticChat.paper.TestUtils.createTestUUID
+import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
@@ -157,6 +158,8 @@ class PlayerSettingsManagerTest {
fun `queueSave should not write on the calling thread`() {
val (manager, storage, _) = createManager()
manager.initialize()
+ manager.updateSettings(PlayerChatSettings(uuid = createTestUUID(1), japaneseConversionEnabled = false))
+ clearMocks(storage, answers = false)
manager.queueSave()
@@ -165,6 +168,19 @@ class PlayerSettingsManagerTest {
}
@Test
+ fun `queueSave should not write when nothing has changed`() {
+ val (manager, storage, _) = createManager()
+ manager.initialize()
+ clearMocks(storage, answers = false)
+
+ manager.queueSave()
+
+ // updateSettings is the only thing that changes a setting and it queues its own save, so a
+ // quit would otherwise re-serialize every stored player to write identical bytes.
+ verify(exactly = 0) { storage.queueAsyncSave(any()) }
+ }
+
+ @Test
fun `multiple players should have independent settings`() {
val (manager, _, _) = createManager()
manager.initialize()
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/settings/YamlPlayerSettingsStorageTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/settings/YamlPlayerSettingsStorageTest.kt
new file mode 100644
index 0000000..594e475
--- /dev/null
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/settings/YamlPlayerSettingsStorageTest.kt
@@ -0,0 +1,109 @@
+package dev.m1sk9.lunaticChat.paper.settings
+
+import dev.m1sk9.lunaticChat.engine.settings.PlayerSettingsData
+import dev.m1sk9.lunaticChat.paper.TestUtils
+import dev.m1sk9.lunaticChat.paper.storage.FileStore
+import org.junit.jupiter.api.io.TempDir
+import java.nio.file.Path
+import java.util.UUID
+import kotlin.io.path.listDirectoryEntries
+import kotlin.io.path.readText
+import kotlin.io.path.writeText
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class YamlPlayerSettingsStorageTest {
+ private val alice = UUID.fromString("00000001-0000-0000-0000-000000000000")
+ private val bob = UUID.fromString("00000002-0000-0000-0000-000000000000")
+
+ @TempDir
+ lateinit var directory: Path
+
+ private fun sampleData() =
+ PlayerSettingsData(
+ japaneseConversion = mapOf(alice to true, bob to false),
+ directMessageNotification = mapOf(alice to false),
+ channelMessageNotification = mapOf(bob to true),
+ )
+
+ private fun withStorage(block: (YamlPlayerSettingsStorage, Path, TestUtils.ManualScheduler, TestUtils.TestLogger) -> Unit) {
+ val settingsFile = directory.resolve("player-settings.yaml")
+ val scheduler = TestUtils.ManualScheduler()
+ val logger = TestUtils.TestLogger()
+ val store = FileStore(settingsFile, scheduler, logger)
+ block(YamlPlayerSettingsStorage(store, logger), settingsFile, scheduler, logger)
+ }
+
+ @Test
+ fun `saved settings are loaded back unchanged`() =
+ withStorage { storage, _, _, _ ->
+ val data = sampleData()
+
+ storage.saveToDisk(data)
+
+ assertEquals(data, storage.loadFromDisk())
+ }
+
+ @Test
+ fun `saving leaves no temporary file beside the settings file`() =
+ withStorage { storage, settingsFile, _, _ ->
+ storage.saveToDisk(sampleData())
+
+ assertEquals(listOf(settingsFile), settingsFile.parent.listDirectoryEntries())
+ }
+
+ @Test
+ fun `a missing file loads as empty settings without complaint`() =
+ withStorage { storage, _, _, logger ->
+ assertEquals(PlayerSettingsData(), storage.loadFromDisk())
+ assertTrue(logger.severeMessages.isEmpty())
+ }
+
+ @Test
+ fun `an unparseable file falls back to empty settings rather than failing startup`() =
+ withStorage { storage, settingsFile, _, logger ->
+ settingsFile.writeText("japaneseConversion: [this is not a map")
+
+ assertEquals(PlayerSettingsData(), storage.loadFromDisk())
+ assertTrue(logger.severeMessages.any { it.contains("Failed to load settings file") })
+ }
+
+ @Test
+ fun `a failed write leaves the previous file intact`() =
+ withStorage { storage, settingsFile, _, logger ->
+ storage.saveToDisk(sampleData())
+ val saved = settingsFile.readText()
+
+ // A directory where the temporary file has to be created cannot be written to.
+ settingsFile.parent.toFile().setWritable(false)
+ try {
+ storage.saveToDisk(PlayerSettingsData(japaneseConversion = mapOf(alice to false)))
+ } finally {
+ settingsFile.parent.toFile().setWritable(true)
+ }
+
+ // Loading a torn file discards every player's settings, so a failed write must not
+ // replace what is already there.
+ assertEquals(saved, settingsFile.readText())
+ assertTrue(logger.severeMessages.any { it.contains("Failed to save settings") })
+ }
+
+ @Test
+ fun `a queued save reads the settings when the write runs, not when it is queued`() =
+ withStorage { storage, _, scheduler, _ ->
+ var supplied = false
+
+ storage.queueAsyncSave {
+ supplied = true
+ sampleData()
+ }
+
+ assertTrue(!supplied, "the snapshot must not be taken while queueing")
+
+ scheduler.runPending()
+
+ assertTrue(supplied)
+ assertEquals(sampleData(), storage.loadFromDisk())
+ }
+}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/AtomicWriteTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/storage/AtomicWriteTest.kt
index ea0c35f..d8d8a99 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/AtomicWriteTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/storage/AtomicWriteTest.kt
@@ -1,10 +1,9 @@
-package dev.m1sk9.lunaticChat.paper
+package dev.m1sk9.lunaticChat.paper.storage
-import java.nio.file.Files
+import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CyclicBarrier
-import kotlin.io.path.exists
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.readText
import kotlin.io.path.writeText
@@ -14,14 +13,10 @@ import kotlin.test.assertEquals
import kotlin.test.assertTrue
class AtomicWriteTest {
- private fun withTemporaryDirectory(block: (Path) -> Unit) {
- val directory = Files.createTempDirectory("atomic-write-test")
- try {
- block(directory)
- } finally {
- directory.toFile().deleteRecursively()
- }
- }
+ @TempDir
+ lateinit var temporaryDirectory: Path
+
+ private fun withTemporaryDirectory(block: (Path) -> Unit) = block(temporaryDirectory)
@Test
fun `writes the content and leaves no temporary file behind`() =
@@ -71,6 +66,5 @@ class AtomicWriteTest {
assertTrue(failures.isEmpty(), "writes failed: ${failures.map { it.toString() }}")
assertContains(contents, target.readText())
assertEquals(listOf(target), directory.listDirectoryEntries())
- assertTrue(target.exists())
}
}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/storage/DebouncedSaverTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/storage/DebouncedSaverTest.kt
new file mode 100644
index 0000000..01814ed
--- /dev/null
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/storage/DebouncedSaverTest.kt
@@ -0,0 +1,61 @@
+package dev.m1sk9.lunaticChat.paper.storage
+
+import dev.m1sk9.lunaticChat.paper.TestUtils
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class DebouncedSaverTest {
+ @Test
+ fun `a burst of requests costs one write`() {
+ val scheduler = TestUtils.ManualScheduler()
+ val saver = DebouncedSaver(scheduler)
+ var writes = 0
+
+ repeat(10) { saver.request { writes++ } }
+ scheduler.runPending()
+
+ assertEquals(1, writes)
+ }
+
+ @Test
+ fun `the write runs at the scheduled time, not when it was requested`() {
+ val scheduler = TestUtils.ManualScheduler()
+ val saver = DebouncedSaver(scheduler)
+ var writes = 0
+
+ saver.request { writes++ }
+
+ assertEquals(0, writes)
+ scheduler.runPending()
+ assertEquals(1, writes)
+ }
+
+ @Test
+ fun `a request after the write lands is scheduled again`() {
+ val scheduler = TestUtils.ManualScheduler()
+ val saver = DebouncedSaver(scheduler)
+ var writes = 0
+
+ saver.request { writes++ }
+ scheduler.runPending()
+ saver.request { writes++ }
+ scheduler.runPending()
+
+ assertEquals(2, writes)
+ }
+
+ @Test
+ fun `a saver serves one file only`() {
+ val scheduler = TestUtils.ManualScheduler()
+ val saver = DebouncedSaver(scheduler)
+ val written = mutableListOf<String>()
+
+ // Why FileStore owns one saver each: a request arriving while a write is pending is dropped,
+ // so a shared saver would silently lose the second file's save.
+ saver.request { written.add("channels.json") }
+ saver.request { written.add("settings.yml") }
+ scheduler.runPending()
+
+ assertEquals(listOf("channels.json"), written)
+ }
+}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManagerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManagerTest.kt
index 03938fc..a6060ce 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManagerTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManagerTest.kt
@@ -4,6 +4,8 @@ import dev.m1sk9.lunaticChat.engine.protocol.PluginMessage
import dev.m1sk9.lunaticChat.paper.TestUtils
import dev.m1sk9.lunaticChat.paper.chat.handler.DirectMessageHandler
import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager
+import io.mockk.coEvery
+import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
@@ -15,8 +17,6 @@ import java.util.logging.Logger
import kotlin.test.Test
class CrossServerDirectMessageManagerTest {
- private fun <T> sync(block: suspend () -> T): T = runBlocking { block() }
-
private class Fixture(
cacheSize: Int = 100,
) {
@@ -56,11 +56,11 @@ class CrossServerDirectMessageManagerTest {
fun `sendCrossServerMessage relays via plugin channel and delegates display`() {
val f = Fixture()
val sender = TestUtils.createMockPlayer(name = "Alice")
- every { sync { f.dmHandler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi") } } returns "hi"
+ coEvery { f.dmHandler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi") } returns "hi"
- sync { f.manager.sendCrossServerMessage(sender, "Bob", "survival", "hi") }
+ runBlocking { f.manager.sendCrossServerMessage(sender, "Bob", "survival", "hi") }
- verify { sync { f.dmHandler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi") } }
+ coVerify { f.dmHandler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi") }
verify { sender.sendPluginMessage(f.plugin, "lunaticchat:main", any<ByteArray>()) }
}
@@ -143,9 +143,9 @@ class CrossServerDirectMessageManagerTest {
fun `sendCrossServerMessage prunes the dedup cache when over capacity`() {
val f = Fixture(cacheSize = 1)
val sender = TestUtils.createMockPlayer(name = "Alice")
- every { sync { f.dmHandler.handleOutgoingCrossServerMessage(any(), any(), any(), any()) } } returns "hi"
+ coEvery { f.dmHandler.handleOutgoingCrossServerMessage(any(), any(), any(), any()) } returns "hi"
- repeat(3) { sync { f.manager.sendCrossServerMessage(sender, "Bob$it", "survival", "hi") } }
+ repeat(3) { runBlocking { f.manager.sendCrossServerMessage(sender, "Bob$it", "survival", "hi") } }
verify(atLeast = 1) { sender.sendPluginMessage(f.plugin, "lunaticchat:main", any<ByteArray>()) }
}