From 7c64008becf68f874b6d78922cad9b5c7c675fb0 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 21 Nov 2025 23:27:12 +0200 Subject: [PATCH 1/3] refactor: simplify workspace start status management Current approach with a secondary poll loop that handles the start action of a workspace is overengineered. Basically the problem is the CLI takes too long before moving the workspace into the queued/starting state, during which the user doesn't have any feedback. To address the issue we: - stopped the main poll loop from updating the environment - moved the environment in the queued state immediately after the start button was pushed. - started a poll loop that moved the workspace from queued state to starting space only after that state became available in the backend. The intermediary stopped state is skipped by the secondary poll loop. @asher pointed out that a better approach can be implemented. We already store the status, and workspace and the agent in the environment. When the start comes in: 1. We directly update the env. status to "queued" 2. We only change the environment status if there is difference in the existing workspace&agent status vs the status from the main poll loop 3. no secondary poll loop is needed. --- .../coder/toolbox/CoderRemoteEnvironment.kt | 73 +++++++++---------- 1 file changed, 34 insertions(+), 39 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 4b9c607..9f68a7e 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -37,7 +37,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import java.io.File import java.nio.file.Path -import java.util.concurrent.atomic.AtomicBoolean import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -55,14 +54,14 @@ class CoderRemoteEnvironment( private var workspace: Workspace, private var agent: WorkspaceAgent, ) : RemoteProviderEnvironment("${workspace.name}.${agent.name}"), BeforeConnectionHook, AfterDisconnectHook { - private var wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent) + private var environmentStatus = WorkspaceAndAgentStatus.from(workspace, agent) override var name: String = "${workspace.name}.${agent.name}" private var isConnected: MutableStateFlow = MutableStateFlow(false) override val connectionRequest: MutableStateFlow = MutableStateFlow(false) override val state: MutableStateFlow = - MutableStateFlow(wsRawStatus.toRemoteEnvironmentState(context)) + MutableStateFlow(environmentStatus.toRemoteEnvironmentState(context)) override val description: MutableStateFlow = MutableStateFlow(EnvironmentDescription.General(context.i18n.pnotr(workspace.templateDisplayName))) override val additionalEnvironmentInformation: MutableMap = mutableMapOf() @@ -71,7 +70,6 @@ class CoderRemoteEnvironment( private val networkMetricsMarshaller = Moshi.Builder().build().adapter(NetworkMetrics::class.java) private val proxyCommandHandle = SshCommandProcessHandle(context) private var pollJob: Job? = null - private val startIsInProgress = AtomicBoolean(false) init { if (context.settingsStore.shouldAutoConnect(id)) { @@ -84,7 +82,7 @@ class CoderRemoteEnvironment( private fun getAvailableActions(): List { val actions = mutableListOf() - if (wsRawStatus.canStop()) { + if (environmentStatus.canStop()) { actions.add(Action(context, "Open web terminal") { context.desktop.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) { context.ui.showErrorInfoPopup(it) @@ -114,7 +112,7 @@ class CoderRemoteEnvironment( } ) - if (wsRawStatus.canStart()) { + if (environmentStatus.canStart()) { if (workspace.outdated) { actions.add(Action(context, "Update and start") { val build = client.updateWorkspace(workspace) @@ -123,34 +121,18 @@ class CoderRemoteEnvironment( ) } else { actions.add(Action(context, "Start") { - try { - // needed in order to make sure Queuing is not overridden by the - // general polling loop with the `Stopped` state - startIsInProgress.set(true) - val startJob = context.cs - .launch(CoroutineName("Start Workspace Action CLI Runner") + Dispatchers.IO) { - cli.startWorkspace(workspace.ownerName, workspace.name) - } - // cli takes 15 seconds to move the workspace in queueing/starting state - // while the user won't see anything happening in TBX after start is clicked - // During those 15 seconds we work around by forcing a `Queuing` state - while (startJob.isActive && client.workspace(workspace.id).latestBuild.status.isNotStarted()) { - state.update { - WorkspaceAndAgentStatus.QUEUED.toRemoteEnvironmentState(context) - } - delay(1.seconds) + context.cs + .launch(CoroutineName("Start Workspace Action CLI Runner") + Dispatchers.IO) { + cli.startWorkspace(workspace.ownerName, workspace.name) } - startIsInProgress.set(false) - // retrieve the status again and update the status - update(client.workspace(workspace.id), agent) - } finally { - startIsInProgress.set(false) - } - } - ) + // cli takes 15 seconds to move the workspace in queueing/starting state + // while the user won't see anything happening in TBX after start is clicked + // During those 15 seconds we work around by forcing a `Queuing` state + updateStatus(WorkspaceAndAgentStatus.QUEUED) + }) } } - if (wsRawStatus.canStop()) { + if (environmentStatus.canStop()) { if (workspace.outdated) { actions.add(Action(context, "Update and restart") { val build = client.updateWorkspace(workspace) @@ -170,12 +152,14 @@ class CoderRemoteEnvironment( actions.add(Action(context, "Delete workspace", highlightInRed = true) { context.cs.launch(CoroutineName("Delete Workspace Action")) { var dialogText = - if (wsRawStatus.canStop()) "This will close the workspace and remove all its information, including files, unsaved changes, history, and usage data." + if (environmentStatus.canStop()) "This will close the workspace and remove all its information, including files, unsaved changes, history, and usage data." else "This will remove all information from the workspace, including files, unsaved changes, history, and usage data." dialogText += "\n\nType \"${workspace.name}\" below to confirm:" val confirmation = context.ui.showTextInputPopup( - if (wsRawStatus.canStop()) context.i18n.ptrl("Delete running workspace?") else context.i18n.ptrl("Delete workspace?"), + if (environmentStatus.canStop()) context.i18n.ptrl("Delete running workspace?") else context.i18n.ptrl( + "Delete workspace?" + ), context.i18n.pnotr(dialogText), context.i18n.ptrl("Workspace name"), TextType.General, @@ -264,23 +248,34 @@ class CoderRemoteEnvironment( * Update the workspace/agent status to the listeners, if it has changed. */ fun update(workspace: Workspace, agent: WorkspaceAgent) { - if (startIsInProgress.get()) { - context.logger.info("Skipping update for $id - workspace start is in progress") + if (WorkspaceAndAgentStatus.from(this.workspace, this.agent) == WorkspaceAndAgentStatus.from( + workspace, + agent + ) + ) { + context.logger.debug("Skipping update for $id - previous and current status ") return } this.workspace = workspace this.agent = agent - wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent) + // workspace&agent status can be different from "environment status" + // which is forced to queued state when a workspace is scheduled to start + updateStatus(WorkspaceAndAgentStatus.from(workspace, agent)) // we have to regenerate the action list in order to force a redraw // because the actions don't have a state flow on the enabled property actionsList.update { getAvailableActions() } + } + + private fun updateStatus(status: WorkspaceAndAgentStatus) { + environmentStatus = status context.cs.launch(CoroutineName("Workspace Status Updater")) { state.update { - wsRawStatus.toRemoteEnvironmentState(context) + environmentStatus.toRemoteEnvironmentState(context) } } + 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}") } /** @@ -310,7 +305,7 @@ class CoderRemoteEnvironment( * Returns true if the SSH connection was scheduled to start, false otherwise. */ fun startSshConnection(): Boolean { - if (wsRawStatus.ready() && !isConnected.value) { + if (environmentStatus.ready() && !isConnected.value) { context.cs.launch(CoroutineName("SSH Connection Trigger")) { connectionRequest.update { true @@ -336,7 +331,7 @@ class CoderRemoteEnvironment( withTimeout(5.minutes) { var workspaceStillExists = true while (context.cs.isActive && workspaceStillExists) { - if (wsRawStatus == WorkspaceAndAgentStatus.DELETING || wsRawStatus == WorkspaceAndAgentStatus.DELETED) { + if (environmentStatus == WorkspaceAndAgentStatus.DELETING || environmentStatus == WorkspaceAndAgentStatus.DELETED) { workspaceStillExists = false context.envPageManager.showPluginEnvironmentsPage() } else { From 62eea901504aabca831da2b1494f186cb2039c2a Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 21 Nov 2025 23:47:41 +0200 Subject: [PATCH 2/3] refactor: available actions refresh logic And improved debug messages --- .../coder/toolbox/CoderRemoteEnvironment.kt | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 9f68a7e..ac9b9d6 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -65,7 +65,7 @@ class CoderRemoteEnvironment( override val description: MutableStateFlow = MutableStateFlow(EnvironmentDescription.General(context.i18n.pnotr(workspace.templateDisplayName))) override val additionalEnvironmentInformation: MutableMap = mutableMapOf() - override val actionsList: MutableStateFlow> = MutableStateFlow(getAvailableActions()) + override val actionsList: MutableStateFlow> = MutableStateFlow(emptyList()) private val networkMetricsMarshaller = Moshi.Builder().build().adapter(NetworkMetrics::class.java) private val proxyCommandHandle = SshCommandProcessHandle(context) @@ -76,14 +76,17 @@ class CoderRemoteEnvironment( context.logger.info("resuming SSH connection to $id — last session was still active.") startSshConnection() } + refreshAvailableActions() } fun asPairOfWorkspaceAndAgent(): Pair = Pair(workspace, agent) - private fun getAvailableActions(): List { + private fun refreshAvailableActions() { val actions = mutableListOf() + context.logger.debug("Refreshing available actions for workspace $id with status: $environmentStatus") if (environmentStatus.canStop()) { actions.add(Action(context, "Open web terminal") { + context.logger.debug("Launching web terminal for $id...") context.desktop.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) { context.ui.showErrorInfoPopup(it) } @@ -95,8 +98,9 @@ class CoderRemoteEnvironment( val urlTemplate = context.settingsStore.workspaceViewUrl ?: client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString() val url = urlTemplate - .replace("\$workspaceOwner", "${workspace.ownerName}") + .replace("\$workspaceOwner", workspace.ownerName) .replace("\$workspaceName", workspace.name) + context.logger.debug("Opening the dashboard for $id...") context.desktop.browse( url ) { @@ -106,21 +110,22 @@ class CoderRemoteEnvironment( ) actions.add(Action(context, "View template") { + context.logger.debug("Opening the template for $id...") context.desktop.browse(client.url.withPath("/templates/${workspace.templateName}").toString()) { context.ui.showErrorInfoPopup(it) } - } - ) + }) if (environmentStatus.canStart()) { if (workspace.outdated) { actions.add(Action(context, "Update and start") { + context.logger.debug("Updating and starting $id...") val build = client.updateWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) - } - ) + }) } else { actions.add(Action(context, "Start") { + context.logger.debug("Starting $id... ") context.cs .launch(CoroutineName("Start Workspace Action CLI Runner") + Dispatchers.IO) { cli.startWorkspace(workspace.ownerName, workspace.name) @@ -135,6 +140,7 @@ class CoderRemoteEnvironment( if (environmentStatus.canStop()) { if (workspace.outdated) { actions.add(Action(context, "Update and restart") { + context.logger.debug("Updating and re-starting $id...") val build = client.updateWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) } @@ -142,7 +148,7 @@ class CoderRemoteEnvironment( } actions.add(Action(context, "Stop") { tryStopSshConnection() - + context.logger.debug("Stoping $id...") val build = client.stopWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) } @@ -169,10 +175,14 @@ class CoderRemoteEnvironment( if (confirmation != workspace.name) { return@launch } + context.logger.debug("Deleting $id...") deleteWorkspace() } }) - return actions + + actionsList.update { + actions + } } private suspend fun tryStopSshConnection() { @@ -253,7 +263,6 @@ class CoderRemoteEnvironment( agent ) ) { - context.logger.debug("Skipping update for $id - previous and current status ") return } this.workspace = workspace @@ -261,11 +270,10 @@ class CoderRemoteEnvironment( // workspace&agent status can be different from "environment status" // which is forced to queued state when a workspace is scheduled to start updateStatus(WorkspaceAndAgentStatus.from(workspace, agent)) + // we have to regenerate the action list in order to force a redraw // because the actions don't have a state flow on the enabled property - actionsList.update { - getAvailableActions() - } + refreshAvailableActions() } private fun updateStatus(status: WorkspaceAndAgentStatus) { From ea43cd4ffd9f39ec5852a74876e5bc85ce1873f4 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Sat, 22 Nov 2025 00:44:31 +0200 Subject: [PATCH 3/3] fix: hide start action after calling the CLI Start action was still visible because the action list was not yet refreshed. --- src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index ac9b9d6..b06f451 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -134,6 +134,8 @@ class CoderRemoteEnvironment( // while the user won't see anything happening in TBX after start is clicked // During those 15 seconds we work around by forcing a `Queuing` state updateStatus(WorkspaceAndAgentStatus.QUEUED) + // force refresh of the actions list (Start should no longer be available) + refreshAvailableActions() }) } }