Skip to content

Commit a68ab3a

Browse files
authored
fix: NPE during error reporting (#186)
The try/catch block raised NPE in the `notify` if another exception was raised after the context containing the URL was reset - so that means an error in the onConnect handler. In addition, some of the reset steps were moved after onConnect to make sure they execute only if onConnect callback is successful. Because of the fault in how the steps were arranged, the original exception was never logged instead a misleading NPE was treated by the coroutine's exception handler.
1 parent 22f53f6 commit a68ab3a

File tree

6 files changed

+106
-53
lines changed

6 files changed

+106
-53
lines changed

CHANGELOG.md

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

77
- improved diagnose support
88

9+
### Fixed
10+
11+
- NPE during error reporting
12+
913
## 0.6.3 - 2025-08-25
1014

1115
### Added

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,6 @@ class CoderRemoteProvider(
409409
context.logger.info("Displaying ${client.url} in the UI")
410410
pollJob = poll(client, cli)
411411
context.logger.info("Workspace poll job created with reference $pollJob")
412-
context.envPageManager.showPluginEnvironmentsPage()
413412
}
414413

415414
private fun MutableStateFlow<LoadableState<List<CoderRemoteEnvironment>>>.showLoadingMessage() {

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

Lines changed: 7 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,19 @@ package com.coder.toolbox.views
33
import com.coder.toolbox.CoderToolboxContext
44
import com.coder.toolbox.cli.CoderCLIManager
55
import com.coder.toolbox.sdk.CoderRestClient
6-
import com.coder.toolbox.sdk.ex.APIResponseException
76
import com.coder.toolbox.views.state.CoderCliSetupWizardState
87
import com.coder.toolbox.views.state.WizardStep
98
import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState
109
import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription
1110
import com.jetbrains.toolbox.api.ui.components.UiField
12-
import kotlinx.coroutines.CoroutineName
1311
import kotlinx.coroutines.flow.MutableStateFlow
12+
import kotlinx.coroutines.flow.StateFlow
1413
import kotlinx.coroutines.flow.update
15-
import kotlinx.coroutines.launch
16-
import java.util.UUID
1714

1815
class CoderCliSetupWizardPage(
1916
private val context: CoderToolboxContext,
2017
private val settingsPage: CoderSettingsPage,
21-
private val visibilityState: MutableStateFlow<ProviderVisibilityState>,
18+
visibilityState: StateFlow<ProviderVisibilityState>,
2219
initialAutoSetup: Boolean = false,
2320
jumpToMainPageOnError: Boolean = false,
2421
onConnect: suspend (
@@ -31,33 +28,28 @@ class CoderCliSetupWizardPage(
3128
context.ui.showUiPage(settingsPage)
3229
})
3330

34-
private val deploymentUrlStep = DeploymentUrlStep(context, this::notify)
31+
private val deploymentUrlStep = DeploymentUrlStep(context, visibilityState)
3532
private val tokenStep = TokenStep(context)
3633
private val connectStep = ConnectStep(
3734
context,
3835
shouldAutoLogin = shouldAutoSetup,
3936
jumpToMainPageOnError,
40-
this::notify,
37+
visibilityState,
4138
this::displaySteps,
4239
onConnect
4340
)
41+
private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass)
4442

4543
/**
4644
* Fields for this page, displayed in order.
4745
*/
4846
override val fields: MutableStateFlow<List<UiField>> = MutableStateFlow(emptyList())
4947
override val actionButtons: MutableStateFlow<List<RunnableActionDescription>> = MutableStateFlow(emptyList())
5048

51-
private val errorBuffer = mutableListOf<Throwable>()
5249

5350
override fun beforeShow() {
5451
displaySteps()
55-
if (errorBuffer.isNotEmpty() && visibilityState.value.applicationVisible) {
56-
errorBuffer.forEach {
57-
showError(it)
58-
}
59-
errorBuffer.clear()
60-
}
52+
errorReporter.flush()
6153
}
6254

6355
private fun displaySteps() {
@@ -124,30 +116,5 @@ class CoderCliSetupWizardPage(
124116
/**
125117
* Show an error as a popup on this page.
126118
*/
127-
fun notify(logPrefix: String, ex: Throwable) {
128-
context.logger.error(ex, logPrefix)
129-
if (!visibilityState.value.applicationVisible) {
130-
context.logger.debug("Toolbox is not yet visible, scheduling error to be displayed later")
131-
errorBuffer.add(ex)
132-
return
133-
}
134-
showError(ex)
135-
}
136-
137-
private fun showError(ex: Throwable) {
138-
val textError = if (ex is APIResponseException) {
139-
if (!ex.reason.isNullOrBlank()) {
140-
ex.reason
141-
} else ex.message
142-
} else ex.message
143-
144-
context.cs.launch(CoroutineName("Coder Setup Visual Error Reporting")) {
145-
context.ui.showSnackbar(
146-
UUID.randomUUID().toString(),
147-
context.i18n.ptrl("Error encountered while setting up Coder"),
148-
context.i18n.pnotr(textError ?: ""),
149-
context.i18n.ptrl("Dismiss")
150-
)
151-
}
152-
}
119+
fun notify(message: String, ex: Throwable) = errorReporter.report(message, ex)
153120
}

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

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.coder.toolbox.plugin.PluginManager
77
import com.coder.toolbox.sdk.CoderRestClient
88
import com.coder.toolbox.views.state.CoderCliSetupContext
99
import com.coder.toolbox.views.state.CoderCliSetupWizardState
10+
import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState
1011
import com.jetbrains.toolbox.api.ui.components.LabelField
1112
import com.jetbrains.toolbox.api.ui.components.RowGroup
1213
import com.jetbrains.toolbox.api.ui.components.ValidationErrorField
@@ -27,24 +28,23 @@ class ConnectStep(
2728
private val context: CoderToolboxContext,
2829
private val shouldAutoLogin: StateFlow<Boolean>,
2930
private val jumpToMainPageOnError: Boolean,
30-
private val notify: (String, Throwable) -> Unit,
31+
visibilityState: StateFlow<ProviderVisibilityState>,
3132
private val refreshWizard: () -> Unit,
32-
private val onConnect: suspend (
33-
client: CoderRestClient,
34-
cli: CoderCLIManager,
35-
) -> Unit,
33+
private val onConnect: suspend (client: CoderRestClient, cli: CoderCLIManager) -> Unit,
3634
) : WizardStep {
3735
private var signInJob: Job? = null
3836

3937
private val statusField = LabelField(context.i18n.pnotr(""))
4038
private val errorField = ValidationErrorField(context.i18n.pnotr(""))
39+
private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass)
4140

4241
override val panel: RowGroup = RowGroup(
4342
RowGroup.RowField(statusField),
4443
RowGroup.RowField(errorField)
4544
)
4645

4746
override fun onVisible() {
47+
errorReporter.flush()
4848
errorField.textState.update {
4949
context.i18n.pnotr("")
5050
}
@@ -73,6 +73,9 @@ class ConnectStep(
7373
errorField.textState.update { context.i18n.ptrl("Token is required") }
7474
return
7575
}
76+
// Capture the host name early for error reporting
77+
val hostName = CoderCliSetupContext.url!!.host
78+
7679
signInJob?.cancel()
7780
signInJob = context.cs.launch(CoroutineName("Http and CLI Setup")) {
7881
try {
@@ -100,21 +103,23 @@ class ConnectStep(
100103
yield()
101104
cli.login(client.token!!)
102105
}
103-
logAndReportProgress("Successfully configured ${CoderCliSetupContext.url!!.host}...")
106+
logAndReportProgress("Successfully configured ${hostName}...")
104107
// allows interleaving with the back/cancel action
105108
yield()
106-
CoderCliSetupContext.reset()
107-
CoderCliSetupWizardState.goToFirstStep()
108109
context.logger.info("Connection setup done, initializing the workspace poller...")
109110
onConnect(client, cli)
111+
112+
CoderCliSetupContext.reset()
113+
CoderCliSetupWizardState.goToFirstStep()
114+
context.envPageManager.showPluginEnvironmentsPage()
110115
} catch (ex: CancellationException) {
111116
if (ex.message != USER_HIT_THE_BACK_BUTTON) {
112-
notify("Connection to ${CoderCliSetupContext.url!!.host} was configured", ex)
117+
errorReporter.report("Connection to $hostName was configured", ex)
113118
handleNavigation()
114119
refreshWizard()
115120
}
116121
} catch (ex: Exception) {
117-
notify("Failed to configure ${CoderCliSetupContext.url!!.host}", ex)
122+
errorReporter.report("Failed to configure $hostName", ex)
118123
handleNavigation()
119124
refreshWizard()
120125
}

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import com.coder.toolbox.util.toURL
66
import com.coder.toolbox.util.validateStrictWebUrl
77
import com.coder.toolbox.views.state.CoderCliSetupContext
88
import com.coder.toolbox.views.state.CoderCliSetupWizardState
9+
import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState
910
import com.jetbrains.toolbox.api.ui.components.CheckboxField
1011
import com.jetbrains.toolbox.api.ui.components.LabelField
1112
import com.jetbrains.toolbox.api.ui.components.LabelStyleType
1213
import com.jetbrains.toolbox.api.ui.components.RowGroup
1314
import com.jetbrains.toolbox.api.ui.components.TextField
1415
import com.jetbrains.toolbox.api.ui.components.TextType
1516
import com.jetbrains.toolbox.api.ui.components.ValidationErrorField
17+
import kotlinx.coroutines.flow.StateFlow
1618
import kotlinx.coroutines.flow.update
1719
import java.net.MalformedURLException
1820
import java.net.URL
@@ -25,9 +27,11 @@ import java.net.URL
2527
*/
2628
class DeploymentUrlStep(
2729
private val context: CoderToolboxContext,
28-
private val notify: (String, Throwable) -> Unit
30+
visibilityState: StateFlow<ProviderVisibilityState>,
2931
) :
3032
WizardStep {
33+
private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass)
34+
3135
private val urlField = TextField(context.i18n.ptrl("Deployment URL"), "", TextType.General)
3236
private val emptyLine = LabelField(context.i18n.pnotr(""), LabelStyleType.Normal)
3337

@@ -66,6 +70,7 @@ class DeploymentUrlStep(
6670
signatureFallbackStrategyField.checkedState.update {
6771
context.settingsStore.fallbackOnCoderForSignatures.isAllowed()
6872
}
73+
errorReporter.flush()
6974
}
7075

7176
override fun onNext(): Boolean {
@@ -78,7 +83,7 @@ class DeploymentUrlStep(
7883
try {
7984
CoderCliSetupContext.url = validateRawUrl(url)
8085
} catch (e: MalformedURLException) {
81-
notify("URL is invalid", e)
86+
errorReporter.report("URL is invalid", e)
8287
return false
8388
}
8489
if (context.settingsStore.requireTokenAuth) {
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.coder.toolbox.views
2+
3+
import com.coder.toolbox.CoderToolboxContext
4+
import com.coder.toolbox.sdk.ex.APIResponseException
5+
import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState
6+
import kotlinx.coroutines.flow.StateFlow
7+
import kotlinx.coroutines.launch
8+
import java.util.UUID
9+
10+
sealed class ErrorReporter {
11+
12+
/**
13+
* Logs and show errors as popups.
14+
*/
15+
abstract fun report(message: String, ex: Throwable)
16+
17+
/**
18+
* Processes any buffered errors when the application becomes visible.
19+
*/
20+
abstract fun flush()
21+
22+
companion object {
23+
fun create(
24+
context: CoderToolboxContext,
25+
visibilityState: StateFlow<ProviderVisibilityState>,
26+
callerClass: Class<*>
27+
): ErrorReporter = ErrorReporterImpl(context, visibilityState, callerClass)
28+
}
29+
}
30+
31+
private class ErrorReporterImpl(
32+
private val context: CoderToolboxContext,
33+
private val visibilityState: StateFlow<ProviderVisibilityState>,
34+
private val callerClass: Class<*>
35+
) : ErrorReporter() {
36+
private val errorBuffer = mutableListOf<Throwable>()
37+
38+
override fun report(message: String, ex: Throwable) {
39+
context.logger.error(ex, "[${callerClass.simpleName}] $message")
40+
if (!visibilityState.value.applicationVisible) {
41+
context.logger.debug("Toolbox is not yet visible, scheduling error to be displayed later")
42+
errorBuffer.add(ex)
43+
return
44+
}
45+
showError(ex)
46+
}
47+
48+
private fun showError(ex: Throwable) {
49+
val textError = if (ex is APIResponseException) {
50+
if (!ex.reason.isNullOrBlank()) {
51+
ex.reason
52+
} else ex.message
53+
} else ex.message ?: ex.toString()
54+
context.cs.launch {
55+
context.ui.showSnackbar(
56+
UUID.randomUUID().toString(),
57+
context.i18n.ptrl("Error encountered while setting up Coder"),
58+
context.i18n.pnotr(textError ?: ""),
59+
context.i18n.ptrl("Dismiss")
60+
)
61+
}
62+
}
63+
64+
65+
override fun flush() {
66+
if (errorBuffer.isNotEmpty() && visibilityState.value.applicationVisible) {
67+
errorBuffer.forEach {
68+
showError(it)
69+
}
70+
errorBuffer.clear()
71+
}
72+
}
73+
}

0 commit comments

Comments
 (0)