diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c35ec18..c75b3f39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,12 @@ ### Added - include a Coder support bundle for the selected workspace and agent only when using the environment's _Collect logs_ - action in Toolbox + action in Toolbox; requires Coder server and CLI 2.10.0 or newer. Toolbox logs and eligible local SSH logs remain + available when a Coder support bundle cannot be generated - include active and recent local CLI SSH logs in workspace log collection. For CLI 2.4.0 and newer the plugin now passes an explicit `--log-dir` to the SSH ProxyCommand (the configured directory, `CODER_SSH_LOG_DIR`, or `ssh-logs/` in the deployment's plugin data directory) and collects and cleans up logs from that same directory +- add a flight recorder that keeps recent plugin messages below Toolbox's log level in memory for connection diagnostics ### Changed @@ -18,8 +20,7 @@ - remove stored credentials on explicit logout, and remove old plaintext session files after successful keyring login - bound credential and header commands to 60 seconds while preserving unbounded workspace starts and complete progress lines - -- upgraded the Toolbox plugin API, dropping support for Toolbox versions older than 3.7.2 +- upgraded the Toolbox plugin API, dropping support for Toolbox versions older than 3.9.0 - display live workspace start output from the Coder CLI and poll build status and provisioner output while updating, restarting, or stopping workspaces diff --git a/README.md b/README.md index 26fecc06..964ddcf7 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ Connects your JetBrains IDE to Coder workspaces To install this plugin using JetBrains Toolbox, follow the steps below. -1. Install [JetBrains Toolbox](https://www.jetbrains.com/toolbox-app/). Make sure it's the `2.6.0.40632` release or - above. +1. Install [JetBrains Toolbox](https://www.jetbrains.com/toolbox-app/) **3.9 or newer**. Earlier versions are not + supported. 2. Launch the Toolbox app and sign in with your JetBrains account (if needed). ### Install Coder plugin via URI @@ -348,13 +348,123 @@ error. ## Debugging and Reporting issues -Enabling debug logging is essential for diagnosing issues with the Toolbox plugin, especially when SSH connections to -the remote environment fail — it provides detailed output that includes SSH negotiation and command execution, which is -not visible at the default log level. +The plugin's flight recorder keeps recent messages below Toolbox's log level in memory and replays them at connection +diagnostic checkpoints and when collecting a Coder support bundle. This can preserve the context leading up to an issue +without enabling debug logging beforehand. Enable debug logging when you need continuous detailed output, including +messages from Toolbox itself. If you encounter a problem with Coder's JetBrains Toolbox plugin, follow the steps below to gather more information and help us diagnose and resolve it quickly. +### Flight recorder + +The flight recorder is enabled by default with a capacity of **1000 log entries** from the Coder plugin logger. You can +configure the capacity up to **10000 entries** using **Connection log buffer size**. Messages that Toolbox accepts at +its current log level are logged normally; messages it filters out are captured in a fixed capacity array ring buffer. +When the buffer fills, each new entry replaces the oldest entry. + +Each entry preserves its original log level, message text, and exception details when present. Its UTC timestamp marks +when the prepared entry is accepted into the buffer, after any lazy message evaluation and exception formatting. +Entries are replayed and evicted in that acceptance order. The limit counts entries, not lines or bytes: a multiline +message or a message with a stack trace occupies one entry, so memory usage depends on message size. Capture covers +messages sent through the Coder plugin logger; CLI SSH file logs are collected separately as described in +[Coder Support Bundles](#coder-support-bundles). + +There is one shared buffer per running plugin instance, so a replay can contain context from several workspaces. +Messages retain any `client_session_id` recorded with them. Environment-specific replays also use the triggering +environment's current session ID; provider-wide replays do not add a session prefix. The buffer remains in memory until +replayed or discarded. Entries that have not been replayed are lost when the plugin instance ends or Toolbox exits. + +#### Configuring the buffer + +Open the **Coder main page** (the page listing your environments), click the **action menu on the right side of the +page header**, and select **Settings**. Under **General**, change **Connection log buffer size** and click **Save**. + +| Setting | Plugin settings key | Default | Valid values | +|----------------------------|---------------------------|----------------|----------------------------------------------| +| Connection log buffer size | `connectionLogBufferSize` | `1000` entries | Whole numbers from `0` to `10000`, inclusive | + +Changes take effect without restarting Toolbox: + +- Reducing the capacity keeps the newest entries that fit and discards older entries. +- Increasing the capacity preserves all retained entries and allows more entries to be recorded. +- Setting the capacity to `0` clears the buffer and disables recording and replay. Normal Toolbox logging continues. +- The settings page rejects invalid input. An absent, malformed, or out-of-range stored value falls back to `1000`. + +#### Toolbox log levels and replay levels + +The recorder follows Toolbox's effective log level, including `--debug` and `--trace` launch flags. Changes to the +Toolbox log level apply live. Each replay call uses a level Toolbox currently persists, while the original level of +each entry stays in the message text. + +| Toolbox effective level | Logged normally | New entries captured in memory | Replay level | +|-------------------------|--------------------------|--------------------------------|-------------------------------------------------| +| ERROR | ERROR | WARN, INFO, DEBUG, TRACE | ERROR | +| WARN | ERROR, WARN | INFO, DEBUG, TRACE | WARN | +| INFO | ERROR, WARN, INFO | DEBUG, TRACE | INFO | +| DEBUG | ERROR, WARN, INFO, DEBUG | TRACE | INFO | +| TRACE | All levels | None | INFO for entries retained from an earlier level | +| OFF | None | None | No replay | + +With Toolbox logging set to **OFF**, neither recording nor replay runs. Existing buffered entries remain available +after logging is re-enabled, unless the capacity is set to `0` or the plugin instance ends in the meantime. + +The plugin's **HTTP logging level** is independent of this setting. For example, the recorder cannot capture HTTP +headers or bodies when the HTTP logging setting does not generate those messages. See +[HTTP Request Logging](#http-request-logging). + +#### Automatic replay triggers + +The following events flush the recorder. The reason appears in the replay header and footer; `` is the environment +ID, normally `workspace.agent`. + +| Event | Replay reason | +|------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------| +| An environment changes from reachable to unreachable | `environment_status_changed ` | +| The workspace filter changes | `workspace_filter_changed` | +| A workspace restart, stop, or deletion is requested | `workspace_restarting `, `workspace_stopping `, `workspace_deleting ` | +| An SSH disconnect request times out before workspace stop | `ssh_disconnect_timeout ` | +| Toolbox invokes the SSH disconnect callback | `ssh_disconnected ` | +| An environment's SSH session is removed during disposal or after manual disconnect | `ssh_session_removed `, `ssh_session_removed_after_manual_disconnect ` | +| Logout begins, CLI logout fails, or the provider closes | `logout`, `cli_logout_failed`, `provider_closed` | +| The Coder session expires or access token refresh fails | `coder_session_expired`, `token_refresh_failed` | +| Unsafe deployment-controlled workspace, owner, or agent identifiers are reported | `invalid_deployment_data` | +| A workspace/deployment action fails | `action_failed ` | +| Workspace log collection starts Coder support bundle export | `support_bundle` | + +Lifecycle flushes run immediately after their triggering log message, so that message is included if Toolbox filters +it. Support bundle export flushes before collecting the bundle. These checkpoints also include normal state changes +and user actions; a replay block alone does not mean that a connection failed. + +#### Reading replayed logs + +A flush replays entries from oldest to newest and removes each batch after it is logged. An empty buffer produces no +header, entries, or footer. A completed flush makes one call for the header, one call per chunk of up to **100 +entries**, +and one call for the footer. For example, 250 entries produce five Toolbox log calls. + +Example message text, before Toolbox adds its own timestamp and metadata: + +```text +[buffered] replaying 2 buffered entries (support_bundle) +[buffered] 2026-10-22T06:10:01.002Z DEBUG Connecting to demo.agent +[buffered] retry scheduled +[buffered] 2026-10-22T06:10:03.415Z TRACE Waiting for agent +[buffered] end of buffered logs (support_bundle) +``` + +The `retry scheduled` line is a continuation of the first entry. Every continuation line gets a bare `[buffered] ` +prefix. Entry timestamps use ISO 8601 in UTC with millisecond precision and show when the prepared entry was accepted +into the buffer. Toolbox adds its own timestamp for the replay time, which can use a different time zone. + +Each chunk is one multiline Toolbox record. Only its first line receives Toolbox's usual timestamp, severity, and +other metadata; subsequent lines contain the buffered message text. Original severity labels such as `DEBUG` and +`TRACE` therefore remain visible even when the Toolbox record itself is logged as INFO, WARN, or ERROR. + +Capture and export are best-effort because Toolbox schedules its own log writes and the effective level can change +between recording and writing a message. Successfully replayed entries are not repeated by later flushes. If logging +is switched to OFF during replay, the remaining entries stay buffered until another flush can run. + ### Enable Debug Logging To help with troubleshooting or to gain more insight into the behavior of the plugin and the SSH connection to the @@ -375,10 +485,23 @@ There is no need to restart Toolbox, as it will begin logging at the __DEBUG__ l > [!WARNING] > Toolbox does not persist log level configuration between restarts. -#### Viewing the Logs +### Viewing the Logs + +Normal plugin logs and replayed flight recorder entries are written through Toolbox's logger into the **Toolbox App +log files**. Search for `[buffered]` to find replay blocks and their reasons. The recorder does not create a separate +log file, and the **SSH proxy log directory** and plugin **Data directory** settings do not change where replayed +entries are written. + +Open **Toolbox App Menu > Settings > About > Show log files** to locate the files. The standard directories are: -Once enabled, debug logs will be written to the Toolbox log files. You can access logs directly via Toolbox App Menu > -About > Show log files. +| Operating system | Toolbox App log directory | +|------------------|------------------------------------------| +| Windows | `%LOCALAPPDATA%\JetBrains\Toolbox\logs\` | +| macOS | `~/Library/Logs/JetBrains/Toolbox/` | +| Linux | `~/.local/share/JetBrains/Toolbox/logs/` | + +These locations are documented in +the [JetBrains Toolbox App FAQ](https://www.jetbrains.com/help/toolbox-app/frequently-asked-questions.html). Alternatively, use the workspace environment's _Collect logs_ action to generate a ZIP file with Toolbox diagnostics and a Coder support bundle. See [Coder Support Bundles](#coder-support-bundles) below for the navigation steps and @@ -403,12 +526,18 @@ To collect logs with a Coder support bundle: > [!NOTE] > The general Toolbox **Settings > About > Collect logs and diagnostic data** action does **not** generate a Coder -> support bundle. +> support bundle or explicitly flush the flight recorder. The **Collect logs** action in an individual environment's +> action menu requests both. Previously replayed entries remain available in the Toolbox App log files. The plugin runs `coder support bundle` using the deployment's existing CLI login and targets the selected workspace and its agent, when available. It places `coder-support.zip` inside the environment's diagnostic directory in the Toolbox log archive. Collection does not start a stopped workspace. +Before running the Coder CLI, the plugin flushes the flight recorder with reason `support_bundle` and the current +session ID when available. Replayed entries go into the Toolbox App logs collected with the diagnostics; the recorder +does not write a separate file into `coder-support.zip`. This replay is requested even if Coder bundle generation +subsequently fails. Keep Toolbox logging enabled when collecting logs, since OFF disables replay. + Recent local CLI SSH logs are added in place to `coder-support.zip` under `toolbox-logs/ssh/`. If the bundle could not be generated or is not a valid archive, the logs are written to a separate `coder-ssh.zip` in the diagnostic directory instead, using the same layout. @@ -448,9 +577,11 @@ source, and agent diagnostics. Available information depends on your permissions Review the bundle before sharing, following [Coder's support-bundle guidance](https://coder.com/docs/support/support-bundle). -Support bundles require a Coder CLI that supports `coder support bundle` (Coder 2.10 or newer). Collection runs until -the CLI finishes or you cancel log collection. If the CLI is unavailable or unsupported, or authentication or -connectivity fails, the plugin removes any partial bundle and attempts to include +Support bundles require **Coder server and CLI 2.10.0 or newer**, which introduced +[`coder support bundle`](https://coder.com/changelog/coder-v2-10-0). Collection runs until the CLI finishes or you +cancel +log collection. If the CLI is unavailable or unsupported, or authentication or connectivity fails, the plugin removes +any partial bundle and attempts to include `coder-support-error.txt` instead. Local SSH logs and Toolbox's other diagnostics remain available. ### HTTP Request Logging @@ -462,8 +593,8 @@ issues with Coder deployments. This feature allows you to monitor all HTTP reque You can configure HTTP logging verbosity through the Coder Settings page: -1. Navigate to the Coder Workspaces page -2. Click on the deployment action menu (three dots) +1. Open the Coder main page, which lists your environments +2. Click the action menu (three dots) on the right side of the page header 3. Select "Settings" 4. Find the "HTTP logging level" dropdown @@ -513,7 +644,8 @@ When reporting issues, include HTTP logs to help diagnose: ## Coder Settings The Coder Settings allows users to control CLI download behavior, SSH configuration, TLS parameters, and data storage -paths. The options can be configured from the plugin's main Workspaces page > deployment action menu > Settings. +paths. On the Coder main page (the page listing your environments), click the action menu on the right side of the +page header and select **Settings**. ### Workspace list filtering @@ -562,6 +694,14 @@ explicit entry per resolved workspace/agent using `Host coder-jetbrains-toolbox----.--`, and its proxy command targets `/.`. +### Connection logging settings + +- `Connection log buffer size` (`connectionLogBufferSize`) sets the maximum number of log entries below Toolbox's + log level to keep in memory for connection diagnostics. It defaults to `1000` and accepts whole numbers from `0` + to `10000`; `0` clears the buffer and disables buffering. Invalid stored values fall back to `1000`. Saving a new + value resizes the buffer without a restart, keeping the newest entries that fit. See + [Flight recorder](#flight-recorder) for capture, automatic replay, log levels, and output details. + ### CLI related settings - `Binary source` specifies the source URL or relative path from which the Coder CLI should be downloaded. If a relative diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6a28804c..17c0ffc7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -toolbox-plugin-api = "1.13.87111" +toolbox-plugin-api = "1.15.88839" kotlin = "2.3.10" coroutines = "1.10.2" serialization = "1.9.0" diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index f79a9900..c199d829 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -162,7 +162,6 @@ class CoderRemoteEnvironment( private fun refreshAvailableActions() { val actions = mutableListOf() - context.logger.debug("Refreshing available actions for workspace $id with status: $environmentStatus") if (environmentStatus.canStop() && agent != null) { actions.add(Action(context, "Open web terminal") { context.logger.debug("Launching web terminal for $id...") @@ -240,6 +239,7 @@ class CoderRemoteEnvironment( Action(context, "Update and restart") { withProgress("Updating and restarting workspace…") { context.logger.debug(currentSessionId(), "Updating and re-starting $id...") + context.logger.flush("workspace_restarting $id", currentSessionId()) val build = client.updateWorkspace(workspace) applyWorkspaceSnapshot(workspace.copy(latestBuild = build), agent) workspaceRefreshTrigger.trySend(true) @@ -252,6 +252,7 @@ class CoderRemoteEnvironment( withProgress("Stopping workspace…") { tryStopSshConnection() context.logger.debug(currentSessionId(), "Stopping $id...") + context.logger.flush("workspace_stopping $id", currentSessionId()) val build = client.stopWorkspace(workspace) applyWorkspaceSnapshot(workspace.copy(latestBuild = build), agent) workspaceRefreshTrigger.trySend(true) @@ -279,6 +280,7 @@ class CoderRemoteEnvironment( if (confirmation == workspace.name) { withProgress("Deleting workspace…") { context.logger.debug(currentSessionId(), "Deleting $id...") + context.logger.flush("workspace_deleting $id", currentSessionId()) deleteWorkspace() } } @@ -301,6 +303,7 @@ class CoderRemoteEnvironment( "The SSH connection to workspace $name could not be dropped in time, " + "going to stop the workspace while the SSH connection is live" context.logger.warn(currentSessionId(), message) + context.logger.flush("ssh_disconnect_timeout $id", currentSessionId()) } } } @@ -386,6 +389,7 @@ class CoderRemoteEnvironment( } removedSessionId?.let { sessionId -> context.logger.info(sessionId, "Removed Toolbox SSH session for $id") + context.logger.flush("ssh_session_removed $id", sessionId) } } @@ -412,6 +416,7 @@ class CoderRemoteEnvironment( "Toolbox is disconnecting SSH from $id $disconnectKind.$stateInference " + "Latest known Coder state: $latestCoderState", ) + context.logger.flush("ssh_disconnected $id", sessionId) context.logger.info(sessionId, "Stopping the network metrics poll job for $id") pollJob?.cancel() pollJob = null @@ -425,6 +430,7 @@ class CoderRemoteEnvironment( SessionIdRegistry.removeSession(workspace.name, it.name) }?.let { context.logger.info(it, "Removed Toolbox SSH session for $id after manual disconnect") + context.logger.flush("ssh_session_removed_after_manual_disconnect $id", it) } } } @@ -479,6 +485,7 @@ class CoderRemoteEnvironment( val previousEnvironmentStatus = environmentStatus val previousWorkspace = workspace val previousAgent = agent + val wasReachable = state.value.isReachable val sessionId = currentSessionId() environmentStatus = newState @@ -499,6 +506,9 @@ class CoderRemoteEnvironment( "agent lifecycle state: ${previousAgent?.lifecycleState} -> ${agent?.lifecycleState}, " + "login before ready: ${previousAgent?.loginBeforeReady} -> ${agent?.loginBeforeReady}" context.logger.info(sessionId, message) + if (wasReachable && !state.value.isReachable) { + context.logger.flush("environment_status_changed $id", sessionId) + } } /** diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index e1cd454f..f710396f 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -195,6 +195,7 @@ class CoderRemoteProvider( "Your Coder session has expired. Please re-authenticate and try again.", ex ) + context.logger.flush("coder_session_expired") break } context.logger.error(sessionIds, ex, "workspace polling error encountered") @@ -359,6 +360,7 @@ class CoderRemoteProvider( val activeClient = client val activeCli = cli context.logger.info(sessionIds, "Logging out ${activeClient?.me?.username}...") + context.logger.flush("logout") try { pollJob?.cancelAndJoin() activeClient?.close() @@ -376,6 +378,7 @@ class CoderRemoteProvider( "Sign in and try logging out again to remove it.", ex, ) + context.logger.flush("cli_logout_failed") } finally { close() } @@ -422,6 +425,7 @@ class CoderRemoteProvider( accountDropdownField.visibility.update { false } router.clear() context.logger.info(sessionIds, "Coder plugin is now closed") + context.logger.flush("provider_closed") } private fun softClose() { diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index 2aeda7a1..3567b252 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -34,7 +34,9 @@ data class CoderToolboxContext( val secrets: CoderSecretsStore, val proxySettings: ToolboxProxySettings, ) { - val logger: CoderLogger = CoderLogger(underlyingLogger, ui, cs, i18n) + val logger: CoderLogger = + underlyingLogger as? CoderLogger ?: CoderLogger(underlyingLogger, ui, cs, i18n, settingsStore.readOnly()) + val connectionMonitoringService: ConnectionMonitoringService = ConnectionMonitoringService(this) /** diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt index 638fba28..fdf73697 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt @@ -1,5 +1,6 @@ package com.coder.toolbox +import com.coder.toolbox.diagnostics.CoderLogger import com.coder.toolbox.settings.Environment import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore @@ -28,9 +29,12 @@ class CoderToolboxExtension : RemoteDevExtension { // All services must be passed in here and threaded as necessary. override fun createRemoteProviderPluginInstance(serviceLocator: ServiceLocator): RemoteProvider { val ui = serviceLocator.getService() - val logger = serviceLocator.getService(Logger::class.java) val cs = serviceLocator.getService() val i18n = serviceLocator.getService() + val delegate = serviceLocator.getService() + val settingsStore = + CoderSettingsStore(serviceLocator.getService(), Environment(), delegate) + val logger = CoderLogger(delegate, ui, cs, i18n, settingsStore.readOnly()) return CoderRemoteProvider( CoderToolboxContext( ui, @@ -41,12 +45,12 @@ class CoderToolboxExtension : RemoteDevExtension { serviceLocator.getService(), serviceLocator.getService(), cs, - serviceLocator.getService(), + logger, i18n, - CoderSettingsStore(serviceLocator.getService(), Environment(), logger), + settingsStore, CoderSecretsStore(serviceLocator.getService()), serviceLocator.getService(), ) ) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/coder/toolbox/cli/SshCommandProcessHandle.kt b/src/main/kotlin/com/coder/toolbox/cli/SshCommandProcessHandle.kt index 1b62b4f4..ea001804 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/SshCommandProcessHandle.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/SshCommandProcessHandle.kt @@ -26,11 +26,10 @@ class SshCommandProcessHandle(private val ctx: CoderToolboxContext) { while (stack.isNotEmpty()) { val processHandle = stack.removeLast() val cmdLine = processHandle.info().commandLine().getOrNull() - ctx.logger.debug(sessionId, "SSH command PID: ${processHandle.pid()} Command: $cmdLine") if (cmdLine != null && cmdLine.isSshCommandFor(ws, agent)) { ctx.logger.debug( sessionId, - "SSH command with PID: ${processHandle.pid()} and Command: $cmdLine matches ${ws.name}.${agent.name}" + "SSH command with PID: ${processHandle.pid()} matches ${ws.name}.${agent.name}" ) return processHandle.pid() } else { diff --git a/src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt b/src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt index b219954b..ba4fc6b3 100644 --- a/src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt +++ b/src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt @@ -3,6 +3,8 @@ package com.coder.toolbox.diagnostics import com.coder.toolbox.session.SessionId +import com.coder.toolbox.settings.ReadOnlyCoderSettings +import com.jetbrains.toolbox.api.core.diagnostics.LogLevel import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.ui.ToolboxUi @@ -30,84 +32,157 @@ class CoderLogger( private val ui: ToolboxUi, private val cs: CoroutineScope, private val i18n: LocalizableStringFactory, + settings: ReadOnlyCoderSettings, ) : Logger by delegate { + private val flightRecorder = FlightRecorder(settings, cs, delegate) + override inline fun error(message: String) { + record(LogLevel.ERROR, message) delegate.error(message) } + override inline fun error(noinline message: () -> String) { + delegate.error(record(LogLevel.ERROR, null, message)) + } + override inline fun error(exception: Throwable, message: String) { + record(LogLevel.ERROR, message, exception) delegate.error(exception, message) } + override inline fun error(exception: Throwable, noinline message: () -> String) { + delegate.error(exception, record(LogLevel.ERROR, exception, message)) + } + inline fun error(sessionId: SessionId?, exception: Throwable, message: String) { - delegate.error(exception, withSessionId(sessionId, message)) + error(exception, withSessionId(sessionId, message)) } inline fun error(sessionIds: Set, exception: Throwable, message: String) { - sessionIds.onceOrForEach { delegate.error(exception, withSessionId(it, message)) } + sessionIds.onceOrForEach { error(exception, withSessionId(it, message)) } } override inline fun warn(message: String) { + record(LogLevel.WARN, message) delegate.warn(message) } + override inline fun warn(noinline message: () -> String) { + delegate.warn(record(LogLevel.WARN, null, message)) + } + override inline fun warn(exception: Throwable, message: String) { + record(LogLevel.WARN, message, exception) delegate.warn(exception, message) } + override inline fun warn(exception: Throwable, noinline message: () -> String) { + delegate.warn(exception, record(LogLevel.WARN, exception, message)) + } + inline fun warn(sessionId: SessionId?, message: String) { - delegate.warn(withSessionId(sessionId, message)) + warn(withSessionId(sessionId, message)) } inline fun warn(sessionId: SessionId?, exception: Throwable, message: String) { - delegate.warn(exception, withSessionId(sessionId, message)) + warn(exception, withSessionId(sessionId, message)) } inline fun warn(sessionIds: Set, exception: Throwable, message: String) { - sessionIds.onceOrForEach { delegate.warn(exception, withSessionId(it, message)) } + sessionIds.onceOrForEach { warn(exception, withSessionId(it, message)) } } override inline fun debug(message: String) { + record(LogLevel.DEBUG, message) delegate.debug(message) } + override inline fun debug(noinline message: () -> String) { + delegate.debug(record(LogLevel.DEBUG, null, message)) + } + + override inline fun debug(exception: Throwable, message: String) { + record(LogLevel.DEBUG, message, exception) + delegate.debug(exception, message) + } + + override inline fun debug(exception: Throwable, noinline message: () -> String) { + delegate.debug(exception, record(LogLevel.DEBUG, exception, message)) + } + inline fun debug(sessionId: SessionId?, message: String) { - delegate.debug(withSessionId(sessionId, message)) + debug(withSessionId(sessionId, message)) } inline fun debug(sessionIds: Set, message: String) { - sessionIds.onceOrForEach { delegate.debug(withSessionId(it, message)) } + sessionIds.onceOrForEach { debug(withSessionId(it, message)) } } override inline fun info(message: String) { + record(LogLevel.INFO, message) delegate.info(message) } + override inline fun info(noinline message: () -> String) { + delegate.info(record(LogLevel.INFO, null, message)) + } + + override inline fun info(exception: Throwable, message: String) { + record(LogLevel.INFO, message, exception) + delegate.info(exception, message) + } + + override inline fun info(exception: Throwable, noinline message: () -> String) { + delegate.info(exception, record(LogLevel.INFO, exception, message)) + } + inline fun info(sessionId: SessionId?, message: String) { - delegate.info(withSessionId(sessionId, message)) + info(withSessionId(sessionId, message)) } inline fun info(sessionIds: Set, message: String) { - sessionIds.onceOrForEach { delegate.info(withSessionId(it, message)) } + sessionIds.onceOrForEach { info(withSessionId(it, message)) } + } + + override inline fun trace(message: String) { + record(LogLevel.TRACE, message) + delegate.trace(message) + } + + override inline fun trace(noinline message: () -> String) { + delegate.trace(record(LogLevel.TRACE, null, message)) + } + + @Deprecated("Do not trace with exceptions", level = DeprecationLevel.ERROR) + @Suppress("DEPRECATION_ERROR") + override inline fun trace(exception: Throwable, message: String) { + record(LogLevel.TRACE, message, exception) + delegate.trace(exception, message) + } + + @Deprecated("Do not trace with exceptions", level = DeprecationLevel.ERROR) + @Suppress("DEPRECATION_ERROR") + override inline fun trace(exception: Throwable, noinline message: () -> String) { + delegate.trace(exception, record(LogLevel.TRACE, exception, message)) } inline fun logAndShowError(title: String, error: String) { - delegate.error(error) + error(error) showInfoPopup(title, error) } inline fun logAndShowError(sessionId: SessionId?, title: String, error: String) { - delegate.error(withSessionId(sessionId, error)) + error(withSessionId(sessionId, error)) showInfoPopup(title, error) } inline fun logAndShowError(title: String, error: String, exception: Throwable) { - delegate.error(exception, error) + error(exception, error) showInfoPopup(title, error) } inline fun logAndShowError(sessionId: SessionId?, title: String, error: String, exception: Throwable) { - delegate.error(exception, withSessionId(sessionId, error)) + error(exception, withSessionId(sessionId, error)) showInfoPopup(title, error) } @@ -117,17 +192,17 @@ class CoderLogger( error: String, exception: Throwable, ) { - sessionIds.onceOrForEach { delegate.error(exception, withSessionId(it, error)) } + sessionIds.onceOrForEach { error(exception, withSessionId(it, error)) } showInfoPopup(title, error) } inline fun logAndShowWarning(title: String, warning: String) { - delegate.warn(warning) + warn(warning) showInfoPopup(title, warning) } inline fun logAndShowWarning(sessionId: SessionId?, title: String, warning: String) { - delegate.warn(withSessionId(sessionId, warning)) + warn(withSessionId(sessionId, warning)) showInfoPopup(title, warning) } @@ -137,20 +212,34 @@ class CoderLogger( warning: String, exception: Throwable, ) { - sessionIds.onceOrForEach { delegate.warn(exception, withSessionId(it, warning)) } + sessionIds.onceOrForEach { warn(exception, withSessionId(it, warning)) } showInfoPopup(title, warning) } inline fun logAndShowWarning(title: String, warning: String, exception: Throwable) { - delegate.warn(exception, warning) + warn(exception, warning) showInfoPopup(title, warning) } inline fun logAndShowInfo(title: String, info: String) { - delegate.info(info) + info(info) showInfoPopup(title, info) } + @PublishedApi + internal fun record(level: LogLevel, message: String, exception: Throwable? = null) { + flightRecorder.record(level, message, exception) + } + + @PublishedApi + internal fun record(level: LogLevel, exception: Throwable?, message: () -> String): () -> String = + flightRecorder.record(level, exception, message) + + /** Replays and removes buffered logs, oldest first, with an optional session prefix. */ + fun flush(reason: String, sessionId: SessionId? = null) { + flightRecorder.flush(reason, sessionId) + } + /** * Displays an informational popup on a child of the plugin coroutine scope rather than on * the caller's coroutine, without waiting for it. @@ -175,7 +264,7 @@ class CoderLogger( } catch (_: CancellationException) { // Expected when the plugin scope shuts down while the popup is open. } catch (ex: Exception) { - delegate.error(ex, "Failed to display popup with title '$title'") + error(ex, "Failed to display popup with title '$title'") } } } diff --git a/src/main/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollector.kt b/src/main/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollector.kt index 66a0e8ff..248fed82 100644 --- a/src/main/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollector.kt +++ b/src/main/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollector.kt @@ -24,6 +24,7 @@ internal class CoderSupportBundleCollector( override suspend fun collectAdditionalDiagnostics(logsRootFolder: Path) = withContext(Dispatchers.IO) { val sessionId = currentSessionId() try { + logger.flush("support_bundle", sessionId) Files.createDirectories(logsRootFolder) collectSupportBundle(logsRootFolder, sessionId) // Without a usable support bundle the SSH logs still go into an archive of their own. diff --git a/src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt b/src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt new file mode 100644 index 00000000..2e452f16 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt @@ -0,0 +1,151 @@ +package com.coder.toolbox.diagnostics + +import com.coder.toolbox.session.SessionId +import com.coder.toolbox.settings.CONNECTION_LOG_BUFFER_SIZE_RANGE +import com.coder.toolbox.settings.ReadOnlyCoderSettings +import com.jetbrains.toolbox.api.core.diagnostics.LogLevel +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch +import java.time.Clock +import java.time.Instant +import java.time.format.DateTimeFormatterBuilder + +private const val REPLAY_CHUNK_SIZE = 100 +private const val BUFFERED_PREFIX = "[buffered] " +private val BUFFERED_TIMESTAMP_FORMATTER = DateTimeFormatterBuilder().appendInstant(3).toFormatter() + +internal data class FlightRecorderEntry(val level: LogLevel, val timestamp: Instant, val message: String) + +/** Captures recent messages filtered by Toolbox without retaining message suppliers or exceptions. */ +internal class FlightRecorder( + readOnlySettings: ReadOnlyCoderSettings, + scope: CoroutineScope, + private val logger: Logger, +) { + private val lock = Any() + private val clock: Clock = Clock.systemUTC() + private var entries = arrayOfNulls(readOnlySettings.connectionLogBufferSize) + private var oldestRecordIndex = 0 + private var count = 0 + + init { + scope.launch(CoroutineName("Connection log buffer capacity"), start = CoroutineStart.UNDISPATCHED) { + try { + readOnlySettings.observe { it.connectionLogBufferSize }.collect(::resize) + } finally { + resize(0) + } + } + } + + private val capacity: Int get() = synchronized(lock) { entries.size } + + fun record(level: LogLevel, message: String, exception: Throwable? = null) { + if (capacity == 0 || logger.effectiveLevel.value == LogLevel.OFF || logger.isEnabled(level)) return + recordEntry(level, message, exception) + } + + fun record(level: LogLevel, exception: Throwable?, message: () -> String): () -> String { + if (capacity == 0 || logger.effectiveLevel.value == LogLevel.OFF || logger.isEnabled(level)) return message + val resolved = message() + recordEntry(level, resolved, exception) + // Toolbox may evaluate this later if its level changes. Never evaluate the original supplier twice. + return { resolved } + } + + fun flush(reason: String, sessionId: SessionId? = null) { + synchronized(lock) { + if (count == 0 || logger.effectiveLevel.value == LogLevel.OFF) return + if (!replay("${BUFFERED_PREFIX}replaying $count buffered entries ($reason)", sessionId)) return + + while (count > 0) { + if (logger.effectiveLevel.value == LogLevel.OFF) return + val chunkSize = minOf(count, REPLAY_CHUNK_SIZE) + val message = buildString { + repeat(chunkSize) { index -> + if (index > 0) append('\n') + val entry = requireNotNull(entries[(oldestRecordIndex + index) % entries.size]) + appendBufferedEntry(entry) + } + } + if (!replay(message, sessionId)) return + + repeat(chunkSize) { + entries[oldestRecordIndex] = null + oldestRecordIndex = if (oldestRecordIndex == entries.lastIndex) 0 else oldestRecordIndex + 1 + } + count -= chunkSize + } + + replay("${BUFFERED_PREFIX}end of buffered logs ($reason)", sessionId) + } + } + + private fun StringBuilder.appendBufferedEntry(entry: FlightRecorderEntry) { + append(BUFFERED_PREFIX) + append(BUFFERED_TIMESTAMP_FORMATTER.format(entry.timestamp)) + append(' ').append(entry.level.name).append(' ') + entry.message.lineSequence().forEachIndexed { index, line -> + if (index > 0) append('\n').append(BUFFERED_PREFIX) + append(line) + } + } + + private fun replay(message: String, sessionId: SessionId?): Boolean { + val level = logger.effectiveLevel.value + if (level == LogLevel.OFF) return false + val text = withSessionId(sessionId, message) + when (level) { + LogLevel.TRACE, LogLevel.DEBUG, LogLevel.INFO -> logger.info(text) + LogLevel.WARN -> logger.warn(text) + LogLevel.ERROR -> logger.error(text) + LogLevel.OFF -> return false + } + return true + } + + private fun recordEntry(level: LogLevel, message: String, exception: Throwable?) { + val text = if (exception == null) message else "$message\n${exception.stackTraceToString()}" + synchronized(lock) { + if (entries.isEmpty()) return + val entry = FlightRecorderEntry(level, clock.instant(), text) + val newestRecordIndex = oldestRecordIndex + count + entries[if (newestRecordIndex >= entries.size) newestRecordIndex - entries.size else newestRecordIndex] = + entry + if (count < entries.size) { + count++ + } else { + oldestRecordIndex = if (oldestRecordIndex == entries.lastIndex) 0 else oldestRecordIndex + 1 + } + } + } + + /** Retains the newest entries, in insertion order, when the capacity changes. Zero clears stored entries. */ + private fun resize(capacity: Int) { + require(capacity in CONNECTION_LOG_BUFFER_SIZE_RANGE) + synchronized(lock) { + if (capacity == entries.size) return + val retained = minOf(count, capacity) + val resized = arrayOfNulls(capacity) + if (retained > 0) { + val start = (oldestRecordIndex + count - retained) % entries.size + val firstPart = minOf(retained, entries.size - start) + entries.copyInto(resized, destinationOffset = 0, startIndex = start, endIndex = start + firstPart) + if (firstPart < retained) { + entries.copyInto( + resized, + destinationOffset = firstPart, + startIndex = 0, + endIndex = retained - firstPart + ) + } + } + entries = resized + oldestRecordIndex = 0 + count = retained + } + } +} diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index b70fcc91..2c1c3c87 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -417,6 +417,7 @@ open class CoderRestClient( private fun reportInvalidDeploymentData(ex: InvalidCoderIdentifierException) { if (isInvalidDeploymentDataWarningShown) { context.logger.warn(ex, "Ignoring invalid deployment-controlled identifiers") + context.logger.flush("invalid_deployment_data") return } @@ -426,6 +427,7 @@ open class CoderRestClient( INVALID_DEPLOYMENT_DATA_WARNING, ex, ) + context.logger.flush("invalid_deployment_data") } /** @@ -451,6 +453,7 @@ open class CoderRestClient( true } catch (e: Exception) { context.logger.error(e, "Failed to refresh access token") + context.logger.flush("token_refresh_failed") // propagate the exception to the main workspace polling loop throw e } diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index c6928e0c..3bb57391 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -1,14 +1,29 @@ package com.coder.toolbox.settings +import kotlinx.coroutines.flow.Flow import java.net.URL import java.nio.file.Path import java.util.Locale.getDefault +internal const val DEFAULT_CONNECTION_LOG_BUFFER_SIZE = 1_000 +internal val CONNECTION_LOG_BUFFER_SIZE_RANGE = 0..10_000 + /** * Read-only interface for accessing Coder settings */ interface ReadOnlyCoderSettings { + /** + * Observes the effective value returned by [select], starting with its current value. + * Subsequent values are emitted only when they differ according to [Any.equals]. + * Select immutable values, or immutable data classes when observing several settings together. + * The selector must be a quick, side-effect-free read of these settings. + * + * Changes made through this settings instance are observed. Slow collectors may skip intermediate + * updates and receive the latest value. Cancelling collection unsubscribes the observer. + */ + fun observe(select: (ReadOnlyCoderSettings) -> T): Flow + /** * The last used deployment URL. */ @@ -58,6 +73,13 @@ interface ReadOnlyCoderSettings { */ val httpClientLogLevel: HttpLoggingVerbosity + /** + * Maximum number of entries below Toolbox's log level to retain for connection diagnostics. + * Defaults to 1000, accepts 0 through 10000, and uses 0 to disable buffering. + * Captured entries retain their original log level, timestamp, and message. + */ + val connectionLogBufferSize: Int + /** * Default CLI binary name based on OS and architecture */ diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index 83e08323..c7449c11 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -1,5 +1,7 @@ package com.coder.toolbox.store +import com.coder.toolbox.settings.CONNECTION_LOG_BUFFER_SIZE_RANGE +import com.coder.toolbox.settings.DEFAULT_CONNECTION_LOG_BUFFER_SIZE import com.coder.toolbox.settings.Environment import com.coder.toolbox.settings.HttpLoggingVerbosity import com.coder.toolbox.settings.ReadOnlyCoderSettings @@ -15,6 +17,10 @@ import com.coder.toolbox.util.toURL import com.coder.toolbox.util.withPath import com.jetbrains.toolbox.api.core.PluginSettingsStore import com.jetbrains.toolbox.api.core.diagnostics.Logger +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import java.net.URL import java.nio.file.Files import java.nio.file.Path @@ -27,8 +33,11 @@ class CoderSettingsStore( private val logger: Logger ) : ReadOnlyCoderSettings { + private val settingsLock = Any() + private val revision = MutableStateFlow(0L) + // Internal TLS settings implementation - private class TLSSettings( + private data class TLSSettings( override val certPath: String?, override val keyPath: String?, override val caPath: String?, @@ -48,6 +57,9 @@ class CoderSettingsStore( get() = SignatureFallbackStrategy.fromValue(store[FALLBACK_ON_CODER_FOR_SIGNATURES]) override val httpClientLogLevel: HttpLoggingVerbosity get() = HttpLoggingVerbosity.fromValue(store[HTTP_CLIENT_LOG_LEVEL]) + override val connectionLogBufferSize: Int + get() = store[CONNECTION_LOG_BUFFER_SIZE]?.toIntOrNull() + ?.takeIf { it in CONNECTION_LOG_BUFFER_SIZE_RANGE } ?: DEFAULT_CONNECTION_LOG_BUFFER_SIZE override val defaultCliBinaryNameByOsAndArch: String get() = getCoderCLIForOS(getOS(), getArch()) override val defaultSignatureNameByOsAndArch: String get() = getCoderSignatureForOS(getOS(), getArch()) override val dataDirectory: String? get() = store[DATA_DIRECTORY] @@ -172,105 +184,126 @@ class CoderSettingsStore( // a readonly cast fun readOnly(): ReadOnlyCoderSettings = this + override fun observe(select: (ReadOnlyCoderSettings) -> T): Flow = revision + .map { synchronized(settingsLock) { select(this) } } + .distinctUntilChanged() + + private fun writeSetting(key: String, value: String) { + synchronized(settingsLock) { + store[key] = value + // A failed write leaves the revision unchanged. Selectors share this lock so they + // cannot read an update before the backing store's write has returned. + revision.value += 1 + } + } + // Write operations fun updateLastUsedUrl(url: URL) { - store[LAST_USED_URL] = url.toString() + writeSetting(LAST_USED_URL, url.toString()) } fun updateUseAppNameAsTitle(appNameAsTitle: Boolean) { - store[APP_NAME_AS_TITLE] = appNameAsTitle.toString() + writeSetting(APP_NAME_AS_TITLE, appNameAsTitle.toString()) } fun updateBinarySource(source: String) { - store[BINARY_SOURCE] = source + writeSetting(BINARY_SOURCE, source) } fun updateBinaryDestination(dest: String) { - store[BINARY_DESTINATION] = dest + writeSetting(BINARY_DESTINATION, dest) } fun updateDataDirectory(dir: String) { - store[DATA_DIRECTORY] = dir + writeSetting(DATA_DIRECTORY, dir) } fun updateEnableDownloads(shouldEnableDownloads: Boolean) { - store[ENABLE_DOWNLOADS] = shouldEnableDownloads.toString() + writeSetting(ENABLE_DOWNLOADS, shouldEnableDownloads.toString()) } fun updateDisableSignatureVerification(shouldDisableSignatureVerification: Boolean) { - store[DISABLE_SIGNATURE_VALIDATION] = shouldDisableSignatureVerification.toString() + writeSetting(DISABLE_SIGNATURE_VALIDATION, shouldDisableSignatureVerification.toString()) } fun updateSignatureFallbackStrategy(fallback: Boolean) { - store[FALLBACK_ON_CODER_FOR_SIGNATURES] = when (fallback) { + val strategy = when (fallback) { true -> SignatureFallbackStrategy.ALLOW.toString() else -> SignatureFallbackStrategy.FORBIDDEN.toString() } + writeSetting(FALLBACK_ON_CODER_FOR_SIGNATURES, strategy) } fun updateHttpClientLogLevel(level: HttpLoggingVerbosity?) { if (level == null) return - store[HTTP_CLIENT_LOG_LEVEL] = level.toString() + writeSetting(HTTP_CLIENT_LOG_LEVEL, level.toString()) + } + + fun updateConnectionLogBufferSize(size: Int) { + require(size in CONNECTION_LOG_BUFFER_SIZE_RANGE) { + "Connection log buffer size must be between 0 and 10000" + } + writeSetting(CONNECTION_LOG_BUFFER_SIZE, size.toString()) } fun updateHeaderCommand(cmd: String) { - store[HEADER_COMMAND] = cmd + writeSetting(HEADER_COMMAND, cmd) } fun updateUseKeyring(useKeyring: Boolean) { - store[USE_KEYRING] = useKeyring.toString() + writeSetting(USE_KEYRING, useKeyring.toString()) } fun updateCertPath(path: String) { - store[TLS_CERT_PATH] = path + writeSetting(TLS_CERT_PATH, path) } fun updateKeyPath(path: String) { - store[TLS_KEY_PATH] = path + writeSetting(TLS_KEY_PATH, path) } fun updateCAPath(path: String) { - store[TLS_CA_PATH] = path + writeSetting(TLS_CA_PATH, path) } fun updateAltHostname(hostname: String) { - store[TLS_ALTERNATE_HOSTNAME] = hostname + writeSetting(TLS_ALTERNATE_HOSTNAME, hostname) } fun updateDisableAutostart(shouldDisableAutostart: Boolean) { - store[DISABLE_AUTOSTART] = shouldDisableAutostart.toString() + writeSetting(DISABLE_AUTOSTART, shouldDisableAutostart.toString()) } fun updateSshConnectionTimeoutInSeconds(sshConnectionTimeoutInSeconds: Int) { - store[SSH_CONNECTION_TIMEOUT_IN_SECONDS] = sshConnectionTimeoutInSeconds.toString() + writeSetting(SSH_CONNECTION_TIMEOUT_IN_SECONDS, sshConnectionTimeoutInSeconds.toString()) } fun updateEnableSshWildcardConfig(enable: Boolean) { - store[ENABLE_SSH_WILDCARD_CONFIG] = enable.toString() + writeSetting(ENABLE_SSH_WILDCARD_CONFIG, enable.toString()) } fun updateSshConfigPath(path: String) { - store[SSH_CONFIG_PATH] = path + writeSetting(SSH_CONFIG_PATH, path) } fun updateSshLogDir(path: String) { - store[SSH_LOG_DIR] = path + writeSetting(SSH_LOG_DIR, path) } fun updateNetworkInfoDir(path: String) { - store[NETWORK_INFO_DIR] = path + writeSetting(NETWORK_INFO_DIR, path) } fun updateSshConfigOptions(options: String) { - store[SSH_CONFIG_OPTIONS] = options + writeSetting(SSH_CONFIG_OPTIONS, options) } fun updateAutoConnect(workspaceId: String, autoConnect: Boolean) { - store["$SSH_AUTO_CONNECT_PREFIX$workspaceId"] = autoConnect.toString() + writeSetting("$SSH_AUTO_CONNECT_PREFIX$workspaceId", autoConnect.toString()) } fun updatePreferAuthViaOAuth2(preferAuthViaOAuth2: Boolean) { - store[PREFER_OAUTH2_IF_AVAILABLE] = preferAuthViaOAuth2.toString() + writeSetting(PREFER_OAUTH2_IF_AVAILABLE, preferAuthViaOAuth2.toString()) } private fun getDefaultGlobalDataDir(): Path { diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index df7d9b95..a1cc09a2 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -23,6 +23,8 @@ internal const val FALLBACK_ON_CODER_FOR_SIGNATURES = "signatureFallbackStrategy internal const val HTTP_CLIENT_LOG_LEVEL = "httpClientLogLevel" +internal const val CONNECTION_LOG_BUFFER_SIZE = "connectionLogBufferSize" + internal const val DATA_DIRECTORY = "dataDirectory" internal const val ENABLE_DOWNLOADS = "enableDownloads" diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt index 68e5517b..3ec6500f 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt @@ -92,12 +92,14 @@ class Action( ex.reason } else ex.message } else ex.message + val sessionId = currentSessionId() context.logger.logAndShowError( - currentSessionId(), + sessionId, "Error while running `$description`", textError ?: "", ex, ) + context.logger.flush("action_failed $description", sessionId) } } } diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index 1fddc9c6..a9ce5c77 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -1,6 +1,7 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.settings.CONNECTION_LOG_BUFFER_SIZE_RANGE import com.coder.toolbox.settings.HttpLoggingVerbosity.BASIC import com.coder.toolbox.settings.HttpLoggingVerbosity.BODY import com.coder.toolbox.settings.HttpLoggingVerbosity.HEADERS @@ -14,6 +15,8 @@ import com.jetbrains.toolbox.api.ui.components.CheckboxField import com.jetbrains.toolbox.api.ui.components.ComboBoxField import com.jetbrains.toolbox.api.ui.components.ComboBoxField.LabelledValue import com.jetbrains.toolbox.api.ui.components.FieldModifier +import com.jetbrains.toolbox.api.ui.components.LabelField +import com.jetbrains.toolbox.api.ui.components.LabelStyleType import com.jetbrains.toolbox.api.ui.components.SectionField import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType @@ -88,6 +91,26 @@ class CoderSettingsPage( settings.headerCommand ?: "", TextType.General ) + + private val connectionLogBufferSizeField = TextField( + context.i18n.ptrl("Connection log buffer size"), + settings.connectionLogBufferSize.toString(), + TextType.Integer, + validator = { value -> + if (value.toIntOrNull()?.let { it in CONNECTION_LOG_BUFFER_SIZE_RANGE } == true) { + ValidationResult.Valid + } else { + ValidationResult.Invalid( + context.i18n.ptrl("Connection log buffer size must be a whole number between 0 and 10000") + ) + } + } + ) + + private val connectionLogBufferDescription = LabelField( + context.i18n.ptrl("Flight recorder capacity. Set to 0 to disable."), + LabelStyleType.Secondary + ) private val useKeyringField = CheckboxField( settings.useKeyring, context.i18n.ptrl("Store CLI session in OS keyring when supported (CLI >= 2.29.0)") @@ -198,6 +221,8 @@ class CoderSettingsPage( useAppNameField, disableAutostartField, httpLoggingField, + connectionLogBufferSizeField, + connectionLogBufferDescription, ) ), SectionField( @@ -252,6 +277,7 @@ class CoderSettingsPage( updateDisableSignatureVerification(disableSignatureVerificationField.checkedState.value) updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value) updateHttpClientLogLevel(httpLoggingField.selectedValueState.value) + updateConnectionLogBufferSize(connectionLogBufferSizeField.contentState.value.toInt()) updateHeaderCommand(headerCommandField.contentState.value) // The active CLI keeps its credential backend until the next sign-in. if (shouldShowKeyringField) updateUseKeyring(useKeyringField.checkedState.value) @@ -293,6 +319,9 @@ class CoderSettingsPage( override fun beforeShow() { // update the value of all fields + connectionLogBufferSizeField.contentState.update { + settings.connectionLogBufferSize.toString() + } binarySourceField.contentState.update { settings.binarySource ?: "" } diff --git a/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt b/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt index af881d76..732cb4f2 100644 --- a/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt @@ -127,6 +127,7 @@ class NewEnvironmentPage( mutableWorkspaceSearchQuery.value = query workspaceRefreshTrigger.send(true) context.logger.info("Workspace filter changed, refreshing workspaces...") + context.logger.flush("workspace_filter_changed") } } // 2. Reflect the query back into the dropdowns diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index 3b264228..6d018fa6 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -67,6 +67,15 @@ msgstr "" msgid "Coder Settings" msgstr "" +msgid "Connection log buffer size" +msgstr "" + +msgid "Connection log buffer size must be a whole number between 0 and 10000" +msgstr "" + +msgid "Flight recorder capacity. Set to 0 to disable." +msgstr "" + msgid "Binary source" msgstr "" diff --git a/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt b/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt index 9d78fbd1..9cce4ff9 100644 --- a/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt +++ b/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt @@ -804,7 +804,7 @@ class CoderRemoteEnvironmentTest { every { i18n.pnotr(any()) } answers { localizedStrings.getOrPut(firstArg()) { mockk(relaxed = true) } } - val coderLogger = CoderLogger(logger, mockk(relaxed = true), scope, i18n) + val coderLogger = CoderLogger(logger, mockk(relaxed = true), scope, i18n, mockk(relaxed = true)) every { context.cs } returns scope every { context.logger } returns coderLogger every { context.settingsStore } returns settingsStore diff --git a/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt b/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt index c1fad6e8..5eb6843b 100644 --- a/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt +++ b/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt @@ -67,6 +67,7 @@ class CoderRemoteProviderTest { mockk(relaxed = true), CoroutineScope(Dispatchers.Unconfined), mockk(relaxed = true), + mockk(relaxed = true), ) val settingsStore = mockk(relaxed = true) every { mockContext.settingsStore } returns settingsStore diff --git a/src/test/kotlin/com/coder/toolbox/cli/EnsureCLITest.kt b/src/test/kotlin/com/coder/toolbox/cli/EnsureCLITest.kt index c6da80e4..036768e0 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/EnsureCLITest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/EnsureCLITest.kt @@ -30,6 +30,7 @@ import com.sun.net.httpserver.HttpServer import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.BeforeAll import java.net.HttpURLConnection @@ -68,7 +69,7 @@ internal class EnsureCLITest { mockk(), mockk(), mockk(), - mockk(), + CoroutineScope(Dispatchers.Unconfined), mockk(relaxed = true), mockk(relaxed = true), CoderSettingsStore(pluginTestSettingsStore(), Environment(), mockk(relaxed = true)), diff --git a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt new file mode 100644 index 00000000..e961a645 --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt @@ -0,0 +1,1112 @@ +package com.coder.toolbox.diagnostics + +import com.coder.toolbox.CoderRemoteEnvironment +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.cli.SshCommandProcessHandle +import com.coder.toolbox.sdk.DataGen +import com.coder.toolbox.sdk.v2.models.WorkspaceAgentStatus +import com.coder.toolbox.sdk.v2.models.WorkspaceStatus +import com.coder.toolbox.session.SessionId +import com.coder.toolbox.session.SessionIdRegistry +import com.coder.toolbox.settings.ReadOnlyCoderSettings +import com.coder.toolbox.views.Action +import com.jetbrains.toolbox.api.core.diagnostics.LogLevel +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import com.jetbrains.toolbox.api.localization.LocalizableStringFactory +import com.jetbrains.toolbox.api.ui.ToolboxUi +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import java.io.PrintWriter +import java.time.Clock +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.Optional +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.stream.Stream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class CoderLoggerFlightRecorderTest { + private val logLevel = MutableStateFlow(LogLevel.INFO) + private val bufferSize = MutableStateFlow(1000) + private val calls = mutableListOf() + private val delegate = mockk(relaxed = true) { + every { effectiveLevel } returns logLevel + every { isEnabled(any()) } answers { enabled(firstArg()) } + every { trace(any()) } answers { emit(LogLevel.TRACE, firstArg()) } + every { debug(any()) } answers { emit(LogLevel.DEBUG, firstArg()) } + every { info(any()) } answers { emit(LogLevel.INFO, firstArg()) } + every { warn(any()) } answers { emit(LogLevel.WARN, firstArg()) } + every { error(any()) } answers { emit(LogLevel.ERROR, firstArg()) } + every { trace(any<() -> String>()) } answers { emitLazy(LogLevel.TRACE, firstArg()) } + every { debug(any<() -> String>()) } answers { emitLazy(LogLevel.DEBUG, firstArg()) } + every { info(any<() -> String>()) } answers { emitLazy(LogLevel.INFO, firstArg()) } + every { warn(any<() -> String>()) } answers { emitLazy(LogLevel.WARN, firstArg()) } + every { error(any<() -> String>()) } answers { emitLazy(LogLevel.ERROR, firstArg()) } + } + private val settings = mockk { + every { connectionLogBufferSize } answers { bufferSize.value } + every { observe(any<(ReadOnlyCoderSettings) -> Int>()) } returns bufferSize + } + private val ui = mockk(relaxed = true) + private val i18n = mockk(relaxed = true) + + @Test + fun `flush replays 250 entries oldest first in five calls and drains the buffer`() = runTest { + // given + bufferSize.value = 300 + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + repeat(250) { logger.debug("entry-$it") } + assertTrue(calls.isEmpty()) + + // when + logger.flush("support_bundle") + + // then + assertEquals(5, calls.size) + assertEquals("[buffered] replaying 250 buffered entries (support_bundle)", calls.first().message) + assertEquals("[buffered] end of buffered logs (support_bundle)", calls.last().message) + val chunks = calls.subList(1, 4) + assertEquals(listOf(100, 100, 50), chunks.map { it.message.lines().size }) + assertEquals( + List(250) { "entry-$it" }, + chunks.flatMap { it.message.lines() }.map { it.substringAfter(" DEBUG ") }, + ) + assertTrue(calls.all { it.level == LogLevel.INFO }) + + // when + calls.clear() + logger.flush("support_bundle") + + // then + assertTrue(calls.isEmpty()) + } + + @Test + fun `capacity limits recording to the newest entries and the emptied buffer can be reused`() = runTest { + // given + bufferSize.value = 3 + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + repeat(5) { logger.debug("entry-$it") } + + // when + logger.flush("remote_closed") + + // then + assertEquals("[buffered] replaying 3 buffered entries (remote_closed)", calls.first().message) + assertEquals(listOf("entry-2", "entry-3", "entry-4"), replayedDebugMessages()) + + // when + calls.clear() + logger.debug("after flush") + logger.flush("support_bundle") + + // then + assertEquals(listOf("after flush"), replayedDebugMessages()) + } + + @Test + fun `shrinking before a flush drops older entries and enforces the new capacity`() = runTest { + // given + bufferSize.value = 5 + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + repeat(7) { logger.debug("entry-$it") } + + // when + bufferSize.value = 2 + runCurrent() + logger.flush("remote_closed") + + // then + assertEquals("[buffered] replaying 2 buffered entries (remote_closed)", calls.first().message) + assertEquals(listOf("entry-5", "entry-6"), replayedDebugMessages()) + + // when + calls.clear() + repeat(4) { logger.debug("after shrinking-$it") } + logger.flush("smaller capacity overflow") + + // then + assertEquals(listOf("after shrinking-2", "after shrinking-3"), replayedDebugMessages()) + } + + @Test + fun `shrinking a partially filled buffer preserves entries that still fit`() = runTest { + // given + bufferSize.value = 5 + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + logger.debug("first") + logger.debug("second") + + // when + bufferSize.value = 3 + runCurrent() + logger.flush("remote_closed") + + // then + assertEquals(listOf("first", "second"), replayedDebugMessages()) + } + + @Test + fun `growing a wrapped buffer immediately before flushing preserves all retained entries`() = runTest { + // given + bufferSize.value = 3 + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + repeat(5) { logger.debug("entry-$it") } + + // when + bufferSize.value = 5 + runCurrent() + logger.flush("remote_closed") + + // then + assertEquals("[buffered] replaying 3 buffered entries (remote_closed)", calls.first().message) + assertEquals(listOf("entry-2", "entry-3", "entry-4"), replayedDebugMessages()) + } + + @Test + fun `growing capacity changes the recording limit from X to Y`() = runTest { + // given + bufferSize.value = 3 + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + + // when + repeat(5) { logger.debug("original-$it") } + logger.flush("original capacity") + + // then + assertEquals(listOf("original-2", "original-3", "original-4"), replayedDebugMessages()) + + // when + calls.clear() + logger.debug("retained before growing") + bufferSize.value = 5 + runCurrent() + repeat(4) { logger.debug("grown-$it") } + logger.flush("larger capacity") + + // then + assertEquals( + listOf("retained before growing", "grown-0", "grown-1", "grown-2", "grown-3"), + replayedDebugMessages(), + ) + + // when + calls.clear() + repeat(7) { logger.debug("overflow-$it") } + logger.flush("larger capacity overflow") + + // then + assertEquals( + listOf("overflow-2", "overflow-3", "overflow-4", "overflow-5", "overflow-6"), + replayedDebugMessages(), + ) + } + + @Test + fun `batching handles entries below at and above each 100 entry boundary`() = runTest { + listOf( + 1 to listOf(1), + 99 to listOf(99), + 100 to listOf(100), + 101 to listOf(100, 1), + 200 to listOf(100, 100), + 201 to listOf(100, 100, 1), + ).forEach { (entryCount, expectedChunkSizes) -> + // given + calls.clear() + bufferSize.value = entryCount + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + repeat(entryCount) { logger.debug("entry-$it") } + + // when + logger.flush("support_bundle") + + // then + assertEquals(expectedChunkSizes.size + 2, calls.size, "Calls for $entryCount entries") + assertEquals( + expectedChunkSizes, + calls.drop(1).dropLast(1).map { it.message.lines().size }, + "Chunk sizes for $entryCount entries", + ) + } + } + + @Test + fun `buffered block contains the exact header entry metadata and footer`() = runTest { + // given + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + logger.debug("Connecting") + logger.trace("Retrying") + + // when + logger.flush("unreachable /route") + + // then + assertEquals(3, calls.size) + assertEquals("[buffered] replaying 2 buffered entries (unreachable /route)", calls[0].message) + val timestamp = """\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z""" + assertTrue( + Regex("""\[buffered\] $timestamp DEBUG Connecting\n\[buffered\] $timestamp TRACE Retrying""") + .matches(calls[1].message), + calls[1].message, + ) + assertEquals("[buffered] end of buffered logs (unreachable /route)", calls[2].message) + assertEquals(List(3) { LogLevel.INFO }, calls.map { it.level }) + } + + @Test + fun `error logs normally at ERROR while warn info debug and trace replay as error`() = runTest { + // given + logLevel.value = LogLevel.ERROR + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + + // when + logAtEveryLevel(logger) + + // then + assertEquals( + listOf(LogLevel.ERROR).map { LogCall(it, "${it.name} message") }, + calls, + ) + + // when + calls.clear() + logger.flush("support_bundle") + + // then + assertReplayedLevels(listOf(LogLevel.WARN, LogLevel.INFO, LogLevel.DEBUG, LogLevel.TRACE), LogLevel.ERROR) + } + + @Test + fun `error and warn log normally at WARN while info debug and trace replay as warn`() = runTest { + // given + logLevel.value = LogLevel.WARN + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + + // when + logAtEveryLevel(logger) + + // then + assertEquals( + listOf(LogLevel.ERROR, LogLevel.WARN).map { LogCall(it, "${it.name} message") }, + calls, + ) + + // when + calls.clear() + logger.flush("support_bundle") + + // then + assertReplayedLevels(listOf(LogLevel.INFO, LogLevel.DEBUG, LogLevel.TRACE), LogLevel.WARN) + } + + @Test + fun `error warn and info log normally at INFO while debug and trace replay as info`() = runTest { + // given + logLevel.value = LogLevel.INFO + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + + // when + logAtEveryLevel(logger) + + // then + assertEquals( + listOf(LogLevel.ERROR, LogLevel.WARN, LogLevel.INFO).map { LogCall(it, "${it.name} message") }, + calls, + ) + + // when + calls.clear() + logger.flush("support_bundle") + + // then + assertReplayedLevels(listOf(LogLevel.DEBUG, LogLevel.TRACE), LogLevel.INFO) + } + + @Test + fun `error warn info and debug log normally at DEBUG while trace replays as info`() = runTest { + // given + logLevel.value = LogLevel.DEBUG + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + + // when + logAtEveryLevel(logger) + + // then + assertEquals( + listOf(LogLevel.ERROR, LogLevel.WARN, LogLevel.INFO, LogLevel.DEBUG) + .map { LogCall(it, "${it.name} message") }, + calls, + ) + + // when + calls.clear() + logger.flush("support_bundle") + + // then + assertReplayedLevels(listOf(LogLevel.TRACE), LogLevel.INFO) + } + + @Test + fun `every level logs normally at TRACE and flush emits nothing`() = runTest { + // given + logLevel.value = LogLevel.TRACE + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + + // when + logAtEveryLevel(logger) + + // then + assertEquals( + listOf(LogLevel.ERROR, LogLevel.WARN, LogLevel.INFO, LogLevel.DEBUG, LogLevel.TRACE) + .map { LogCall(it, "${it.name} message") }, + calls, + ) + + // when + calls.clear() + logger.flush("support_bundle") + + // then + assertReplayedLevels(emptyList(), LogLevel.INFO) + } + + @Test + fun `flush selects the active replay method and preserves original levels within a chunk`() = runTest { + mapOf( + LogLevel.TRACE to LogLevel.INFO, + LogLevel.DEBUG to LogLevel.INFO, + LogLevel.INFO to LogLevel.INFO, + LogLevel.WARN to LogLevel.WARN, + LogLevel.ERROR to LogLevel.ERROR, + ).forEach { (activeLevel, replayLevel) -> + // given + calls.clear() + logLevel.value = LogLevel.ERROR + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + logger.trace("trace") + logger.debug("debug") + logger.info("info") + logger.warn("warning") + + // when + logLevel.value = activeLevel + logger.flush("unreachable /route") + + // then + assertEquals(List(3) { replayLevel }, calls.map { it.level }) + assertEquals( + listOf("TRACE trace", "DEBUG debug", "INFO info", "WARN warning"), + calls[1].message.lines().map { it.substringAfter("Z ") }, + ) + } + } + + @Test + fun `replay keeps original UTC timestamps and prefixes continuation lines and session calls`() = runTest { + // given + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + val sessionId = SessionId.generate() + val before = Instant.now().truncatedTo(ChronoUnit.MILLIS) + logger.trace("first\r\nsecond\rthird") + logger.debug(IllegalStateException("failure"), "failed") + val after = Instant.now().truncatedTo(ChronoUnit.MILLIS) + + // when + logger.flush("certificate_error /route", sessionId) + + // then + val sessionPrefix = "client_session_id=$sessionId " + assertEquals( + "${sessionPrefix}[buffered] replaying 2 buffered entries (certificate_error /route)", + calls.first().message, + ) + assertEquals( + "${sessionPrefix}[buffered] end of buffered logs (certificate_error /route)", + calls.last().message, + ) + val lines = calls[1].message.removePrefix(sessionPrefix).lines() + val firstLine = assertNotNull( + Regex("""\[buffered\] (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z) TRACE first""") + .matchEntire(lines.first()), + ) + assertTrue(Instant.parse(firstLine.groupValues[1]) in before..after) + assertEquals("[buffered] second", lines[1]) + assertEquals("[buffered] third", lines[2]) + assertTrue(lines[3].endsWith("Z DEBUG failed")) + assertEquals("[buffered] java.lang.IllegalStateException: failure", lines[4]) + assertTrue(lines.drop(1).all { it.startsWith("[buffered] ") }) + } + + @Test + fun `concurrent lazy logging timestamps entries in buffer acceptance order`() = runTest { + // given + bufferSize.value = 2 + val firstTimestamp = Instant.parse("2026-10-23T12:00:00.001Z") + val secondTimestamp = firstTimestamp.plusMillis(1) + val clock = mockk { + every { instant() } returnsMany listOf(firstTimestamp, secondTimestamp) + } + mockkStatic(Clock::class) + val logger = try { + every { Clock.systemUTC() } returns clock + CoderLogger(delegate, ui, backgroundScope, i18n, settings) + } finally { + unmockkStatic(Clock::class) + } + val supplierStarted = CountDownLatch(1) + val finishSupplier = CountDownLatch(1) + val producers = Executors.newFixedThreadPool(2) + try { + // when + val delayed = producers.submit { + logger.debug { + supplierStarted.countDown() + assertTrue(finishSupplier.await(10, TimeUnit.SECONDS)) + "prepared later" + } + } + assertTrue(supplierStarted.await(5, TimeUnit.SECONDS)) + producers.submit { logger.debug("prepared first") }.get(5, TimeUnit.SECONDS) + finishSupplier.countDown() + delayed.get(5, TimeUnit.SECONDS) + logger.flush("support_bundle") + + // then + assertEquals(listOf("prepared first", "prepared later"), replayedDebugMessages()) + assertEquals( + listOf( + "[buffered] $firstTimestamp DEBUG prepared first", + "[buffered] $secondTimestamp DEBUG prepared later", + ), + calls[1].message.lines(), + ) + } finally { + finishSupplier.countDown() + producers.shutdownNow() + producers.awaitTermination(5, TimeUnit.SECONDS) + } + } + + @Test + fun `concurrent exception formatting retains the newest accepted entry at capacity one`() = runTest { + // given + bufferSize.value = 1 + val firstTimestamp = Instant.parse("2026-10-23T12:00:00.001Z") + val secondTimestamp = firstTimestamp.plusMillis(1) + val clock = mockk { + every { instant() } returnsMany listOf(firstTimestamp, secondTimestamp) + } + mockkStatic(Clock::class) + val logger = try { + every { Clock.systemUTC() } returns clock + CoderLogger(delegate, ui, backgroundScope, i18n, settings) + } finally { + unmockkStatic(Clock::class) + } + val formattingStarted = CountDownLatch(1) + val finishFormatting = CountDownLatch(1) + val exception = object : RuntimeException("delayed exception") { + override fun printStackTrace(writer: PrintWriter) { + formattingStarted.countDown() + assertTrue(finishFormatting.await(10, TimeUnit.SECONDS)) + super.printStackTrace(writer) + } + } + val producers = Executors.newFixedThreadPool(2) + try { + // when + val delayed = producers.submit { logger.debug(exception, "prepared later") } + assertTrue(formattingStarted.await(5, TimeUnit.SECONDS)) + producers.submit { logger.debug("prepared first") }.get(5, TimeUnit.SECONDS) + finishFormatting.countDown() + delayed.get(5, TimeUnit.SECONDS) + logger.flush("support_bundle") + + // then + assertEquals(3, calls.size) + assertEquals("[buffered] replaying 1 buffered entries (support_bundle)", calls.first().message) + assertEquals("[buffered] $secondTimestamp DEBUG prepared later", calls[1].message.lines().first()) + assertTrue(calls[1].message.contains("delayed exception")) + assertTrue(calls.none { it.message.contains("prepared first") }) + } finally { + finishFormatting.countDown() + producers.shutdownNow() + producers.awaitTermination(5, TimeUnit.SECONDS) + } + } + + @Test + fun `initial OFF records no strings or lambdas and flush makes no logging calls`() = runTest { + // given + logLevel.value = LogLevel.OFF + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + var evaluations = 0 + + // when + logAtEveryLevel(logger) + logLazyAtEveryLevel(logger) { level -> evaluations++; "${level.name} message" } + + // then + assertEquals(0, evaluations) + assertTrue(calls.isEmpty()) + + // when + clearMocks(delegate, answers = false, recordedCalls = true) + logger.flush("support_bundle") + + // then + assertNoLoggingCalls(delegate) + + // when + logLevel.value = LogLevel.INFO + logger.flush("after enabling logging") + + // then + assertTrue(calls.isEmpty()) + } + + @Test + fun `initial zero capacity skips recording and flush makes no logging calls`() = runTest { + // given + bufferSize.value = 0 + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + + // when + logAtEveryLevel(logger) + + // then + assertEquals( + listOf(LogLevel.ERROR, LogLevel.WARN, LogLevel.INFO).map { LogCall(it, "${it.name} message") }, + calls, + ) + + // when + var evaluations = 0 + logger.debug { evaluations++; "ignored debug" } + logger.trace { evaluations++; "ignored trace" } + + // then + assertEquals(0, evaluations) + + // when + calls.clear() + clearMocks(delegate, answers = false, recordedCalls = true) + logger.flush("support_bundle") + + // then + assertNoLoggingCalls(delegate) + + // when + bufferSize.value = 3 + runCurrent() + logger.flush("after enabling recording") + + // then + assertTrue(calls.isEmpty()) + } + + @Test + fun `flushing an empty recorder makes no logging calls`() = runTest { + // given + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + + // when + logger.flush("support_bundle") + + // then + assertTrue(calls.isEmpty()) + assertNoLoggingCalls(delegate) + } + + @Test + fun `off skips capture and flushing without discarding existing records`() = runTest { + // given + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + logger.debug("before off") + var evaluations = 0 + + // when + logLevel.value = LogLevel.OFF + logger.debug("ignored string") + logger.debug { evaluations++; "ignored lambda" } + logger.flush("remote_closed") + + // then + assertEquals(0, evaluations) + assertTrue(calls.isEmpty()) + + // when + logLevel.value = LogLevel.INFO + logger.flush("support_bundle") + + // then + assertEquals("[buffered] replaying 1 buffered entries (support_bundle)", calls.first().message) + assertTrue(calls[1].message.endsWith("DEBUG before off")) + } + + @Test + fun `zero capacity clears records and recording resumes after increasing capacity`() = runTest { + // given + bufferSize.value = 2 + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + logger.debug("discarded") + var evaluations = 0 + + // when + bufferSize.value = 0 + runCurrent() + logger.debug { evaluations++; "disabled" } + logger.flush("support_bundle") + + // then + assertEquals(0, evaluations) + assertTrue(calls.isEmpty()) + + // when + bufferSize.value = 2 + runCurrent() + logger.debug("after enabling") + logger.flush("support_bundle") + + // then + assertEquals("[buffered] replaying 1 buffered entries (support_bundle)", calls.first().message) + assertTrue(calls[1].message.endsWith("DEBUG after enabling")) + } + + @Test + fun `failed replay leaves the unwritten chunk available for another flush`() = runTest { + // given + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + logger.debug("retained") + every { delegate.info(match { it.contains(" DEBUG ") }) } throws + IllegalStateException("log write failed") + + // when / then + assertFailsWith { logger.flush("first attempt") } + + // given + calls.clear() + every { delegate.info(any()) } answers { + calls += LogCall(LogLevel.INFO, firstArg()) + } + + // when + logger.flush("second attempt") + + // then + assertEquals("[buffered] replaying 1 buffered entries (second attempt)", calls.first().message) + assertTrue(calls[1].message.endsWith("DEBUG retained")) + } + + @Test + fun `turning logging off during a flush retains the remaining chunks`() = runTest { + // given + bufferSize.value = 300 + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + repeat(250) { logger.debug("entry-$it") } + every { delegate.info(any()) } answers { + val message = firstArg() + calls += LogCall(LogLevel.INFO, message) + if (message.contains(" DEBUG ")) logLevel.value = LogLevel.OFF + } + + // when + logger.flush("remote_closed") + + // then + assertEquals(2, calls.size) + assertEquals(100, calls[1].message.lines().size) + + // given + calls.clear() + logLevel.value = LogLevel.INFO + every { delegate.info(any()) } answers { + calls += LogCall(LogLevel.INFO, firstArg()) + } + + // when + logger.flush("support_bundle") + + // then + assertEquals(4, calls.size) + assertEquals("[buffered] replaying 150 buffered entries (support_bundle)", calls.first().message) + assertTrue(calls[1].message.lines().first().endsWith("DEBUG entry-100")) + } + + @Test + fun `captured lambda is evaluated once even if Toolbox enables its level before delegation`() = runTest { + // given + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + var evaluations = 0 + + // when + logger.debug { + evaluations++ + logLevel.value = LogLevel.DEBUG + "lazy message" + } + + // then + assertEquals(listOf(LogCall(LogLevel.DEBUG, "lazy message")), calls) + + // when + calls.clear() + logLevel.value = LogLevel.INFO + logger.flush("support_bundle") + + // then + assertEquals(1, evaluations) + assertTrue(calls[1].message.endsWith("DEBUG lazy message")) + } + + @Test + fun `enabled lambdas log normally once and do not enter the recorder`() = runTest { + // given + logLevel.value = LogLevel.TRACE + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + var evaluations = 0 + + // when + logLazyAtEveryLevel(logger) { level -> evaluations++; "${level.name} message" } + + // then + assertEquals(5, evaluations) + assertEquals( + listOf(LogLevel.ERROR, LogLevel.WARN, LogLevel.INFO, LogLevel.DEBUG, LogLevel.TRACE) + .map { LogCall(it, "${it.name} message") }, + calls, + ) + + // when + calls.clear() + logger.flush("support_bundle") + + // then + assertTrue(calls.isEmpty()) + } + + @Test + fun `filtered lambdas are evaluated once and replay with their original levels`() = runTest { + // given + logLevel.value = LogLevel.ERROR + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + var evaluations = 0 + + // when + logger.warn { evaluations++; "warning" } + logger.info { evaluations++; "information" } + logger.debug { evaluations++; "debugging" } + logger.trace { evaluations++; "tracing" } + + // then + assertEquals(4, evaluations) + assertTrue(calls.isEmpty()) + + // when + logger.flush("support_bundle") + + // then + assertEquals(4, evaluations) + assertEquals(List(3) { LogLevel.ERROR }, calls.map { it.level }) + assertEquals( + listOf("WARN warning", "INFO information", "DEBUG debugging", "TRACE tracing"), + calls[1].message.lines().map { it.substringAfter("Z ") }, + ) + } + + @Test + fun `filtered exception lambda replays its message and exception without evaluating again`() = runTest { + // given + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + var evaluations = 0 + logger.debug(IllegalStateException("failure")) { evaluations++; "failed request" } + assertTrue(calls.isEmpty()) + + // when + logger.flush("support_bundle") + + // then + assertEquals(1, evaluations) + val lines = calls[1].message.lines() + assertTrue(lines.first().endsWith("Z DEBUG failed request")) + assertEquals("[buffered] java.lang.IllegalStateException: failure", lines[1]) + assertTrue(lines.all { it.startsWith("[buffered] ") }) + } + + @Test + fun `action failure replays the preceding context with its current session`() = runTest { + // given + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + val context = mockk(relaxed = true) + every { context.logger } returns logger + every { context.cs } returns this + val sessionId = SessionId.generate() + val failure = IllegalStateException("stop failed") + val action = Action(context, "Stop workspace") { throw failure } + .withCurrentSessionId { sessionId } + logger.debug(sessionId, "before stopping") + + // when + action.run() + runCurrent() + + // then + verify(exactly = 1) { delegate.error(failure, "client_session_id=$sessionId stop failed") } + val prefix = "client_session_id=$sessionId " + assertEquals(3, calls.size) + assertEquals( + "${prefix}[buffered] replaying 1 buffered entries (action_failed Stop workspace)", + calls.first().message, + ) + assertTrue(calls[1].message.endsWith("DEBUG ${prefix}before stopping")) + assertEquals( + "${prefix}[buffered] end of buffered logs (action_failed Stop workspace)", + calls.last().message, + ) + } + + @Test + fun `environment becoming unreachable replays context and the filtered trigger through CoderLogger`() = runTest { + // given + logLevel.value = LogLevel.ERROR + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + val context = mockk(relaxed = true) + every { context.logger } returns logger + every { context.cs } returns backgroundScope + every { context.i18n } returns i18n + val workspace = DataGen.workspace( + name = "workspace-${UUID.randomUUID()}", + agents = mapOf("agent" to UUID.randomUUID().toString()), + ) + val agent = requireNotNull(workspace.latestBuild.resources.single().agents).single() + val environment = CoderRemoteEnvironment( + context, mockk(relaxed = true), mockk(relaxed = true), Channel(Channel.CONFLATED), workspace, agent, + ) + val sessionId = SessionIdRegistry.startSession(context, workspace.name, agent.name) + try { + logger.flush("setup") + calls.clear() + logger.debug(sessionId, "before status update") + assertTrue(environment.state.value.isReachable) + + // when + environment.update( + workspace.copy(latestBuild = workspace.latestBuild.copy(status = WorkspaceStatus.STOPPING)), + agent, + ) + + // then + assertFalse(environment.state.value.isReachable) + val prefix = "client_session_id=$sessionId " + val reason = "environment_status_changed ${environment.id}" + assertEquals(List(3) { LogLevel.ERROR }, calls.map { it.level }) + assertEquals("${prefix}[buffered] replaying 2 buffered entries ($reason)", calls.first().message) + val lines = calls[1].message.lines() + assertTrue(lines[0].endsWith("DEBUG ${prefix}before status update")) + assertTrue(lines[1].contains("INFO ${prefix}Overall status for workspace ${environment.id}")) + assertEquals("${prefix}[buffered] end of buffered logs ($reason)", calls.last().message) + } finally { + environment.dispose() + SessionIdRegistry.removeSession(workspace.name, agent.name) + } + } + + @Test + fun `automatic disconnect replay omits arguments from visited and matched processes`() = runTest { + // given + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + val context = mockk(relaxed = true) + every { context.logger } returns logger + every { context.cs } returns backgroundScope + every { context.i18n } returns i18n + val workspace = DataGen.workspace( + name = "workspace-${UUID.randomUUID()}", + agents = mapOf("agent" to UUID.randomUUID().toString()), + ) + val agent = requireNotNull(workspace.latestBuild.resources.single().agents).single() + val environment = CoderRemoteEnvironment( + context, mockk(relaxed = true), mockk(relaxed = true), Channel(Channel.CONFLATED), workspace, agent, + ) + val sessionId = SessionIdRegistry.startSession(context, workspace.name, agent.name) + val sshCommand = "ssh -o ProxyCommand='coder --token matched-token ssh --stdio' ${environment.id}" + val unrelatedCommand = "helper --token unrelated-token --password unrelated-password" + val sshProcess = mockk { + every { pid() } returns 12345L + every { info().commandLine() } returns Optional.of(sshCommand) + } + val unrelatedProcess = mockk { + every { pid() } returns 23456L + every { info().commandLine() } returns Optional.of(unrelatedCommand) + every { children() } answers { Stream.empty() } + } + val currentProcess = mockk { + every { children() } answers { Stream.of(sshProcess, unrelatedProcess) } + } + calls.clear() + mockkStatic(ProcessHandle::class) + try { + every { ProcessHandle.current() } returns currentProcess + + // when + val pid = SshCommandProcessHandle(context).findByWorkspaceAndAgent(workspace, agent) + + // then + assertEquals(12345L, pid) + verify(exactly = 1) { unrelatedProcess.info().commandLine() } + assertTrue(calls.isEmpty()) + + // when + environment.afterDisconnect(isManual = false) + + // then + val output = calls.joinToString("\n") { it.message } + for (argument in listOf( + sshCommand, + unrelatedCommand, + "matched-token", + "unrelated-token", + "unrelated-password" + )) { + assertFalse(output.contains(argument), "Process arguments must not appear in replayed logs") + } + val prefix = "client_session_id=$sessionId " + val replay = calls.filter { it.message.startsWith("${prefix}[buffered]") } + val reason = "ssh_disconnected ${environment.id}" + assertEquals(3, replay.size) + assertEquals("${prefix}[buffered] replaying 1 buffered entries ($reason)", replay.first().message) + assertTrue(replay[1].message.endsWith("DEBUG ${prefix}SSH command with PID: 12345 matches ${environment.id}")) + assertEquals("${prefix}[buffered] end of buffered logs ($reason)", replay.last().message) + assertTrue(calls.all { it.level == LogLevel.INFO }) + } finally { + unmockkStatic(ProcessHandle::class) + environment.dispose() + SessionIdRegistry.removeSession(workspace.name, agent.name) + } + } + + @Test + fun `status updates without losing reachability preserve buffered context`() = runTest { + // given + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + val context = mockk(relaxed = true) + every { context.logger } returns logger + every { context.cs } returns backgroundScope + every { context.i18n } returns i18n + val workspace = DataGen.workspace( + name = "workspace-${UUID.randomUUID()}", + agents = mapOf("agent" to UUID.randomUUID().toString()), + ).let { it.copy(latestBuild = it.latestBuild.copy(status = WorkspaceStatus.STOPPED)) } + val agent = requireNotNull(workspace.latestBuild.resources.single().agents).single() + val environment = CoderRemoteEnvironment( + context, mockk(relaxed = true), mockk(relaxed = true), Channel(Channel.CONFLATED), workspace, agent, + ) + try { + assertFalse(environment.state.value.isReachable) + logger.debug("before status updates") + val updates = listOf( + Triple(WorkspaceStatus.FAILED, agent, false), + Triple(WorkspaceStatus.RUNNING, agent, true), + Triple(WorkspaceStatus.RUNNING, agent.copy(status = WorkspaceAgentStatus.DISCONNECTED), true), + ) + for ((status, updatedAgent, expectedReachability) in updates) { + // when + environment.update( + workspace.copy(latestBuild = workspace.latestBuild.copy(id = UUID.randomUUID(), status = status)), + updatedAgent, + ) + + // then + assertEquals(expectedReachability, environment.state.value.isReachable) + assertTrue(calls.none { it.message.startsWith("[buffered]") }) + } + assertEquals(3, calls.size) + assertTrue(calls.all { it.level == LogLevel.INFO && it.message.startsWith("Overall status for workspace") }) + + // when + calls.clear() + logger.flush("support_bundle") + + // then + assertEquals(listOf("before status updates"), replayedDebugMessages()) + } finally { + environment.dispose() + } + } + + private fun assertReplayedLevels(bufferedLevels: List, replayLevel: LogLevel) { + if (bufferedLevels.isEmpty()) { + assertTrue(calls.isEmpty()) + } else { + assertEquals(List(3) { replayLevel }, calls.map { it.level }) + assertEquals( + "[buffered] replaying ${bufferedLevels.size} buffered entries (support_bundle)", + calls.first().message, + ) + assertEquals( + bufferedLevels.map { "${it.name} ${it.name} message" }, + calls[1].message.lines().map { it.substringAfter("Z ") }, + ) + assertEquals("[buffered] end of buffered logs (support_bundle)", calls.last().message) + } + } + + private fun logAtEveryLevel(logger: CoderLogger) { + logger.error("ERROR message") + logger.warn("WARN message") + logger.info("INFO message") + logger.debug("DEBUG message") + logger.trace("TRACE message") + } + + private fun logLazyAtEveryLevel(logger: CoderLogger, message: (LogLevel) -> String) { + logger.error { message(LogLevel.ERROR) } + logger.warn { message(LogLevel.WARN) } + logger.info { message(LogLevel.INFO) } + logger.debug { message(LogLevel.DEBUG) } + logger.trace { message(LogLevel.TRACE) } + } + + private fun replayedDebugMessages(): List = + calls.drop(1).dropLast(1).flatMap { it.message.lines() }.map { it.substringAfter(" DEBUG ") } + + private fun assertNoLoggingCalls(logger: Logger) { + verify(exactly = 0) { + logger.error(any()) + logger.warn(any()) + logger.info(any()) + logger.debug(any()) + logger.trace(any()) + } + } + + private data class LogCall(val level: LogLevel, val message: String) + + private fun enabled(level: LogLevel): Boolean = logLevel.value != LogLevel.OFF && level >= logLevel.value + + private fun emit(level: LogLevel, message: String) { + if (enabled(level)) calls += LogCall(level, message) + } + + private fun emitLazy(level: LogLevel, message: () -> String) { + if (enabled(level)) calls += LogCall(level, message()) + } +} diff --git a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerTest.kt b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerTest.kt index 9f8b33e1..51d8e8cf 100644 --- a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerTest.kt @@ -17,7 +17,7 @@ class CoderLoggerTest { private val delegate = mockk(relaxed = true) private val ui = mockk(relaxed = true) private val i18n = mockk(relaxed = true) - private val logger = CoderLogger(delegate, ui, CoroutineScope(Dispatchers.Unconfined), i18n) + private val logger = CoderLogger(delegate, ui, CoroutineScope(Dispatchers.Unconfined), i18n, mockk(relaxed = true)) private val sessionId = SessionId.generate() private val prefix = "client_session_id=$sessionId" diff --git a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollectorTest.kt b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollectorTest.kt index 5a17460a..869e37d1 100644 --- a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollectorTest.kt +++ b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollectorTest.kt @@ -1,11 +1,15 @@ package com.coder.toolbox.diagnostics import com.coder.toolbox.session.SessionId +import com.coder.toolbox.settings.ReadOnlyCoderSettings +import com.jetbrains.toolbox.api.core.diagnostics.LogLevel import com.jetbrains.toolbox.api.core.diagnostics.Logger import io.mockk.confirmVerified +import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.io.TempDir import java.nio.file.Files @@ -23,12 +27,61 @@ class CoderSupportBundleCollectorTest { @TempDir lateinit var root: Path + @Test + fun `support export replays buffered logs before collecting the bundle and drains the recorder`() = runTest { + // given + val messages = mutableListOf() + val delegate = mockk(relaxed = true) { + every { effectiveLevel } returns MutableStateFlow(LogLevel.INFO) + every { isEnabled(any()) } answers { firstArg() >= LogLevel.INFO } + every { info(any()) } answers { messages += firstArg() } + } + val bufferSize = MutableStateFlow(2) + val settings = mockk { + every { connectionLogBufferSize } returns bufferSize.value + every { observe(any<(ReadOnlyCoderSettings) -> Int>()) } returns bufferSize + } + val logger = CoderLogger(delegate, mockk(), backgroundScope, mockk(), settings) + val sessionId = SessionId.generate() + var messagesAtCollection: List? = null + val collector = CoderSupportBundleCollector( + logger, root.resolve("ssh-logs"), currentSessionId = { sessionId }, + ) { + messagesAtCollection = messages.toList() + writeBundle(it) + } + logger.debug("before export") + logger.trace("connection details") + + // when + collector.collectAdditionalDiagnostics(root.resolve("diagnostics")) + + // then + val prefix = "client_session_id=$sessionId " + assertEquals(3, messages.size) + assertEquals(messages, messagesAtCollection) + assertEquals("${prefix}[buffered] replaying 2 buffered entries (support_bundle)", messages.first()) + val lines = messages[1].lines() + assertTrue(lines[0].endsWith("DEBUG before export")) + assertTrue(lines[1].endsWith("TRACE connection details")) + assertEquals("${prefix}[buffered] end of buffered logs (support_bundle)", messages.last()) + assertTrue(Files.exists(root.resolve("diagnostics/coder-support.zip"))) + + // when + messages.clear() + logger.flush("after_export") + + // then + assertTrue(messages.isEmpty()) + } + @Test fun `unavailable diagnostics directory stops collection and logs one warning`() = runTest { val destination = Files.writeString(root.resolve("diagnostics"), "existing file") val sshLogs = Files.createDirectories(root.resolve("ssh-logs")) val sshLog = Files.writeString(sshLogs.resolve("coder-ssh-session-1.log"), "SSH session") - val logger = mockk(relaxed = true) + val delegate = mockk(relaxed = true) + val logger = CoderLogger(delegate, mockk(), backgroundScope, mockk(), mockk(relaxed = true)) var bundleCollected = false val collector = CoderSupportBundleCollector(logger, sshLogs) { bundleCollected = true @@ -39,8 +92,8 @@ class CoderSupportBundleCollectorTest { assertFalse(bundleCollected) assertEquals("existing file", Files.readString(destination)) assertEquals("SSH session", Files.readString(sshLog)) - verify(exactly = 1) { logger.warn(any(), "Could not collect Coder workspace diagnostics") } - confirmVerified(logger) + verify(exactly = 1) { delegate.warn(any(), "Could not collect Coder workspace diagnostics") } + confirmVerified(delegate) } @Test @@ -68,7 +121,7 @@ class CoderSupportBundleCollectorTest { val sshLogs = Files.createDirectories(root.resolve("ssh-logs")) Files.writeString(sshLogs.resolve("coder-ssh-session-1.log"), "SSH session") val delegate = mockk(relaxed = true) - val logger = CoderLogger(delegate, mockk(), backgroundScope, mockk()) + val logger = CoderLogger(delegate, mockk(), backgroundScope, mockk(), mockk(relaxed = true)) val sessionId = SessionId.generate() val collector = CoderSupportBundleCollector(logger, sshLogs, currentSessionId = { sessionId }) { Files.writeString(it, "partial") @@ -104,7 +157,7 @@ class CoderSupportBundleCollectorTest { fun `SSH log failure preserves the support bundle`() = runTest { val invalidDirectory = Files.writeString(root.resolve("ssh-logs"), "not a directory") val delegate = mockk(relaxed = true) - val logger = CoderLogger(delegate, mockk(), backgroundScope, mockk()) + val logger = CoderLogger(delegate, mockk(), backgroundScope, mockk(), mockk(relaxed = true)) val sessionId = SessionId.generate() val collector = CoderSupportBundleCollector(logger, invalidDirectory, currentSessionId = { sessionId }) { Files.writeString(it, "bundle") diff --git a/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerOfflineTest.kt b/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerOfflineTest.kt index 36e8cb4c..484631db 100644 --- a/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerOfflineTest.kt +++ b/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerOfflineTest.kt @@ -11,6 +11,7 @@ import com.squareup.moshi.Types import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -93,8 +94,9 @@ class IdeFeedManagerOfflineTest { logger = CoderLogger( mockk(relaxed = true), mockk(relaxed = true), - mockk(relaxed = true), + CoroutineScope(Dispatchers.Unconfined), mockk(relaxed = true), + mockk(relaxed = true), ) settingsStore = mockk(relaxed = true) diff --git a/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerTest.kt b/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerTest.kt index 46244e9d..ebf1ad8d 100644 --- a/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerTest.kt @@ -9,6 +9,7 @@ import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -86,8 +87,9 @@ class IdeFeedManagerTest { logger = CoderLogger( mockk(relaxed = true), mockk(relaxed = true), - mockk(relaxed = true), + CoroutineScope(Dispatchers.Unconfined), mockk(relaxed = true), + mockk(relaxed = true), ) every { context.logger } returns logger diff --git a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt index d1ebda5c..563af933 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt @@ -23,6 +23,7 @@ import com.coder.toolbox.store.TLS_ALTERNATE_HOSTNAME import com.coder.toolbox.store.TLS_CA_PATH import com.coder.toolbox.util.pluginTestSettingsStore import com.coder.toolbox.util.sslContextFromPEMs +import com.jetbrains.toolbox.api.core.diagnostics.LogLevel import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableString @@ -43,9 +44,11 @@ import com.sun.net.httpserver.HttpServer import com.sun.net.httpserver.HttpsConfigurator import com.sun.net.httpserver.HttpsServer import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking import okio.buffer import okio.source @@ -103,7 +106,10 @@ internal class BaseHttpHandler( class CoderRestClientTest { private val ui = mockk(relaxed = true) - private val logger = mockk(relaxed = true) + private val logger = mockk(relaxed = true) { + every { effectiveLevel } returns MutableStateFlow(LogLevel.INFO) + every { isEnabled(any()) } answers { firstArg() >= LogLevel.INFO } + } private val moshi = Moshi.Builder() .add(InstantConverter()) diff --git a/src/test/kotlin/com/coder/toolbox/session/SessionIdRegistryTest.kt b/src/test/kotlin/com/coder/toolbox/session/SessionIdRegistryTest.kt index 2c8655bf..86434b3b 100644 --- a/src/test/kotlin/com/coder/toolbox/session/SessionIdRegistryTest.kt +++ b/src/test/kotlin/com/coder/toolbox/session/SessionIdRegistryTest.kt @@ -25,8 +25,9 @@ class SessionIdRegistryTest { private val coderLogger = CoderLogger( logger, mockk(relaxed = true), - mockk(relaxed = true), + CoroutineScope(Dispatchers.Unconfined), mockk(relaxed = true), + mockk(relaxed = true), ) private val context = mockk(relaxed = true) diff --git a/src/test/kotlin/com/coder/toolbox/store/CoderSettingsObservationTest.kt b/src/test/kotlin/com/coder/toolbox/store/CoderSettingsObservationTest.kt new file mode 100644 index 00000000..063478b2 --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/store/CoderSettingsObservationTest.kt @@ -0,0 +1,267 @@ +package com.coder.toolbox.store + +import com.coder.toolbox.settings.Environment +import com.coder.toolbox.settings.ReadOnlyTLSSettings +import com.coder.toolbox.util.pluginTestSettingsStore +import com.jetbrains.toolbox.api.core.PluginSettingsStore +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import java.io.IOException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class CoderSettingsObservationTest { + @Test + fun `observation starts with the effective persisted buffer size`() = runTest { + listOf("2500" to 2500, "0" to 0, "10000" to 10000, "invalid" to 1000, "10001" to 1000) + .forEach { (raw, expected) -> + val settings = settings(pluginTestSettingsStore(CONNECTION_LOG_BUFFER_SIZE to raw)).readOnly() + + assertEquals(expected, settings.observe { it.connectionLogBufferSize }.first()) + } + assertEquals(1000, settings().readOnly().observe { it.connectionLogBufferSize }.first()) + } + + @Test + fun `observation reads the current value when collection starts`() = runTest { + val settings = settings() + val sizes = settings.readOnly().observe { it.connectionLogBufferSize } + + settings.updateConnectionLogBufferSize(2500) + + assertEquals(2500, sizes.first()) + } + + @Test + fun `buffer updates are delivered after persistence`() = runTest { + val backingStore = pluginTestSettingsStore() + val settings = settings(backingStore) + val observed = mutableListOf>() + settings.readOnly().observe { it.connectionLogBufferSize } + .onEach { observed += it to backingStore[CONNECTION_LOG_BUFFER_SIZE] } + .launchIn(backgroundScope) + runCurrent() + + settings.updateConnectionLogBufferSize(0) + runCurrent() + settings.updateConnectionLogBufferSize(10000) + runCurrent() + + assertEquals(listOf(1000 to null, 0 to "0", 10000 to "10000"), observed) + } + + @Test + fun `unchanged effective values and unrelated settings do not emit`() = runTest { + val settings = settings(pluginTestSettingsStore(CONNECTION_LOG_BUFFER_SIZE to "invalid")) + val observed = mutableListOf() + settings.observe { it.connectionLogBufferSize }.onEach { observed += it }.launchIn(backgroundScope) + runCurrent() + + settings.updateConnectionLogBufferSize(1000) + runCurrent() + settings.updateHeaderCommand("new header command") + runCurrent() + settings.updateConnectionLogBufferSize(1000) + runCurrent() + + assertEquals(listOf(1000), observed) + } + + @Test + fun `rejected buffer sizes do not invalidate observers`() = runTest { + val settings = settings() + var reads = 0 + val observed = mutableListOf() + settings.observe { + reads++ + it.connectionLogBufferSize + } + .onEach { observed += it } + .launchIn(backgroundScope) + runCurrent() + + assertFailsWith { settings.updateConnectionLogBufferSize(-1) } + assertFailsWith { settings.updateConnectionLogBufferSize(10001) } + runCurrent() + + assertEquals(1, reads) + assertEquals(listOf(1000), observed) + } + + @Test + fun `failed persistence does not invalidate observers and a later successful write does`() = runTest { + val persisted = pluginTestSettingsStore() + var failWrites = true + val backingStore = object : PluginSettingsStore by persisted { + override fun put(key: String, value: String): String? { + if (failWrites) throw IOException("Cannot persist settings") + return persisted.put(key, value) + } + } + val settings = settings(backingStore) + var reads = 0 + val observed = mutableListOf() + settings.observe { + reads++ + it.connectionLogBufferSize + } + .onEach { observed += it } + .launchIn(backgroundScope) + runCurrent() + + assertFailsWith { settings.updateConnectionLogBufferSize(2500) } + runCurrent() + assertEquals(1, reads) + assertEquals(1000, settings.connectionLogBufferSize) + + failWrites = false + settings.updateConnectionLogBufferSize(2500) + runCurrent() + + assertEquals(listOf(1000, 2500), observed) + } + + @Test + fun `subscribers receive the current value and can unsubscribe independently`() = runTest { + val settings = settings() + val sizes = settings.readOnly().observe { it.connectionLogBufferSize } + val first = mutableListOf() + val second = mutableListOf() + val subscription = sizes.onEach { first += it }.launchIn(backgroundScope) + runCurrent() + settings.updateConnectionLogBufferSize(2500) + runCurrent() + + sizes.onEach { second += it }.launchIn(backgroundScope) + runCurrent() + settings.updateConnectionLogBufferSize(0) + runCurrent() + subscription.cancelAndJoin() + settings.updateConnectionLogBufferSize(10000) + runCurrent() + + assertEquals(listOf(1000, 2500, 0), first) + assertEquals(listOf(2500, 0, 10000), second) + } + + @Test + fun `slow subscribers receive the latest size without blocking writes`() = runTest { + val settings = settings() + val release = CompletableDeferred() + val observed = mutableListOf() + settings.observe { it.connectionLogBufferSize }.onEach { + observed += it + if (it == 1000) release.await() + }.launchIn(backgroundScope) + runCurrent() + + settings.updateConnectionLogBufferSize(0) + settings.updateConnectionLogBufferSize(10000) + settings.updateConnectionLogBufferSize(2500) + runCurrent() + assertEquals(listOf(1000), observed) + + release.complete(Unit) + runCurrent() + + assertEquals(listOf(1000, 2500), observed) + } + + @Test + fun `TLS selections compare their values rather than their object identities`() = runTest { + val settings = settings() + val observed = mutableListOf() + settings.readOnly().observe { it.tls }.onEach { observed += it }.launchIn(backgroundScope) + runCurrent() + + settings.updateConnectionLogBufferSize(0) + runCurrent() + settings.updateCertPath("cert.pem") + runCurrent() + settings.updateCertPath("cert.pem") + runCurrent() + settings.updateKeyPath("key.pem") + runCurrent() + + assertEquals( + listOf(null to null, "cert.pem" to null, "cert.pem" to "key.pem"), + observed.map { it.certPath to it.keyPath } + ) + } + + @Test + fun `selectors can observe several settings as one immutable value`() = runTest { + val settings = settings() + val observed = mutableListOf>() + settings.readOnly().observe { it.connectionLogBufferSize to it.useKeyring } + .onEach { observed += it } + .launchIn(backgroundScope) + runCurrent() + + settings.updateUseKeyring(false) + runCurrent() + settings.updateConnectionLogBufferSize(2500) + runCurrent() + settings.updateUseKeyring(false) + runCurrent() + + assertEquals(listOf(1000 to true, 1000 to false, 2500 to false), observed) + } + + @Test + fun `workspace setting changes notify only affected selections`() = runTest { + val settings = settings() + val observed = mutableListOf() + settings.readOnly().observe { it.shouldAutoConnect("workspace") } + .onEach { observed += it } + .launchIn(backgroundScope) + runCurrent() + + settings.updateAutoConnect("other-workspace", true) + runCurrent() + settings.updateAutoConnect("workspace", true) + runCurrent() + + assertEquals(listOf(false, true), observed) + } + + @Test + fun `concurrent writes leave observers consistent with persisted settings`() = runTest { + val backingStore = pluginTestSettingsStore() + val settings = settings(backingStore) + val observed = mutableListOf>() + settings.observe { it.connectionLogBufferSize to it.connectionLogBufferSize } + .onEach { observed += it } + .launchIn(backgroundScope) + runCurrent() + + withContext(Dispatchers.Default) { + coroutineScope { + repeat(100) { size -> launch { settings.updateConnectionLogBufferSize(size) } } + } + } + runCurrent() + + assertTrue(observed.all { (first, second) -> first == second }) + val persisted = backingStore[CONNECTION_LOG_BUFFER_SIZE]?.toInt() + assertEquals(persisted to persisted, observed.last()) + } + + private fun settings(backingStore: PluginSettingsStore = pluginTestSettingsStore()): CoderSettingsStore = + CoderSettingsStore(backingStore, Environment(), mockk(relaxed = true)) +} diff --git a/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt b/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt index 8dab0160..d983a74a 100644 --- a/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt +++ b/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt @@ -12,6 +12,7 @@ import java.nio.file.Path import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertFailsWith class CoderSettingsStoreTest { private var originalOsName: String? = null @@ -46,6 +47,44 @@ class CoderSettingsStoreTest { assertTrue(store.useKeyring) } + @Test + fun `connection log buffer defaults to 1000`() { + assertEquals(1000, store.readOnly().connectionLogBufferSize) + } + + @Test + fun `connection log buffer persists valid sizes under the documented key`() { + val backingStore = pluginTestSettingsStore() + val settings = CoderSettingsStore(backingStore, Environment(), mockk(relaxed = true)) + + listOf(0, 1, 2500, 10000).forEach { size -> + settings.updateConnectionLogBufferSize(size) + + assertEquals(size.toString(), backingStore["connectionLogBufferSize"]) + val reloaded = CoderSettingsStore(backingStore, Environment(), mockk(relaxed = true)) + assertEquals(size, reloaded.readOnly().connectionLogBufferSize) + } + } + + @Test + fun `invalid stored connection log buffer sizes fall back to the default`() { + listOf("", "invalid", "1.5", "-1", "10001", "2147483648").forEach { value -> + val settings = storeWith("connectionLogBufferSize" to value) + + assertEquals(1000, settings.readOnly().connectionLogBufferSize, value) + } + } + + @Test + fun `invalid connection log buffer updates preserve the saved size`() { + store.updateConnectionLogBufferSize(2500) + + listOf(Int.MIN_VALUE, -1, 10001, Int.MAX_VALUE).forEach { size -> + assertFailsWith { store.updateConnectionLogBufferSize(size) } + assertEquals(2500, store.readOnly().connectionLogBufferSize) + } + } + @Test fun `Default CLI and signature for Windows AMD64`() = assertBinaryAndSignature("Windows 10", "amd64", "coder-windows-amd64.exe", "coder-windows-amd64.exe.asc") diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index 7b768d22..e8f56423 100644 --- a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -78,6 +78,7 @@ class CoderProtocolHandlerTest { mockk(relaxed = true), CoroutineScope(dispatcher), mockk(relaxed = true), + mockk(relaxed = true), ) environment = mockk { every { id } returns "env-1" diff --git a/src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt b/src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt index 3f57b1ce..ad6b16c7 100644 --- a/src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt @@ -33,6 +33,7 @@ class ConnectionMonitoringServiceTest { mockk(relaxed = true), CoroutineScope(Dispatchers.Unconfined), mockk(relaxed = true), + mockk(relaxed = true), ) init { diff --git a/src/test/kotlin/com/coder/toolbox/views/ActionTest.kt b/src/test/kotlin/com/coder/toolbox/views/ActionTest.kt index d0e5ec5b..8f31c316 100644 --- a/src/test/kotlin/com/coder/toolbox/views/ActionTest.kt +++ b/src/test/kotlin/com/coder/toolbox/views/ActionTest.kt @@ -27,6 +27,7 @@ class ActionTest { mockk(relaxed = true), testScope, mockk(relaxed = true), + mockk(relaxed = true), ) every { context.cs } returns testScope every { context.logger } returns coderLogger @@ -55,6 +56,7 @@ class ActionTest { mockk(relaxed = true), testScope, mockk(relaxed = true), + mockk(relaxed = true), ) every { context.cs } returns testScope every { context.logger } returns coderLogger diff --git a/src/test/kotlin/com/coder/toolbox/views/CoderSettingsPageTest.kt b/src/test/kotlin/com/coder/toolbox/views/CoderSettingsPageTest.kt new file mode 100644 index 00000000..5dedef59 --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/views/CoderSettingsPageTest.kt @@ -0,0 +1,106 @@ +package com.coder.toolbox.views + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.settings.Environment +import com.coder.toolbox.store.CoderSettingsStore +import com.coder.toolbox.util.pluginTestSettingsStore +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import com.jetbrains.toolbox.api.localization.LocalizableString +import com.jetbrains.toolbox.api.ui.components.FieldModifier +import com.jetbrains.toolbox.api.ui.components.SectionField +import com.jetbrains.toolbox.api.ui.components.TextField +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CoderSettingsPageTest { + @TempDir + lateinit var tempDir: Path + + private val settings = CoderSettingsStore( + pluginTestSettingsStore(), Environment(), mockk(relaxed = true) + ) + private val context = mockk(relaxed = true) + private val bufferLabel = mockk() + + init { + every { context.settingsStore } returns settings + every { context.i18n.ptrl("Connection log buffer size") } returns bufferLabel + } + + @Test + fun `connection log buffer field defaults to 1000 and accepts the inclusive range`() { + val page = createPage() + val field = bufferField(page) + assertEquals("1000", field.contentState.value) + + listOf("0", "1", "2500", "10000").forEach { value -> + field.contentState.value = value + assertTrue(page.actionButtons.value.single().validate(), value) + } + } + + @Test + fun `save validation rejects invalid connection log buffer input`() { + val page = createPage() + val field = bufferField(page) + + listOf("", "invalid", "1.5", "-1", "10001", "2147483648").forEach { value -> + field.contentState.value = value + assertFalse(page.actionButtons.value.single().validate(), value) + assertTrue(field.modifiers.value.any { it is FieldModifier.LocalizableError }) + assertEquals(1000, settings.connectionLogBufferSize) + } + + field.contentState.value = "1000" + assertTrue(page.actionButtons.value.single().validate()) + assertTrue(field.modifiers.value.none { it is FieldModifier.LocalizableError }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `save persists the buffer size and reopening reloads the current value`() = runTest { + val testScope = this + every { context.cs } returns testScope + val page = createPage() + val field = bufferField(page) + val save = page.actionButtons.value.single() + + listOf("0", "10000").forEach { value -> + field.contentState.value = value + assertTrue(save.validate()) + save.run() + advanceUntilIdle() + assertEquals(value.toInt(), settings.readOnly().connectionLogBufferSize) + } + + settings.updateConnectionLogBufferSize(2500) + page.beforeShow() + try { + assertEquals("2500", field.contentState.value) + } finally { + page.afterHide() + advanceUntilIdle() + } + } + + private fun createPage(): CoderSettingsPage { + settings.updateSshConfigPath(tempDir.resolve("config").toString()) + return CoderSettingsPage(context, Channel(Channel.CONFLATED)) {} + } + + private fun bufferField(page: CoderSettingsPage): TextField = page.fields.value + .filterIsInstance() + .flatMap { it.contentState.value } + .filterIsInstance() + .single { it.labelState.value === bufferLabel } +}