From 48ed27d1203dbe8a9d3713f6d57ec61de2d6a26a Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 15 Sep 2025 23:58:18 +0300 Subject: [PATCH 1/2] fix: report errors while running actions JetBrains team reported in the past a couple of errors in the log, one of them being `A workspace build is already active`. The issue can be reproduced if the user hits the `Stop` action for example quite quick. It takes maybe one or two seconds to make rest api request, then for the backend to enqueue the build and change the workspace action. If we hit the action buttons really fast then this error could be reproduced. One approach I tried was to disable the action buttons in the context menu for the duration the request is executed. But for some reason the "enabled" property is not working in context menu, only when the actions are rendered on a UI "page". Instead, I decided to refactor the existing code and (also) visually report the errors in the UI screen to make the user aware in some cases that a job is already running on the backend. --- CHANGELOG.md | 1 + .../coder/toolbox/CoderRemoteEnvironment.kt | 83 +++++++++---------- .../com/coder/toolbox/CoderRemoteProvider.kt | 10 +-- .../toolbox/views/CoderCliSetupWizardPage.kt | 12 +-- .../com/coder/toolbox/views/CoderPage.kt | 24 +++++- .../coder/toolbox/views/CoderSettingsPage.kt | 2 +- 6 files changed, 70 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee09b84..22230b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - token is no longer required when authentication is done via certificates +- errors while running actions are now reported ## 0.6.4 - 2025-09-03 diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index d4531a6..df27a37 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -81,68 +81,61 @@ class CoderRemoteEnvironment( private fun getAvailableActions(): List { val actions = mutableListOf() if (wsRawStatus.canStop()) { - actions.add(Action(context.i18n.ptrl("Open web terminal")) { - context.cs.launch(CoroutineName("Open Web Terminal Action")) { - context.desktop.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) { - context.ui.showErrorInfoPopup(it) - } + actions.add(Action(context, "Open web terminal") { + context.desktop.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) { + context.ui.showErrorInfoPopup(it) } - }) + } + ) } actions.add( - Action(context.i18n.ptrl("Open in dashboard")) { - context.cs.launch(CoroutineName("Open in Dashboard Action")) { - context.desktop.browse( - client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString() - ) { - context.ui.showErrorInfoPopup(it) - } - } - }) - - actions.add(Action(context.i18n.ptrl("View template")) { - context.cs.launch(CoroutineName("View Template Action")) { - context.desktop.browse(client.url.withPath("/templates/${workspace.templateName}").toString()) { + Action(context, "Open in dashboard") { + context.desktop.browse( + client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString() + ) { context.ui.showErrorInfoPopup(it) } } - }) + ) + + actions.add(Action(context, "View template") { + context.desktop.browse(client.url.withPath("/templates/${workspace.templateName}").toString()) { + context.ui.showErrorInfoPopup(it) + } + } + ) if (wsRawStatus.canStart()) { if (workspace.outdated) { - actions.add(Action(context.i18n.ptrl("Update and start")) { - context.cs.launch(CoroutineName("Update and Start Action")) { - val build = client.updateWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) - } - }) + actions.add(Action(context, "Update and start") { + val build = client.updateWorkspace(workspace) + update(workspace.copy(latestBuild = build), agent) + } + ) } else { - actions.add(Action(context.i18n.ptrl("Start")) { - context.cs.launch(CoroutineName("Start Action")) { - val build = client.startWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) + actions.add(Action(context, "Start") { + val build = client.startWorkspace(workspace) + update(workspace.copy(latestBuild = build), agent) - } - }) + } + ) } } if (wsRawStatus.canStop()) { if (workspace.outdated) { - actions.add(Action(context.i18n.ptrl("Update and restart")) { - context.cs.launch(CoroutineName("Update and Restart Action")) { - val build = client.updateWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) - } - }) - } - actions.add(Action(context.i18n.ptrl("Stop")) { - context.cs.launch(CoroutineName("Stop Action")) { - tryStopSshConnection() - - val build = client.stopWorkspace(workspace) + actions.add(Action(context, "Update and restart") { + val build = client.updateWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) } - }) + ) + } + actions.add(Action(context, "Stop") { + tryStopSshConnection() + + val build = client.stopWorkspace(workspace) + update(workspace.copy(latestBuild = build), agent) + } + ) } return actions } diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 5d5acbb..f4e1ffc 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -222,15 +222,13 @@ class CoderRemoteProvider( override val additionalPluginActions: StateFlow> = MutableStateFlow( listOf( - Action(context.i18n.ptrl("Create workspace")) { - context.cs.launch(CoroutineName("Create Workspace Action")) { - context.desktop.browse(client?.url?.withPath("/templates").toString()) { - context.ui.showErrorInfoPopup(it) - } + Action(context, "Create workspace") { + context.desktop.browse(client?.url?.withPath("/templates").toString()) { + context.ui.showErrorInfoPopup(it) } }, CoderDelimiter(context.i18n.pnotr("")), - Action(context.i18n.ptrl("Settings")) { + Action(context, "Settings") { context.ui.showUiPage(settingsPage) }, ) diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt index bca3606..eca1179 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt @@ -24,9 +24,9 @@ class CoderCliSetupWizardPage( ) -> Unit, ) : CoderPage(MutableStateFlow(context.i18n.ptrl("Setting up Coder")), false) { private val shouldAutoSetup = MutableStateFlow(initialAutoSetup) - private val settingsAction = Action(context.i18n.ptrl("Settings"), actionBlock = { + private val settingsAction = Action(context, "Settings") { context.ui.showUiPage(settingsPage) - }) + } private val deploymentUrlStep = DeploymentUrlStep(context, visibilityState) private val tokenStep = TokenStep(context) @@ -60,7 +60,7 @@ class CoderCliSetupWizardPage( } actionButtons.update { listOf( - Action(context.i18n.ptrl("Next"), closesPage = false, actionBlock = { + Action(context, "Next", closesPage = false, actionBlock = { if (deploymentUrlStep.onNext()) { displaySteps() } @@ -77,13 +77,13 @@ class CoderCliSetupWizardPage( } actionButtons.update { listOf( - Action(context.i18n.ptrl("Connect"), closesPage = false, actionBlock = { + Action(context, "Connect", closesPage = false, actionBlock = { if (tokenStep.onNext()) { displaySteps() } }), settingsAction, - Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = { + Action(context, "Back", closesPage = false, actionBlock = { tokenStep.onBack() displaySteps() }) @@ -99,7 +99,7 @@ class CoderCliSetupWizardPage( actionButtons.update { listOf( settingsAction, - Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = { + Action(context, "Back", closesPage = false, actionBlock = { connectStep.onBack() shouldAutoSetup.update { false diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt index eec0765..7a8c5a5 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt @@ -1,12 +1,16 @@ package com.coder.toolbox.views +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.sdk.ex.APIResponseException import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.UiPage +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch /** * Base page that handles the icon, displaying error notifications, and @@ -48,15 +52,27 @@ abstract class CoderPage( * An action that simply runs the provided callback. */ class Action( - description: LocalizableString, + private val context: CoderToolboxContext, + private val description: String, closesPage: Boolean = false, enabled: () -> Boolean = { true }, - private val actionBlock: () -> Unit, + private val actionBlock: suspend () -> Unit, ) : RunnableActionDescription { - override val label: LocalizableString = description + override val label: LocalizableString = context.i18n.ptrl(description) override val shouldClosePage: Boolean = closesPage override val isEnabled: Boolean = enabled() override fun run() { - actionBlock() + context.cs.launch(CoroutineName("$description Action")) { + try { + actionBlock() + } catch (ex: Exception) { + val textError = if (ex is APIResponseException) { + if (!ex.reason.isNullOrBlank()) { + ex.reason + } else ex.message + } else ex.message + context.logAndShowError("Error while running `$description`", textError ?: "", ex) + } + } } } diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index 3444683..5d5f115 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -116,7 +116,7 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf override val actionButtons: StateFlow> = MutableStateFlow( listOf( - Action(context.i18n.ptrl("Save"), closesPage = true) { + Action(context, "Save", closesPage = true) { context.settingsStore.updateBinarySource(binarySourceField.contentState.value) context.settingsStore.updateBinaryDirectory(binaryDirectoryField.contentState.value) context.settingsStore.updateDataDirectory(dataDirectoryField.contentState.value) From b396cd3b59e54c727654c99243e049bda6747754 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 16 Sep 2025 00:23:02 +0300 Subject: [PATCH 2/2] impl: improve logging around resource cleanup Another error reported by JetBrains is a `RejectedExecutionException` in the rest api client, and from the stack trace it seems the thread pool in the rest client was at some point shutdown. I think it is some sort of race condition, some thread calling shutting down the rest api client while the UI thread still executes polling and user's action. I tried to reproduce the issue with no success, and so I'm improving the logging around plugin de-initialization in the hope that next time the sequence of events is more helpful. --- .../com/coder/toolbox/CoderRemoteProvider.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index f4e1ffc..f076123 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -202,8 +202,10 @@ class CoderRemoteProvider( * first page. */ private fun logout() { + context.logger.info("Logging out ${client?.me?.username}...") WorkspaceConnectionManager.reset() close() + context.logger.info("User ${client?.me?.username} logged out successfully") } /** @@ -244,12 +246,16 @@ class CoderRemoteProvider( it.cancel() context.logger.info("Cancelled workspace poll job ${pollJob.toString()}") } - client?.close() + client?.let { + it.close() + context.logger.info("REST API client closed and resources released") + } + client = null lastEnvironments.clear() environments.value = LoadableState.Value(emptyList()) isInitialized.update { false } - client = null CoderCliSetupWizardState.goToFirstStep() + context.logger.info("Coder plugin is now closed") } override val svgIcon: SvgIcon = @@ -317,12 +323,12 @@ class CoderRemoteProvider( uri, shouldDoAutoSetup() ) { restClient, cli -> - // stop polling and de-initialize resources + context.logger.info("Stopping workspace polling and de-initializing resources") close() isInitialized.update { false } - // start initialization with the new settings + context.logger.info("Starting initialization with the new settings") this@CoderRemoteProvider.client = restClient coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString()))