Skip to content

Commit 296e311

Browse files
authored
impl: enhanced workflow for network disruptions (#162)
Currently, when the network connection drops, the Coder TBX plugin resets itself, redirects users to the authentication page, and terminates active SSH sessions to remote IDEs. This disrupts the user experience, forcing users to manually reconnect once the network is restored. Additionally, since the SSH session to the remote IDE is lost, the JBClient is unable to re-establish a connection with the remote backend. This PR aims to improve that experience by adopting a behavior similar to the SSH plugin. Instead of clearing the list of workspaces or dropping existing SSH sessions during a network outage, we retain them. Once the network is restored, the plugin will automatically reinitialize the HTTP client and regenerate the SSH configuration—only if the number of workspaces has changed during the disconnection—without requiring user intervention. Additionally we also add support for remembering SSH connections that were not manually disconnected by the user. This allows the plugin to automatically restore those connections on the next startup enabling remote IDEs that remained open to reconnect once the SSH link is re-established.
1 parent 60cbfe9 commit 296e311

File tree

10 files changed

+61
-41
lines changed

10 files changed

+61
-41
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Changed
6+
7+
- improved workflow when network connection is flaky
8+
59
## 0.5.2 - 2025-07-22
610

711
### Fixed

gradle.properties

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
version=0.5.2
1+
version=0.6.0
22
group=com.coder.toolbox
3-
name=coder-toolbox
3+
name=coder-toolbox

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

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ class CoderRemoteEnvironment(
6868
private val proxyCommandHandle = SshCommandProcessHandle(context)
6969
private var pollJob: Job? = null
7070

71+
init {
72+
if (context.settingsStore.shouldAutoConnect(id)) {
73+
context.logger.info("resuming SSH connection to $id — last session was still active.")
74+
startSshConnection()
75+
}
76+
}
77+
7178
fun asPairOfWorkspaceAndAgent(): Pair<Workspace, WorkspaceAgent> = Pair(workspace, agent)
7279

7380
private fun getAvailableActions(): List<ActionDescription> {
@@ -158,6 +165,7 @@ class CoderRemoteEnvironment(
158165
override fun beforeConnection() {
159166
context.logger.info("Connecting to $id...")
160167
isConnected.update { true }
168+
context.settingsStore.updateAutoConnect(this.id, true)
161169
pollJob = pollNetworkMetrics()
162170
}
163171

@@ -180,12 +188,9 @@ class CoderRemoteEnvironment(
180188
}
181189
context.logger.debug("Loading metrics from ${metricsFile.absolutePath} for $id")
182190
try {
183-
val metrics = networkMetricsMarshaller.fromJson(metricsFile.readText())
184-
if (metrics == null) {
185-
return@launch
186-
}
191+
val metrics = networkMetricsMarshaller.fromJson(metricsFile.readText()) ?: return@launch
187192
context.logger.debug("$id metrics: $metrics")
188-
additionalEnvironmentInformation.put(context.i18n.ptrl("Network Status"), metrics.toPretty())
193+
additionalEnvironmentInformation[context.i18n.ptrl("Network Status")] = metrics.toPretty()
189194
} catch (e: Exception) {
190195
context.logger.error(
191196
e,
@@ -203,6 +208,10 @@ class CoderRemoteEnvironment(
203208
pollJob?.cancel()
204209
this.connectionRequest.update { false }
205210
isConnected.update { false }
211+
if (isManual) {
212+
// if the user manually disconnects the ssh connection we should not connect automatically
213+
context.settingsStore.updateAutoConnect(this.id, false)
214+
}
206215
context.logger.info("Disconnected from $id")
207216
}
208217

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

Lines changed: 23 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ class CoderRemoteProvider(
8080
)
8181
)
8282

83+
private val errorBuffer = mutableListOf<Throwable>()
84+
8385
/**
8486
* With the provided client, start polling for workspaces. Every time a new
8587
* workspace is added, reconfigure SSH using the provided cli (including the
@@ -160,23 +162,20 @@ class CoderRemoteProvider(
160162
} catch (ex: Exception) {
161163
val elapsed = lastPollTime.elapsedNow()
162164
if (elapsed > POLL_INTERVAL * 2) {
163-
context.logger.info("wake-up from an OS sleep was detected, going to re-initialize the http client...")
164-
client.setupSession()
165+
context.logger.info("wake-up from an OS sleep was detected")
165166
} else {
166-
context.logger.error(ex, "workspace polling error encountered, trying to auto-login")
167+
context.logger.error(ex, "workspace polling error encountered")
167168
if (ex is APIResponseException && ex.isTokenExpired) {
168169
WorkspaceConnectionManager.shouldEstablishWorkspaceConnections = true
170+
close()
171+
context.envPageManager.showPluginEnvironmentsPage()
172+
errorBuffer.add(ex)
173+
break
169174
}
170-
close()
171-
// force auto-login
172-
firstRun = true
173-
context.envPageManager.showPluginEnvironmentsPage()
174-
break
175175
}
176176
}
177177

178-
// TODO: Listening on a web socket might be better?
179-
select<Unit> {
178+
select {
180179
onTimeout(POLL_INTERVAL) {
181180
context.logger.trace("workspace poller waked up by the $POLL_INTERVAL timeout")
182181
}
@@ -196,9 +195,6 @@ class CoderRemoteProvider(
196195
* first page.
197196
*/
198197
private fun logout() {
199-
// Keep the URL and token to make it easy to log back in, but set
200-
// rememberMe to false so we do not try to automatically log in.
201-
context.secrets.rememberMe = false
202198
WorkspaceConnectionManager.reset()
203199
close()
204200
}
@@ -360,22 +356,17 @@ class CoderRemoteProvider(
360356
override fun getOverrideUiPage(): UiPage? {
361357
// Show the setup page if we have not configured the client yet.
362358
if (client == null) {
363-
val errorBuffer = mutableListOf<Throwable>()
364359
// When coming back to the application, initializeSession immediately.
365-
val autoSetup = shouldDoAutoSetup()
366-
context.secrets.lastToken.let { lastToken ->
367-
context.secrets.lastDeploymentURL.let { lastDeploymentURL ->
368-
if (autoSetup && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) {
369-
try {
370-
CoderCliSetupWizardState.goToStep(WizardStep.CONNECT)
371-
return CoderCliSetupWizardPage(context, settingsPage, visibilityState, true, ::onConnect)
372-
} catch (ex: Exception) {
373-
errorBuffer.add(ex)
374-
}
375-
}
360+
if (shouldDoAutoSetup()) {
361+
try {
362+
CoderCliSetupWizardState.goToStep(WizardStep.CONNECT)
363+
return CoderCliSetupWizardPage(context, settingsPage, visibilityState, true, ::onConnect)
364+
} catch (ex: Exception) {
365+
errorBuffer.add(ex)
366+
} finally {
367+
firstRun = false
376368
}
377369
}
378-
firstRun = false
379370

380371
// Login flow.
381372
val setupWizardPage =
@@ -384,21 +375,24 @@ class CoderRemoteProvider(
384375
errorBuffer.forEach {
385376
setupWizardPage.notify("Error encountered", it)
386377
}
378+
errorBuffer.clear()
387379
// and now reset the errors, otherwise we show it every time on the screen
388380
return setupWizardPage
389381
}
390382
return null
391383
}
392384

393-
private fun shouldDoAutoSetup(): Boolean = firstRun && context.secrets.rememberMe == true
385+
/**
386+
* Auto-login only on first the firs run if there is a url & token configured or the auth
387+
* should be done via certificates.
388+
*/
389+
private fun shouldDoAutoSetup(): Boolean = firstRun && (context.secrets.canAutoLogin || !settings.requireTokenAuth)
394390

395391
private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) {
396392
// Store the URL and token for use next time.
397393
context.secrets.lastDeploymentURL = client.url.toString()
398394
context.secrets.lastToken = client.token ?: ""
399395
context.secrets.storeTokenFor(client.url, context.secrets.lastToken)
400-
// Currently we always remember, but this could be made an option.
401-
context.secrets.rememberMe = true
402396
this.client = client
403397
pollJob?.cancel()
404398
environments.showLoadingMessage()

src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ open class CoderRestClient(
6060
setupSession()
6161
}
6262

63-
fun setupSession() {
63+
private fun setupSession() {
6464
moshi =
6565
Moshi.Builder()
6666
.add(ArchConverter())

src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,11 @@ interface ReadOnlyCoderSettings {
146146
* Return the URL and token from the config, if they exist.
147147
*/
148148
fun readConfig(dir: Path): Pair<String?, String?>
149+
150+
/**
151+
* Returns whether the SSH connection should be automatically established.
152+
*/
153+
fun shouldAutoConnect(workspaceId: String): Boolean
149154
}
150155

151156
/**

src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,8 @@ class CoderSecretsStore(private val store: PluginSecretStore) {
2424
var lastToken: String
2525
get() = get("last-token")
2626
set(value) = set("last-token", value)
27-
var rememberMe: Boolean
28-
get() = get("remember-me").toBoolean()
29-
set(value) = set("remember-me", value.toString())
27+
val canAutoLogin: Boolean
28+
get() = lastDeploymentURL.isNotBlank() && lastToken.isNotBlank()
3029

3130
fun tokenFor(url: URL): String? = store[url.host]
3231

src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@ class CoderSettingsStore(
142142
}
143143
}
144144

145+
override fun shouldAutoConnect(workspaceId: String): Boolean {
146+
return store["$SSH_AUTO_CONNECT_PREFIX$workspaceId"]?.toBooleanStrictOrNull() ?: false
147+
}
148+
145149
// a readonly cast
146150
fun readOnly(): ReadOnlyCoderSettings = this
147151

@@ -213,6 +217,10 @@ class CoderSettingsStore(
213217
store[SSH_CONFIG_OPTIONS] = options
214218
}
215219

220+
fun updateAutoConnect(workspaceId: String, autoConnect: Boolean) {
221+
store["$SSH_AUTO_CONNECT_PREFIX$workspaceId"] = autoConnect.toString()
222+
}
223+
216224
private fun getDefaultGlobalDataDir(): Path {
217225
return when (getOS()) {
218226
OS.WINDOWS -> Paths.get(env.get("LOCALAPPDATA"), "coder-toolbox")

src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,5 @@ internal const val SSH_CONFIG_OPTIONS = "sshConfigOptions"
4242

4343
internal const val NETWORK_INFO_DIR = "networkInfoDir"
4444

45+
internal const val SSH_AUTO_CONNECT_PREFIX = "ssh_auto_connect_"
46+

src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,6 @@ class ConnectStep(
128128
if (shouldAutoLogin.value) {
129129
CoderCliSetupContext.reset()
130130
CoderCliSetupWizardState.goToFirstStep()
131-
context.secrets.rememberMe = false
132131
} else {
133132
if (context.settingsStore.requireTokenAuth) {
134133
CoderCliSetupWizardState.goToPreviousStep()

0 commit comments

Comments
 (0)