Skip to content

Commit 9fdd99b

Browse files
authored
fix: rendering glitches when a Workspace is stopped (#102)
… 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. - resolves #98
1 parent 970f4c5 commit 9fdd99b

File tree

4 files changed

+48
-12
lines changed

4 files changed

+48
-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

+22-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,8 @@ class CoderRemoteEnvironment(
106111
} else {
107112
actions.add(Action(context.i18n.ptrl("Stop")) {
108113
context.cs.launch {
114+
tryStopSshConnection()
115+
109116
val build = client.stopWorkspace(workspace)
110117
update(workspace.copy(latestBuild = build), agent)
111118
}
@@ -115,18 +122,30 @@ class CoderRemoteEnvironment(
115122
return actions
116123
}
117124

125+
private suspend fun tryStopSshConnection() {
126+
if (isConnected.value) {
127+
connectionRequest.update {
128+
false
129+
}
130+
131+
if (isConnected.waitForFalseWithTimeout(10.seconds) == null) {
132+
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")
133+
}
134+
}
135+
}
136+
118137
override fun getBeforeConnectionHooks(): List<BeforeConnectionHook> = listOf(this)
119138

120139
override fun getAfterDisconnectHooks(): List<AfterDisconnectHook> = listOf(this)
121140

122141
override fun beforeConnection() {
123142
context.logger.info("Connecting to $id...")
124-
this.isConnected = true
143+
isConnected.update { true }
125144
}
126145

127146
override fun afterDisconnect() {
128147
this.connectionRequest.update { false }
129-
this.isConnected = false
148+
isConnected.update { false }
130149
context.logger.info("Disconnected from $id")
131150
}
132151

@@ -161,17 +180,14 @@ class CoderRemoteEnvironment(
161180
agent
162181
)
163182

164-
private var isConnected = false
165-
override val connectionRequest: MutableStateFlow<Boolean> = MutableStateFlow(false)
166-
167183
/**
168184
* Does nothing. In theory, we could do something like start the workspace
169185
* when you click into the workspace, but you would still need to press
170186
* "connect" anyway before the content is populated so there does not seem
171187
* to be much value.
172188
*/
173189
override fun setVisible(visibilityState: EnvironmentVisibilityState) {
174-
if (wsRawStatus.ready() && visibilityState.contentsVisible == true && isConnected == false) {
190+
if (wsRawStatus.ready() && visibilityState.contentsVisible == true && isConnected.value == false) {
175191
context.cs.launch {
176192
connectionRequest.update {
177193
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)