Skip to content

Commit a2c028e

Browse files
authored
fix: simplify URI handling when the same deployment URL is already opened (#227)
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 The fix focuses on simply recreating the HTTP client and updating the CLI token when the URI URL matches the existing deployment URL, instead of running a full login. This PR splits responsibilities more cleanly: - CoderProtocolHandler now only finds the workspace and agent and handles IDE installation and launch. - the logic for creating a new HTTP client, updating the CLI, cleaning up old resources (polling loop, environment cache), and handling deployment URL changes is separated out. The benefits would be: - shared logic for cleanup and re-initialization, with less coupling and clearer, more maintainable code. - a clean way to check whether the URI’s deployment URL matches the current one and react appropriately when they differ.
1 parent 874e8cc commit a2c028e

File tree

9 files changed

+182
-163
lines changed

9 files changed

+182
-163
lines changed

CHANGELOG.md

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

33
## Unreleased
44

5+
### Changed
6+
7+
- streamlined URI handling with a faster workflow, clearer progress, and an overall smoother experience
8+
9+
### Fixed
10+
11+
- URI handling on Linux can now launch IDEs on newly started workspaces
12+
513
## 0.8.0 - 2025-12-03
614

715
### Added

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
version=0.8.0
1+
version=0.8.1
22
group=com.coder.toolbox
33
name=coder-toolbox

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

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -276,10 +276,8 @@ class CoderRemoteEnvironment(
276276

277277
private fun updateStatus(status: WorkspaceAndAgentStatus) {
278278
environmentStatus = status
279-
context.cs.launch(CoroutineName("Workspace Status Updater")) {
280-
state.update {
281-
environmentStatus.toRemoteEnvironmentState(context)
282-
}
279+
state.update {
280+
environmentStatus.toRemoteEnvironmentState(context)
283281
}
284282
context.logger.debug("Overall status for workspace $id is $environmentStatus. Workspace status: ${workspace.latestBuild.status}, agent status: ${agent.status}, agent lifecycle state: ${agent.lifecycleState}, login before ready: ${agent.loginBeforeReady}")
285283
}
@@ -312,10 +310,8 @@ class CoderRemoteEnvironment(
312310
*/
313311
fun startSshConnection(): Boolean {
314312
if (environmentStatus.ready() && !isConnected.value) {
315-
context.cs.launch(CoroutineName("SSH Connection Trigger")) {
316-
connectionRequest.update {
317-
true
318-
}
313+
connectionRequest.update {
314+
true
319315
}
320316
return true
321317
}

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

Lines changed: 127 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,20 @@ 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
89
import com.coder.toolbox.util.CoderProtocolHandler
910
import com.coder.toolbox.util.DialogUi
11+
import com.coder.toolbox.util.TOKEN
12+
import com.coder.toolbox.util.URL
13+
import com.coder.toolbox.util.WebUrlValidationResult.Invalid
14+
import com.coder.toolbox.util.toQueryParameters
15+
import com.coder.toolbox.util.toURL
16+
import com.coder.toolbox.util.token
17+
import com.coder.toolbox.util.url
18+
import com.coder.toolbox.util.validateStrictWebUrl
1019
import com.coder.toolbox.util.waitForTrue
1120
import com.coder.toolbox.util.withPath
1221
import com.coder.toolbox.views.Action
@@ -37,13 +46,16 @@ import kotlinx.coroutines.launch
3746
import kotlinx.coroutines.selects.onTimeout
3847
import kotlinx.coroutines.selects.select
3948
import java.net.URI
49+
import java.net.URL
50+
import java.util.concurrent.atomic.AtomicBoolean
4051
import kotlin.coroutines.cancellation.CancellationException
4152
import kotlin.time.Duration.Companion.seconds
4253
import kotlin.time.TimeSource
4354
import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as DropDownMenu
4455
import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as dropDownFactory
4556

4657
private val POLL_INTERVAL = 5.seconds
58+
private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI"
4759

4860
@OptIn(ExperimentalCoroutinesApi::class)
4961
class CoderRemoteProvider(
@@ -61,11 +73,13 @@ class CoderRemoteProvider(
6173

6274
// The REST client, if we are signed in
6375
private var client: CoderRestClient? = null
76+
private var cli: CoderCLIManager? = null
6477

6578
// On the first load, automatically log in if we can.
6679
private var firstRun = true
6780

6881
private val isInitialized: MutableStateFlow<Boolean> = MutableStateFlow(false)
82+
private val isHandlingUri: AtomicBoolean = AtomicBoolean(false)
6983
private val coderHeaderPage = NewEnvironmentPage(context.i18n.pnotr(context.deploymentUrl.toString()))
7084
private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, triggerSshConfig) {
7185
client?.let { restClient ->
@@ -82,7 +96,7 @@ class CoderRemoteProvider(
8296
providerVisible = false
8397
)
8498
)
85-
private val linkHandler = CoderProtocolHandler(context, dialogUi, settingsPage, visibilityState, isInitialized)
99+
private val linkHandler = CoderProtocolHandler(context)
86100

87101
override val loadingEnvironmentsDescription: LocalizableString = context.i18n.ptrl("Loading workspaces...")
88102
override val environments: MutableStateFlow<LoadableState<List<CoderRemoteEnvironment>>> = MutableStateFlow(
@@ -254,6 +268,17 @@ class CoderRemoteProvider(
254268
* Also called as part of our own logout.
255269
*/
256270
override fun close() {
271+
softClose()
272+
client = null
273+
cli = null
274+
lastEnvironments.clear()
275+
environments.value = LoadableState.Value(emptyList())
276+
isInitialized.update { false }
277+
CoderCliSetupWizardState.goToFirstStep()
278+
context.logger.info("Coder plugin is now closed")
279+
}
280+
281+
private fun softClose() {
257282
pollJob?.let {
258283
it.cancel()
259284
context.logger.info("Cancelled workspace poll job ${pollJob.toString()}")
@@ -262,12 +287,6 @@ class CoderRemoteProvider(
262287
it.close()
263288
context.logger.info("REST API client closed and resources released")
264289
}
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")
271290
}
272291

273292
override val svgIcon: SvgIcon =
@@ -331,27 +350,49 @@ class CoderRemoteProvider(
331350
*/
332351
override suspend fun handleUri(uri: URI) {
333352
try {
334-
linkHandler.handle(
335-
uri,
336-
shouldDoAutoSetup()
337-
) { restClient, cli ->
338-
context.logger.info("Stopping workspace polling and de-initializing resources")
339-
close()
340-
isInitialized.update {
341-
false
353+
val params = uri.toQueryParameters()
354+
if (params.isEmpty()) {
355+
// probably a plugin installation scenario
356+
context.logAndShowInfo("URI will not be handled", "No query parameters were provided")
357+
return
358+
}
359+
isHandlingUri.set(true)
360+
// this switches to the main plugin screen, even
361+
// if last opened provider was not Coder
362+
context.envPageManager.showPluginEnvironmentsPage()
363+
coderHeaderPage.isBusy.update { true }
364+
context.logger.info("Handling $uri...")
365+
val newUrl = resolveDeploymentUrl(params)?.toURL() ?: return
366+
val newToken = if (context.settingsStore.requiresMTlsAuth) null else resolveToken(params) ?: return
367+
if (sameUrl(newUrl, client?.url)) {
368+
if (context.settingsStore.requiresTokenAuth) {
369+
newToken?.let {
370+
refreshSession(newUrl, it)
371+
}
372+
}
373+
} else {
374+
CoderCliSetupContext.apply {
375+
url = newUrl
376+
token = newToken
342377
}
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()))
378+
CoderCliSetupWizardState.goToStep(WizardStep.CONNECT)
379+
CoderCliSetupWizardPage(
380+
context, settingsPage, visibilityState,
381+
initialAutoSetup = true,
382+
jumpToMainPageOnError = true,
383+
connectSynchronously = true,
384+
onConnect = ::onConnect
385+
).apply {
386+
beforeShow()
349387
}
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()
354388
}
389+
// force the poll loop to run
390+
triggerProviderVisible.send(true)
391+
// wait for environments to be populated
392+
isInitialized.waitForTrue()
393+
394+
linkHandler.handle(params, newUrl, this.client!!, this.cli!!)
395+
coderHeaderPage.isBusy.update { false }
355396
} catch (ex: Exception) {
356397
val textError = if (ex is APIResponseException) {
357398
if (!ex.reason.isNullOrBlank()) {
@@ -363,7 +404,63 @@ class CoderRemoteProvider(
363404
textError ?: ""
364405
)
365406
context.envPageManager.showPluginEnvironmentsPage()
407+
} finally {
408+
coderHeaderPage.isBusy.update { false }
409+
isHandlingUri.set(false)
410+
firstRun = false
411+
}
412+
}
413+
414+
private suspend fun resolveDeploymentUrl(params: Map<String, String>): String? {
415+
val deploymentURL = params.url() ?: askUrl()
416+
if (deploymentURL.isNullOrBlank()) {
417+
context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"${URL}\" is missing from URI")
418+
return null
419+
}
420+
val validationResult = deploymentURL.validateStrictWebUrl()
421+
if (validationResult is Invalid) {
422+
context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "\"$URL\" is invalid: ${validationResult.reason}")
423+
return null
366424
}
425+
return deploymentURL
426+
}
427+
428+
private suspend fun resolveToken(params: Map<String, String>): String? {
429+
val token = params.token()
430+
if (token.isNullOrBlank()) {
431+
context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$TOKEN\" is missing from URI")
432+
return null
433+
}
434+
return token
435+
}
436+
437+
private fun sameUrl(first: URL, second: URL?): Boolean = first.toURI().normalize() == second?.toURI()?.normalize()
438+
439+
private suspend fun refreshSession(url: URL, token: String): Pair<CoderRestClient, CoderCLIManager> {
440+
context.logger.info("Stopping workspace polling and re-initializing the http client and cli with a new token")
441+
softClose()
442+
val newRestClient = CoderRestClient(
443+
context,
444+
url,
445+
token,
446+
PluginManager.pluginInfo.version,
447+
).apply { initializeSession() }
448+
val newCli = CoderCLIManager(context, url).apply {
449+
login(token)
450+
}
451+
this.client = newRestClient
452+
this.cli = newCli
453+
pollJob = poll(newRestClient, newCli)
454+
context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI")
455+
return newRestClient to newCli
456+
}
457+
458+
private suspend fun askUrl(): String? {
459+
context.popupPluginMainPage()
460+
return dialogUi.ask(
461+
context.i18n.ptrl("Deployment URL"),
462+
context.i18n.ptrl("Enter the full URL of your Coder deployment")
463+
)
367464
}
368465

369466
/**
@@ -373,6 +470,9 @@ class CoderRemoteProvider(
373470
* list.
374471
*/
375472
override fun getOverrideUiPage(): UiPage? {
473+
if (isHandlingUri.get()) {
474+
return null
475+
}
376476
// Show the setup page if we have not configured the client yet.
377477
if (client == null) {
378478
// When coming back to the application, initializeSession immediately.
@@ -420,6 +520,7 @@ class CoderRemoteProvider(
420520

421521
private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) {
422522
// Store the URL and token for use next time.
523+
close()
423524
context.settingsStore.updateLastUsedUrl(client.url)
424525
if (context.settingsStore.requiresTokenAuth) {
425526
context.secrets.storeTokenFor(client.url, client.token ?: "")
@@ -428,10 +529,7 @@ class CoderRemoteProvider(
428529
context.logger.info("Deployment URL was stored and will be available for automatic connection")
429530
}
430531
this.client = client
431-
pollJob?.let {
432-
it.cancel()
433-
context.logger.info("Cancelled workspace poll job ${pollJob.toString()} in order to start a new one")
434-
}
532+
this.cli = cli
435533
environments.showLoadingMessage()
436534
if (context.settingsStore.useAppNameAsTitle) {
437535
coderHeaderPage.setTitle(context.i18n.pnotr(client.appName))

0 commit comments

Comments
 (0)