From 7dabcc072a7fff595ee6003026448ececd410b1d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 11:12:16 +0000 Subject: [PATCH 01/12] fix(heartbeat): deliver every heartbeat and count only the user's activity Delivery: - The daemon's {"status":"disabled"} reply counted as delivered, so heartbeats were dropped while the status bar said ShellTime. They are now kept, the status bar shows "tracking off", and a notification explains how to turn it on. - The queue went out as one message, which servers failed to store past about 2,800 rows. Messages now carry at most 500, the queue keeps the newest 5,000, and a flush cut short (the project closing) keeps what it didn't send. What is left when a project closes goes to the daemon's resend file. - The 5-second socket timeout couldn't interrupt blocking socket I/O, so a daemon that accepted and never answered hung the flush. Accuracy: - Typing counts only while the IDE is active and an editor of this project has focus: reloads from disk (git checkout, a formatter or agent in a terminal) and the same file open in another project's window don't. - Tabs restored when a project opens, caret moves while the IDE is in the background, and files a refactoring saves without opening them no longer count. Folders the project excludes (build output) are skipped. - Languages are reported as VS Code language ids, so a language adds up across editors. - A file outside the project's base directory counts toward its Git repository. The branch lookup only reads the repository cache, which the platform requires on the EDT. - The config is read from the files the CLI reads (config.yaml, .yml or .toml plus config.local.*, with the CLI's TOML keys), and the settings page keeps the file's endpoints. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi --- .../jetbrains/actions/FlushAction.kt | 44 +++-- .../jetbrains/actions/ShowStatusAction.kt | 38 ++-- .../jetbrains/config/ConfigLoader.kt | 140 ++++++++------- .../shelltime/jetbrains/config/Constants.kt | 19 +- .../jetbrains/config/ShellTimeConfig.kt | 3 +- .../jetbrains/heartbeat/HeartbeatCollector.kt | 31 +++- .../jetbrains/heartbeat/HeartbeatData.kt | 14 +- .../jetbrains/heartbeat/HeartbeatSender.kt | 138 ++++++++++---- .../jetbrains/heartbeat/OfflineStore.kt | 40 +++++ .../jetbrains/listeners/FileEditorListener.kt | 10 +- .../services/ShellTimeProjectService.kt | 49 ++++- .../jetbrains/services/ShellTimeService.kt | 15 +- .../jetbrains/socket/SocketClient.kt | 106 +++++++---- .../shelltime/jetbrains/ui/StatusBarWidget.kt | 23 +-- .../xyz/shelltime/jetbrains/utils/GitUtils.kt | 25 ++- .../shelltime/jetbrains/utils/LanguageIds.kt | 132 ++++++++++++++ .../shelltime/jetbrains/utils/ProjectUtils.kt | 80 +++------ .../messages/ShellTimeBundle.properties | 4 + .../jetbrains/config/ConfigDiscoveryTest.kt | 72 ++++++++ .../jetbrains/config/ConfigTomlTest.kt | 36 ++++ .../jetbrains/heartbeat/HeartbeatDataTest.kt | 8 +- .../heartbeat/HeartbeatSenderTest.kt | 170 ++++++++++++++++++ .../jetbrains/heartbeat/OfflineStoreTest.kt | 69 +++++++ .../jetbrains/heartbeat/TestHeartbeats.kt | 27 +++ .../jetbrains/socket/SocketClientTest.kt | 151 ++++++++++++++++ .../jetbrains/utils/LanguageIdsTest.kt | 35 ++++ .../jetbrains/utils/ProjectUtilsTest.kt | 14 +- 27 files changed, 1212 insertions(+), 281 deletions(-) create mode 100644 src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/OfflineStore.kt create mode 100644 src/main/kotlin/xyz/shelltime/jetbrains/utils/LanguageIds.kt create mode 100644 src/test/kotlin/xyz/shelltime/jetbrains/config/ConfigDiscoveryTest.kt create mode 100644 src/test/kotlin/xyz/shelltime/jetbrains/config/ConfigTomlTest.kt create mode 100644 src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt create mode 100644 src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/OfflineStoreTest.kt create mode 100644 src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/TestHeartbeats.kt create mode 100644 src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt create mode 100644 src/test/kotlin/xyz/shelltime/jetbrains/utils/LanguageIdsTest.kt diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/actions/FlushAction.kt b/src/main/kotlin/xyz/shelltime/jetbrains/actions/FlushAction.kt index a49ddb4..bd451e9 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/actions/FlushAction.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/actions/FlushAction.kt @@ -3,11 +3,13 @@ package xyz.shelltime.jetbrains.actions import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import xyz.shelltime.jetbrains.ShellTimeBundle import xyz.shelltime.jetbrains.config.Constants +import xyz.shelltime.jetbrains.heartbeat.DaemonState import xyz.shelltime.jetbrains.services.ShellTimeProjectService /** @@ -19,26 +21,36 @@ class FlushAction : AnAction() { val project = e.project ?: return val projectService = project.service() - val flushedCount = projectService.forceFlush() - val (message, type) = when { - flushedCount > 0 -> { - ShellTimeBundle.message("action.flush.success", flushedCount) to NotificationType.INFORMATION - } - flushedCount == 0 && projectService.getPendingCount() == 0 -> { - ShellTimeBundle.message("action.flush.empty") to NotificationType.INFORMATION + // The socket round trip must not block the UI thread + ApplicationManager.getApplication().executeOnPooledThread { + val result = projectService.forceFlush() + val pending = projectService.getPendingCount() + + val (message, type) = when { + result.state == DaemonState.DISABLED -> { + ShellTimeBundle.message("action.flush.disabled", pending) to NotificationType.WARNING + } + result.sent > 0 && pending == 0 -> { + ShellTimeBundle.message("action.flush.success", result.sent) to NotificationType.INFORMATION + } + result.sent == 0 && pending == 0 -> { + ShellTimeBundle.message("action.flush.empty") to NotificationType.INFORMATION + } + else -> { + ShellTimeBundle.message("action.flush.failed") to NotificationType.WARNING + } } - else -> { - ShellTimeBundle.message("action.flush.failed") to NotificationType.WARNING + + if (!project.isDisposed) { + showNotification( + project, + ShellTimeBundle.message("action.flush.title"), + message, + type + ) } } - - showNotification( - project, - ShellTimeBundle.message("action.flush.title"), - message, - type - ) } private fun showNotification( diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/actions/ShowStatusAction.kt b/src/main/kotlin/xyz/shelltime/jetbrains/actions/ShowStatusAction.kt index 4ce3a88..24e6092 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/actions/ShowStatusAction.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/actions/ShowStatusAction.kt @@ -4,6 +4,7 @@ import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import kotlinx.coroutines.runBlocking @@ -22,11 +23,12 @@ class ShowStatusAction : AnAction() { val service = ShellTimeService.getInstance() val socketClient = service.getSocketClient() - runBlocking { - val status = socketClient.getStatus() + // The socket round trip must not block the UI thread + ApplicationManager.getApplication().executeOnPooledThread { + val status = runBlocking { socketClient.getStatus() } val message = if (status != null && socketClient.isConnected()) { - val uptime = formatUptime(status.uptime ?: 0) + val uptime = status.uptime ?: "Unknown" ShellTimeBundle.message( "action.showStatus.connected", status.version ?: "Unknown", @@ -37,31 +39,17 @@ class ShowStatusAction : AnAction() { ShellTimeBundle.message("action.showStatus.disconnected") } - showNotification( - project, - ShellTimeBundle.message("action.showStatus.title"), - message, - if (socketClient.isConnected()) NotificationType.INFORMATION else NotificationType.WARNING - ) + if (!project.isDisposed) { + showNotification( + project, + ShellTimeBundle.message("action.showStatus.title"), + message, + if (socketClient.isConnected()) NotificationType.INFORMATION else NotificationType.WARNING + ) + } } } - private fun formatUptime(seconds: Long): String { - if (seconds <= 0) return "Unknown" - - val days = seconds / 86400 - val hours = (seconds % 86400) / 3600 - val minutes = (seconds % 3600) / 60 - val secs = seconds % 60 - - return buildString { - if (days > 0) append("${days}d ") - if (hours > 0) append("${hours}h ") - if (minutes > 0) append("${minutes}m ") - append("${secs}s") - }.trim() - } - private fun showNotification( project: Project, title: String, diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/config/ConfigLoader.kt b/src/main/kotlin/xyz/shelltime/jetbrains/config/ConfigLoader.kt index 2610bb5..00cd259 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/config/ConfigLoader.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/config/ConfigLoader.kt @@ -7,47 +7,40 @@ import java.nio.file.Files import java.nio.file.attribute.BasicFileAttributes /** - * Loads and caches configuration from ~/.shelltime/config.toml or YAML variants + * Loads and caches configuration from the files the ShellTime CLI reads: the first of + * ~/.shelltime/config.yaml, config.yml and config.toml, with the first config.local.* + * merged over it (cli/model/config.go) + * + * @param configDir Directory holding the config files, ~/.shelltime by default */ -class ConfigLoader { +class ConfigLoader( + private val configDir: String = expandPath(Constants.CONFIG_DIR) +) { private var cachedConfig: ShellTimeConfig? = null - private var cachedMtime: Long = 0 - private var cachedPath: String? = null - - private val configPaths = listOf( - "~/.shelltime/config.toml", - "~/.shelltime/config.yaml", - "~/.shelltime/config.yml" - ) + private var cachedSignature: List? = null /** * Get the configuration, loading from file if needed */ fun getConfig(): ShellTimeConfig { - val configFile = findConfigFile() - - if (configFile != null) { - val mtime = getFileMtime(configFile) - val path = configFile.absolutePath - - // Return cached config if file hasn't changed - if (cachedConfig != null && cachedPath == path && cachedMtime == mtime) { - return cachedConfig!! - } + val baseFile = findFile(Constants.BASE_CONFIG_FILES) + val localFile = findFile(Constants.LOCAL_CONFIG_FILES) + val signature = listOf( + baseFile?.absolutePath ?: "", getFileMtime(baseFile), + localFile?.absolutePath ?: "", getFileMtime(localFile) + ) - // Load and cache new config - val fileConfig = loadConfigFile(configFile) - cachedConfig = mergeConfig(fileConfig) - cachedMtime = mtime - cachedPath = path - return cachedConfig!! - } + // Return cached config if no file has changed + cachedConfig?.let { if (cachedSignature == signature) return it } - // Return default config if no file found - if (cachedConfig == null) { - cachedConfig = ShellTimeConfig() + val config = if (baseFile != null) { + mergeConfig(mergeLocalConfig(loadConfigFile(baseFile), localFile?.let { loadConfigFile(it) })) + } else { + ShellTimeConfig() } - return cachedConfig!! + cachedConfig = config + cachedSignature = signature + return config } /** @@ -55,31 +48,16 @@ class ConfigLoader { */ fun reloadConfig(): ShellTimeConfig { cachedConfig = null - cachedMtime = 0 - cachedPath = null + cachedSignature = null return getConfig() } - private fun findConfigFile(): File? { - for (path in configPaths) { - val expandedPath = expandPath(path) - val file = File(expandedPath) - if (file.exists() && file.isFile) { - return file - } - } - return null + private fun findFile(names: List): File? { + return names.map { File(configDir, it) }.firstOrNull { it.exists() && it.isFile } } - private fun expandPath(path: String): String { - return if (path.startsWith("~")) { - System.getProperty("user.home") + path.substring(1) - } else { - path - } - } - - private fun getFileMtime(file: File): Long { + private fun getFileMtime(file: File?): Long { + if (file == null) return 0L return try { val attrs = Files.readAttributes(file.toPath(), BasicFileAttributes::class.java) attrs.lastModifiedTime().toMillis() @@ -106,18 +84,19 @@ class ConfigLoader { val codeTracking = toml.getTable("codeTracking")?.let { ct -> CodeTrackingConfig( - enabled = ct.getBoolean("enabled") ?: true, + enabled = ct.getBoolean("enabled"), apiEndpoint = ct.getString("apiEndpoint"), token = ct.getString("token") ) } + // The CLI writes the endpoints as APIEndpoint and WebEndpoint in TOML @Suppress("UNCHECKED_CAST") return ShellTimeFileConfig( socketPath = toml.getString("socketPath"), - token = toml.getString("token"), - apiEndpoint = toml.getString("apiEndpoint"), - webEndpoint = toml.getString("webEndpoint"), + token = toml.getString("Token") ?: toml.getString("token"), + apiEndpoint = toml.getString("APIEndpoint") ?: toml.getString("apiEndpoint"), + webEndpoint = toml.getString("WebEndpoint") ?: toml.getString("webEndpoint"), codeTracking = codeTracking, flushCount = toml.getLong("flushCount")?.toInt(), gcTime = toml.getLong("gcTime")?.toInt(), @@ -131,11 +110,11 @@ class ConfigLoader { @Suppress("UNCHECKED_CAST") private fun loadYamlConfig(file: File): ShellTimeFileConfig { val yaml = Yaml() - val data: Map = yaml.load(file.inputStream()) ?: return ShellTimeFileConfig() + val data: Map = file.inputStream().use { yaml.load(it) } ?: return ShellTimeFileConfig() val codeTracking = (data["codeTracking"] as? Map)?.let { ct -> CodeTrackingConfig( - enabled = ct["enabled"] as? Boolean ?: true, + enabled = ct["enabled"] as? Boolean, apiEndpoint = ct["apiEndpoint"] as? String, token = ct["token"] as? String ) @@ -156,13 +135,41 @@ class ConfigLoader { ) } + /** + * Local settings override base settings when set, as in the CLI's mergeConfig + */ + private fun mergeLocalConfig(base: ShellTimeFileConfig, local: ShellTimeFileConfig?): ShellTimeFileConfig { + if (local == null) return base + return base.copy( + socketPath = local.socketPath?.takeIf { it.isNotEmpty() } ?: base.socketPath, + token = local.token?.takeIf { it.isNotEmpty() } ?: base.token, + apiEndpoint = local.apiEndpoint?.takeIf { it.isNotEmpty() } ?: base.apiEndpoint, + webEndpoint = local.webEndpoint?.takeIf { it.isNotEmpty() } ?: base.webEndpoint, + codeTracking = mergeCodeTracking(base.codeTracking, local.codeTracking), + exclude = local.exclude?.takeIf { it.isNotEmpty() } ?: base.exclude + ) + } + + /** + * Field by field: a local file that only sets the code tracking token keeps tracking on + */ + private fun mergeCodeTracking(base: CodeTrackingConfig?, local: CodeTrackingConfig?): CodeTrackingConfig? { + if (local == null) return base + val merged = base ?: CodeTrackingConfig() + return merged.copy( + enabled = local.enabled ?: merged.enabled, + apiEndpoint = local.apiEndpoint?.takeIf { it.isNotEmpty() } ?: merged.apiEndpoint, + token = local.token?.takeIf { it.isNotEmpty() } ?: merged.token + ) + } + private fun mergeConfig(fileConfig: ShellTimeFileConfig): ShellTimeConfig { // Determine if enabled from codeTracking section or default val enabled = fileConfig.codeTracking?.enabled ?: true return ShellTimeConfig( enabled = enabled, - socketPath = fileConfig.socketPath ?: Constants.DEFAULT_SOCKET_PATH, + socketPath = fileConfig.socketPath?.takeIf { it.isNotEmpty() } ?: Constants.DEFAULT_SOCKET_PATH, heartbeatInterval = Constants.DEFAULT_FLUSH_INTERVAL_MS, debug = false, exclude = fileConfig.exclude ?: emptyList(), @@ -175,7 +182,20 @@ class ConfigLoader { * Get raw file config for accessing apiEndpoint/webEndpoint */ fun getFileConfig(): ShellTimeFileConfig? { - val configFile = findConfigFile() ?: return null - return loadConfigFile(configFile) + val baseFile = findFile(Constants.BASE_CONFIG_FILES) ?: return null + return mergeLocalConfig( + loadConfigFile(baseFile), + findFile(Constants.LOCAL_CONFIG_FILES)?.let { loadConfigFile(it) } + ) + } + + companion object { + private fun expandPath(path: String): String { + return if (path.startsWith("~")) { + System.getProperty("user.home") + path.substring(1) + } else { + path + } + } } } diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/config/Constants.kt b/src/main/kotlin/xyz/shelltime/jetbrains/config/Constants.kt index 41a0ee9..fc78d88 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/config/Constants.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/config/Constants.kt @@ -13,11 +13,26 @@ object Constants { /** Default heartbeat flush interval - 2 minutes */ const val DEFAULT_FLUSH_INTERVAL_MS = 120_000L + /** Shortest flush interval accepted, so a 0 from the settings can't spin the timer */ + const val MIN_FLUSH_INTERVAL_MS = 10_000L + + /** Heartbeats per socket message, so a backlog never goes out as one huge request */ + const val MAX_HEARTBEATS_PER_MESSAGE = 500 + + /** Upper bound for heartbeats kept while the daemon is unreachable, oldest dropped first */ + const val MAX_PENDING_HEARTBEATS = 5_000 + /** Socket connection timeout - 5 seconds */ const val CONNECTION_TIMEOUT_MS = 5_000L - /** Default config file path */ - const val DEFAULT_CONFIG_PATH = "~/.shelltime/config.toml" + /** Directory of the ShellTime CLI's config */ + const val CONFIG_DIR = "~/.shelltime" + + /** Config files in the CLI's priority order (cli/model/config.go): the first that exists */ + val BASE_CONFIG_FILES = listOf("config.yaml", "config.yml", "config.toml") + + /** Local overrides merged over the base config: the first that exists */ + val LOCAL_CONFIG_FILES = listOf("config.local.yaml", "config.local.yml", "config.local.toml") /** Plugin identifier */ const val PLUGIN_ID = "shelltime" diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/config/ShellTimeConfig.kt b/src/main/kotlin/xyz/shelltime/jetbrains/config/ShellTimeConfig.kt index 86c64c5..aede48f 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/config/ShellTimeConfig.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/config/ShellTimeConfig.kt @@ -4,7 +4,8 @@ package xyz.shelltime.jetbrains.config * Code tracking specific configuration */ data class CodeTrackingConfig( - val enabled: Boolean = true, + /** Null when the file doesn't say; tracking then defaults to on */ + val enabled: Boolean? = null, val apiEndpoint: String? = null, val token: String? = null ) diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt index 91484be..e4abea4 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt @@ -2,6 +2,7 @@ package xyz.shelltime.jetbrains.heartbeat import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.editor.Document import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.EditorFactory @@ -12,6 +13,7 @@ import com.intellij.openapi.editor.event.DocumentListener import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.openapi.util.Disposer import com.intellij.openapi.vfs.VirtualFile import com.intellij.xdebugger.XDebuggerManager @@ -181,9 +183,19 @@ class HeartbeatCollector( if (file == null) return false if (!file.isInLocalFileSystem) return false if (ProjectUtils.shouldExclude(file.path, project.basePath)) return false + // Build output and other folders the project itself excludes + if (isExcludedFromProject(file)) return false return true } + private fun isExcludedFromProject(file: VirtualFile): Boolean { + return try { + ProjectFileIndex.getInstance(project).isExcluded(file) + } catch (e: Exception) { + false + } + } + /** * Find an editor of this project that shows the document, preferring the selected one */ @@ -204,9 +216,13 @@ class HeartbeatCollector( if (!isValidFile(file)) return - // The multicaster reports changes to every document in the IDE; only count - // documents that are open in an editor of this project. - val editor = findEditor(document) ?: return + // The multicaster reports changes to every document in the IDE. Only typing counts: + // the IDE is active and an editor of this project showing the document has focus. + // Reloads from disk (git checkout, formatters, an agent in a terminal) and the same + // file open in another project's window don't. + if (!ApplicationManager.getApplication().isActive) return + val editor = EditorFactory.getInstance().getEditors(document, project) + .firstOrNull { it.contentComponent.hasFocus() } ?: return val lineNumber = editor.caretModel.logicalPosition.line + 1 val cursorPosition = editor.caretModel.logicalPosition.column @@ -224,6 +240,8 @@ class HeartbeatCollector( private fun handleCaretChange(event: CaretEvent) { val editor = event.editor if (editor.project != project) return + // Programmatic caret moves while the IDE is in the background aren't the user's + if (!ApplicationManager.getApplication().isActive) return val document = editor.document val file = FileDocumentManager.getInstance().getFile(document) ?: return @@ -267,9 +285,10 @@ class HeartbeatCollector( if (!isValidFile(file)) return val document = FileDocumentManager.getInstance().getDocument(file!!) ?: return - val editor = findEditor(document) - val lineNumber = editor?.let { it.caretModel.logicalPosition.line + 1 } - val cursorPosition = editor?.caretModel?.logicalPosition?.column + // A refactoring saves files that aren't open; only saves of files the user has open count + val editor = findEditor(document) ?: return + val lineNumber = editor.caretModel.logicalPosition.line + 1 + val cursorPosition = editor.caretModel.logicalPosition.column // Saves always bypass debouncing val heartbeat = createHeartbeat(file, document, true, lineNumber, cursorPosition) diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatData.kt b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatData.kt index addc08c..f3da04e 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatData.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatData.kt @@ -105,16 +105,26 @@ data class SocketMessage( ) /** - * Response from daemon status request + * Response from daemon status request. The daemon formats uptime itself, e.g. "2h 5m 3s". */ @Serializable data class StatusResponse( val version: String? = null, - val uptime: Long? = null, + val startedAt: String? = null, + val uptime: String? = null, val platform: String? = null, val goVersion: String? = null ) +/** + * The daemon's reply to a heartbeat message: "ok", or "disabled" when it drops editor + * heartbeats because code tracking is off in its config + */ +@Serializable +data class HeartbeatAck( + val status: String? = null +) + /** * Response from version check API */ diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSender.kt b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSender.kt index 7d6a8bc..99c9e32 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSender.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSender.kt @@ -3,15 +3,32 @@ package xyz.shelltime.jetbrains.heartbeat import com.intellij.openapi.Disposable import kotlinx.coroutines.* import xyz.shelltime.jetbrains.config.Constants +import xyz.shelltime.jetbrains.socket.HeartbeatDelivery import xyz.shelltime.jetbrains.socket.SocketClient import xyz.shelltime.jetbrains.utils.Logger -import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.ConcurrentLinkedDeque + +/** + * State of the daemon as the last flush found it + */ +enum class DaemonState { + CONNECTED, + DISCONNECTED, + + /** The daemon runs but drops editor heartbeats: code tracking was off when it started */ + DISABLED +} + +/** + * Outcome of one flush + */ +data class FlushResult(val sent: Int, val state: DaemonState) /** * Callback interface for status updates */ interface HeartbeatSenderCallback { - fun onStatusChanged(connected: Boolean, pendingCount: Int) + fun onStatusChanged(state: DaemonState, pendingCount: Int) } /** @@ -20,20 +37,25 @@ interface HeartbeatSenderCallback { class HeartbeatSender( private val collector: HeartbeatCollector, private val socketClient: SocketClient, - private var flushInterval: Long = Constants.DEFAULT_FLUSH_INTERVAL_MS, + flushInterval: Long = Constants.DEFAULT_FLUSH_INTERVAL_MS, debug: Boolean = false ) : Disposable { private val logger = Logger("Sender", debug) private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private var flushJob: Job? = null private var callback: HeartbeatSenderCallback? = null + private var flushInterval = flushInterval.coerceAtLeast(Constants.MIN_FLUSH_INTERVAL_MS) - // Queue for heartbeats that failed to send (offline support) - private val failedHeartbeats = ConcurrentLinkedQueue() + // Heartbeats that failed to send (offline support), oldest first + private val failedHeartbeats = ConcurrentLinkedDeque() @Volatile private var isRunning = false + @Volatile + var state = DaemonState.DISCONNECTED + private set + /** * Set callback for status updates */ @@ -71,59 +93,92 @@ class HeartbeatSender( } /** - * Flush pending heartbeats to the daemon + * Flush pending heartbeats to the daemon, at most MAX_HEARTBEATS_PER_MESSAGE per message * - * @return Number of heartbeats successfully sent + * @return How many heartbeats the daemon took, and the state it was found in */ - suspend fun flush(): Int { - // Collect heartbeats from collector - val newHeartbeats = collector.flush() - - // Combine with any previously failed heartbeats + suspend fun flush(): FlushResult { + // Previously failed heartbeats first (older), then the collector's val allHeartbeats = mutableListOf() - - // Add failed heartbeats first (older) while (true) { - val hb = failedHeartbeats.poll() ?: break + val hb = failedHeartbeats.pollFirst() ?: break allHeartbeats.add(hb) } - - // Add new heartbeats - allHeartbeats.addAll(newHeartbeats) + allHeartbeats.addAll(collector.flush()) if (allHeartbeats.isEmpty()) { logger.log("No heartbeats to flush") updateStatus() - return 0 + return FlushResult(0, state) } logger.log("Flushing ${allHeartbeats.size} heartbeats") - val payload = HeartbeatPayload(heartbeats = allHeartbeats) - val success = socketClient.sendHeartbeats(payload) + var sent = 0 + var result = DaemonState.CONNECTED + try { + for (chunk in allHeartbeats.chunked(Constants.MAX_HEARTBEATS_PER_MESSAGE)) { + when (socketClient.sendHeartbeats(HeartbeatPayload(heartbeats = chunk))) { + HeartbeatDelivery.OK -> sent += chunk.size + HeartbeatDelivery.DISABLED -> { + result = DaemonState.DISABLED + break + } + HeartbeatDelivery.FAILED -> { + result = DaemonState.DISCONNECTED + break + } + } + } + } finally { + // Keep what was not delivered, also when the flush was cancelled mid-send + requeue(allHeartbeats.subList(sent, allHeartbeats.size)) + } - if (success) { - logger.log("Successfully sent ${allHeartbeats.size} heartbeats") - updateStatus() - return allHeartbeats.size + state = result + if (sent == allHeartbeats.size) { + logger.log("Successfully sent $sent heartbeats") } else { - // Re-queue failed heartbeats for retry - allHeartbeats.forEach { failedHeartbeats.add(it) } - logger.log("Failed to send heartbeats, queued ${allHeartbeats.size} for retry") - updateStatus() - return 0 + logger.log("Sent $sent heartbeats, queued ${allHeartbeats.size - sent} for retry ($result)") + } + updateStatus() + return FlushResult(sent, result) + } + + /** + * Put heartbeats back ahead of newer ones, dropping the oldest beyond MAX_PENDING_HEARTBEATS + */ + private fun requeue(heartbeats: List) { + for (hb in heartbeats.asReversed()) { + failedHeartbeats.addFirst(hb) + } + while (failedHeartbeats.size > Constants.MAX_PENDING_HEARTBEATS) { + failedHeartbeats.pollFirst() } } /** * Force an immediate flush (used by manual flush action) */ - fun forceFlush(): Int { + fun forceFlush(): FlushResult { return runBlocking { flush() } } + /** + * Take every heartbeat not yet delivered, oldest first, leaving none queued + */ + fun drainPending(): List { + val pending = mutableListOf() + while (true) { + val hb = failedHeartbeats.pollFirst() ?: break + pending.add(hb) + } + pending.addAll(collector.flush()) + return pending + } + /** * Get total pending count (collector + failed) */ @@ -136,16 +191,33 @@ class HeartbeatSender( */ private fun updateStatus() { callback?.onStatusChanged( - connected = socketClient.isConnected(), + state = state, pendingCount = getPendingCount() ) } + /** + * Probe the daemon without sending heartbeats, so the status bar is right before the + * first flush and while nothing is pending. A running daemon keeps the disabled state + * until it accepts heartbeats again. + */ + suspend fun refreshState() { + val connected = socketClient.getStatus() != null || socketClient.isConnected() + state = when { + !connected -> DaemonState.DISCONNECTED + state == DaemonState.DISABLED -> DaemonState.DISABLED + else -> DaemonState.CONNECTED + } + updateStatus() + } + /** * Update flush interval */ fun setFlushInterval(interval: Long) { - flushInterval = interval + val coerced = interval.coerceAtLeast(Constants.MIN_FLUSH_INTERVAL_MS) + if (coerced == flushInterval) return + flushInterval = coerced if (isRunning) { stop() start() diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/OfflineStore.kt b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/OfflineStore.kt new file mode 100644 index 0000000..1332755 --- /dev/null +++ b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/OfflineStore.kt @@ -0,0 +1,40 @@ +package xyz.shelltime.jetbrains.heartbeat + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import xyz.shelltime.jetbrains.config.Constants +import java.io.File + +/** + * The daemon's file for heartbeats it could not send (cli/model/db.go HEARTBEAT_LOG_FILE): + * one {"heartbeats": [...]} payload per line, resent when the daemon starts and every + * 30 minutes. Heartbeat ids make a resend of one that did arrive harmless. + */ +object OfflineStore { + val DEFAULT_FILE = File(System.getProperty("user.home"), ".shelltime/coding-heartbeat.data.log") + + // Don't grow the file without bound when no daemon ever reads it + private const val MAX_FILE_BYTES = 50L * 1024 * 1024 + + private val json = Json { encodeDefaults = true } + + /** + * Leave heartbeats that could not be delivered before the IDE closes for the daemon + * to send once it runs again + * + * @return Whether they were written + */ + fun save(heartbeats: List, file: File = DEFAULT_FILE): Boolean { + if (heartbeats.isEmpty()) return true + return try { + if (file.exists() && file.length() > MAX_FILE_BYTES) return false + file.parentFile?.mkdirs() + val lines = heartbeats.chunked(Constants.MAX_HEARTBEATS_PER_MESSAGE) + .joinToString("") { json.encodeToString(HeartbeatPayload(heartbeats = it)) + "\n" } + file.appendText(lines) + true + } catch (e: Exception) { + false + } + } +} diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/listeners/FileEditorListener.kt b/src/main/kotlin/xyz/shelltime/jetbrains/listeners/FileEditorListener.kt index 1c39c9b..87695b3 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/listeners/FileEditorListener.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/listeners/FileEditorListener.kt @@ -14,14 +14,14 @@ import com.intellij.openapi.vfs.VirtualFile import xyz.shelltime.jetbrains.services.ShellTimeProjectService /** - * Listener for file editor events (open, close, switch) + * Listener for file editor events (switching tabs) + * + * fileOpened isn't used: it fires for every tab restored with the project and for files + * opened in the background, which the user never looked at. A file the user opens + * becomes the selected one, which selectionChanged reports. */ class FileEditorListener(private val project: Project) : FileEditorManagerListener { - override fun fileOpened(source: FileEditorManager, file: VirtualFile) { - project.service().onFileSwitch(file) - } - override fun selectionChanged(event: FileEditorManagerEvent) { event.newFile?.let { file -> project.service().onFileSwitch(file) diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt b/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt index 429bf83..e59f23f 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt @@ -1,15 +1,23 @@ package xyz.shelltime.jetbrains.services +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType import com.intellij.openapi.Disposable import com.intellij.openapi.components.Service import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile +import xyz.shelltime.jetbrains.ShellTimeBundle +import xyz.shelltime.jetbrains.config.Constants +import xyz.shelltime.jetbrains.heartbeat.DaemonState +import xyz.shelltime.jetbrains.heartbeat.FlushResult import xyz.shelltime.jetbrains.heartbeat.HeartbeatCollector import xyz.shelltime.jetbrains.heartbeat.HeartbeatSender import xyz.shelltime.jetbrains.heartbeat.HeartbeatSenderCallback +import xyz.shelltime.jetbrains.heartbeat.OfflineStore import xyz.shelltime.jetbrains.utils.SystemUtils import xyz.shelltime.jetbrains.version.VersionChecker import kotlinx.coroutines.* +import java.util.concurrent.atomic.AtomicBoolean /** * Project-level service for ShellTime @@ -30,6 +38,31 @@ class ShellTimeProjectService(private val project: Project) : Disposable { companion object { /** How long closing a project may wait for the last heartbeats to be sent */ private const val FINAL_FLUSH_TIMEOUT_MS = 2_000L + + /** The tracking-disabled notice is shown once per IDE session, not per project */ + private val disabledNoticeShown = AtomicBoolean(false) + } + + /** Forwards sender updates to the status bar widget and explains a disabled daemon */ + private val senderCallback = object : HeartbeatSenderCallback { + override fun onStatusChanged(state: DaemonState, pendingCount: Int) { + if (state == DaemonState.DISABLED) { + notifyTrackingDisabled() + } + statusCallback?.onStatusChanged(state, pendingCount) + } + } + + private fun notifyTrackingDisabled() { + if (project.isDisposed || !disabledNoticeShown.compareAndSet(false, true)) return + NotificationGroupManager.getInstance() + .getNotificationGroup(Constants.NOTIFICATION_GROUP_ID) + .createNotification( + ShellTimeBundle.message("action.showStatus.title"), + ShellTimeBundle.message("notification.trackingDisabled"), + NotificationType.WARNING + ) + .notify(project) } init { @@ -57,8 +90,7 @@ class ShellTimeProjectService(private val project: Project) : Disposable { debug = debug ) - // Set status callback if already registered - statusCallback?.let { sender.setCallback(it) } + sender.setCallback(senderCallback) // Resolve the machine name once, off the EDT scope.launch { SystemUtils.getMachineName() } @@ -66,8 +98,9 @@ class ShellTimeProjectService(private val project: Project) : Disposable { // Register for editor events collector.start(this) - // Start the sender + // Start the sender, and show the daemon's state before the first flush sender.start() + scope.launch { sender.refreshState() } // Check CLI version in background (non-blocking) checkCliVersion() @@ -107,7 +140,7 @@ class ShellTimeProjectService(private val project: Project) : Disposable { fun setStatusCallback(callback: HeartbeatSenderCallback) { statusCallback = callback if (::sender.isInitialized) { - sender.setCallback(callback) + callback.onStatusChanged(sender.state, sender.getPendingCount()) } } @@ -130,10 +163,10 @@ class ShellTimeProjectService(private val project: Project) : Disposable { /** * Force flush heartbeats * - * @return Number of heartbeats flushed + * @return How many heartbeats the daemon took, and its state */ - fun forceFlush(): Int { - if (!::sender.isInitialized) return 0 + fun forceFlush(): FlushResult { + if (!::sender.isInitialized) return FlushResult(0, DaemonState.DISCONNECTED) return sender.forceFlush() } @@ -189,6 +222,8 @@ class ShellTimeProjectService(private val project: Project) : Disposable { } catch (e: Exception) { // Best effort only } + // Not delivered: the daemon sends them once it runs again + OfflineStore.save(sender.drainPending()) sender.dispose() } if (::collector.isInitialized) { diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeService.kt b/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeService.kt index f972585..0fbe980 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeService.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeService.kt @@ -71,12 +71,12 @@ class ShellTimeService { socketPath: String, heartbeatInterval: Long ) { - currentSettings = ShellTimeConfig( + // Keep the file's endpoints, which the settings page doesn't show + currentSettings = currentSettings.copy( enabled = enabled, socketPath = socketPath, heartbeatInterval = heartbeatInterval, - debug = debug, - exclude = currentSettings.exclude + debug = debug ) // Update socket client @@ -88,14 +88,7 @@ class ShellTimeService { * Reload configuration from file */ fun reloadConfig() { - val fileConfig = configLoader.reloadConfig() - currentSettings = ShellTimeConfig( - enabled = fileConfig.enabled, - socketPath = fileConfig.socketPath, - heartbeatInterval = fileConfig.heartbeatInterval, - debug = fileConfig.debug, - exclude = fileConfig.exclude - ) + currentSettings = configLoader.reloadConfig() socketClient.setSocketPath(currentSettings.socketPath) socketClient.setDebug(currentSettings.debug) diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/socket/SocketClient.kt b/src/main/kotlin/xyz/shelltime/jetbrains/socket/SocketClient.kt index 793b41f..982f1df 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/socket/SocketClient.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/socket/SocketClient.kt @@ -1,11 +1,13 @@ package xyz.shelltime.jetbrains.socket import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import xyz.shelltime.jetbrains.config.Constants +import xyz.shelltime.jetbrains.heartbeat.HeartbeatAck import xyz.shelltime.jetbrains.heartbeat.HeartbeatPayload import xyz.shelltime.jetbrains.heartbeat.SocketMessage import xyz.shelltime.jetbrains.heartbeat.StatusResponse @@ -16,6 +18,20 @@ import java.nio.ByteBuffer import java.nio.channels.SocketChannel import java.nio.charset.StandardCharsets +/** + * What became of a heartbeat message + */ +enum class HeartbeatDelivery { + /** The daemon took the heartbeats */ + OK, + + /** The daemon dropped them: code tracking is off in its config */ + DISABLED, + + /** No connection or no reply */ + FAILED +} + /** * Unix domain socket client for communicating with the ShellTime daemon */ @@ -48,16 +64,22 @@ class SocketClient( * Send heartbeats to the daemon * * @param payload The heartbeat payload to send - * @return True if successfully sent, false otherwise + * @return Whether the daemon took the heartbeats */ - suspend fun sendHeartbeats(payload: HeartbeatPayload): Boolean { + suspend fun sendHeartbeats(payload: HeartbeatPayload): HeartbeatDelivery { val message = SocketMessage( type = "heartbeat", payload = payload ) - val response = send(json.encodeToString(message)) - return response != null + val response = send(json.encodeToString(message)) ?: return HeartbeatDelivery.FAILED + val ack = try { + json.decodeFromString(response) + } catch (e: Exception) { + // Any other reply means the daemon read the message + null + } + return if (ack?.status == "disabled") HeartbeatDelivery.DISABLED else HeartbeatDelivery.OK } /** @@ -87,41 +109,51 @@ class SocketClient( * @return The response string, or null if failed */ private suspend fun send(message: String): String? = withContext(Dispatchers.IO) { - withTimeoutOrNull(Constants.CONNECTION_TIMEOUT_MS) { - try { - val socketAddress = UnixDomainSocketAddress.of(socketPath) - SocketChannel.open(StandardProtocolFamily.UNIX).use { channel -> - channel.connect(socketAddress) - channel.configureBlocking(true) - - // Send message with newline delimiter - val messageWithNewline = message + "\n" - val sendBuffer = ByteBuffer.wrap(messageWithNewline.toByteArray(StandardCharsets.UTF_8)) - while (sendBuffer.hasRemaining()) { - channel.write(sendBuffer) - } - - // Read response - val receiveBuffer = ByteBuffer.allocate(8192) - val bytesRead = channel.read(receiveBuffer) - - if (bytesRead > 0) { - receiveBuffer.flip() - val response = StandardCharsets.UTF_8.decode(receiveBuffer).toString().trim() - lastConnectionSuccess = true - logger.log("Sent message, received: $response") - response - } else { - lastConnectionSuccess = true - logger.log("Sent message, no response received") - "" - } + // Blocking socket I/O ignores coroutine cancellation unless it is interruptible, so + // without it the timeout could not stop a daemon that accepts and never answers + val response = withTimeoutOrNull(Constants.CONNECTION_TIMEOUT_MS) { + runInterruptible { exchange(message) } + } + if (response == null) { + lastConnectionSuccess = false + } + response + } + + private fun exchange(message: String): String? { + return try { + val socketAddress = UnixDomainSocketAddress.of(socketPath) + SocketChannel.open(StandardProtocolFamily.UNIX).use { channel -> + channel.connect(socketAddress) + channel.configureBlocking(true) + + // Send message with newline delimiter + val messageWithNewline = message + "\n" + val sendBuffer = ByteBuffer.wrap(messageWithNewline.toByteArray(StandardCharsets.UTF_8)) + while (sendBuffer.hasRemaining()) { + channel.write(sendBuffer) + } + + // Read response + val receiveBuffer = ByteBuffer.allocate(8192) + val bytesRead = channel.read(receiveBuffer) + + if (bytesRead > 0) { + receiveBuffer.flip() + val response = StandardCharsets.UTF_8.decode(receiveBuffer).toString().trim() + lastConnectionSuccess = true + logger.log("Sent message, received: $response") + response + } else { + lastConnectionSuccess = true + logger.log("Sent message, no response received") + "" } - } catch (e: Exception) { - lastConnectionSuccess = false - logger.log("Failed to connect to daemon: ${e.message}") - null } + } catch (e: Exception) { + lastConnectionSuccess = false + logger.log("Failed to connect to daemon: ${e.message}") + null } } diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/ui/StatusBarWidget.kt b/src/main/kotlin/xyz/shelltime/jetbrains/ui/StatusBarWidget.kt index 5c0eff6..d303507 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/ui/StatusBarWidget.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/ui/StatusBarWidget.kt @@ -11,6 +11,7 @@ import com.intellij.openapi.wm.StatusBarWidget import com.intellij.openapi.wm.impl.status.EditorBasedWidget import com.intellij.util.Consumer import xyz.shelltime.jetbrains.ShellTimeBundle +import xyz.shelltime.jetbrains.heartbeat.DaemonState import xyz.shelltime.jetbrains.heartbeat.HeartbeatSenderCallback import xyz.shelltime.jetbrains.services.ShellTimeProjectService import java.awt.event.MouseEvent @@ -26,7 +27,7 @@ class ShellTimeStatusBarWidget(project: Project) : EditorBasedWidget(project), S } @Volatile - private var connected = false + private var state = DaemonState.DISCONNECTED @Volatile private var pendingCount = 0 @@ -36,18 +37,18 @@ class ShellTimeStatusBarWidget(project: Project) : EditorBasedWidget(project), S override fun getPresentation(): StatusBarWidget.WidgetPresentation = this override fun getText(): String { - return if (connected) { - ShellTimeBundle.message("statusbar.connected") - } else { - ShellTimeBundle.message("statusbar.disconnected") + return when (state) { + DaemonState.CONNECTED -> ShellTimeBundle.message("statusbar.connected") + DaemonState.DISABLED -> ShellTimeBundle.message("statusbar.disabled") + DaemonState.DISCONNECTED -> ShellTimeBundle.message("statusbar.disconnected") } } override fun getTooltipText(): String { - return if (connected) { - ShellTimeBundle.message("statusbar.tooltip.connected", pendingCount) - } else { - ShellTimeBundle.message("statusbar.tooltip.disconnected", pendingCount) + return when (state) { + DaemonState.CONNECTED -> ShellTimeBundle.message("statusbar.tooltip.connected", pendingCount) + DaemonState.DISABLED -> ShellTimeBundle.message("statusbar.tooltip.disabled", pendingCount) + DaemonState.DISCONNECTED -> ShellTimeBundle.message("statusbar.tooltip.disconnected", pendingCount) } } @@ -75,8 +76,8 @@ class ShellTimeStatusBarWidget(project: Project) : EditorBasedWidget(project), S } } - override fun onStatusChanged(connected: Boolean, pendingCount: Int) { - this.connected = connected + override fun onStatusChanged(state: DaemonState, pendingCount: Int) { + this.state = state this.pendingCount = pendingCount // Update the status bar on EDT diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/utils/GitUtils.kt b/src/main/kotlin/xyz/shelltime/jetbrains/utils/GitUtils.kt index d0cd324..a923d71 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/utils/GitUtils.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/utils/GitUtils.kt @@ -22,15 +22,38 @@ object GitUtils { } return try { + // The quick lookup only reads the repository cache. getRepositoryForFile may update + // it synchronously, which the platform reports as an error on the EDT. val repositoryManager = GitRepositoryManager.getInstance(project) - val repository = repositoryManager.getRepositoryForFile(file) + val repository = repositoryManager.getRepositoryForFileQuick(file) repository?.currentBranch?.name ?: "" } catch (e: Exception) { + "" + } catch (e: NoClassDefFoundError) { // Git4Idea plugin may not be available "" } } + /** + * Get the root of the Git repository that contains a file + * + * @return The repository root path, or null when the file isn't in a known repository + */ + fun getRepositoryRoot(project: Project?, file: VirtualFile?): String? { + if (project == null || file == null) { + return null + } + + return try { + GitRepositoryManager.getInstance(project).getRepositoryForFileQuick(file)?.root?.path + } catch (e: Exception) { + null + } catch (e: NoClassDefFoundError) { + null + } + } + /** * Get the current Git branch for a file path * diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/utils/LanguageIds.kt b/src/main/kotlin/xyz/shelltime/jetbrains/utils/LanguageIds.kt new file mode 100644 index 0000000..f725d54 --- /dev/null +++ b/src/main/kotlin/xyz/shelltime/jetbrains/utils/LanguageIds.kt @@ -0,0 +1,132 @@ +package xyz.shelltime.jetbrains.utils + +/** + * Maps IDE file types to VS Code language identifiers, the ids the other ShellTime editor + * plugins report and the web merges on, so one language isn't split across editors + */ +object LanguageIds { + /** Lowercased IntelliJ FileType names */ + private val fileTypeIds = mapOf( + "kotlin" to "kotlin", + "java" to "java", + "javascript" to "javascript", + "jsx harmony" to "javascriptreact", + "typescript" to "typescript", + "typescript jsx" to "typescriptreact", + "python" to "python", + "go" to "go", + "rust" to "rust", + "ruby" to "ruby", + "php" to "php", + "swift" to "swift", + "scala" to "scala", + "groovy" to "groovy", + "dart" to "dart", + "lua" to "lua", + "c#" to "csharp", + "html" to "html", + "css" to "css", + "scss" to "scss", + "less" to "less", + "xml" to "xml", + "json" to "json", + "json5" to "json", + "yaml" to "yaml", + "toml" to "toml", + "markdown" to "markdown", + "sql" to "sql", + "graphql" to "graphql", + "vue.js" to "vue", + "svelte" to "svelte", + "shell script" to "shellscript", + "dockerfile" to "dockerfile", + "makefile" to "makefile", + "properties" to "properties", + "protobuf" to "proto", + "plain_text" to "plaintext" + ) + + /** Lowercased file extensions, for file types above that don't say (TextMate, C/C++) */ + private val extensionIds = mapOf( + "ts" to "typescript", + "mts" to "typescript", + "cts" to "typescript", + "tsx" to "typescriptreact", + "js" to "javascript", + "mjs" to "javascript", + "cjs" to "javascript", + "jsx" to "javascriptreact", + "py" to "python", + "pyi" to "python", + "rs" to "rust", + "rb" to "ruby", + "go" to "go", + "kt" to "kotlin", + "kts" to "kotlin", + "java" to "java", + "swift" to "swift", + "scala" to "scala", + "cs" to "csharp", + "c" to "c", + "h" to "c", + "cpp" to "cpp", + "cc" to "cpp", + "cxx" to "cpp", + "hpp" to "cpp", + "hh" to "cpp", + "hxx" to "cpp", + "m" to "objective-c", + "mm" to "objective-cpp", + "sh" to "shellscript", + "bash" to "shellscript", + "zsh" to "shellscript", + "md" to "markdown", + "mdx" to "mdx", + "yml" to "yaml", + "yaml" to "yaml", + "json" to "json", + "jsonc" to "jsonc", + "toml" to "toml", + "xml" to "xml", + "html" to "html", + "htm" to "html", + "css" to "css", + "scss" to "scss", + "less" to "less", + "sql" to "sql", + "graphql" to "graphql", + "gql" to "graphql", + "vue" to "vue", + "svelte" to "svelte", + "dart" to "dart", + "lua" to "lua", + "php" to "php", + "gradle" to "groovy", + "groovy" to "groovy", + "tf" to "terraform", + "proto" to "proto", + "txt" to "plaintext" + ) + + /** Lowercased file names without an extension */ + private val fileNameIds = mapOf( + "dockerfile" to "dockerfile", + "makefile" to "makefile" + ) + + /** + * @param fileTypeName The IDE's FileType name, e.g. "TypeScript JSX" + * @param extension The file extension without the dot, if any + * @param fileName The file name + */ + fun languageId(fileTypeName: String, extension: String?, fileName: String): String { + val typeName = fileTypeName.lowercase() + fileTypeIds[typeName]?.let { return it } + + val ext = extension?.lowercase() + if (!ext.isNullOrEmpty()) { + return extensionIds[ext] ?: ext + } + return fileNameIds[fileName.lowercase()] ?: typeName + } +} diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtils.kt b/src/main/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtils.kt index 402852d..2cab18d 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtils.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtils.kt @@ -9,42 +9,47 @@ import java.io.File */ object ProjectUtils { /** - * Get the project name (last 2 path components) + * Get the project name (last 2 path components of the project root) * * @param project The current project * @param file The current file * @return The project name */ fun getProjectName(project: Project?, file: VirtualFile?): String { - // Try to get from project first - project?.basePath?.let { basePath -> - return extractLastPathComponents(basePath, 2) + val root = getProjectRootPath(project, file) + if (root.isEmpty()) { + return file?.name ?: "Unknown" } - - // Fall back to file's parent directories - file?.let { f -> - val parent = f.parent?.path ?: return f.name - return extractLastPathComponents(parent, 2) - } - - return "Unknown" + return extractLastPathComponents(root, 2) } /** - * Get the project root path + * Get the project root path: the project's base path for its own files; for a file + * outside it (another attached root, a file opened from elsewhere) the Git repository + * it is in, else its directory, as the other editors report it * * @param project The current project * @param file The current file (fallback) * @return The project root path */ fun getProjectRootPath(project: Project?, file: VirtualFile?): String { - // Try project base path first - project?.basePath?.let { return it } + val basePath = project?.basePath + if (basePath != null && (file == null || isUnder(file.path, basePath))) { + return basePath + } + + GitUtils.getRepositoryRoot(project, file)?.let { return it } // Fall back to file's parent directory file?.parent?.path?.let { return it } - return "" + return basePath ?: "" + } + + private fun isUnder(path: String, root: String): Boolean { + val normalizedPath = path.replace('\\', '/') + val normalizedRoot = root.replace('\\', '/').trimEnd('/') + return normalizedPath == normalizedRoot || normalizedPath.startsWith("$normalizedRoot/") } /** @@ -55,40 +60,7 @@ object ProjectUtils { */ fun getLanguage(file: VirtualFile?): String { if (file == null) return "unknown" - - // Try to get from file type - val fileType = file.fileType - val typeName = fileType.name.lowercase() - - // Map common file types to language IDs - return when (typeName) { - "kotlin" -> "kotlin" - "java" -> "java" - "javascript" -> "javascript" - "typescript" -> "typescript" - "python" -> "python" - "go" -> "go" - "rust" -> "rust" - "c" -> "c" - "c++" -> "cpp" - "ruby" -> "ruby" - "php" -> "php" - "swift" -> "swift" - "scala" -> "scala" - "html" -> "html" - "css" -> "css" - "xml" -> "xml" - "json" -> "json" - "yaml" -> "yaml" - "markdown" -> "markdown" - "sql" -> "sql" - "shell script" -> "shellscript" - "plain_text" -> "plaintext" - else -> { - // Try to infer from extension - file.extension?.lowercase() ?: typeName - } - } + return LanguageIds.languageId(file.fileType.name, file.extension, file.name) } /** @@ -130,15 +102,13 @@ object ProjectUtils { return true } - // Exclude common non-code paths + // Exclude paths that never hold the user's sources. Build output (build/, out/, + // target/) is left to the project's own excluded folders: a package can be named + // build too. val excludePatterns = listOf( "/.idea/", - "/build/", - "/out/", - "/target/", "/node_modules/", "/.gradle/", - "/vendor/", "/__pycache__/" ) diff --git a/src/main/resources/messages/ShellTimeBundle.properties b/src/main/resources/messages/ShellTimeBundle.properties index 1467169..c3e1faf 100644 --- a/src/main/resources/messages/ShellTimeBundle.properties +++ b/src/main/resources/messages/ShellTimeBundle.properties @@ -3,8 +3,10 @@ # Status Bar statusbar.connected=ShellTime statusbar.disconnected=ShellTime (offline) +statusbar.disabled=ShellTime (tracking off) statusbar.tooltip.connected=ShellTime: Connected ({0} pending) statusbar.tooltip.disconnected=ShellTime: Disconnected ({0} pending) +statusbar.tooltip.disabled=ShellTime: The daemon has code tracking turned off ({0} pending). Set codeTracking.enabled to true in your ShellTime config (an older daemon also needs a restart). # Actions action.showStatus.title=ShellTime Status @@ -14,6 +16,7 @@ action.flush.title=Flush Heartbeats action.flush.success=Flushed {0} heartbeats to daemon action.flush.empty=No pending heartbeats to flush action.flush.failed=Failed to flush heartbeats. Daemon may not be running. +action.flush.disabled=The daemon has code tracking turned off, {0} heartbeats kept. Set codeTracking.enabled to true in ~/.shelltime/config.yaml (an older daemon also needs shelltime daemon reinstall). # Settings settings.title=ShellTime Settings @@ -25,3 +28,4 @@ settings.heartbeatInterval=Heartbeat flush interval (ms) # Notifications notification.daemonConnected=Connected to ShellTime daemon notification.daemonDisconnected=Disconnected from ShellTime daemon. Heartbeats will be queued. +notification.trackingDisabled=The ShellTime daemon is not recording editor activity because code tracking is off. Set codeTracking.enabled to true in ~/.shelltime/config.yaml (an older daemon also needs shelltime daemon reinstall). Your activity is kept until then. diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/config/ConfigDiscoveryTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/config/ConfigDiscoveryTest.kt new file mode 100644 index 0000000..75605f2 --- /dev/null +++ b/src/test/kotlin/xyz/shelltime/jetbrains/config/ConfigDiscoveryTest.kt @@ -0,0 +1,72 @@ +package xyz.shelltime.jetbrains.config + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +class ConfigDiscoveryTest { + + @TempDir + lateinit var dir: File + + private fun write(name: String, content: String) = File(dir, name).writeText(content) + + @Test + fun `reads config yaml, which shelltime init writes`() { + write("config.yaml", "socketPath: /tmp/yaml.sock\ncodeTracking:\n enabled: false\n") + + val config = ConfigLoader(dir.path).getConfig() + + assertEquals("/tmp/yaml.sock", config.socketPath) + assertFalse(config.enabled) + } + + @Test + fun `prefers config yaml over config yml, like the CLI`() { + write("config.yml", "socketPath: /tmp/yml.sock\n") + write("config.yaml", "socketPath: /tmp/yaml.sock\n") + + assertEquals("/tmp/yaml.sock", ConfigLoader(dir.path).getConfig().socketPath) + } + + @Test + fun `merges config local over the base file`() { + write("config.yaml", "socketPath: /tmp/base.sock\nwebEndpoint: https://shelltime.xyz\ncodeTracking:\n enabled: false\n") + write("config.local.yaml", "socketPath: /tmp/local.sock\ncodeTracking:\n enabled: true\n") + + val config = ConfigLoader(dir.path).getConfig() + + assertEquals("/tmp/local.sock", config.socketPath) + assertEquals("https://shelltime.xyz", config.webEndpoint) + assertTrue(config.enabled) + } + + @Test + fun `a local file that only sets the token keeps tracking as the base says`() { + write("config.yaml", "codeTracking:\n enabled: false\n") + write("config.local.yaml", "codeTracking:\n token: secret\n") + + assertFalse(ConfigLoader(dir.path).getConfig().enabled) + } + + @Test + fun `reloads when a file changes`() { + val loader = ConfigLoader(dir.path) + write("config.yaml", "socketPath: /tmp/first.sock\n") + assertEquals("/tmp/first.sock", loader.getConfig().socketPath) + + write("config.local.yaml", "socketPath: /tmp/second.sock\n") + assertEquals("/tmp/second.sock", loader.getConfig().socketPath) + } + + @Test + fun `defaults without any config file`() { + val config = ConfigLoader(dir.path).getConfig() + + assertTrue(config.enabled) + assertEquals(Constants.DEFAULT_SOCKET_PATH, config.socketPath) + } +} diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/config/ConfigTomlTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/config/ConfigTomlTest.kt new file mode 100644 index 0000000..bc5f472 --- /dev/null +++ b/src/test/kotlin/xyz/shelltime/jetbrains/config/ConfigTomlTest.kt @@ -0,0 +1,36 @@ +package xyz.shelltime.jetbrains.config + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +class ConfigTomlTest { + + @TempDir + lateinit var dir: File + + @Test + fun `reads the keys the CLI writes to config toml`() { + File(dir, "config.toml").writeText( + "APIEndpoint = \"http://localhost:27312\"\nWebEndpoint = \"http://localhost:3302\"\n" + + "socketPath = \"/tmp/toml.sock\"\n\n[codeTracking]\nenabled = true\n" + ) + + val config = ConfigLoader(dir.path).getConfig() + + assertEquals("http://localhost:27312", config.apiEndpoint) + assertEquals("http://localhost:3302", config.webEndpoint) + assertEquals("/tmp/toml.sock", config.socketPath) + assertTrue(config.enabled) + } + + @Test + fun `config yaml wins over an older config toml`() { + File(dir, "config.toml").writeText("socketPath = \"/tmp/old.sock\"\n") + File(dir, "config.yaml").writeText("socketPath: /tmp/new.sock\n") + + assertEquals("/tmp/new.sock", ConfigLoader(dir.path).getConfig().socketPath) + } +} diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatDataTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatDataTest.kt index c59100f..f476f57 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatDataTest.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatDataTest.kt @@ -97,13 +97,15 @@ class HeartbeatDataTest { @Test fun `StatusResponse deserializes correctly`() { - val jsonString = """{"version":"1.0.0","uptime":3600,"platform":"darwin/arm64","goVersion":"1.21"}""" + // What the daemon sends (cli/daemon/socket.go handleStatus) + val jsonString = """{"version":"1.0.0","startedAt":"2026-10-24T07:05:59.862759872Z","uptime":"1h 0m 0s","goVersion":"go1.27.1","platform":"darwin/arm64"}""" val response = json.decodeFromString(jsonString) assertEquals("1.0.0", response.version) - assertEquals(3600L, response.uptime) + assertEquals("1h 0m 0s", response.uptime) + assertEquals("2026-10-24T07:05:59.862759872Z", response.startedAt) assertEquals("darwin/arm64", response.platform) - assertEquals("1.21", response.goVersion) + assertEquals("go1.27.1", response.goVersion) } @Test diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt new file mode 100644 index 0000000..5aff8af --- /dev/null +++ b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt @@ -0,0 +1,170 @@ +package xyz.shelltime.jetbrains.heartbeat + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import xyz.shelltime.jetbrains.config.Constants +import xyz.shelltime.jetbrains.socket.HeartbeatDelivery +import xyz.shelltime.jetbrains.socket.SocketClient + +class HeartbeatSenderTest { + + private val collected = ArrayDeque() + private val sentMessages = mutableListOf>() + private val replies = ArrayDeque() + + private lateinit var collector: HeartbeatCollector + private lateinit var socket: SocketClient + private lateinit var sender: HeartbeatSender + + @BeforeEach + fun setUp() { + collector = mockk(relaxed = true) + every { collector.flush() } answers { + val all = collected.toList() + collected.clear() + all + } + every { collector.getPendingCount() } answers { collected.size } + + socket = mockk(relaxed = true) + coEvery { socket.sendHeartbeats(any()) } answers { + sentMessages.add(firstArg().heartbeats.map { it.heartbeatId }) + replies.removeFirstOrNull() ?: HeartbeatDelivery.OK + } + + sender = HeartbeatSender(collector, socket) + } + + @AfterEach + fun tearDown() { + sender.dispose() + } + + private fun collect(heartbeats: List) = collected.addAll(heartbeats) + + @Test + fun `large backlog is sent in messages of at most MAX_HEARTBEATS_PER_MESSAGE`() = runBlocking { + collect(testHeartbeats(1_200)) + + val result = sender.flush() + + assertEquals(FlushResult(1_200, DaemonState.CONNECTED), result) + assertEquals(listOf(500, 500, 200), sentMessages.map { it.size }) + assertEquals(testHeartbeats(1_200).map { it.heartbeatId }, sentMessages.flatten()) + assertEquals(0, sender.getPendingCount()) + } + + @Test + fun `failed message keeps it and the rest for the next flush, oldest first`() = runBlocking { + collect(testHeartbeats(1_200)) + replies.addAll(listOf(HeartbeatDelivery.OK, HeartbeatDelivery.FAILED)) + + val first = sender.flush() + + assertEquals(FlushResult(500, DaemonState.DISCONNECTED), first) + assertEquals(DaemonState.DISCONNECTED, sender.state) + assertEquals(700, sender.getPendingCount()) + + collect(testHeartbeats(2, prefix = "new")) + sentMessages.clear() + val second = sender.flush() + + assertEquals(FlushResult(702, DaemonState.CONNECTED), second) + val expected = testHeartbeats(1_200).drop(500).map { it.heartbeatId } + listOf("new-0", "new-1") + assertEquals(expected, sentMessages.flatten()) + assertEquals(0, sender.getPendingCount()) + } + + @Test + fun `disabled daemon keeps the heartbeats and reports the disabled state`() = runBlocking { + val states = mutableListOf() + sender.setCallback(object : HeartbeatSenderCallback { + override fun onStatusChanged(state: DaemonState, pendingCount: Int) { + states.add(state) + } + }) + collect(testHeartbeats(3)) + replies.add(HeartbeatDelivery.DISABLED) + + val result = sender.flush() + + assertEquals(FlushResult(0, DaemonState.DISABLED), result) + assertEquals(3, sender.getPendingCount()) + assertEquals(listOf(DaemonState.DISABLED), states) + } + + @Test + fun `queue keeps the newest MAX_PENDING_HEARTBEATS while the daemon is down`() = runBlocking { + replies.addAll(List(20) { HeartbeatDelivery.FAILED }) + collect(testHeartbeats(Constants.MAX_PENDING_HEARTBEATS, prefix = "old")) + sender.flush() + collect(testHeartbeats(10, prefix = "new")) + sender.flush() + + assertEquals(Constants.MAX_PENDING_HEARTBEATS, sender.getPendingCount()) + val pending = sender.drainPending().map { it.heartbeatId } + assertEquals("old-10", pending.first()) + assertEquals("new-9", pending.last()) + } + + @Test + fun `cancelled flush keeps what was not delivered`() { + collect(testHeartbeats(1_200)) + var calls = 0 + coEvery { socket.sendHeartbeats(any()) } answers { + calls++ + if (calls == 2) throw CancellationException("IDE closing") + HeartbeatDelivery.OK + } + + assertThrows { runBlocking { sender.flush() } } + + assertEquals(700, sender.getPendingCount()) + } + + @Test + fun `drainPending returns undelivered heartbeats before newly collected ones and empties the queue`() = runBlocking { + collect(testHeartbeats(2, prefix = "old")) + replies.add(HeartbeatDelivery.FAILED) + sender.flush() + collect(testHeartbeats(1, prefix = "new")) + + val drained = sender.drainPending().map { it.heartbeatId } + + assertEquals(listOf("old-0", "old-1", "new-0"), drained) + assertEquals(0, sender.getPendingCount()) + } + + @Test + fun `empty flush sends nothing`() = runBlocking { + val result = sender.flush() + + assertEquals(0, result.sent) + coVerify(exactly = 0) { socket.sendHeartbeats(any()) } + } + + @Test + fun `refreshState keeps the disabled state while the daemon answers`() = runBlocking { + collect(testHeartbeats(1)) + replies.add(HeartbeatDelivery.DISABLED) + sender.flush() + coEvery { socket.getStatus() } returns StatusResponse(version = "0.1.95") + + sender.refreshState() + assertEquals(DaemonState.DISABLED, sender.state) + + coEvery { socket.getStatus() } returns null + every { socket.isConnected() } returns false + sender.refreshState() + assertEquals(DaemonState.DISCONNECTED, sender.state) + } +} diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/OfflineStoreTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/OfflineStoreTest.kt new file mode 100644 index 0000000..11bd60a --- /dev/null +++ b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/OfflineStoreTest.kt @@ -0,0 +1,69 @@ +package xyz.shelltime.jetbrains.heartbeat + +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.File +import java.io.RandomAccessFile +import java.nio.file.Files + +class OfflineStoreTest { + + private lateinit var dir: File + private lateinit var file: File + + @BeforeEach + fun setUp() { + dir = Files.createTempDirectory("st-offline").toFile() + file = File(dir, ".shelltime/coding-heartbeat.data.log") + } + + @AfterEach + fun tearDown() { + dir.deleteRecursively() + } + + private fun savedPayloads(): List { + val json = Json { ignoreUnknownKeys = true } + return file.readLines().filter { it.isNotBlank() }.map { json.decodeFromString(it) } + } + + @Test + fun `writes one payload line per message the daemon resends`() { + assertTrue(OfflineStore.save(testHeartbeats(1_100), file)) + + val payloads = savedPayloads() + assertEquals(listOf(500, 500, 100), payloads.map { it.heartbeats.size }) + assertEquals(testHeartbeats(1_100), payloads.flatMap { it.heartbeats }) + } + + @Test + fun `appends to what the daemon has not resent yet`() { + file.parentFile.mkdirs() + file.writeText("{\"heartbeats\":[]}\n") + + assertTrue(OfflineStore.save(testHeartbeats(2), file)) + + val lines = file.readLines() + assertEquals(2, lines.size) + assertEquals("{\"heartbeats\":[]}", lines[0]) + assertEquals(listOf("hb-0", "hb-1"), savedPayloads()[1].heartbeats.map { it.heartbeatId }) + } + + @Test + fun `nothing to save writes no file`() { + assertTrue(OfflineStore.save(emptyList(), file)) + assertFalse(file.exists()) + } + + @Test + fun `stops growing a file no daemon reads`() { + file.parentFile.mkdirs() + RandomAccessFile(file, "rw").use { it.setLength(50L * 1024 * 1024 + 1) } + + assertFalse(OfflineStore.save(testHeartbeats(1), file)) + assertEquals(50L * 1024 * 1024 + 1, file.length()) + } +} diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/TestHeartbeats.kt b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/TestHeartbeats.kt new file mode 100644 index 0000000..75d2bf8 --- /dev/null +++ b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/TestHeartbeats.kt @@ -0,0 +1,27 @@ +package xyz.shelltime.jetbrains.heartbeat + +/** + * A heartbeat with the given id, for tests that only care which heartbeats travel + */ +fun testHeartbeat(id: String, time: Long = 1704067200): HeartbeatData = HeartbeatData( + heartbeatId = id, + entity = "/project/src/Main.kt", + entityType = "file", + category = "coding", + time = time, + project = "user/project", + projectRootPath = "/project", + branch = "main", + language = "kotlin", + editor = "jetbrains", + editorVersion = "IntelliJ IDEA 2024.1", + plugin = "shelltime", + pluginVersion = "0.0.1", + machine = "laptop", + os = "Linux", + osVersion = "6.1", + isWrite = false +) + +fun testHeartbeats(count: Int, prefix: String = "hb"): List = + (0 until count).map { testHeartbeat("$prefix-$it") } diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt new file mode 100644 index 0000000..7005101 --- /dev/null +++ b/src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt @@ -0,0 +1,151 @@ +package xyz.shelltime.jetbrains.socket + +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import xyz.shelltime.jetbrains.heartbeat.HeartbeatPayload +import xyz.shelltime.jetbrains.heartbeat.testHeartbeats +import java.io.File +import java.net.StandardProtocolFamily +import java.net.UnixDomainSocketAddress +import java.nio.ByteBuffer +import java.nio.channels.ServerSocketChannel +import java.nio.channels.SocketChannel +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit + +class SocketClientTest { + + private lateinit var dir: File + private lateinit var socketPath: String + private var server: ServerSocketChannel? = null + private val received = CopyOnWriteArrayList() + + @BeforeEach + fun setUp() { + dir = Files.createTempDirectory("st-sock").toFile() + socketPath = File(dir, "d.sock").absolutePath + } + + @AfterEach + fun tearDown() { + server?.close() + dir.deleteRecursively() + } + + /** + * A daemon that reads one line per connection and answers it with [reply], or never + * answers when [reply] is null + */ + private fun startDaemon(reply: String?) { + val channel = ServerSocketChannel.open(StandardProtocolFamily.UNIX) + channel.bind(UnixDomainSocketAddress.of(socketPath)) + server = channel + Thread { + while (channel.isOpen) { + val conn = try { + channel.accept() + } catch (e: Exception) { + return@Thread + } + Thread { serve(conn, reply) }.apply { isDaemon = true }.start() + } + }.apply { isDaemon = true }.start() + } + + private fun serve(conn: SocketChannel, reply: String?) { + conn.use { + val line = StringBuilder() + val buffer = ByteBuffer.allocate(64 * 1024) + while (!line.endsWith("\n")) { + buffer.clear() + if (conn.read(buffer) < 0) return + buffer.flip() + line.append(StandardCharsets.UTF_8.decode(buffer)) + } + received.add(line.toString().trim()) + if (reply == null) { + Thread.sleep(TimeUnit.SECONDS.toMillis(30)) + return + } + conn.write(ByteBuffer.wrap((reply + "\n").toByteArray(StandardCharsets.UTF_8))) + } + } + + private fun payload(count: Int) = HeartbeatPayload(heartbeats = testHeartbeats(count)) + + @Test + fun `ok reply delivers the heartbeats as one heartbeat message`() = runBlocking { + startDaemon("""{"status":"ok"}""") + val client = SocketClient(socketPath) + + assertEquals(HeartbeatDelivery.OK, client.sendHeartbeats(payload(3))) + assertTrue(client.isConnected()) + + assertEquals(1, received.size) + val message = Json.parseToJsonElement(received[0]).jsonObject + assertEquals("heartbeat", message["type"]!!.jsonPrimitive.content) + val heartbeats = message["payload"]!!.jsonObject["heartbeats"]!!.jsonArray + assertEquals(listOf("hb-0", "hb-1", "hb-2"), heartbeats.map { it.jsonObject["heartbeatId"]!!.jsonPrimitive.content }) + } + + @Test + fun `disabled reply is not a delivery`() = runBlocking { + startDaemon("""{"status":"disabled"}""") + val client = SocketClient(socketPath) + + assertEquals(HeartbeatDelivery.DISABLED, client.sendHeartbeats(payload(1))) + // The daemon runs, it only drops editor heartbeats + assertTrue(client.isConnected()) + } + + @Test + fun `reply from an older daemon without a status still counts as delivered`() = runBlocking { + startDaemon("ok") + val client = SocketClient(socketPath) + + assertEquals(HeartbeatDelivery.OK, client.sendHeartbeats(payload(1))) + } + + @Test + fun `no daemon fails`() = runBlocking { + val client = SocketClient(socketPath) + + assertEquals(HeartbeatDelivery.FAILED, client.sendHeartbeats(payload(1))) + assertFalse(client.isConnected()) + } + + @Test + @Timeout(15) + fun `daemon that never answers fails after the connection timeout`() = runBlocking { + startDaemon(null) + val client = SocketClient(socketPath) + + val started = System.nanoTime() + assertEquals(HeartbeatDelivery.FAILED, client.sendHeartbeats(payload(1))) + val elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started) + + assertTrue(elapsedMs < 10_000, "took ${elapsedMs}ms") + assertFalse(client.isConnected()) + } + + @Test + fun `status reply is parsed`() = runBlocking { + startDaemon("""{"version":"0.1.95","uptime":"1h2m3s","startedAt":"2026-10-24T08:00:00Z"}""") + val client = SocketClient(socketPath) + + val status = client.getStatus() + assertNotNull(status) + assertEquals("0.1.95", status!!.version) + assertEquals("1h2m3s", status.uptime) + } +} diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/utils/LanguageIdsTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/utils/LanguageIdsTest.kt new file mode 100644 index 0000000..40b086f --- /dev/null +++ b/src/test/kotlin/xyz/shelltime/jetbrains/utils/LanguageIdsTest.kt @@ -0,0 +1,35 @@ +package xyz.shelltime.jetbrains.utils + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class LanguageIdsTest { + + @Test + fun `file types map to the VS Code language ids`() { + assertEquals("typescriptreact", LanguageIds.languageId("TypeScript JSX", "tsx", "App.tsx")) + assertEquals("typescript", LanguageIds.languageId("TypeScript", "ts", "index.ts")) + assertEquals("csharp", LanguageIds.languageId("C#", "cs", "Program.cs")) + assertEquals("shellscript", LanguageIds.languageId("Shell Script", "sh", "run.sh")) + assertEquals("plaintext", LanguageIds.languageId("PLAIN_TEXT", "txt", "notes.txt")) + assertEquals("kotlin", LanguageIds.languageId("Kotlin", "kts", "build.gradle.kts")) + } + + @Test + fun `file types that don't say fall back to the extension`() { + // TextMate highlighting in IDEs without the language plugin, CLion's C/C++ + assertEquals("python", LanguageIds.languageId("TextMate", "py", "main.py")) + assertEquals("rust", LanguageIds.languageId("TextMate", "rs", "lib.rs")) + assertEquals("cpp", LanguageIds.languageId("ObjectiveC", "hpp", "util.hpp")) + assertEquals("c", LanguageIds.languageId("ObjectiveC", "h", "util.h")) + assertEquals("groovy", LanguageIds.languageId("Gradle", "gradle", "build.gradle")) + assertEquals("zig", LanguageIds.languageId("TextMate", "zig", "main.zig")) + } + + @Test + fun `files without an extension use their name`() { + assertEquals("dockerfile", LanguageIds.languageId("Dockerfile", null, "Dockerfile")) + assertEquals("makefile", LanguageIds.languageId("TextMate", null, "Makefile")) + assertEquals("unknown file type", LanguageIds.languageId("Unknown File Type", null, "LICENSE")) + } +} diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtilsTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtilsTest.kt index 954b759..0c19a09 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtilsTest.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtilsTest.kt @@ -17,10 +17,12 @@ class ProjectUtilsTest { } @Test - fun `shouldExclude returns true for build directories`() { - assertTrue(ProjectUtils.shouldExclude("/project/build/classes/Main.class")) - assertTrue(ProjectUtils.shouldExclude("/project/out/production/file.class")) - assertTrue(ProjectUtils.shouldExclude("/project/target/classes/Main.class")) + fun `shouldExclude leaves build directory names to the project's excluded folders`() { + // Real sources live in packages named build, out, target or vendor + assertFalse(ProjectUtils.shouldExclude("/project/src/main/kotlin/com/acme/build/Foo.kt", "/project")) + assertFalse(ProjectUtils.shouldExclude("/project/internal/build/build.go", "/project")) + assertFalse(ProjectUtils.shouldExclude("/project/vendor/lib/lib.go", "/project")) + assertFalse(ProjectUtils.shouldExclude("/project/target/classes/Main.class", "/project")) } @Test @@ -45,7 +47,7 @@ class ProjectUtilsTest { @Test fun `shouldExclude still excludes directories inside the project`() { - assertTrue(ProjectUtils.shouldExclude("/home/ci/build/repo/build/classes/Main.class", "/home/ci/build/repo")) + assertTrue(ProjectUtils.shouldExclude("/home/ci/build/repo/.idea/workspace.xml", "/home/ci/build/repo")) assertTrue(ProjectUtils.shouldExclude("/home/ci/build/repo/.git/config", "/home/ci/build/repo")) assertTrue(ProjectUtils.shouldExclude("/app/web/node_modules/pkg/index.js", "/app")) } @@ -53,7 +55,7 @@ class ProjectUtilsTest { @Test fun `shouldExclude checks the full path for files outside the project`() { assertTrue(ProjectUtils.shouldExclude("/other/node_modules/pkg/index.js", "/app")) - assertTrue(ProjectUtils.shouldExclude("/application/build/Main.class", "/app")) + assertTrue(ProjectUtils.shouldExclude("/application/.gradle/caches/file", "/app")) } @Test From ea9233df942206dd90196f72f403728b45703723 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 11:12:16 +0000 Subject: [PATCH 02/12] docs(readme): describe config discovery, tracking-off state and resend file Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi --- CLAUDE.md | 9 +++++++-- README.md | 17 ++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cffa3c3..a9b2f4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,13 +53,18 @@ IDE Events (file open/edit/save) → FileEditorListener / FileSaveListener (plugin.xml listeners) → ShellTimeProjectService → HeartbeatCollector (debounces events, max 1 per file per 30s) - → HeartbeatSender (periodic flush every 2 min) + → HeartbeatSender (periodic flush every 2 min, at most 500 per message) → SocketClient (Unix socket to daemon) ``` +The daemon answers `{"status":"ok"}`, or `{"status":"disabled"}` when code tracking is off in its config. +`disabled` and no reply keep the heartbeats queued; on project close what is left goes to the daemon's resend file +(`OfflineStore`, `~/.shelltime/coding-heartbeat.data.log`). + ### Configuration Sources -1. **File config**: `~/.shelltime/config.toml` or `~/.shelltime/config.yaml` (loaded by ConfigLoader) +1. **File config**: the files the CLI reads, the first of `~/.shelltime/config.yaml`, `config.yml` and `config.toml` with + the first `config.local.*` merged over it (loaded by ConfigLoader when the IDE starts) 2. **IDE settings**: Settings → Tools → ShellTime (ShellTimeConfigurable) ### Key Extension Points (plugin.xml) diff --git a/README.md b/README.md index 92d96fd..0f49bb2 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,9 @@ Or if using `~/.shelltime/config.toml`: enabled = true ``` -The plugin reads the same file when the IDE starts: `codeTracking.enabled` decides whether it tracks, and `socketPath` (if set) tells it where to find the daemon. +Recent daemons pick the change up right away; older ones need a restart (`shelltime daemon reinstall`). + +The plugin reads the same files as the CLI: the first of `~/.shelltime/config.yaml`, `config.yml` and `config.toml`, with the first `config.local.yaml`, `config.local.yml` or `config.local.toml` merged over it. `codeTracking.enabled` decides whether it tracks, and `socketPath` (if set) tells it where to find the daemon. ### Verify Installation @@ -124,14 +126,14 @@ Access commands from **Tools** → **ShellTime**: The plugin monitors your IDE activity and sends heartbeats to a local daemon: -1. **Event Monitoring** - Tracks file opens and tab switches, edits, cursor movements and saves, including in tabs restored when a project opens. Each event is attributed to the project the file is open in. +1. **Event Monitoring** - Tracks switching to a file, typing, cursor movements and saves of open files. Each event is attributed to the project the file is open in. Only what you do counts: tabs restored when a project opens, edits while the IDE is in the background, files reloaded from disk (after `git checkout`, or a formatter or agent in a terminal) and files a refactoring saves without opening them are ignored. 2. **Debouncing** - Batches events to reduce overhead (max 1 heartbeat per file per 30 seconds). Saves always count; repeated cursor or tab events at the same position are skipped. -3. **Periodic Flush** - Sends collected heartbeats to the daemon every 2 minutes (configurable) -4. **Offline Support** - Keeps heartbeats in memory when the daemon is unavailable and retries on the next flush. Closing a project or the IDE flushes whatever is still pending (waiting at most 2 seconds). +3. **Periodic Flush** - Sends collected heartbeats to the daemon every 2 minutes (configurable), at most 500 per message +4. **Offline Support** - Keeps heartbeats in memory (up to 5,000, oldest dropped first) while the daemon is unavailable or has code tracking turned off, and retries on the next flush. Closing a project or the IDE flushes whatever is still pending (waiting at most 2 seconds); what the daemon can't take then goes to `~/.shelltime/coding-heartbeat.data.log`, which the daemon resends once it runs. -Each heartbeat records the file, project, Git branch (through the bundled Git plugin), language, line count and cursor position, IDE and plugin versions, OS, and machine hostname. The hostname is the same one the ShellTime CLI reports, so IDE, terminal and AI activity on one computer are grouped together. Heartbeats sent while a debugger session is running are categorized as `debugging` instead of `coding`. +Each heartbeat records the file, project, Git branch (through the bundled Git plugin), language (as the VS Code language id, e.g. `typescriptreact` or `shellscript`, so the same language from different editors adds up), line count and cursor position, IDE and plugin versions, OS, and machine hostname. The hostname is the same one the ShellTime CLI reports, so IDE, terminal and AI activity on one computer are grouped together. Heartbeats sent while a debugger session is running are categorized as `debugging` instead of `coding`. -Files outside the local file system are not tracked, and neither are paths inside the project under `.git`, `.idea`, `build`, `out`, `target`, `node_modules`, `.gradle`, `vendor` or `__pycache__`. These are matched relative to the project root, so a project that itself lives under e.g. `~/build/` is still tracked. +Files outside the local file system are not tracked, and neither are folders the project excludes (build output such as `build`, `out` or `target`) or paths inside the project under `.git`, `.idea`, `node_modules`, `.gradle` or `__pycache__`. These are matched relative to the project root, so a project that itself lives under e.g. `~/build/` is still tracked. A file outside the project's base directory counts toward the Git repository it is in. ## CLI Update Check @@ -142,9 +144,10 @@ When a project opens, the plugin asks the daemon for its version and checks it a The plugin shows its status in the IDE status bar: - **ShellTime** - Connected and tracking +- **ShellTime (tracking off)** - The daemon runs but has code tracking turned off in its config (heartbeats queued) - **ShellTime (offline)** - Daemon not reachable (heartbeats queued) -The state updates on each flush, so it shows offline until the first heartbeats have been sent. Hover over it for the number of pending heartbeats, or click it to view daemon status. +The state is checked when a project opens and updates on each flush. Hover over it for the number of pending heartbeats, or click it to view daemon status. ## Supported IDEs From fd8be392a9437b08af572d2f33b9a14f3d2c4725 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 12:36:37 +0000 Subject: [PATCH 03/12] fix(socket): count a closed connection without a reply only while the daemon still answers status A stopping daemon closes its listener, then drops a heartbeat message it can no longer queue without replying, so treating every blank reply as delivered lost up to 500 heartbeats. Daemons before 0.1.48 never replied to heartbeats, so a blank reply is still delivered when a status probe answers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi --- CLAUDE.md | 4 +- .../jetbrains/socket/SocketClient.kt | 8 +++- .../jetbrains/socket/SocketClientTest.kt | 48 +++++++++++++++++-- 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a9b2f4c..0260d8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,9 @@ IDE Events (file open/edit/save) ``` The daemon answers `{"status":"ok"}`, or `{"status":"disabled"}` when code tracking is off in its config. -`disabled` and no reply keep the heartbeats queued; on project close what is left goes to the daemon's resend file +A connection closed without a reply counts as delivered only if the daemon then answers `status`: daemons before 0.1.48 +never replied, and a stopping daemon closes its listener before it drops a message. Otherwise `disabled`, no reply and +no connection keep the heartbeats queued; on project close what is left goes to the daemon's resend file (`OfflineStore`, `~/.shelltime/coding-heartbeat.data.log`). ### Configuration Sources diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/socket/SocketClient.kt b/src/main/kotlin/xyz/shelltime/jetbrains/socket/SocketClient.kt index 982f1df..f6f3736 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/socket/SocketClient.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/socket/SocketClient.kt @@ -28,7 +28,7 @@ enum class HeartbeatDelivery { /** The daemon dropped them: code tracking is off in its config */ DISABLED, - /** No connection or no reply */ + /** No connection, a timeout, or no reply from a daemon that is stopping */ FAILED } @@ -73,6 +73,12 @@ class SocketClient( ) val response = send(json.encodeToString(message)) ?: return HeartbeatDelivery.FAILED + if (response.isBlank()) { + // Daemons before 0.1.48 took heartbeats without ever replying, so retrying would + // resend them forever. A stopping daemon also closes without a reply when it drops + // a message, but it closes its listener first: one that still answers took them. + return if (getStatus() != null) HeartbeatDelivery.OK else HeartbeatDelivery.FAILED + } val ack = try { json.decodeFromString(response) } catch (e: Exception) { diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt index 7005101..1bbfd31 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt @@ -47,6 +47,14 @@ class SocketClientTest { * answers when [reply] is null */ private fun startDaemon(reply: String?) { + startDaemon { _ -> reply } + } + + /** + * A daemon that answers each message with what [reply] returns for it: never when that + * is null, and nothing when it is empty (it closes the connection) + */ + private fun startDaemon(reply: (String) -> String?) { val channel = ServerSocketChannel.open(StandardProtocolFamily.UNIX) channel.bind(UnixDomainSocketAddress.of(socketPath)) server = channel @@ -62,7 +70,7 @@ class SocketClientTest { }.apply { isDaemon = true }.start() } - private fun serve(conn: SocketChannel, reply: String?) { + private fun serve(conn: SocketChannel, reply: (String) -> String?) { conn.use { val line = StringBuilder() val buffer = ByteBuffer.allocate(64 * 1024) @@ -72,17 +80,24 @@ class SocketClientTest { buffer.flip() line.append(StandardCharsets.UTF_8.decode(buffer)) } - received.add(line.toString().trim()) - if (reply == null) { + val message = line.toString().trim() + received.add(message) + val answer = reply(message) + if (answer == null) { Thread.sleep(TimeUnit.SECONDS.toMillis(30)) return } - conn.write(ByteBuffer.wrap((reply + "\n").toByteArray(StandardCharsets.UTF_8))) + if (answer.isNotEmpty()) { + conn.write(ByteBuffer.wrap((answer + "\n").toByteArray(StandardCharsets.UTF_8))) + } } } private fun payload(count: Int) = HeartbeatPayload(heartbeats = testHeartbeats(count)) + private fun typeOf(message: String) = + Json.parseToJsonElement(message).jsonObject["type"]!!.jsonPrimitive.content + @Test fun `ok reply delivers the heartbeats as one heartbeat message`() = runBlocking { startDaemon("""{"status":"ok"}""") @@ -116,6 +131,31 @@ class SocketClientTest { assertEquals(HeartbeatDelivery.OK, client.sendHeartbeats(payload(1))) } + @Test + fun `no reply from a daemon that still answers status counts as delivered`() = runBlocking { + // Daemons before 0.1.48 took heartbeats without replying + startDaemon { message -> if (typeOf(message) == "status") """{"version":"0.1.47"}""" else "" } + val client = SocketClient(socketPath) + + assertEquals(HeartbeatDelivery.OK, client.sendHeartbeats(payload(1))) + assertTrue(client.isConnected()) + assertEquals(listOf("heartbeat", "status"), received.map { typeOf(it) }) + } + + @Test + fun `no reply from a daemon that is stopping fails`() = runBlocking { + // A stopping daemon closes its listener, then drops the message it could not queue + startDaemon { _ -> + server?.close() + "" + } + val client = SocketClient(socketPath) + + assertEquals(HeartbeatDelivery.FAILED, client.sendHeartbeats(payload(1))) + assertFalse(client.isConnected()) + assertEquals(listOf("heartbeat"), received.map { typeOf(it) }) + } + @Test fun `no daemon fails`() = runBlocking { val client = SocketClient(socketPath) From 764bb55b802fd98281e4d1625c744daf9d16b3d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 12:36:37 +0000 Subject: [PATCH 04/12] fix(heartbeat): wait for a cancelled periodic flush before the final flush on project close stop() only cancelled the flush job, so a flush cancelled mid-send could put its heartbeats back after dispose had drained the queue to the resend file and cleared it. dispose now calls stopAndJoin() first. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi --- .../jetbrains/heartbeat/HeartbeatSender.kt | 10 ++++++ .../services/ShellTimeProjectService.kt | 3 +- .../heartbeat/HeartbeatSenderTest.kt | 34 +++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSender.kt b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSender.kt index 99c9e32..ddb26dc 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSender.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSender.kt @@ -92,6 +92,16 @@ class HeartbeatSender( logger.log("Stopped heartbeat sender") } + /** + * Stop the periodic flush timer and wait until a flush it was running has queued what it + * did not deliver again, so nothing reads the queue before that + */ + suspend fun stopAndJoin() { + val job = flushJob + stop() + job?.cancelAndJoin() + } + /** * Flush pending heartbeats to the daemon, at most MAX_HEARTBEATS_PER_MESSAGE per message * diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt b/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt index e59f23f..b5eddca 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt @@ -213,10 +213,11 @@ class ShellTimeProjectService(private val project: Project) : Disposable { collector.stop() } if (::sender.isInitialized) { - sender.stop() // Send what is still queued; this runs when the project closes and on IDE exit try { runBlocking(Dispatchers.IO) { + // A periodic flush cancelled mid-send puts its heartbeats back first + sender.stopAndJoin() withTimeoutOrNull(FINAL_FLUSH_TIMEOUT_MS) { sender.flush() } } } catch (e: Exception) { diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt index 5aff8af..57459f9 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt @@ -5,11 +5,17 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout import org.junit.jupiter.api.assertThrows import xyz.shelltime.jetbrains.config.Constants import xyz.shelltime.jetbrains.socket.HeartbeatDelivery @@ -131,6 +137,34 @@ class HeartbeatSenderTest { assertEquals(700, sender.getPendingCount()) } + @Test + @Timeout(10) + fun `stopAndJoin waits for a periodic flush cancelled mid-send to put its heartbeats back`() { + collect(testHeartbeats(3)) + val sending = CompletableDeferred() + coEvery { socket.sendHeartbeats(any()) } coAnswers { + sending.complete(Unit) + try { + awaitCancellation() + } finally { + // Closing the interrupted socket takes a moment + withContext(NonCancellable) { delay(200) } + } + } + // MIN_FLUSH_INTERVAL_MS would make the test wait 10 s for the first periodic flush + HeartbeatSender::class.java.getDeclaredField("flushInterval") + .apply { isAccessible = true } + .setLong(sender, 10) + sender.start() + + runBlocking { + sending.await() + sender.stopAndJoin() + } + + assertEquals(listOf("hb-0", "hb-1", "hb-2"), sender.drainPending().map { it.heartbeatId }) + } + @Test fun `drainPending returns undelivered heartbeats before newly collected ones and empties the queue`() = runBlocking { collect(testHeartbeats(2, prefix = "old")) From 421f92b8c3a43dfd3d2740e0d1a84bc01bb18f17 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 12:36:37 +0000 Subject: [PATCH 05/12] fix(collector): ignore tab switches while the IDE is in the background handleFileSwitch lacked the isActive guard the document and caret handlers have, so the tab a project restores in the background, and tabs a debugger selects there, produced heartbeats. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi --- README.md | 2 +- .../xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0f49bb2..a982997 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ Access commands from **Tools** → **ShellTime**: The plugin monitors your IDE activity and sends heartbeats to a local daemon: -1. **Event Monitoring** - Tracks switching to a file, typing, cursor movements and saves of open files. Each event is attributed to the project the file is open in. Only what you do counts: tabs restored when a project opens, edits while the IDE is in the background, files reloaded from disk (after `git checkout`, or a formatter or agent in a terminal) and files a refactoring saves without opening them are ignored. +1. **Event Monitoring** - Tracks switching to a file, typing, cursor movements and saves of open files. Each event is attributed to the project the file is open in. Only what you do counts: tabs a project restores behind the selected one, tab switches and edits while the IDE is in the background, files reloaded from disk (after `git checkout`, or a formatter or agent in a terminal) and files a refactoring saves without opening them are ignored. 2. **Debouncing** - Batches events to reduce overhead (max 1 heartbeat per file per 30 seconds). Saves always count; repeated cursor or tab events at the same position are skipped. 3. **Periodic Flush** - Sends collected heartbeats to the daemon every 2 minutes (configurable), at most 500 per message 4. **Offline Support** - Keeps heartbeats in memory (up to 5,000, oldest dropped first) while the daemon is unavailable or has code tracking turned off, and retries on the next flush. Closing a project or the IDE flushes whatever is still pending (waiting at most 2 seconds); what the daemon can't take then goes to `~/.shelltime/coding-heartbeat.data.log`, which the daemon resends once it runs. diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt index e4abea4..15c25d0 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt @@ -263,6 +263,9 @@ class HeartbeatCollector( * Handle file switch events */ fun handleFileSwitch(file: VirtualFile?) { + // The tab a project restores while the IDE is in the background, and tabs a debugger + // or another tool selects there, aren't the user's + if (!ApplicationManager.getApplication().isActive) return if (!isValidFile(file)) return val document = FileDocumentManager.getInstance().getDocument(file!!) ?: return From b6474c2d150f721dd180e91e0703d31de4ef941a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:18:04 +0000 Subject: [PATCH 06/12] fix(heartbeat): keep what a flush still sending leaves after the project closed A Flush action or the flush that turning tracking off starts can still be sending when the project closes. Once the queue has been drained for the daemon's resend file, what such a flush doesn't deliver now goes to that file too, instead of into a queue nothing reads again. The sender takes the file as a parameter, so tests never write the daemon's real one. A reply-less close now counts as delivered when the daemon still takes connections, not when it answers status: daemon 0.1.42 took heartbeats without replying and has no status handler. The test task fails a test that hangs after 60 seconds with its stack trace and logs each test's result, instead of stalling CI until its timeout. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi --- CLAUDE.md | 4 +- build.gradle.kts | 7 +++ .../jetbrains/heartbeat/HeartbeatSender.kt | 44 ++++++++++++++----- .../jetbrains/socket/SocketClient.kt | 9 ++-- .../heartbeat/HeartbeatSenderTest.kt | 36 ++++++++++++++- .../jetbrains/socket/SocketClientTest.kt | 8 ++-- 6 files changed, 85 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0260d8f..4895f12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,8 +58,8 @@ IDE Events (file open/edit/save) ``` The daemon answers `{"status":"ok"}`, or `{"status":"disabled"}` when code tracking is off in its config. -A connection closed without a reply counts as delivered only if the daemon then answers `status`: daemons before 0.1.48 -never replied, and a stopping daemon closes its listener before it drops a message. Otherwise `disabled`, no reply and +A connection closed without a reply counts as delivered only if the daemon still takes connections: daemons 0.1.42 to +0.1.47 never replied, and a stopping daemon closes its listener before it drops a message. Otherwise `disabled`, no reply and no connection keep the heartbeats queued; on project close what is left goes to the daemon's resend file (`OfflineStore`, `~/.shelltime/coding-heartbeat.data.log`). diff --git a/build.gradle.kts b/build.gradle.kts index 51b1600..b3cebfb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -128,6 +128,13 @@ kover { tasks { test { useJUnitPlatform() + // A test that hangs fails with its stack trace instead of stalling the job until it times out + systemProperty("junit.jupiter.execution.timeout.default", "60s") + systemProperty("junit.jupiter.execution.timeout.thread.mode.default", "SEPARATE_THREAD") + testLogging { + events("passed", "skipped", "failed") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } } wrapper { diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSender.kt b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSender.kt index ddb26dc..b2de631 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSender.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSender.kt @@ -6,6 +6,7 @@ import xyz.shelltime.jetbrains.config.Constants import xyz.shelltime.jetbrains.socket.HeartbeatDelivery import xyz.shelltime.jetbrains.socket.SocketClient import xyz.shelltime.jetbrains.utils.Logger +import java.io.File import java.util.concurrent.ConcurrentLinkedDeque /** @@ -38,7 +39,8 @@ class HeartbeatSender( private val collector: HeartbeatCollector, private val socketClient: SocketClient, flushInterval: Long = Constants.DEFAULT_FLUSH_INTERVAL_MS, - debug: Boolean = false + debug: Boolean = false, + private val offlineFile: File = OfflineStore.DEFAULT_FILE ) : Disposable { private val logger = Logger("Sender", debug) private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) @@ -46,6 +48,12 @@ class HeartbeatSender( private var callback: HeartbeatSenderCallback? = null private var flushInterval = flushInterval.coerceAtLeast(Constants.MIN_FLUSH_INTERVAL_MS) + // Guards requeue against drainPending, so nothing is put back after the queue was drained + private val queueLock = Any() + + @Volatile + private var drained = false + // Heartbeats that failed to send (offline support), oldest first private val failedHeartbeats = ConcurrentLinkedDeque() @@ -159,11 +167,19 @@ class HeartbeatSender( * Put heartbeats back ahead of newer ones, dropping the oldest beyond MAX_PENDING_HEARTBEATS */ private fun requeue(heartbeats: List) { - for (hb in heartbeats.asReversed()) { - failedHeartbeats.addFirst(hb) - } - while (failedHeartbeats.size > Constants.MAX_PENDING_HEARTBEATS) { - failedHeartbeats.pollFirst() + synchronized(queueLock) { + if (drained) { + // The project closed while this flush was sending (a Flush action, or tracking + // turned off): its queue is already gone, so the daemon sends these later + OfflineStore.save(heartbeats, offlineFile) + return + } + for (hb in heartbeats.asReversed()) { + failedHeartbeats.addFirst(hb) + } + while (failedHeartbeats.size > Constants.MAX_PENDING_HEARTBEATS) { + failedHeartbeats.pollFirst() + } } } @@ -180,13 +196,17 @@ class HeartbeatSender( * Take every heartbeat not yet delivered, oldest first, leaving none queued */ fun drainPending(): List { - val pending = mutableListOf() - while (true) { - val hb = failedHeartbeats.pollFirst() ?: break - pending.add(hb) + synchronized(queueLock) { + // From now on a flush still sending leaves what it doesn't deliver in the daemon's file + drained = true + val pending = mutableListOf() + while (true) { + val hb = failedHeartbeats.pollFirst() ?: break + pending.add(hb) + } + pending.addAll(collector.flush()) + return pending } - pending.addAll(collector.flush()) - return pending } /** diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/socket/SocketClient.kt b/src/main/kotlin/xyz/shelltime/jetbrains/socket/SocketClient.kt index f6f3736..c866cfc 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/socket/SocketClient.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/socket/SocketClient.kt @@ -74,10 +74,11 @@ class SocketClient( val response = send(json.encodeToString(message)) ?: return HeartbeatDelivery.FAILED if (response.isBlank()) { - // Daemons before 0.1.48 took heartbeats without ever replying, so retrying would - // resend them forever. A stopping daemon also closes without a reply when it drops - // a message, but it closes its listener first: one that still answers took them. - return if (getStatus() != null) HeartbeatDelivery.OK else HeartbeatDelivery.FAILED + // Daemons 0.1.42 to 0.1.47 took heartbeats without ever replying (0.1.42 doesn't + // answer status either), so retrying would resend them forever. A stopping daemon + // also closes without a reply when it drops a message, but it closes its listener + // first: one that still takes connections took them. + return if (testConnection()) HeartbeatDelivery.OK else HeartbeatDelivery.FAILED } val ack = try { json.decodeFromString(response) diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt index 57459f9..9ca471c 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt @@ -7,6 +7,7 @@ import io.mockk.mockk import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking @@ -20,6 +21,8 @@ import org.junit.jupiter.api.assertThrows import xyz.shelltime.jetbrains.config.Constants import xyz.shelltime.jetbrains.socket.HeartbeatDelivery import xyz.shelltime.jetbrains.socket.SocketClient +import java.io.File +import java.nio.file.Files class HeartbeatSenderTest { @@ -30,6 +33,7 @@ class HeartbeatSenderTest { private lateinit var collector: HeartbeatCollector private lateinit var socket: SocketClient private lateinit var sender: HeartbeatSender + private lateinit var offlineFile: File @BeforeEach fun setUp() { @@ -47,12 +51,15 @@ class HeartbeatSenderTest { replies.removeFirstOrNull() ?: HeartbeatDelivery.OK } - sender = HeartbeatSender(collector, socket) + // Never the daemon's real file: a running daemon would upload what lands there + offlineFile = File(Files.createTempDirectory("st-sender").toFile(), "coding-heartbeat.data.log") + sender = HeartbeatSender(collector, socket, offlineFile = offlineFile) } @AfterEach fun tearDown() { sender.dispose() + offlineFile.parentFile.deleteRecursively() } private fun collect(heartbeats: List) = collected.addAll(heartbeats) @@ -165,6 +172,33 @@ class HeartbeatSenderTest { assertEquals(listOf("hb-0", "hb-1", "hb-2"), sender.drainPending().map { it.heartbeatId }) } + @Test + @Timeout(10) + fun `a flush that fails after the queue was drained leaves its heartbeats in the daemon's file`() { + // A Flush action still sending when the project closes + collect(testHeartbeats(3)) + val sending = CompletableDeferred() + val reply = CompletableDeferred() + coEvery { socket.sendHeartbeats(any()) } coAnswers { + sending.complete(Unit) + reply.await() + } + + runBlocking { + val flush = async { sender.flush() } + sending.await() + assertEquals(emptyList(), sender.drainPending()) + reply.complete(HeartbeatDelivery.FAILED) + flush.await() + } + + assertEquals(0, sender.getPendingCount()) + val saved = offlineFile.readLines().flatMap { + kotlinx.serialization.json.Json.decodeFromString(it).heartbeats + } + assertEquals(listOf("hb-0", "hb-1", "hb-2"), saved.map { it.heartbeatId }) + } + @Test fun `drainPending returns undelivered heartbeats before newly collected ones and empties the queue`() = runBlocking { collect(testHeartbeats(2, prefix = "old")) diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt index 1bbfd31..55761ad 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt @@ -132,14 +132,14 @@ class SocketClientTest { } @Test - fun `no reply from a daemon that still answers status counts as delivered`() = runBlocking { - // Daemons before 0.1.48 took heartbeats without replying - startDaemon { message -> if (typeOf(message) == "status") """{"version":"0.1.47"}""" else "" } + fun `no reply from a daemon that still takes connections counts as delivered`() = runBlocking { + // Daemons 0.1.42 to 0.1.47 took heartbeats without replying; 0.1.42 doesn't answer status + startDaemon { _ -> "" } val client = SocketClient(socketPath) assertEquals(HeartbeatDelivery.OK, client.sendHeartbeats(payload(1))) assertTrue(client.isConnected()) - assertEquals(listOf("heartbeat", "status"), received.map { typeOf(it) }) + assertEquals(listOf("heartbeat"), received.map { typeOf(it) }) } @Test From f596cfc3776eb466e5de083d3716fdcc2491409f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:53:38 +0000 Subject: [PATCH 07/12] test(heartbeat): fake the collector and daemon in the sender tests MockK runs a coAnswers block in a runBlocking of its own, so a mocked send that suspends blocks its thread outside the flush's coroutine, and cancelling the flush never reaches it. On the IDE's test classpath the two tests that suspend in a send hung, and the threads they left behind timed out every later test that touched kotlin-reflect. The sender now takes a HeartbeatSource (the collector) and a HeartbeatTransport (the socket client), and the test uses plain fakes whose suspend functions run in the sender's coroutine. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi --- CLAUDE.md | 3 +- .../jetbrains/heartbeat/HeartbeatCollector.kt | 6 +- .../jetbrains/heartbeat/HeartbeatSender.kt | 16 ++- .../jetbrains/socket/SocketClient.kt | 19 ++- .../heartbeat/HeartbeatSenderTest.kt | 120 +++++++++++------- 5 files changed, 109 insertions(+), 55 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4895f12..23ff33b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,7 @@ no connection keep the heartbeats queued; on project close what is left goes to ### Testing -- JUnit 5 + MockK for mocking +- JUnit 5 + MockK for mocking. Fake anything that suspends instead (see `HeartbeatSenderTest`): MockK runs a `coAnswers` + block in a `runBlocking` of its own, which cancellation never reaches, and on the IDE's test classpath that hung CI - Kover for coverage - IntelliJ test framework for integration tests diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt index 15c25d0..b450570 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt @@ -38,7 +38,7 @@ class HeartbeatCollector( private val project: Project, private val pluginVersion: String, debug: Boolean = false -) : Disposable { +) : Disposable, HeartbeatSource { private val logger = Logger("Collector", debug) private val lastHeartbeat = ConcurrentHashMap() @Volatile @@ -307,7 +307,7 @@ class HeartbeatCollector( * * @return List of pending heartbeats, clearing the queue */ - fun flush(): List { + override fun flush(): List { val heartbeats = mutableListOf() while (true) { val heartbeat = pendingHeartbeats.poll() ?: break @@ -320,7 +320,7 @@ class HeartbeatCollector( /** * Get the number of pending heartbeats */ - fun getPendingCount(): Int = pendingHeartbeats.size + override fun getPendingCount(): Int = pendingHeartbeats.size /** * Set debug logging diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSender.kt b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSender.kt index b2de631..aa81189 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSender.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSender.kt @@ -4,7 +4,7 @@ import com.intellij.openapi.Disposable import kotlinx.coroutines.* import xyz.shelltime.jetbrains.config.Constants import xyz.shelltime.jetbrains.socket.HeartbeatDelivery -import xyz.shelltime.jetbrains.socket.SocketClient +import xyz.shelltime.jetbrains.socket.HeartbeatTransport import xyz.shelltime.jetbrains.utils.Logger import java.io.File import java.util.concurrent.ConcurrentLinkedDeque @@ -25,6 +25,16 @@ enum class DaemonState { */ data class FlushResult(val sent: Int, val state: DaemonState) +/** + * Where the sender takes newly collected heartbeats from + */ +interface HeartbeatSource { + /** Take every collected heartbeat, oldest first, leaving none queued */ + fun flush(): List + + fun getPendingCount(): Int +} + /** * Callback interface for status updates */ @@ -36,8 +46,8 @@ interface HeartbeatSenderCallback { * Periodically sends collected heartbeats to the daemon */ class HeartbeatSender( - private val collector: HeartbeatCollector, - private val socketClient: SocketClient, + private val collector: HeartbeatSource, + private val socketClient: HeartbeatTransport, flushInterval: Long = Constants.DEFAULT_FLUSH_INTERVAL_MS, debug: Boolean = false, private val offlineFile: File = OfflineStore.DEFAULT_FILE diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/socket/SocketClient.kt b/src/main/kotlin/xyz/shelltime/jetbrains/socket/SocketClient.kt index c866cfc..29b9818 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/socket/SocketClient.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/socket/SocketClient.kt @@ -32,13 +32,24 @@ enum class HeartbeatDelivery { FAILED } +/** + * What the heartbeat sender needs from the daemon + */ +interface HeartbeatTransport { + suspend fun sendHeartbeats(payload: HeartbeatPayload): HeartbeatDelivery + + suspend fun getStatus(): StatusResponse? + + fun isConnected(): Boolean +} + /** * Unix domain socket client for communicating with the ShellTime daemon */ class SocketClient( private var socketPath: String = Constants.DEFAULT_SOCKET_PATH, debug: Boolean = false -) { +) : HeartbeatTransport { private val logger = Logger("SocketClient", debug) private val json = Json { ignoreUnknownKeys = true @@ -51,7 +62,7 @@ class SocketClient( /** * Check if we successfully connected on the last attempt */ - fun isConnected(): Boolean = lastConnectionSuccess + override fun isConnected(): Boolean = lastConnectionSuccess /** * Update the socket path @@ -66,7 +77,7 @@ class SocketClient( * @param payload The heartbeat payload to send * @return Whether the daemon took the heartbeats */ - suspend fun sendHeartbeats(payload: HeartbeatPayload): HeartbeatDelivery { + override suspend fun sendHeartbeats(payload: HeartbeatPayload): HeartbeatDelivery { val message = SocketMessage( type = "heartbeat", payload = payload @@ -94,7 +105,7 @@ class SocketClient( * * @return StatusResponse if successful, null otherwise */ - suspend fun getStatus(): StatusResponse? { + override suspend fun getStatus(): StatusResponse? { val message = SocketMessage(type = "status") val response = send(json.encodeToString(message)) diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt index 9ca471c..490d01f 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt @@ -1,9 +1,5 @@ package xyz.shelltime.jetbrains.heartbeat -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.NonCancellable @@ -12,6 +8,7 @@ import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.BeforeEach @@ -20,40 +17,76 @@ import org.junit.jupiter.api.Timeout import org.junit.jupiter.api.assertThrows import xyz.shelltime.jetbrains.config.Constants import xyz.shelltime.jetbrains.socket.HeartbeatDelivery -import xyz.shelltime.jetbrains.socket.SocketClient +import xyz.shelltime.jetbrains.socket.HeartbeatTransport import java.io.File import java.nio.file.Files -class HeartbeatSenderTest { - +/** + * The collector's queue, filled by the tests + */ +private class FakeSource : HeartbeatSource { private val collected = ArrayDeque() - private val sentMessages = mutableListOf>() - private val replies = ArrayDeque() - private lateinit var collector: HeartbeatCollector - private lateinit var socket: SocketClient + @Synchronized + fun collect(heartbeats: List) = collected.addAll(heartbeats) + + @Synchronized + override fun flush(): List = collected.toList().also { collected.clear() } + + @Synchronized + override fun getPendingCount(): Int = collected.size +} + +/** + * A daemon that answers each message with the next reply (OK once they run out), or with send + * + * Plain suspend functions, not MockK: coAnswers runs its block in a runBlocking of its own, which + * the sender's cancellation never reaches, and on the IDE's test classpath that hung these tests. + */ +private class FakeDaemon : HeartbeatTransport { + private val messages = mutableListOf>() + val replies = ArrayDeque() + + @Volatile + var send: (suspend () -> HeartbeatDelivery)? = null + + @Volatile + var status: StatusResponse? = null + + @Volatile + var connected = false + + /** The heartbeat ids of each message, in the order they were sent */ + val sentMessages: List> + @Synchronized get() = messages.toList() + + override suspend fun sendHeartbeats(payload: HeartbeatPayload): HeartbeatDelivery { + val reply = synchronized(this) { + messages.add(payload.heartbeats.map { it.heartbeatId }) + replies.removeFirstOrNull() + } + return send?.invoke() ?: reply ?: HeartbeatDelivery.OK + } + + override suspend fun getStatus(): StatusResponse? = status + + override fun isConnected(): Boolean = connected +} + +class HeartbeatSenderTest { + + private lateinit var source: FakeSource + private lateinit var daemon: FakeDaemon private lateinit var sender: HeartbeatSender private lateinit var offlineFile: File @BeforeEach fun setUp() { - collector = mockk(relaxed = true) - every { collector.flush() } answers { - val all = collected.toList() - collected.clear() - all - } - every { collector.getPendingCount() } answers { collected.size } - - socket = mockk(relaxed = true) - coEvery { socket.sendHeartbeats(any()) } answers { - sentMessages.add(firstArg().heartbeats.map { it.heartbeatId }) - replies.removeFirstOrNull() ?: HeartbeatDelivery.OK - } - + source = FakeSource() + daemon = FakeDaemon() // Never the daemon's real file: a running daemon would upload what lands there offlineFile = File(Files.createTempDirectory("st-sender").toFile(), "coding-heartbeat.data.log") - sender = HeartbeatSender(collector, socket, offlineFile = offlineFile) + sender = HeartbeatSender(source, daemon, offlineFile = offlineFile) } @AfterEach @@ -62,7 +95,7 @@ class HeartbeatSenderTest { offlineFile.parentFile.deleteRecursively() } - private fun collect(heartbeats: List) = collected.addAll(heartbeats) + private fun collect(heartbeats: List) = source.collect(heartbeats) @Test fun `large backlog is sent in messages of at most MAX_HEARTBEATS_PER_MESSAGE`() = runBlocking { @@ -71,15 +104,15 @@ class HeartbeatSenderTest { val result = sender.flush() assertEquals(FlushResult(1_200, DaemonState.CONNECTED), result) - assertEquals(listOf(500, 500, 200), sentMessages.map { it.size }) - assertEquals(testHeartbeats(1_200).map { it.heartbeatId }, sentMessages.flatten()) + assertEquals(listOf(500, 500, 200), daemon.sentMessages.map { it.size }) + assertEquals(testHeartbeats(1_200).map { it.heartbeatId }, daemon.sentMessages.flatten()) assertEquals(0, sender.getPendingCount()) } @Test fun `failed message keeps it and the rest for the next flush, oldest first`() = runBlocking { collect(testHeartbeats(1_200)) - replies.addAll(listOf(HeartbeatDelivery.OK, HeartbeatDelivery.FAILED)) + daemon.replies.addAll(listOf(HeartbeatDelivery.OK, HeartbeatDelivery.FAILED)) val first = sender.flush() @@ -88,12 +121,11 @@ class HeartbeatSenderTest { assertEquals(700, sender.getPendingCount()) collect(testHeartbeats(2, prefix = "new")) - sentMessages.clear() val second = sender.flush() assertEquals(FlushResult(702, DaemonState.CONNECTED), second) val expected = testHeartbeats(1_200).drop(500).map { it.heartbeatId } + listOf("new-0", "new-1") - assertEquals(expected, sentMessages.flatten()) + assertEquals(expected, daemon.sentMessages.drop(2).flatten()) assertEquals(0, sender.getPendingCount()) } @@ -106,7 +138,7 @@ class HeartbeatSenderTest { } }) collect(testHeartbeats(3)) - replies.add(HeartbeatDelivery.DISABLED) + daemon.replies.add(HeartbeatDelivery.DISABLED) val result = sender.flush() @@ -117,7 +149,7 @@ class HeartbeatSenderTest { @Test fun `queue keeps the newest MAX_PENDING_HEARTBEATS while the daemon is down`() = runBlocking { - replies.addAll(List(20) { HeartbeatDelivery.FAILED }) + daemon.replies.addAll(List(20) { HeartbeatDelivery.FAILED }) collect(testHeartbeats(Constants.MAX_PENDING_HEARTBEATS, prefix = "old")) sender.flush() collect(testHeartbeats(10, prefix = "new")) @@ -133,7 +165,7 @@ class HeartbeatSenderTest { fun `cancelled flush keeps what was not delivered`() { collect(testHeartbeats(1_200)) var calls = 0 - coEvery { socket.sendHeartbeats(any()) } answers { + daemon.send = { calls++ if (calls == 2) throw CancellationException("IDE closing") HeartbeatDelivery.OK @@ -149,7 +181,7 @@ class HeartbeatSenderTest { fun `stopAndJoin waits for a periodic flush cancelled mid-send to put its heartbeats back`() { collect(testHeartbeats(3)) val sending = CompletableDeferred() - coEvery { socket.sendHeartbeats(any()) } coAnswers { + daemon.send = { sending.complete(Unit) try { awaitCancellation() @@ -179,7 +211,7 @@ class HeartbeatSenderTest { collect(testHeartbeats(3)) val sending = CompletableDeferred() val reply = CompletableDeferred() - coEvery { socket.sendHeartbeats(any()) } coAnswers { + daemon.send = { sending.complete(Unit) reply.await() } @@ -194,7 +226,7 @@ class HeartbeatSenderTest { assertEquals(0, sender.getPendingCount()) val saved = offlineFile.readLines().flatMap { - kotlinx.serialization.json.Json.decodeFromString(it).heartbeats + Json.decodeFromString(HeartbeatPayload.serializer(), it).heartbeats } assertEquals(listOf("hb-0", "hb-1", "hb-2"), saved.map { it.heartbeatId }) } @@ -202,7 +234,7 @@ class HeartbeatSenderTest { @Test fun `drainPending returns undelivered heartbeats before newly collected ones and empties the queue`() = runBlocking { collect(testHeartbeats(2, prefix = "old")) - replies.add(HeartbeatDelivery.FAILED) + daemon.replies.add(HeartbeatDelivery.FAILED) sender.flush() collect(testHeartbeats(1, prefix = "new")) @@ -217,21 +249,21 @@ class HeartbeatSenderTest { val result = sender.flush() assertEquals(0, result.sent) - coVerify(exactly = 0) { socket.sendHeartbeats(any()) } + assertEquals(emptyList>(), daemon.sentMessages) } @Test fun `refreshState keeps the disabled state while the daemon answers`() = runBlocking { collect(testHeartbeats(1)) - replies.add(HeartbeatDelivery.DISABLED) + daemon.replies.add(HeartbeatDelivery.DISABLED) sender.flush() - coEvery { socket.getStatus() } returns StatusResponse(version = "0.1.95") + daemon.status = StatusResponse(version = "0.1.95") sender.refreshState() assertEquals(DaemonState.DISABLED, sender.state) - coEvery { socket.getStatus() } returns null - every { socket.isConnected() } returns false + daemon.status = null + daemon.connected = false sender.refreshState() assertEquals(DaemonState.DISCONNECTED, sender.state) } From a63f0ed3148876aea82cb637a3a1814637d5e8d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 14:13:56 +0000 Subject: [PATCH 08/12] test: dump every thread when a test times out On CI two HeartbeatSenderTest cases still time out after the move to fakes, so MockK was not what hung them, and the timeout's trace only shows the test thread parked in runBlocking. ThreadDumpOnTimeout, registered for every test through META-INF/services, adds every thread's stack with its locks, the coroutine dump when the debug agent runs, the JVM arguments and where kotlinx-coroutines and kotlin-reflect come from. CLAUDE.md and the fake's comment no longer blame MockK. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi --- CLAUDE.md | 5 +- build.gradle.kts | 2 + .../jetbrains/ThreadDumpOnTimeout.kt | 71 +++++++++++++++++++ .../heartbeat/HeartbeatSenderTest.kt | 6 +- .../org.junit.jupiter.api.extension.Extension | 1 + 5 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 src/test/kotlin/xyz/shelltime/jetbrains/ThreadDumpOnTimeout.kt create mode 100644 src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension diff --git a/CLAUDE.md b/CLAUDE.md index 23ff33b..6557df7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,8 @@ no connection keep the heartbeats queued; on project close what is left goes to ### Testing -- JUnit 5 + MockK for mocking. Fake anything that suspends instead (see `HeartbeatSenderTest`): MockK runs a `coAnswers` - block in a `runBlocking` of its own, which cancellation never reaches, and on the IDE's test classpath that hung CI +- JUnit 5 + MockK for mocking +- Every test has a 60 s timeout (`build.gradle.kts`), and one that times out fails with every thread's stack and the + coroutine dump attached (`ThreadDumpOnTimeout`, registered through `META-INF/services`) - Kover for coverage - IntelliJ test framework for integration tests diff --git a/build.gradle.kts b/build.gradle.kts index b3cebfb..5a55743 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -131,6 +131,8 @@ tasks { // A test that hangs fails with its stack trace instead of stalling the job until it times out systemProperty("junit.jupiter.execution.timeout.default", "60s") systemProperty("junit.jupiter.execution.timeout.thread.mode.default", "SEPARATE_THREAD") + // ThreadDumpOnTimeout (META-INF/services) adds every thread's stack to a test that timed out + systemProperty("junit.jupiter.extensions.autodetection.enabled", "true") testLogging { events("passed", "skipped", "failed") exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/ThreadDumpOnTimeout.kt b/src/test/kotlin/xyz/shelltime/jetbrains/ThreadDumpOnTimeout.kt new file mode 100644 index 0000000..2e3199e --- /dev/null +++ b/src/test/kotlin/xyz/shelltime/jetbrains/ThreadDumpOnTimeout.kt @@ -0,0 +1,71 @@ +package xyz.shelltime.jetbrains + +import org.junit.jupiter.api.extension.ExtensionContext +import org.junit.jupiter.api.extension.TestExecutionExceptionHandler +import java.io.ByteArrayOutputStream +import java.io.PrintStream +import java.lang.management.ManagementFactory +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicInteger + +/** + * Adds every thread's stack, and the coroutines when the coroutine debug agent runs, to a test + * that timed out: the timeout's own trace shows only the test thread, which is usually parked + * waiting for another one. Registered for every test through META-INF/services. + */ +class ThreadDumpOnTimeout : TestExecutionExceptionHandler { + override fun handleTestExecutionException(context: ExtensionContext, throwable: Throwable) { + if (throwable !is TimeoutException) throw throwable + // Later timeouts usually wait on what the first ones left behind + val details = if (dumps.incrementAndGet() <= MAX_DUMPS) { + dump() + } else { + "(threads are dumped for the first $MAX_DUMPS timeouts)" + } + throw AssertionError("${throwable.message}\n$details", throwable) + } + + private fun dump(): String = buildString { + appendLine("JVM arguments: ${ManagementFactory.getRuntimeMXBean().inputArguments}") + for (name in System.getProperties().stringPropertyNames().filter { it.startsWith("kotlinx.coroutines") }.sorted()) { + appendLine("$name=${System.getProperty(name)}") + } + appendLine("kotlinx-coroutines: ${codeSource("kotlinx.coroutines.Job")}") + appendLine("kotlin-reflect: ${codeSource("kotlin.reflect.jvm.internal.KClassImpl")}") + appendLine() + for (info in ManagementFactory.getThreadMXBean().dumpAllThreads(true, true)) { + append("\"${info.threadName}\" #${info.threadId} ${info.threadState}") + info.lockName?.let { append(" on $it") } + info.lockOwnerName?.let { append(" owned by \"$it\"") } + appendLine() + info.stackTrace.forEachIndexed { depth, frame -> + appendLine(" at $frame") + info.lockedMonitors.filter { it.lockedStackDepth == depth }.forEach { appendLine(" - locked $it") } + } + info.lockedSynchronizers.forEach { appendLine(" - holds $it") } + appendLine() + } + append(coroutineDump()) + } + + private fun codeSource(className: String): String = try { + Class.forName(className).protectionDomain?.codeSource?.location?.toString() ?: "unknown" + } catch (e: Throwable) { + "not loaded ($e)" + } + + private fun coroutineDump(): String = try { + val probes = Class.forName("kotlinx.coroutines.debug.internal.DebugProbesImpl") + val out = ByteArrayOutputStream() + probes.getMethod("dumpCoroutines", PrintStream::class.java) + .invoke(probes.getField("INSTANCE").get(null), PrintStream(out, true)) + out.toString() + } catch (e: Throwable) { + "No coroutine dump: ${e.cause ?: e}" + } + + companion object { + private const val MAX_DUMPS = 3 + private val dumps = AtomicInteger() + } +} diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt index 490d01f..6d0f54f 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt @@ -38,10 +38,8 @@ private class FakeSource : HeartbeatSource { } /** - * A daemon that answers each message with the next reply (OK once they run out), or with send - * - * Plain suspend functions, not MockK: coAnswers runs its block in a runBlocking of its own, which - * the sender's cancellation never reaches, and on the IDE's test classpath that hung these tests. + * A daemon that answers each message with the next reply (OK once they run out), or with send, + * which suspends in the flush's own coroutine */ private class FakeDaemon : HeartbeatTransport { private val messages = mutableListOf>() diff --git a/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension new file mode 100644 index 0000000..2fa9451 --- /dev/null +++ b/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension @@ -0,0 +1 @@ +xyz.shelltime.jetbrains.ThreadDumpOnTimeout From 1c1d39c7e3280a9cfce0d24ed4215df531ffbebb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 14:13:56 +0000 Subject: [PATCH 09/12] test: probe coroutine primitives on the IDE's test runtime (temporary) Runs each coroutine primitive the hanging tests use, alone and before any other test, so CI shows which one hangs there. To be removed once the cause is known. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi --- .../jetbrains/AaaCoroutineProbeTest.kt | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/test/kotlin/xyz/shelltime/jetbrains/AaaCoroutineProbeTest.kt diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/AaaCoroutineProbeTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/AaaCoroutineProbeTest.kt new file mode 100644 index 0000000..5c3bbdb --- /dev/null +++ b/src/test/kotlin/xyz/shelltime/jetbrains/AaaCoroutineProbeTest.kt @@ -0,0 +1,102 @@ +package xyz.shelltime.jetbrains + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.serialization.serializer +import org.junit.jupiter.api.MethodOrderer +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestMethodOrder +import org.junit.jupiter.api.Timeout +import xyz.shelltime.jetbrains.heartbeat.HeartbeatPayload + +/** + * TEMPORARY, for CI only: which coroutine primitive hangs on the IDE's test runtime. The class + * sorts first, so nothing else has run before it. + */ +@TestMethodOrder(MethodOrderer.MethodName::class) +class AaaCoroutineProbeTest { + + @Test + @Timeout(5) + fun `01 runBlocking that never suspends`() { + runBlocking { } + } + + @Test + @Timeout(5) + fun `02 suspension resumed on the same thread`() { + runBlocking { + val done = CompletableDeferred() + launch { done.complete(Unit) } + done.await() + } + } + + @Test + @Timeout(5) + fun `03 async in runBlocking`() { + runBlocking { async { 1 }.await() } + } + + @Test + @Timeout(5) + fun `04 async suspended on a deferred the caller completes`() { + runBlocking { + val started = CompletableDeferred() + val go = CompletableDeferred() + val task = async { + started.complete(Unit) + go.await() + } + started.await() + go.complete(Unit) + task.await() + } + } + + @Test + @Timeout(5) + fun `05 delay`() { + runBlocking { delay(10) } + } + + @Test + @Timeout(5) + fun `06 withContext Default`() { + runBlocking { withContext(Dispatchers.Default) { } } + } + + @Test + @Timeout(5) + fun `07 withContext IO`() { + runBlocking { withContext(Dispatchers.IO) { } } + } + + @Test + @Timeout(5) + fun `08 cancelAndJoin a job on Default`() { + runBlocking { + val started = CompletableDeferred() + val job = CoroutineScope(Dispatchers.Default).launch { + started.complete(Unit) + awaitCancellation() + } + started.await() + job.cancelAndJoin() + } + } + + @Test + @Timeout(5) + fun `09 kotlin-reflect through serializer`() { + serializer() + } +} From 8b73ddf0c411596d0f5bcafc8be9ca4936774cd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 14:37:00 +0000 Subject: [PATCH 10/12] fix(build): compile against the Kotlin 1.9 API the IDE provides The plugin runs on the IDE's Kotlin standard library (kotlin.stdlib.default.dependency=false), which is 1.9 on 2024.1, the oldest build pluginSinceBuild allows, and 2.0 on 2024.3. Compiled at the Kotlin 2.3 API level, its coroutines carry debug metadata version 2, which those libraries can't read. In the coroutines' debug mode (on with -ea), reading it threw "Debug metadata version mismatch. Expected: 1, got 2", the dispatcher reported a fatal CoroutinesInternalError and the coroutine was never resumed: on CI every test that resumed a coroutine on another dispatcher, or after a cancellation, hung, and the project service's dispose waits for exactly such a cancelled flush. apiVersion 1.9 writes version 1 metadata and keeps the code to the standard library 2024.1 has. With kotlin-stdlib 2.0.21 and the kotlinx.coroutines debug agent on the test classpath, the harness went from 19 hanging tests to none. The temporary probe tests are removed; ThreadDumpOnTimeout now also names the standard library version. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi --- CLAUDE.md | 8 ++ build.gradle.kts | 7 ++ .../jetbrains/AaaCoroutineProbeTest.kt | 102 ------------------ .../jetbrains/ThreadDumpOnTimeout.kt | 5 +- 4 files changed, 19 insertions(+), 103 deletions(-) delete mode 100644 src/test/kotlin/xyz/shelltime/jetbrains/AaaCoroutineProbeTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index 6557df7..2dad477 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,6 +78,14 @@ no connection keep the heartbeats queued; on project close what is left goes to - `applicationListeners`: FileSaveListener - `projectListeners`: FileEditorListener +### Kotlin version + +The IDE provides the Kotlin standard library (`kotlin.stdlib.default.dependency=false`), so `build.gradle.kts` sets +`apiVersion` to 1.9, the library of 2024.1 (`pluginSinceBuild=241`). Raise it only together with `pluginSinceBuild`: a +newer API version also writes coroutine debug metadata an older library can't read, and with `-ea` (as in tests) a +coroutine whose metadata kotlinx.coroutines read died with "Debug metadata version mismatch" and never resumed, so the +tests hung. + ### Testing - JUnit 5 + MockK for mocking diff --git a/build.gradle.kts b/build.gradle.kts index 5a55743..2a28122 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,6 +17,13 @@ version = providers.gradleProperty("pluginVersion").get() kotlin { jvmToolchain(17) + compilerOptions { + // The IDE provides the Kotlin standard library (kotlin.stdlib.default.dependency=false), and 2024.1, the oldest + // build pluginSinceBuild allows, ships 1.9. A newer API version also writes coroutine debug metadata that the + // IDE's library can't read: in the coroutines' debug mode (on with -ea, as in tests) reading it threw "Debug + // metadata version mismatch" and the coroutine was never resumed. + apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9) + } } repositories { diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/AaaCoroutineProbeTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/AaaCoroutineProbeTest.kt deleted file mode 100644 index 5c3bbdb..0000000 --- a/src/test/kotlin/xyz/shelltime/jetbrains/AaaCoroutineProbeTest.kt +++ /dev/null @@ -1,102 +0,0 @@ -package xyz.shelltime.jetbrains - -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import kotlinx.serialization.serializer -import org.junit.jupiter.api.MethodOrderer -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestMethodOrder -import org.junit.jupiter.api.Timeout -import xyz.shelltime.jetbrains.heartbeat.HeartbeatPayload - -/** - * TEMPORARY, for CI only: which coroutine primitive hangs on the IDE's test runtime. The class - * sorts first, so nothing else has run before it. - */ -@TestMethodOrder(MethodOrderer.MethodName::class) -class AaaCoroutineProbeTest { - - @Test - @Timeout(5) - fun `01 runBlocking that never suspends`() { - runBlocking { } - } - - @Test - @Timeout(5) - fun `02 suspension resumed on the same thread`() { - runBlocking { - val done = CompletableDeferred() - launch { done.complete(Unit) } - done.await() - } - } - - @Test - @Timeout(5) - fun `03 async in runBlocking`() { - runBlocking { async { 1 }.await() } - } - - @Test - @Timeout(5) - fun `04 async suspended on a deferred the caller completes`() { - runBlocking { - val started = CompletableDeferred() - val go = CompletableDeferred() - val task = async { - started.complete(Unit) - go.await() - } - started.await() - go.complete(Unit) - task.await() - } - } - - @Test - @Timeout(5) - fun `05 delay`() { - runBlocking { delay(10) } - } - - @Test - @Timeout(5) - fun `06 withContext Default`() { - runBlocking { withContext(Dispatchers.Default) { } } - } - - @Test - @Timeout(5) - fun `07 withContext IO`() { - runBlocking { withContext(Dispatchers.IO) { } } - } - - @Test - @Timeout(5) - fun `08 cancelAndJoin a job on Default`() { - runBlocking { - val started = CompletableDeferred() - val job = CoroutineScope(Dispatchers.Default).launch { - started.complete(Unit) - awaitCancellation() - } - started.await() - job.cancelAndJoin() - } - } - - @Test - @Timeout(5) - fun `09 kotlin-reflect through serializer`() { - serializer() - } -} diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/ThreadDumpOnTimeout.kt b/src/test/kotlin/xyz/shelltime/jetbrains/ThreadDumpOnTimeout.kt index 2e3199e..655d6a1 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/ThreadDumpOnTimeout.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/ThreadDumpOnTimeout.kt @@ -30,6 +30,7 @@ class ThreadDumpOnTimeout : TestExecutionExceptionHandler { for (name in System.getProperties().stringPropertyNames().filter { it.startsWith("kotlinx.coroutines") }.sorted()) { appendLine("$name=${System.getProperty(name)}") } + appendLine("kotlin-stdlib ${KotlinVersion.CURRENT}: ${codeSource("kotlin.KotlinVersion")}") appendLine("kotlinx-coroutines: ${codeSource("kotlinx.coroutines.Job")}") appendLine("kotlin-reflect: ${codeSource("kotlin.reflect.jvm.internal.KClassImpl")}") appendLine() @@ -48,8 +49,10 @@ class ThreadDumpOnTimeout : TestExecutionExceptionHandler { append(coroutineDump()) } + // The IDE's class loader leaves codeSource empty, so ask for the class file itself private fun codeSource(className: String): String = try { - Class.forName(className).protectionDomain?.codeSource?.location?.toString() ?: "unknown" + val type = Class.forName(className) + type.getResource("${type.simpleName}.class")?.toString() ?: "unknown" } catch (e: Throwable) { "not loaded ($e)" } From 33ec5969031b7cc535d12220961b96e62bef0a96 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 14:58:26 +0000 Subject: [PATCH 11/12] test: add the timeout dump with ExtendWith instead of autodetection JUnit extension autodetection also loaded the IntelliJ test framework's own extensions. Its thread leak tracker failed the never-answering daemon test (the fake daemon thread sleeps out its 30 s), and under its interceptors VersionCheckerTest, unchanged and passing on main, timed out and left kotlin-reflect failing every later case. ThreadDumpOnTimeout is now added to the sender and socket tests with @ExtendWith. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi --- CLAUDE.md | 5 +++-- build.gradle.kts | 2 -- .../kotlin/xyz/shelltime/jetbrains/ThreadDumpOnTimeout.kt | 4 +++- .../xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt | 3 +++ .../xyz/shelltime/jetbrains/socket/SocketClientTest.kt | 3 +++ .../services/org.junit.jupiter.api.extension.Extension | 1 - 6 files changed, 12 insertions(+), 6 deletions(-) delete mode 100644 src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension diff --git a/CLAUDE.md b/CLAUDE.md index 2dad477..b1c5ec9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,8 @@ tests hung. ### Testing - JUnit 5 + MockK for mocking -- Every test has a 60 s timeout (`build.gradle.kts`), and one that times out fails with every thread's stack and the - coroutine dump attached (`ThreadDumpOnTimeout`, registered through `META-INF/services`) +- Every test has a 60 s timeout (`build.gradle.kts`). `@ExtendWith(ThreadDumpOnTimeout::class)` (the sender and socket + tests) attaches every thread's stack and the coroutine dump to one that times out. Don't turn on JUnit extension + autodetection: it also loads the IntelliJ test framework's extensions, whose thread leak tracker fails these tests - Kover for coverage - IntelliJ test framework for integration tests diff --git a/build.gradle.kts b/build.gradle.kts index 2a28122..a84644d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -138,8 +138,6 @@ tasks { // A test that hangs fails with its stack trace instead of stalling the job until it times out systemProperty("junit.jupiter.execution.timeout.default", "60s") systemProperty("junit.jupiter.execution.timeout.thread.mode.default", "SEPARATE_THREAD") - // ThreadDumpOnTimeout (META-INF/services) adds every thread's stack to a test that timed out - systemProperty("junit.jupiter.extensions.autodetection.enabled", "true") testLogging { events("passed", "skipped", "failed") exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/ThreadDumpOnTimeout.kt b/src/test/kotlin/xyz/shelltime/jetbrains/ThreadDumpOnTimeout.kt index 655d6a1..71ddf9e 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/ThreadDumpOnTimeout.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/ThreadDumpOnTimeout.kt @@ -11,7 +11,9 @@ import java.util.concurrent.atomic.AtomicInteger /** * Adds every thread's stack, and the coroutines when the coroutine debug agent runs, to a test * that timed out: the timeout's own trace shows only the test thread, which is usually parked - * waiting for another one. Registered for every test through META-INF/services. + * waiting for another one. Add it with @ExtendWith: autodetection would also load the IntelliJ test + * framework's extensions (its thread leak tracker, its logged-error interceptor), which these plain + * JUnit tests don't expect. */ class ThreadDumpOnTimeout : TestExecutionExceptionHandler { override fun handleTestExecutionException(context: ExtensionContext, throwable: Throwable) { diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt index 6d0f54f..c73fb0b 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt @@ -15,6 +15,8 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.Timeout import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import xyz.shelltime.jetbrains.ThreadDumpOnTimeout import xyz.shelltime.jetbrains.config.Constants import xyz.shelltime.jetbrains.socket.HeartbeatDelivery import xyz.shelltime.jetbrains.socket.HeartbeatTransport @@ -71,6 +73,7 @@ private class FakeDaemon : HeartbeatTransport { override fun isConnected(): Boolean = connected } +@ExtendWith(ThreadDumpOnTimeout::class) class HeartbeatSenderTest { private lateinit var source: FakeSource diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt index 55761ad..7249577 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt @@ -10,6 +10,8 @@ import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.Timeout +import org.junit.jupiter.api.extension.ExtendWith +import xyz.shelltime.jetbrains.ThreadDumpOnTimeout import xyz.shelltime.jetbrains.heartbeat.HeartbeatPayload import xyz.shelltime.jetbrains.heartbeat.testHeartbeats import java.io.File @@ -23,6 +25,7 @@ import java.nio.file.Files import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.TimeUnit +@ExtendWith(ThreadDumpOnTimeout::class) class SocketClientTest { private lateinit var dir: File diff --git a/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension deleted file mode 100644 index 2fa9451..0000000 --- a/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension +++ /dev/null @@ -1 +0,0 @@ -xyz.shelltime.jetbrains.ThreadDumpOnTimeout From 3c68c44ab7093bab728f6c5aa0864c1829a1189e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 15:09:17 +0000 Subject: [PATCH 12/12] test: run the version check against a local server, stop daemon threads VersionCheckerTest mocked URL's constructors, which routes every URL in the JVM through the mock, the class loader's resource reads included. On CI kotlin-reflect first initialized inside that window (MockK asks it whether a mocked type is a value class), read its built-ins through a relaxed mock stream that never ends and hung; every later case then failed with "Recursion detected in a lazy value". It passed on main only while something else had initialized kotlin-reflect first. The test now runs the checker against a local HttpServer, and the encoding case checks the exact query the server receives. The IntelliJ test framework's thread leak tracker runs on these plain JUnit tests whether or not extension autodetection is on, so the previous commit's reason was wrong: it failed the never-answering daemon test because the fake daemon's thread slept out 30 s. The fake daemon now releases and joins its threads in tearDown. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019PvzR6GVwT4zShCyuHopyi --- CLAUDE.md | 8 +- .../jetbrains/ThreadDumpOnTimeout.kt | 4 +- .../jetbrains/socket/SocketClientTest.kt | 22 ++- .../jetbrains/version/VersionCheckerTest.kt | 176 ++++++++---------- 4 files changed, 98 insertions(+), 112 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b1c5ec9..474de30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,7 +90,11 @@ tests hung. - JUnit 5 + MockK for mocking - Every test has a 60 s timeout (`build.gradle.kts`). `@ExtendWith(ThreadDumpOnTimeout::class)` (the sender and socket - tests) attaches every thread's stack and the coroutine dump to one that times out. Don't turn on JUnit extension - autodetection: it also loads the IntelliJ test framework's extensions, whose thread leak tracker fails these tests + tests) attaches every thread's stack and the coroutine dump to one that times out +- The IntelliJ test framework's JUnit 5 extensions run on every test, also plain ones: its thread leak tracker fails a + test that leaves a thread running, so stop the threads a test starts (see `SocketClientTest`'s fake daemon) +- Don't mock `URL`'s constructors (`mockkConstructor(URL::class)`): every URL in the JVM goes through the mock, the class + loader's resource reads included, so a library that initializes during the test (kotlin-reflect, for MockK) reads the + mock's stream and hangs. `VersionCheckerTest` runs against a local `HttpServer` - Kover for coverage - IntelliJ test framework for integration tests diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/ThreadDumpOnTimeout.kt b/src/test/kotlin/xyz/shelltime/jetbrains/ThreadDumpOnTimeout.kt index 71ddf9e..b691a6d 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/ThreadDumpOnTimeout.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/ThreadDumpOnTimeout.kt @@ -11,9 +11,7 @@ import java.util.concurrent.atomic.AtomicInteger /** * Adds every thread's stack, and the coroutines when the coroutine debug agent runs, to a test * that timed out: the timeout's own trace shows only the test thread, which is usually parked - * waiting for another one. Add it with @ExtendWith: autodetection would also load the IntelliJ test - * framework's extensions (its thread leak tracker, its logged-error interceptor), which these plain - * JUnit tests don't expect. + * waiting for another one. Add it to a test class with @ExtendWith. */ class ThreadDumpOnTimeout : TestExecutionExceptionHandler { override fun handleTestExecutionException(context: ExtensionContext, throwable: Throwable) { diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt index 7249577..4a4610a 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt @@ -23,6 +23,7 @@ import java.nio.channels.SocketChannel import java.nio.charset.StandardCharsets import java.nio.file.Files import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @ExtendWith(ThreadDumpOnTimeout::class) @@ -33,6 +34,11 @@ class SocketClientTest { private var server: ServerSocketChannel? = null private val received = CopyOnWriteArrayList() + // The daemon's threads, stopped before the test ends: the IntelliJ test framework fails a test + // that leaves a thread running + private val threads = CopyOnWriteArrayList() + private val release = CountDownLatch(1) + @BeforeEach fun setUp() { dir = Files.createTempDirectory("st-sock").toFile() @@ -41,7 +47,9 @@ class SocketClientTest { @AfterEach fun tearDown() { + release.countDown() server?.close() + threads.forEach { it.join(TimeUnit.SECONDS.toMillis(5)) } dir.deleteRecursively() } @@ -61,16 +69,20 @@ class SocketClientTest { val channel = ServerSocketChannel.open(StandardProtocolFamily.UNIX) channel.bind(UnixDomainSocketAddress.of(socketPath)) server = channel - Thread { + start { while (channel.isOpen) { val conn = try { channel.accept() } catch (e: Exception) { - return@Thread + return@start } - Thread { serve(conn, reply) }.apply { isDaemon = true }.start() + start { serve(conn, reply) } } - }.apply { isDaemon = true }.start() + } + } + + private fun start(body: () -> Unit) { + threads.add(Thread(body).apply { isDaemon = true; start() }) } private fun serve(conn: SocketChannel, reply: (String) -> String?) { @@ -87,7 +99,7 @@ class SocketClientTest { received.add(message) val answer = reply(message) if (answer == null) { - Thread.sleep(TimeUnit.SECONDS.toMillis(30)) + release.await(30, TimeUnit.SECONDS) return } if (answer.isNotEmpty()) { diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/version/VersionCheckerTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/version/VersionCheckerTest.kt index 161c23a..d77027c 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/version/VersionCheckerTest.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/version/VersionCheckerTest.kt @@ -1,39 +1,80 @@ package xyz.shelltime.jetbrains.version -import io.mockk.* +import com.sun.net.httpserver.HttpServer import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import org.junit.jupiter.api.Assertions.* -import java.io.ByteArrayInputStream -import java.net.HttpURLConnection -import java.net.URL - +import xyz.shelltime.jetbrains.config.Constants +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +/** + * Runs the checker against a local HTTP server. Mocking URL's constructors instead routed every URL + * in the JVM through the mock, the class loader's resource reads included, so a library that + * initialized during a test (kotlin-reflect, for MockK) read the mock's stream and hung. + */ class VersionCheckerTest { + private lateinit var server: HttpServer + private val requests = LinkedBlockingQueue() + + @Volatile + private var status = 200 + + @Volatile + private var body = "" + @BeforeEach fun setup() { - mockkConstructor(URL::class) + server = HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0) + server.createContext("/") { exchange -> + try { + requests.add("${exchange.requestURI.rawPath}?${exchange.requestURI.rawQuery}") + val bytes = body.toByteArray() + exchange.responseHeaders.add("Content-Type", "application/json") + // No keep-alive connection, so no JDK thread outlives the test + exchange.responseHeaders.add("Connection", "close") + exchange.sendResponseHeaders(status, if (bytes.isEmpty()) -1 else bytes.size.toLong()) + if (bytes.isNotEmpty()) exchange.responseBody.write(bytes) + } finally { + exchange.close() + } + } + server.start() } @AfterEach fun teardown() { - unmockkAll() + server.stop(0) } + private val apiEndpoint get() = "http://127.0.0.1:${server.address.port}" + + private fun respond(status: Int, body: String) { + this.status = status + this.body = body + } + + /** The next request the checker made, waiting for it to arrive */ + private fun nextRequest(): String? = requests.poll(10, TimeUnit.SECONDS) + @Test fun `dispose cancels coroutine scope without errors`() { - val checker = VersionChecker("https://api.test.com", "https://web.test.com") + val checker = VersionChecker(apiEndpoint, "https://web.test.com") assertDoesNotThrow { checker.dispose() } } @Test fun `checkVersion handles connection failure gracefully`() = runBlocking { - every { anyConstructed().openConnection() } throws java.io.IOException("Connection failed") - - val checker = VersionChecker("https://api.test.com", "https://web.test.com") + // A port nothing listens on + val port = ServerSocket(0, 0, InetAddress.getLoopbackAddress()).use { it.localPort } + val checker = VersionChecker("http://127.0.0.1:$port", "https://web.test.com") // Should not throw - version check failures are silent assertDoesNotThrow { checker.checkVersion("1.0.0", null) } @@ -41,154 +82,85 @@ class VersionCheckerTest { // Allow time for coroutine to execute delay(200) - // Verify the connection was attempted - verify { anyConstructed().openConnection() } - checker.dispose() } @Test fun `checkVersion handles HTTP error response gracefully`() = runBlocking { - val mockConnection = mockk(relaxed = true) - every { anyConstructed().openConnection() } returns mockConnection - every { mockConnection.responseCode } returns 500 - - val checker = VersionChecker("https://api.test.com", "https://web.test.com") + respond(500, "") + val checker = VersionChecker(apiEndpoint, "https://web.test.com") // Should not throw - HTTP errors are handled gracefully assertDoesNotThrow { checker.checkVersion("1.0.0", null) } - // Allow time for coroutine to execute - delay(200) - - // Verify HTTP error was handled - verify { mockConnection.responseCode } - verify { mockConnection.disconnect() } + assertEquals("${Constants.VERSION_CHECK_ENDPOINT}?version=1.0.0", nextRequest()) checker.dispose() } @Test fun `checkVersion shows notification when update is available`() = runBlocking { - val mockConnection = mockk(relaxed = true) - every { anyConstructed().openConnection() } returns mockConnection - every { mockConnection.responseCode } returns 200 - every { mockConnection.inputStream } returns ByteArrayInputStream( - """{"isLatest":false,"latestVersion":"2.0.0","version":"1.0.0"}""".toByteArray() - ) - - val checker = VersionChecker("https://api.test.com", "https://web.test.com") + respond(200, """{"isLatest":false,"latestVersion":"2.0.0","version":"1.0.0"}""") + val checker = VersionChecker(apiEndpoint, "https://web.test.com") assertDoesNotThrow { checker.checkVersion("1.0.0", null) } - // Allow time for coroutine to execute - delay(200) - - // Verify the connection was made and response was read - verify { mockConnection.responseCode } - verify { mockConnection.inputStream } - verify { mockConnection.disconnect() } + assertEquals("${Constants.VERSION_CHECK_ENDPOINT}?version=1.0.0", nextRequest()) checker.dispose() } @Test fun `checkVersion does not show notification when version is up to date`() = runBlocking { - val mockConnection = mockk(relaxed = true) - every { anyConstructed().openConnection() } returns mockConnection - every { mockConnection.responseCode } returns 200 - every { mockConnection.inputStream } returns ByteArrayInputStream( - """{"isLatest":true,"latestVersion":"1.0.0","version":"1.0.0"}""".toByteArray() - ) - - val checker = VersionChecker("https://api.test.com", "https://web.test.com") + respond(200, """{"isLatest":true,"latestVersion":"1.0.0","version":"1.0.0"}""") + val checker = VersionChecker(apiEndpoint, "https://web.test.com") assertDoesNotThrow { checker.checkVersion("1.0.0", null) } - // Allow time for coroutine to execute - delay(200) - - // Verify the connection was made and response was read - verify { mockConnection.responseCode } - verify { mockConnection.inputStream } - verify { mockConnection.disconnect() } + assertEquals("${Constants.VERSION_CHECK_ENDPOINT}?version=1.0.0", nextRequest()) checker.dispose() } @Test fun `version check encodes special characters in version`() = runBlocking { - val mockConnection = mockk(relaxed = true) - val mockInputStream = mockk(relaxed = true) - val mockBufferedReader = mockk(relaxed = true) + respond(200, """{"isLatest":true,"latestVersion":"1.0.0","version":"1.0.0"}""") + val checker = VersionChecker(apiEndpoint, "https://web.test.com") - every { anyConstructed().openConnection() } returns mockConnection - every { mockConnection.responseCode } returns 200 - every { mockConnection.inputStream } returns ByteArrayInputStream( - """{"isLatest":true,"latestVersion":"1.0.0","version":"1.0.0"}""".toByteArray() - ) - - val checker = VersionChecker("https://api.test.com", "https://web.test.com") - - // Version with special characters should be URL-encoded assertDoesNotThrow { checker.checkVersion("1.0.0-beta+build.123", null) } - // Allow time for coroutine to execute - delay(200) - - // Verify the connection was made (URL encoding happens in the URL construction) - verify { anyConstructed().openConnection() } - verify { mockConnection.disconnect() } + assertEquals("${Constants.VERSION_CHECK_ENDPOINT}?version=1.0.0-beta%2Bbuild.123", nextRequest()) checker.dispose() } @Test fun `checkVersion handles malformed JSON response gracefully`() = runBlocking { - val mockConnection = mockk(relaxed = true) - every { anyConstructed().openConnection() } returns mockConnection - every { mockConnection.responseCode } returns 200 - every { mockConnection.inputStream } returns ByteArrayInputStream( - """{"invalid json""".toByteArray() - ) - - val checker = VersionChecker("https://api.test.com", "https://web.test.com") + respond(200, """{"invalid json""") + val checker = VersionChecker(apiEndpoint, "https://web.test.com") // Should not throw - JSON parse errors are handled gracefully assertDoesNotThrow { checker.checkVersion("1.0.0", null) } - // Allow time for coroutine to execute - delay(200) - - // Verify the connection was attempted and response was read - verify { mockConnection.responseCode } - verify { mockConnection.inputStream } - verify { mockConnection.disconnect() } + assertNotNull(nextRequest()) checker.dispose() } @Test fun `hasShownWarning flag prevents multiple notifications`() = runBlocking { - val mockConnection = mockk(relaxed = true) - every { anyConstructed().openConnection() } returns mockConnection - every { mockConnection.responseCode } returns 200 - every { mockConnection.inputStream } returns ByteArrayInputStream( - """{"isLatest":false,"latestVersion":"2.0.0","version":"1.0.0"}""".toByteArray() - ) - - val checker = VersionChecker("https://api.test.com", "https://web.test.com") + respond(200, """{"isLatest":false,"latestVersion":"2.0.0","version":"1.0.0"}""") + val checker = VersionChecker(apiEndpoint, "https://web.test.com") // First call should trigger check checker.checkVersion("1.0.0", null) - delay(200) + assertNotNull(nextRequest()) + // Allow time for the checker to read the reply + delay(500) // Second call should be skipped checker.checkVersion("1.0.0", null) - delay(200) - - // Should only open connection once - verify(exactly = 1) { anyConstructed().openConnection() } + assertNull(requests.poll(500, TimeUnit.MILLISECONDS)) checker.dispose() }