Skip to content

Commit 0e52818

Browse files
committed
Add retry to editor selection
1 parent ee8e7ff commit 0e52818

File tree

3 files changed

+108
-46
lines changed

3 files changed

+108
-46
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: 60 additions & 46 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
@@ -51,8 +52,8 @@ import com.jetbrains.gateway.ssh.HighLevelHostAccessor
5152
import com.jetbrains.gateway.ssh.IdeStatus
5253
import com.jetbrains.gateway.ssh.IdeWithStatus
5354
import com.jetbrains.gateway.ssh.IntelliJPlatformProduct
55+
import com.jetbrains.gateway.ssh.deploy.DeployException
5456
import com.jetbrains.gateway.ssh.util.validateRemotePath
55-
import kotlinx.coroutines.CancellationException
5657
import kotlinx.coroutines.CoroutineScope
5758
import kotlinx.coroutines.Dispatchers
5859
import kotlinx.coroutines.Job
@@ -61,20 +62,24 @@ import kotlinx.coroutines.cancel
6162
import kotlinx.coroutines.cancelAndJoin
6263
import kotlinx.coroutines.launch
6364
import kotlinx.coroutines.runBlocking
64-
import kotlinx.coroutines.time.withTimeout
6565
import kotlinx.coroutines.withContext
66+
import net.schmizz.sshj.common.SSHException
67+
import net.schmizz.sshj.connection.ConnectionException
6668
import java.awt.Component
6769
import java.awt.FlowLayout
68-
import java.time.Duration
6970
import java.util.Locale
71+
import java.util.concurrent.TimeUnit
72+
import java.util.concurrent.TimeoutException
7073
import javax.swing.ComboBoxModel
7174
import javax.swing.DefaultComboBoxModel
75+
import javax.swing.Icon
7276
import javax.swing.JLabel
7377
import javax.swing.JList
7478
import javax.swing.JPanel
7579
import javax.swing.ListCellRenderer
7680
import javax.swing.SwingConstants
7781
import javax.swing.event.DocumentEvent
82+
import kotlin.coroutines.cancellation.CancellationException
7883

