From 00ff51e14633e506c8b728738bd0b1127cdf5988 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 1 May 2025 22:43:50 +0300 Subject: [PATCH 1/2] fix: rendering glitches when a Workspace is stopped while an SSH connection is alive Toolbox raises a class cast exception when Workspaces are stopped while the SSH connection is running. After the workspace was stopped Toolbox refused to show widget with some weird glitches on the screen. The fix in this case is to safely disconnect the SSH before sending the stop command to the workspace. The code will wait at most 10 seconds for the disconnect to happen, and only after that send the stop. --- CHANGELOG.md | 4 ++++ .../coder/toolbox/CoderRemoteEnvironment.kt | 24 ++++++++++++++----- .../toolbox/util/CoderProtocolHandler.kt | 6 ----- .../coder/toolbox/util/StateFlowExtensions.kt | 22 +++++++++++++++++ 4 files changed, 44 insertions(+), 12 deletions(-) create mode 100644 src/main/kotlin/com/coder/toolbox/util/StateFlowExtensions.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 058e1f0..dde6c33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - ssh configuration is simplified, background hostnames have been discarded. +### Fixed + +- rendering glitches when a Workspace is stopped while SSH connection is alive + ## 0.2.0 - 2025-04-24 ### Added diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 69ee6cd..23ee133 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -7,6 +7,7 @@ import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.ex.APIResponseException import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent +import com.coder.toolbox.util.waitForFalseWithTimeout import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action import com.coder.toolbox.views.EnvironmentView @@ -43,6 +44,10 @@ class CoderRemoteEnvironment( private var wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent) override var name: String = "${workspace.name}.${agent.name}" + + private var isConnected: MutableStateFlow = MutableStateFlow(false) + override val connectionRequest: MutableStateFlow = MutableStateFlow(false) + override val state: MutableStateFlow = MutableStateFlow(wsRawStatus.toRemoteEnvironmentState(context)) override val description: MutableStateFlow = @@ -106,6 +111,16 @@ class CoderRemoteEnvironment( } else { actions.add(Action(context.i18n.ptrl("Stop")) { context.cs.launch { + if (isConnected.value) { + connectionRequest.update { + false + } + + if (isConnected.waitForFalseWithTimeout(10.seconds) == null) { + context.logger.warn("The SSH connection to workspace $name could not be dropped in time, going to stop the workspace while the SSH connection is live") + } + } + val build = client.stopWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) } @@ -121,12 +136,12 @@ class CoderRemoteEnvironment( override fun beforeConnection() { context.logger.info("Connecting to $id...") - this.isConnected = true + isConnected.update { true } } override fun afterDisconnect() { this.connectionRequest.update { false } - this.isConnected = false + isConnected.update { false } context.logger.info("Disconnected from $id") } @@ -161,9 +176,6 @@ class CoderRemoteEnvironment( agent ) - private var isConnected = false - override val connectionRequest: MutableStateFlow = MutableStateFlow(false) - /** * Does nothing. In theory, we could do something like start the workspace * when you click into the workspace, but you would still need to press @@ -171,7 +183,7 @@ class CoderRemoteEnvironment( * to be much value. */ override fun setVisible(visibilityState: EnvironmentVisibilityState) { - if (wsRawStatus.ready() && visibilityState.contentsVisible == true && isConnected == false) { + if (wsRawStatus.ready() && visibilityState.contentsVisible == true && isConnected.value == false) { context.cs.launch { connectionRequest.update { true diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index aca2464..ad42d18 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -13,7 +13,6 @@ import com.jetbrains.toolbox.api.localization.LocalizableString import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.time.withTimeout import java.net.HttpURLConnection @@ -331,9 +330,4 @@ private fun CoderToolboxContext.popupPluginMainPage() { this.envPageManager.showPluginEnvironmentsPage(true) } -/** - * Suspends the coroutine until first true value is received. - */ -suspend fun StateFlow.waitForTrue() = this.first { it } - class MissingArgumentException(message: String, ex: Throwable? = null) : IllegalArgumentException(message, ex) diff --git a/src/main/kotlin/com/coder/toolbox/util/StateFlowExtensions.kt b/src/main/kotlin/com/coder/toolbox/util/StateFlowExtensions.kt new file mode 100644 index 0000000..46ae602 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/util/StateFlowExtensions.kt @@ -0,0 +1,22 @@ +package com.coder.toolbox.util + +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration + +/** + * Suspends the coroutine until first true value is received. + */ +suspend fun StateFlow.waitForTrue() = this.first { it } + +/** + * Suspends the coroutine until first false value is received. + */ +suspend fun StateFlow.waitForFalseWithTimeout(duration: Duration): Boolean? { + if (!this.value) return false + + return withTimeoutOrNull(duration) { + this@waitForFalseWithTimeout.first { !it } + } +} \ No newline at end of file From 7e370a073a9ab994678373b521fc81a1626c2e1f Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 1 May 2025 23:02:34 +0300 Subject: [PATCH 2/2] refactor: abstract ssh disconnect logic into a new method --- .../coder/toolbox/CoderRemoteEnvironment.kt | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 23ee133..adafeb0 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -111,15 +111,7 @@ class CoderRemoteEnvironment( } else { actions.add(Action(context.i18n.ptrl("Stop")) { context.cs.launch { - if (isConnected.value) { - connectionRequest.update { - false - } - - if (isConnected.waitForFalseWithTimeout(10.seconds) == null) { - context.logger.warn("The SSH connection to workspace $name could not be dropped in time, going to stop the workspace while the SSH connection is live") - } - } + tryStopSshConnection() val build = client.stopWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) @@ -130,6 +122,18 @@ class CoderRemoteEnvironment( return actions } + private suspend fun tryStopSshConnection() { + if (isConnected.value) { + connectionRequest.update { + false + } + + if (isConnected.waitForFalseWithTimeout(10.seconds) == null) { + context.logger.warn("The SSH connection to workspace $name could not be dropped in time, going to stop the workspace while the SSH connection is live") + } + } + } + override fun getBeforeConnectionHooks(): List = listOf(this) override fun getAfterDisconnectHooks(): List = listOf(this)