Skip to content

Commit 00ff51e

Browse files
committed
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.
1 parent 970f4c5 commit 00ff51e

File tree

4 files changed

+44
-12
lines changed

4 files changed

+44
-12
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66

77
- ssh configuration is simplified, background hostnames have been discarded.
88

9+
### Fixed
10+
11+
- rendering glitches when a Workspace is stopped while SSH connection is alive
12+
913
## 0.2.0 - 2025-04-24
1014

1115
### Added

src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt

+18-6
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.coder.toolbox.sdk.CoderRestClient
77
import com.coder.toolbox.sdk.ex.APIResponseException
88
import com.coder.toolbox.sdk.v2.models.Workspace
99
import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
10+
import com.coder.toolbox.util.waitForFalseWithTimeout
1011
import com.coder.toolbox.util.withPath
1112
import com.coder.toolbox.views.Action
1213
import com.coder.toolbox.views.EnvironmentView
@@ -43,6 +44,10 @@ class CoderRemoteEnvironment(
4344
private var wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent)
4445

4546
override var name: String = "${workspace.name}.${agent.name}"
47+
48+
private var isConnected: MutableStateFlow<Boolean> = MutableStateFlow(false)
49+
override val connectionRequest: MutableStateFlow<Boolean> = MutableStateFlow(false)
50+
4651
override val state: MutableStateFlow<RemoteEnvironmentState> =
4752
MutableStateFlow(wsRawStatus.toRemoteEnvironmentState(context))
4853
override val description: MutableStateFlow<EnvironmentDescription> =
@@ -106,6 +111,16 @@ class CoderRemoteEnvironment(
106111
} else {
107112
actions.add(Action(context.i18n.ptrl("Stop")) {
108113
context.cs.launch {
114+
if (isConnected.value) {
115+
connectionRequest.update {
116+
false
117+
}
118+
119+
if (isConnected.waitForFalseWithTimeout(10.seconds) == null) {
120+
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")
121+
}
122+
}
123+
109124
val build = client.stopWorkspace(workspace)
110125
update(workspace.copy(latestBuild = build), agent)
111126
}
@@ -121,12 +136,12 @@ class CoderRemoteEnvironment(
121136

122137
override fun beforeConnection() {
123138
context.logger.info("Connecting to $id...")
124-
this.isConnected = true
139+
isConnected.update { true }
125140
}
126141

127142
override fun afterDisconnect() {
128143
this.connectionRequest.update { false }
129-
this.isConnected = false
144+
isConnected.update { false }
130145
context.logger.info("Disconnected from $id")
131146
}
132147

@@ -161,17 +176,14 @@ class CoderRemoteEnvironment(
161176
agent
162177
)
163178

164-
private var isConnected = false
165-
override val connectionRequest: MutableStateFlow<Boolean> = MutableStateFlow(false)
166-
167179
/**
168180
* Does nothing. In theory, we could do something like start the workspace
169181
* when you click into the workspace, but you would still need to press
170182
* "connect" anyway before the content is populated so there does not seem
171183
* to be much value.
172184
*/
173185
override fun setVisible(visibilityState: EnvironmentVisibilityState) {
174-
if (wsRawStatus.ready() && visibilityState.contentsVisible == true && isConnected == false) {
186+
if (wsRawStatus.ready() && visibilityState.contentsVisible == true && isConnected.value == false) {
175187
context.cs.launch {
176188
connectionRequest.update {
177189
true

src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt

-6
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import com.jetbrains.toolbox.api.localization.LocalizableString
1313
import kotlinx.coroutines.TimeoutCancellationException
1414
import kotlinx.coroutines.delay
1515
import kotlinx.coroutines.flow.StateFlow
16-
import kotlinx.coroutines.flow.first
1716
import kotlinx.coroutines.launch
1817
import kotlinx.coroutines.time.withTimeout
1918
import java.net.HttpURLConnection
@@ -331,9 +330,4 @@ private fun CoderToolboxContext.popupPluginMainPage() {
331330
this.envPageManager.showPluginEnvironmentsPage(true)
332331
}
333332

334-
/**
335-
* Suspends the coroutine until first true value is received.
336-
*/
337-
suspend fun StateFlow<Boolean>.waitForTrue() = this.first { it }
338-
339333
class MissingArgumentException(message: String, ex: Throwable? = null) : IllegalArgumentException(message, ex)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.coder.toolbox.util
2+
3+
import kotlinx.coroutines.flow.StateFlow
4+
import kotlinx.coroutines.flow.first
5+
import kotlinx.coroutines.withTimeoutOrNull
6+
import kotlin.time.Duration
7+
8+
/**
9+
* Suspends the coroutine until first true value is received.
10+
*/
11+
suspend fun StateFlow<Boolean>.waitForTrue() = this.first { it }
12+
13+
/**
14+
* Suspends the coroutine until first false value is received.
15+
*/
16+
suspend fun StateFlow<Boolean>.waitForFalseWithTimeout(duration: Duration): Boolean? {
17+
if (!this.value) return false
18+
19+
return withTimeoutOrNull(duration) {
20+
this@waitForFalseWithTimeout.first { !it }
21+
}
22+
}

0 commit comments

Comments
 (0)