7984
class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolean) -> Unit) : CoderWorkspacesWizardStep, Disposable {
8085
private val cs = CoroutineScope(Dispatchers.Main)
@@ -100,7 +105,6 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea
100105
row {
101106
label("IDE:")
102107
cbIDE = cell(IDEComboBox(ideComboBoxModel).apply {
103-
renderer = IDECellRenderer()
104108
addActionListener {
105109
setNextButtonEnabled(this.selectedItem != null)
106110
ApplicationManager.getApplication().invokeLater {
@@ -148,19 +152,19 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea
148152
gap(RightGap.SMALL)
149153
}.apply {
150154
background = WelcomeScreenUIManager.getMainAssociatedComponentBackground()
151-
border = JBUI.Borders.empty(0, 16, 0, 16)
155+
border = JBUI.Borders.empty(0, 16)
152156
}
153157

154158
override val previousActionText = IdeBundle.message("button.back")
155159
override val nextActionText = CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.next.text")
156160

157161
override fun onInit(wizardModel: CoderWorkspacesWizardModel) {
158-
// Clear error message as it might still be displaying.
162+
// Clear contents from the last attempt if any.
159163
cbIDEComment.foreground = UIUtil.getContextHelpForeground()
160164
cbIDEComment.text = CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.ide.none.comment")
161-
162-
cbIDE.renderer = IDECellRenderer()
163165
ideComboBoxModel.removeAllElements()
166+
setNextButtonEnabled(false)
167+
164168
val deploymentURL = wizardModel.coderURL.toURL()
165169
val selectedWorkspace = wizardModel.selectedWorkspace
166170
if (selectedWorkspace == null) {
@@ -174,33 +178,53 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea
174178
terminalLink.url = coderClient.coderURL.withPath("/@${coderClient.me.username}/${selectedWorkspace.name}/terminal").toString()
175179

176180
ideResolvingJob = cs.launch {
177-
try {
178-
val executor = withTimeout(Duration.ofSeconds(60)) {
179-
createRemoteExecutor(CoderCLIManager.getHostName(deploymentURL, selectedWorkspace))
180-
}
181-
retrieveIDES(executor, selectedWorkspace)
182-
if (ComponentValidator.getInstance(tfProject).isEmpty) {
183-
installRemotePathValidator(executor)
184-
}
185-
} catch (e: Exception) {
186-
when (e) {
187-
is InterruptedException -> Unit
188-
is CancellationException -> Unit
189-
else -> {
190-
logger.error("Failed to retrieve IDEs for workspace ${selectedWorkspace.name}", e)
191-
withContext(Dispatchers.Main) {
192-
setNextButtonEnabled(false)
193-
cbIDEComment.foreground = UIUtil.getErrorForeground()
194-
cbIDEComment.text = e.message ?: "The error did not provide any further details"
195-
cbIDE.renderer = object : ColoredListCellRenderer<IdeWithStatus>() {
196-
override fun customizeCellRenderer(list: JList<out IdeWithStatus>, value: IdeWithStatus?, index: Int, isSelected: Boolean, cellHasFocus: Boolean) {
197-
background = UIUtil.getListBackground(isSelected, cellHasFocus)
198-
icon = UIUtil.getBalloonErrorIcon()
199-
append(CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.error.text"))
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"))
188+
}
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())
200209
}
201210
}
202211
}
212+
null
203213
}
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
204228
}
205229
}
206230
}
@@ -248,7 +272,7 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea
248272
)
249273
}
250274

251-
private suspend fun retrieveIDES(executor: HighLevelHostAccessor, selectedWorkspace: WorkspaceAgentModel) {
275+
private suspend fun retrieveIDEs(executor: HighLevelHostAccessor, selectedWorkspace: WorkspaceAgentModel): List<IdeWithStatus> {
252276
logger.info("Retrieving available IDE's for ${selectedWorkspace.name} workspace...")
253277
val workspaceOS = if (selectedWorkspace.agentOS != null && selectedWorkspace.agentArch != null) toDeployedOS(selectedWorkspace.agentOS, selectedWorkspace.agentArch) else withContext(Dispatchers.IO) {
254278
executor.guessOs()
@@ -269,21 +293,11 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea
269293
val idesWithStatus = idesWithStatusJob.await()
270294
if (installedIdes.isEmpty()) {
271295
logger.info("No IDE is installed in workspace ${selectedWorkspace.name}")
272-
} else {
273-
withContext(Dispatchers.Main) {
274-
ideComboBoxModel.addAll(installedIdes)
275-
cbIDE.selectedIndex = 0
276-
}
277296
}
278-
279297
if (idesWithStatus.isEmpty()) {
280298
logger.warn("Could not resolve any IDE for workspace ${selectedWorkspace.name}, probably $workspaceOS is not supported by Gateway")
281-
} else {
282-
withContext(Dispatchers.Main) {
283-
ideComboBoxModel.addAll(idesWithStatus)
284-
cbIDE.selectedIndex = 0
285-
}
286299
}
300+
return installedIdes + idesWithStatus
287301
}
288302

289303
private fun toDeployedOS(os: OS, arch: Arch): DeployTargetOS {
@@ -353,12 +367,12 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea
353367
}
354368
}
355369

356-
private class IDECellRenderer : ListCellRenderer<IdeWithStatus> {
370+
private class IDECellRenderer(message: String, cellIcon: Icon = AnimatedIcon.Default.INSTANCE) : ListCellRenderer<IdeWithStatus> {
357371
private val loadingComponentRenderer: ListCellRenderer<IdeWithStatus> = object : ColoredListCellRenderer<IdeWithStatus>() {
358372
override fun customizeCellRenderer(list: JList<out IdeWithStatus>, value: IdeWithStatus?, index: Int, isSelected: Boolean, cellHasFocus: Boolean) {
359373
background = UIUtil.getListBackground(isSelected, cellHasFocus)
360-
icon = AnimatedIcon.Default.INSTANCE
361-
append(CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.loading.text"))
374+
icon = cellIcon
375+
append(message)
362376
}
363377
}
364378

src/main/resources/messages/CoderGatewayBundle.properties

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +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.retry.text=Retrieving products (attempt {0})...
3233
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}
3335
gateway.connector.view.coder.remoteproject.next.text=Start IDE and connect
3436
gateway.connector.view.coder.remoteproject.choose.text=Choose IDE and project for workspace {0}
3537
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)