diff options
| author | Sho Sakuma <me@m1sk9.dev> | 2026-08-05 04:30:07 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-05 04:30:07 +0900 |
| commit | 82a592fe744ab4172c8f86fd2a71ea540575174a (patch) | |
| tree | b683bdb24d1a2ce8746f4fef7cd6b6b1159438ce | |
| parent | 7c544224291e8ed7e23dcf97ea3cc6f42588c338 (diff) | |
| parent | adbcb0d59dfc96050cac9d578b632b5fab38acbb (diff) | |
| download | LunaticChat-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
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>()) } } |
