Skip to content

Commit 8d63289

Browse files
authored
Retry listing editors (#234)
* Surface error when listing editors Also my IDE tells me `is TimeoutCancellationException` is unreachable so I removed it. * Add retry to editor selection * Fix listing editor error text when wrapped
1 parent 1f2cc4e commit 8d63289

File tree

3 files changed

+113
-62
lines changed

3 files changed

+113
-62
lines changed
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.coder.gateway.sdk
2+
3+
import kotlinx.coroutines.delay
4+
import kotlinx.datetime.Clock
5+
import java.util.Random
6+
import java.util.concurrent.TimeUnit
7+
import kotlin.concurrent.timer
8+
import kotlin.math.max
9+
import kotlin.math.min
10+
11+
/**
12+
* Similar to Intellij's except it gives you the next delay, does not do its own
13+
* logging, updates periodically (for counting down), and runs forever.
14+
*/
15+
suspend fun <T> suspendingRetryWithExponentialBackOff(
16+
initialDelayMs: Long = TimeUnit.SECONDS.toMillis(5),
17+
backOffLimitMs: Long = TimeUnit.MINUTES.toMillis(3),
18+
backOffFactor: Int = 2,
19+
backOffJitter: Double = 0.1,
20+
update: (attempt: Int, remainingMs: Long, e: Exception) -> Unit,
21+
action: suspend (attempt: Int) -> T
22+
): T {
23+
val random = Random()
24+
var delayMs = initialDelayMs
25+
for (attempt in 1..Int.MAX_VALUE) {
26+
try {
27+
return action(attempt)
28+
}
29+
catch (e: Exception) {
30+
val end = Clock.System.now().toEpochMilliseconds() + delayMs
31+
val timer = timer(period = TimeUnit.SECONDS.toMillis(1)) {
32+
val now = Clock.System.now().toEpochMilliseconds()
33+
val next = max(end - now, 0)
34+
if (next > 0) {
35+
update(attempt, next, e)
36+
} else {
37+
this.cancel()
38+
}
39+
}
40+
delay(delayMs)
41+
timer.cancel()
42+
delayMs = min(delayMs * backOffFactor, backOffLimitMs) + (random.nextGaussian() * delayMs * backOffJitter).toLong()
43+
}
44+
}
45+
error("Should never be reached")
46+
}

src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt

Lines changed: 64 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import com.coder.gateway.sdk.Arch
88
import com.coder.gateway.sdk.CoderCLIManager
99
import com.coder.gateway.sdk.CoderRestClientService
1010
import com.coder.gateway.sdk.OS
11+
import com.coder.gateway.sdk.suspendingRetryWithExponentialBackOff
1112
import com.coder.gateway.sdk.toURL
1213
import com.coder.gateway.sdk.withPath
1314
import com.coder.gateway.toWorkspaceParams
@@ -27,7 +28,6 @@ import com.intellij.openapi.util.Disposer
2728
import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager
2829
import com.intellij.remote.AuthType
2930
import com.intellij.remote.RemoteCredentialsHolder
30-
import com.intellij.ssh.SshException
3131
import com.intellij.ui.AnimatedIcon
3232
import com.intellij.ui.ColoredListCellRenderer
3333
import com.intellij.ui.DocumentAdapter
@@ -52,31 +52,34 @@ import com.jetbrains.gateway.ssh.HighLevelHostAccessor
5252
import com.jetbrains.gateway.ssh.IdeStatus
5353
import com.jetbrains.gateway.ssh.IdeWithStatus
5454
import com.jetbrains.gateway.ssh.IntelliJPlatformProduct
55+
import com.jetbrains.gateway.ssh.deploy.DeployException
5556
import com.jetbrains.gateway.ssh.util.validateRemotePath
56-
import kotlinx.coroutines.CancellationException
5757
import kotlinx.coroutines.CoroutineScope
5858
import kotlinx.coroutines.Dispatchers
5959
import kotlinx.coroutines.Job
60-
import kotlinx.coroutines.TimeoutCancellationException
6160
import kotlinx.coroutines.async
6261
import kotlinx.coroutines.cancel
6362
import kotlinx.coroutines.cancelAndJoin
6463
import kotlinx.coroutines.launch
6564
import kotlinx.coroutines.runBlocking
66-
import kotlinx.coroutines.time.withTimeout
6765
import kotlinx.coroutines.withContext
66+
import net.schmizz.sshj.common.SSHException
67+
import net.schmizz.sshj.connection.ConnectionException
6868
import java.awt.Component
6969
import java.awt.FlowLayout
70-
import java.time.Duration
7170
import java.util.Locale
71+
import java.util.concurrent.TimeUnit
72+
import java.util.concurrent.TimeoutException
7273
import javax.swing.ComboBoxModel
7374
import javax.swing.DefaultComboBoxModel
75+
import javax.swing.Icon
7476
import javax.swing.JLabel
7577
import javax.swing.JList
7678
import javax.swing.JPanel
7779
import javax.swing.ListCellRenderer
7880
import javax.swing.SwingConstants
7981
import javax.swing.event.DocumentEvent
82+
import kotlin.coroutines.cancellation.CancellationException
8083

8184
class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolean) -> Unit) : CoderWorkspacesWizardStep, Disposable {
8285
private val cs = CoroutineScope(Dispatchers.Main)
@@ -102,11 +105,11 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea
102105
row {
103106
label("IDE:")
104107
cbIDE = cell(IDEComboBox(ideComboBoxModel).apply {
105-
renderer = IDECellRenderer()
106108
addActionListener {
107109
setNextButtonEnabled(this.selectedItem != null)
108110
ApplicationManager.getApplication().invokeLater {
109111
logger.info("Selected IDE: ${this.selectedItem}")
112+
cbIDEComment.foreground = UIUtil.getContextHelpForeground()
110113
when (this.selectedItem?.status) {
111114
IdeStatus.ALREADY_INSTALLED ->
112115
cbIDEComment.text =
@@ -131,7 +134,7 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea
131134
CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.ide.none.comment"),
132135
false, -1, true
133136
)
134-
).component
137+
).resizableColumn().align(AlignX.FILL).component
135138
}.topGap(TopGap.NONE).bottomGap(BottomGap.NONE).layout(RowLayout.PARENT_GRID)
136139
row {
137140
label("Project directory:")
@@ -149,15 +152,19 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea
149152
gap(RightGap.SMALL)
150153
}.apply {
151154
background = WelcomeScreenUIManager.getMainAssociatedComponentBackground()
152-
border = JBUI.Borders.empty(0, 16, 0, 16)
155+
border = JBUI.Borders.empty(0, 16)
153156
}
154157

155158
override val previousActionText = IdeBundle.message("button.back")
156159
override val nextActionText = CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.next.text")
157160

158161
override fun onInit(wizardModel: CoderWorkspacesWizardModel) {
159-
cbIDE.renderer = IDECellRenderer()
162+
// Clear contents from the last attempt if any.
163+
cbIDEComment.foreground = UIUtil.getContextHelpForeground()
164+
cbIDEComment.text = CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.ide.none.comment")
160165
ideComboBoxModel.removeAllElements()
166+
setNextButtonEnabled(false)
167+
161168
val deploymentURL = wizardModel.coderURL.toURL()
162169
val selectedWorkspace = wizardModel.selectedWorkspace
163170
if (selectedWorkspace == null) {
@@ -171,53 +178,60 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea
171178
terminalLink.url = coderClient.coderURL.withPath("/@${coderClient.me.username}/${selectedWorkspace.name}/terminal").toString()
172179

173180
ideResolvingJob = cs.launch {
174-
try {
175-
val executor = withTimeout(Duration.ofSeconds(60)) {
176-
createRemoteExecutor(CoderCLIManager.getHostName(deploymentURL, selectedWorkspace))
177-
}
178-
retrieveIDES(executor, selectedWorkspace)
179-
if (ComponentValidator.getInstance(tfProject).isEmpty) {
180-
installRemotePathValidator(executor)
181-
}
182-
} catch (e: Exception) {
183-
when (e) {
184-
is InterruptedException -> Unit
185-
is CancellationException -> Unit
186-
is TimeoutCancellationException,
187-
is SshException -> {
188-
logger.error("Can't connect to workspace ${selectedWorkspace.name}. Reason: $e")
189-
withContext(Dispatchers.Main) {
190-
setNextButtonEnabled(false)
191-
cbIDE.renderer = object : ColoredListCellRenderer<IdeWithStatus>() {
192-
override fun customizeCellRenderer(list: JList<out IdeWithStatus>, value: IdeWithStatus?, index: Int, isSelected: Boolean, cellHasFocus: Boolean) {
193-
background = UIUtil.getListBackground(isSelected, cellHasFocus)
194-
icon = UIUtil.getBalloonErrorIcon()
195-
append(CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.ssh.error.text"))
196-
}
197-
}
198-
}
181+
val ides = suspendingRetryWithExponentialBackOff(
182+
action={ attempt ->
183+
// Reset text in the select dropdown.
184+
withContext(Dispatchers.Main) {
185+
cbIDE.renderer = IDECellRenderer(
186+
if (attempt > 1) CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.retry.text", attempt)
187+
else CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.loading.text"))
199188
}
200-
201-
else -> {
202-
logger.error("Could not resolve any IDE for workspace ${selectedWorkspace.name}. Reason: $e")
203-
withContext(Dispatchers.Main) {
204-
setNextButtonEnabled(false)
205-
cbIDE.renderer = object : ColoredListCellRenderer<IdeWithStatus>() {
206-
override fun customizeCellRenderer(list: JList<out IdeWithStatus>, value: IdeWithStatus?, index: Int, isSelected: Boolean, cellHasFocus: Boolean) {
207-
background = UIUtil.getListBackground(isSelected, cellHasFocus)
208-
icon = UIUtil.getBalloonErrorIcon()
209-
append(CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.ide.error.text"))
189+
try {
190+
val executor = createRemoteExecutor(CoderCLIManager.getHostName(deploymentURL, selectedWorkspace))
191+
if (ComponentValidator.getInstance(tfProject).isEmpty) {
192+
installRemotePathValidator(executor)
193+
}
194+
retrieveIDEs(executor, selectedWorkspace)
195+
} catch (e: Exception) {
196+
when(e) {
197+
is InterruptedException -> Unit
198+
is CancellationException -> Unit
199+
// Throw to retry these. The main one is
200+
// DeployException which fires when dd times out.
201+
is ConnectionException, is TimeoutException,
202+
is SSHException, is DeployException -> throw e
203+
else -> {
204+
withContext(Dispatchers.Main) {
205+
logger.error("Failed to retrieve IDEs (attempt $attempt)", e)
206+
cbIDEComment.foreground = UIUtil.getErrorForeground()
207+
cbIDEComment.text = e.message ?: "The error did not provide any further details"
208+
cbIDE.renderer = IDECellRenderer(CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.error.text"), UIUtil.getBalloonErrorIcon())
210209
}
211210
}
212211
}
212+
null
213213
}
214+
},
215+
update = { attempt, retryMs, e ->
216+
logger.error("Failed to retrieve IDEs (attempt $attempt; will retry in $retryMs ms)", e)
217+
cbIDEComment.foreground = UIUtil.getErrorForeground()
218+
cbIDEComment.text = e.message ?: "The error did not provide any further details"
219+
val delayS = TimeUnit.MILLISECONDS.toSeconds(retryMs)
220+
val delay = if (delayS < 1) "now" else "in $delayS second${if (delayS > 1) "s" else ""}"
221+
cbIDE.renderer = IDECellRenderer(CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.retry-error.text", delay))
222+
},
223+
)
224+
if (ides != null) {
225+
withContext(Dispatchers.Main) {
226+
ideComboBoxModel.addAll(ides)
227+
cbIDE.selectedIndex = 0
214228
}
215229
}
216230
}
217231
}
218232

219233
private fun installRemotePathValidator(executor: HighLevelHostAccessor) {
220-
var disposable = Disposer.newDisposable(ApplicationManager.getApplication(), CoderLocateRemoteProjectStepView.javaClass.name)
234+
val disposable = Disposer.newDisposable(ApplicationManager.getApplication(), CoderLocateRemoteProjectStepView::class.java.name)
221235
ComponentValidator(disposable).installOn(tfProject)
222236

223237
tfProject.document.addDocumentListener(object : DocumentAdapter() {
@@ -258,7 +272,7 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea
258272
)
259273
}
260274

261-
private suspend fun retrieveIDES(executor: HighLevelHostAccessor, selectedWorkspace: WorkspaceAgentModel) {
275+
private suspend fun retrieveIDEs(executor: HighLevelHostAccessor, selectedWorkspace: WorkspaceAgentModel): List<IdeWithStatus> {
262276
logger.info("Retrieving available IDE's for ${selectedWorkspace.name} workspace...")
263277
val workspaceOS = if (selectedWorkspace.agentOS != null && selectedWorkspace.agentArch != null) toDeployedOS(selectedWorkspace.agentOS, selectedWorkspace.agentArch) else withContext(Dispatchers.IO) {
264278
executor.guessOs()
@@ -279,21 +293,11 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea
279293
val idesWithStatus = idesWithStatusJob.await()
280294
if (installedIdes.isEmpty()) {
281295
logger.info("No IDE is installed in workspace ${selectedWorkspace.name}")
282-
} else {
283-
withContext(Dispatchers.Main) {
284-
ideComboBoxModel.addAll(installedIdes)
285-
cbIDE.selectedIndex = 0
286-
}
287296
}
288-
289297
if (idesWithStatus.isEmpty()) {
290298
logger.warn("Could not resolve any IDE for workspace ${selectedWorkspace.name}, probably $workspaceOS is not supported by Gateway")
291-
} else {
292-
withContext(Dispatchers.Main) {
293-
ideComboBoxModel.addAll(idesWithStatus)
294-
cbIDE.selectedIndex = 0
295-
}
296299
}
300+
return installedIdes + idesWithStatus
297301
}
298302

299303
private fun toDeployedOS(os: OS, arch: Arch): DeployTargetOS {
@@ -363,12 +367,12 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea
363367
}
364368
}
365369

366-
private class IDECellRenderer : ListCellRenderer<IdeWithStatus> {
370+
private class IDECellRenderer(message: String, cellIcon: Icon = AnimatedIcon.Default.INSTANCE) : ListCellRenderer<IdeWithStatus> {
367371
private val loadingComponentRenderer: ListCellRenderer<IdeWithStatus> = object : ColoredListCellRenderer<IdeWithStatus>() {
368372
override fun customizeCellRenderer(list: JList<out IdeWithStatus>, value: IdeWithStatus?, index: Int, isSelected: Boolean, cellHasFocus: Boolean) {
369373
background = UIUtil.getListBackground(isSelected, cellHasFocus)
370-
icon = AnimatedIcon.Default.INSTANCE
371-
append(CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.loading.text"))
374+
icon = cellIcon
375+
append(message)
372376
}
373377
}
374378

src/main/resources/messages/CoderGatewayBundle.properties

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ gateway.connector.view.workspaces.token.rejected=This token was rejected.
2929
gateway.connector.view.workspaces.token.injected=This token was pulled from your CLI config.
3030
gateway.connector.view.workspaces.token.none=No existing token found.
3131
gateway.connector.view.coder.remoteproject.loading.text=Retrieving products...
32-
gateway.connector.view.coder.remoteproject.ide.error.text=Could not retrieve any IDE because an error was encountered. Please check the logs for more details!
33-
gateway.connector.view.coder.remoteproject.ssh.error.text=Can't connect to the workspace. Please make sure Coder Agent is running!
32+
gateway.connector.view.coder.remoteproject.retry.text=Retrieving products (attempt {0})...
33+
gateway.connector.view.coder.remoteproject.error.text=Failed to retrieve IDEs
34+
gateway.connector.view.coder.remoteproject.retry-error.text=Failed to retrieve IDEs...retrying {0}
3435
gateway.connector.view.coder.remoteproject.next.text=Start IDE and connect
3536
gateway.connector.view.coder.remoteproject.choose.text=Choose IDE and project for workspace {0}
3637
gateway.connector.view.coder.remoteproject.ide.download.comment=This IDE will be downloaded from jetbrains.com and installed to the default path on the remote host.

0 commit comments

Comments
 (0)