Skip to content

Commit c9eb261

Browse files
committed
fix: simplify URI handling when the same deployment URL is already opened
Netflix reported that only seems to reproduce on Linux (we've only tested Ubuntu so far). I can’t reproduce it on macOS. First, here’s some context: 1. Polling workspaces: Coder Toolbox polls the deployment every 5 seconds for workspace updates. These updates (new workspaces, deletions,status changes) are stored in a cached “environments” list (an oversimplified explanation). When a URI is executed, we reset the content of the list and run the login sequence, which re-initializes the HTTP poller and CLI using the new deployment URL and token. A new polling loop then begins populating the environments list again. 2. Cache monitoring: Toolbox watches this cached list for changes—especially status changes, which determine when an SSH connection can be established. In Netflix’s case, they launched Toolbox, created a workspace from the Dashboard, and the poller added it to the environments list. When the workspace switched from starting to ready, they used a URI to connect to it. The URI reset the list, then the poller repopulated it. But because the list had the same IDs (but new object references), Toolbox didn’t detect any changes. As a result, it never triggered the SSH connection. This issue only reproduces on Linux, but it might explain some of the sporadic macOS failures Atif mentioned in the past. I need to dig deeper into the Toolbox bytecode to determine whether this is a Toolbox bug, but it does seem like Toolbox wasn’t designed to switch cleanly between multiple deployments and/or users. The current Coder plugin behavior—always performing a full login sequence on every URI—is also ...sub-optimal. It only really makes sense in these scenarios: 1. Toolbox started with deployment A, but the URI targets deployment B. 2. Toolbox started with deployment A/user X, but the URI targets deployment A/user Y. But this design is inefficient for the most common case: connecting via URI to a workspace on the same deployment and same user. While working on the fix, I realized that scenario (2) is not realistic. On the same host machine, why would multiple users log into the same deployment via Toolbox? The whole fix revolves around the idea of just recreating the http client and updating the CLI with the new token instead of going through the full authentication steps when the URI deployment URL is the same as the currently opened URL
1 parent 25e2e27 commit c9eb261

File tree

4 files changed

+144
-81
lines changed

4 files changed

+144
-81
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
### Changed
1111

1212
- workspaces are now started with the help of the CLI
13+
- simplified URI handling when the same deployment URL is already opened
1314

1415
## 0.7.2 - 2025-11-03
1516

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

Lines changed: 60 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.coder.toolbox
22

33
import com.coder.toolbox.browser.browse
44
import com.coder.toolbox.cli.CoderCLIManager
5+
import com.coder.toolbox.plugin.PluginManager
56
import com.coder.toolbox.sdk.CoderRestClient
67
import com.coder.toolbox.sdk.ex.APIResponseException
78
import com.coder.toolbox.sdk.v2.models.WorkspaceStatus
@@ -37,6 +38,7 @@ import kotlinx.coroutines.launch
3738
import kotlinx.coroutines.selects.onTimeout
3839
import kotlinx.coroutines.selects.select
3940
import java.net.URI
41+
import java.net.URL
4042
import kotlin.coroutines.cancellation.CancellationException
4143
import kotlin.time.Duration.Companion.seconds
4244
import kotlin.time.TimeSource
@@ -254,6 +256,16 @@ class CoderRemoteProvider(
254256
* Also called as part of our own logout.
255257
*/
256258
override fun close() {
259+
softClose()
260+
client = null
261+
lastEnvironments.clear()
262+
environments.value = LoadableState.Value(emptyList())
263+
isInitialized.update { false }
264+
CoderCliSetupWizardState.goToFirstStep()
265+
context.logger.info("Coder plugin is now closed")
266+
}
267+
268+
private fun softClose() {
257269
pollJob?.let {
258270
it.cancel()
259271
context.logger.info("Cancelled workspace poll job ${pollJob.toString()}")
@@ -262,12 +274,6 @@ class CoderRemoteProvider(
262274
it.close()
263275
context.logger.info("REST API client closed and resources released")
264276
}
265-
client = null
266-
lastEnvironments.clear()
267-
environments.value = LoadableState.Value(emptyList())
268-
isInitialized.update { false }
269-
CoderCliSetupWizardState.goToFirstStep()
270-
context.logger.info("Coder plugin is now closed")
271277
}
272278

273279
override val svgIcon: SvgIcon =
@@ -333,25 +339,11 @@ class CoderRemoteProvider(
333339
try {
334340
linkHandler.handle(
335341
uri,
336-
shouldDoAutoSetup()
337-
) { restClient, cli ->
338-
context.logger.info("Stopping workspace polling and de-initializing resources")
339-
close()
340-
isInitialized.update {
341-
false
342-
}
343-
context.logger.info("Starting initialization with the new settings")
344-
this@CoderRemoteProvider.client = restClient
345-
if (context.settingsStore.useAppNameAsTitle) {
346-
coderHeaderPage.setTitle(context.i18n.pnotr(restClient.appName))
347-
} else {
348-
coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString()))
349-
}
350-
environments.showLoadingMessage()
351-
pollJob = poll(restClient, cli)
352-
context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI $uri")
353-
isInitialized.waitForTrue()
354-
}
342+
client?.url,
343+
shouldDoAutoSetup(),
344+
::refreshSession,
345+
::performReLogin
346+
)
355347
} catch (ex: Exception) {
356348
val textError = if (ex is APIResponseException) {
357349
if (!ex.reason.isNullOrBlank()) {
@@ -366,6 +358,49 @@ class CoderRemoteProvider(
366358
}
367359
}
368360

361+
private suspend fun refreshSession(url: URL, token: String): Pair<CoderRestClient, CoderCLIManager> {
362+
coderHeaderPage.isBusyCreatingNewEnvironment.update { true }
363+
try {
364+
context.logger.info("Stopping workspace polling and re-initializing the http client and cli with a new token")
365+
softClose()
366+
val restClient = CoderRestClient(
367+
context,
368+
url,
369+
token,
370+
PluginManager.pluginInfo.version,
371+
).apply { initializeSession() }
372+
val cli = CoderCLIManager(context, url).apply {
373+
login(token)
374+
}
375+
this.client = restClient
376+
pollJob = poll(restClient, cli)
377+
triggerProviderVisible.send(true)
378+
context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI")
379+
return restClient to cli
380+
} finally {
381+
coderHeaderPage.isBusyCreatingNewEnvironment.update { false }
382+
}
383+
}
384+
385+
private suspend fun performReLogin(restClient: CoderRestClient, cli: CoderCLIManager) {
386+
context.logger.info("Stopping workspace polling and de-initializing resources")
387+
close()
388+
isInitialized.update {
389+
false
390+
}
391+
context.logger.info("Starting initialization with the new settings")
392+
this@CoderRemoteProvider.client = restClient
393+
if (context.settingsStore.useAppNameAsTitle) {
394+
coderHeaderPage.setTitle(context.i18n.pnotr(restClient.appName))
395+
} else {
396+
coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString()))
397+
}
398+
environments.showLoadingMessage()
399+
pollJob = poll(restClient, cli)
400+
context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI")
401+
isInitialized.waitForTrue()
402+
}
403+
369404
/**
370405
* Return the sign-in page if we do not have a valid client.
371406

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

Lines changed: 81 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.StateFlow
2424
import kotlinx.coroutines.launch
2525
import kotlinx.coroutines.time.withTimeout
2626
import java.net.URI
27+
import java.net.URL
2728
import java.util.UUID
2829
import kotlin.time.Duration
2930
import kotlin.time.Duration.Companion.milliseconds
@@ -52,8 +53,10 @@ open class CoderProtocolHandler(
5253
*/
5354
suspend fun handle(
5455
uri: URI,
56+
currentUrl: URL?,
5557
shouldWaitForAutoLogin: Boolean,
56-
reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit
58+
refreshSession: suspend (URL, String) -> Pair<CoderRestClient, CoderCLIManager>,
59+
performLogin: suspend (CoderRestClient, CoderCLIManager) -> Unit
5760
) {
5861
val params = uri.toQueryParameters()
5962
if (params.isEmpty()) {
@@ -72,65 +75,56 @@ open class CoderProtocolHandler(
7275
val deploymentURL = resolveDeploymentUrl(params) ?: return
7376
val token = if (!context.settingsStore.requiresTokenAuth) null else resolveToken(params) ?: return
7477
val workspaceName = resolveWorkspaceName(params) ?: return
75-
76-
suspend fun onConnect(
77-
restClient: CoderRestClient,
78-
cli: CoderCLIManager
79-
) {
80-
val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL)
81-
if (workspace == null) {
82-
context.envPageManager.showPluginEnvironmentsPage()
83-
return
78+
if (deploymentURL.toURL().toURI().normalize() == currentUrl?.toURI()?.normalize()) {
79+
if (context.settingsStore.requiresTokenAuth) {
80+
token?.let {
81+
val (restClient, cli) = refreshSession(currentUrl, it)
82+
val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL)
83+
if (workspace != null) {
84+
connectToWorkspace(workspace, restClient, cli, workspaceName, deploymentURL, params)
85+
}
86+
}
8487
}
85-
reInitialize(restClient, cli)
86-
context.envPageManager.showPluginEnvironmentsPage()
87-
if (!prepareWorkspace(workspace, restClient, cli, workspaceName, deploymentURL)) return
88-
// we resolve the agent after the workspace is started otherwise we can get misleading
89-
// errors like: no agent available while workspace is starting or stopping
90-
// we also need to retrieve the workspace again to have the latest resources (ex: agent)
91-
// attached to the workspace.
92-
val agent: WorkspaceAgent = resolveAgent(
93-
params,
94-
restClient.workspace(workspace.id)
95-
) ?: return
96-
if (!ensureAgentIsReady(workspace, agent)) return
97-
delay(2.seconds)
98-
val environmentId = "${workspace.name}.${agent.name}"
99-
context.showEnvironmentPage(environmentId)
100-
101-
val productCode = params.ideProductCode()
102-
val buildNumber = params.ideBuildNumber()
103-
val projectFolder = params.projectFolder()
104-
105-
if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) {
106-
launchIde(environmentId, productCode, buildNumber, projectFolder)
88+
} else {
89+
suspend fun onConnect(
90+
restClient: CoderRestClient,
91+
cli: CoderCLIManager
92+
) {
93+
val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL)
94+
if (workspace == null) {
95+
context.envPageManager.showPluginEnvironmentsPage()
96+
return
97+
}
98+
performLogin(restClient, cli)
99+
context.envPageManager.showPluginEnvironmentsPage()
100+
connectToWorkspace(workspace, restClient, cli, workspaceName, deploymentURL, params)
107101
}
108-
}
109102

110-
CoderCliSetupContext.apply {
111-
url = deploymentURL.toURL()
112-
CoderCliSetupContext.token = token
113-
}
114-
CoderCliSetupWizardState.goToStep(WizardStep.CONNECT)
115-
116-
// If Toolbox is already opened and URI is executed the setup page
117-
// from below is never called. I tried a couple of things, including
118-
// yielding the coroutine - but it seems to be of no help. What works
119-
// delaying the coroutine for 66 - to 100 milliseconds, these numbers
120-
// were determined by trial and error.
121-
// The only explanation that I have is that inspecting the TBX bytecode it seems the
122-
// UI event is emitted via MutableSharedFlow(replay = 0) which has a buffer of 4 events
123-
// and a drop oldest strategy. For some reason it seems that the UI collector
124-
// is not yet active, causing the event to be lost unless we wait > 66 ms.
125-
// I think this delay ensures the collector is ready before processEvent() is called.
126-
delay(100.milliseconds)
127-
context.ui.showUiPage(
128-
CoderCliSetupWizardPage(
129-
context, settingsPage, visibilityState, true,
130-
jumpToMainPageOnError = true,
131-
onConnect = ::onConnect
103+
CoderCliSetupContext.apply {
104+
url = deploymentURL.toURL()
105+
CoderCliSetupContext.token = token
106+
}
107+
CoderCliSetupWizardState.goToStep(WizardStep.CONNECT)
108+
109+
// If Toolbox is already opened and URI is executed the setup page
110+
// from below is never called. I tried a couple of things, including
111+
// yielding the coroutine - but it seems to be of no help. What works
112+
// delaying the coroutine for 66 - to 100 milliseconds, these numbers
113+
// were determined by trial and error.
114+
// The only explanation that I have is that inspecting the TBX bytecode it seems the
115+
// UI event is emitted via MutableSharedFlow(replay = 0) which has a buffer of 4 events
116+
// and a drop oldest strategy. For some reason it seems that the UI collector
117+
// is not yet active, causing the event to be lost unless we wait > 66 ms.
118+
// I think this delay ensures the collector is ready before processEvent() is called.
119+
delay(100.milliseconds)
120+
context.ui.showUiPage(
121+
CoderCliSetupWizardPage(
122+
context, settingsPage, visibilityState, true,
123+
jumpToMainPageOnError = true,
124+
onConnect = ::onConnect
125+
)
132126
)
133-
)
127+
}
134128
}
135129

136130
private suspend fun resolveDeploymentUrl(params: Map<String, String>): String? {
@@ -434,6 +428,37 @@ open class CoderProtocolHandler(
434428
}
435429
}
436430

431+
private suspend fun connectToWorkspace(
432+
workspace: Workspace,
433+
restClient: CoderRestClient,
434+
cli: CoderCLIManager,
435+
workspaceName: String,
436+
deploymentURL: String,
437+
params: Map<String, String>
438+
) {
439+
if (!prepareWorkspace(workspace, restClient, cli, workspaceName, deploymentURL)) return
440+
// we resolve the agent after the workspace is started otherwise we can get misleading
441+
// errors like: no agent available while workspace is starting or stopping
442+
// we also need to retrieve the workspace again to have the latest resources (ex: agent)
443+
// attached to the workspace.
444+
val agent: WorkspaceAgent = resolveAgent(
445+
params,
446+
restClient.workspace(workspace.id)
447+
) ?: return
448+
if (!ensureAgentIsReady(workspace, agent)) return
449+
delay(2.seconds)
450+
val environmentId = "${workspace.name}.${agent.name}"
451+
context.showEnvironmentPage(environmentId)
452+
453+
val productCode = params.ideProductCode()
454+
val buildNumber = params.ideBuildNumber()
455+
val projectFolder = params.projectFolder()
456+
457+
if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) {
458+
launchIde(environmentId, productCode, buildNumber, projectFolder)
459+
}
460+
}
461+
437462
private suspend fun askUrl(): String? {
438463
context.popupPluginMainPage()
439464
return dialogUi.ask(

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ abstract class CoderPage(
3434
}
3535
}
3636

37+
override val isBusyCreatingNewEnvironment: MutableStateFlow<Boolean> = MutableStateFlow(false)
38+
3739
/**
3840
* Return the icon, if showing one.
3941
*

0 commit comments

Comments
 (0)