Skip to content

Commit 4ca9190

Browse files
authored
fix: socket connection timeout (#53)
Context: - okhttp uses an HTTP/2 connection to the coder rest api in order to resolves the workspaces. - HTTP/2 uses a single TCP connection for multiple requests (multiplexing). If the connection is idle, the http server can close that connection, with client side ending in a socket timeout if it doesn't detect the drop in time. - similarly on the client side, if the OS goes into sleep mode, the connection might have been interrupted. HTTP/2 doesn't always detect this quickly, leading to stale streams when Toolbox wakes up. Implementation: - we could try to force the client to use HTTP/1 which creates a TCP connection for each request, but from my testing it seems that configuring a retry strategy when a client attempts to reuse a TCP connection that has unexpectedly closed plus detecting large gaps between the last poll time and socket timeout time allows us to reset the client and create fresh TCP connections. - resolves #13
1 parent e6af3ca commit 4ca9190

File tree

5 files changed

+32
-21
lines changed

5 files changed

+32
-21
lines changed

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

+22-7
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,20 @@ import kotlinx.coroutines.isActive
3030
import kotlinx.coroutines.launch
3131
import kotlinx.coroutines.selects.onTimeout
3232
import kotlinx.coroutines.selects.select
33-
import okhttp3.OkHttpClient
33+
import java.net.SocketTimeoutException
3434
import java.net.URI
3535
import java.net.URL
3636
import kotlin.coroutines.cancellation.CancellationException
3737
import kotlin.time.Duration.Companion.seconds
38+
import kotlin.time.TimeSource
3839
import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as DropDownMenu
3940
import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as dropDownFactory
4041

42+
private val POLL_INTERVAL = 5.seconds
43+
4144
@OptIn(ExperimentalCoroutinesApi::class)
4245
class CoderRemoteProvider(
4346
private val context: CoderToolboxContext,
44-
private val httpClient: OkHttpClient,
4547
) : RemoteProvider("Coder") {
4648
// Current polling job.
4749
private var pollJob: Job? = null
@@ -66,7 +68,7 @@ class CoderRemoteProvider(
6668
private var firstRun = true
6769
private val isInitialized: MutableStateFlow<Boolean> = MutableStateFlow(false)
6870
private var coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(getDeploymentURL()?.first ?: ""))
69-
private val linkHandler = CoderProtocolHandler(context, httpClient, dialogUi, isInitialized)
71+
private val linkHandler = CoderProtocolHandler(context, dialogUi, isInitialized)
7072
override val environments: MutableStateFlow<LoadableState<List<RemoteProviderEnvironment>>> = MutableStateFlow(
7173
LoadableState.Value(emptyList())
7274
)
@@ -77,6 +79,7 @@ class CoderRemoteProvider(
7779
* first time).
7880
*/
7981
private fun poll(client: CoderRestClient, cli: CoderCLIManager): Job = context.cs.launch {
82+
var lastPollTime = TimeSource.Monotonic.markNow()
8083
while (isActive) {
8184
try {
8285
context.logger.debug("Fetching workspace agents from ${client.url}")
@@ -134,16 +137,28 @@ class CoderRemoteProvider(
134137
} catch (_: CancellationException) {
135138
context.logger.debug("${client.url} polling loop canceled")
136139
break
140+
} catch (ex: SocketTimeoutException) {
141+
val elapsed = lastPollTime.elapsedNow()
142+
if (elapsed > POLL_INTERVAL * 2) {
143+
context.logger.info("wake-up from an OS sleep was detected, going to re-initialize the http client...")
144+
client.setupSession()
145+
} else {
146+
context.logger.error(ex, "workspace polling error encountered")
147+
pollError = ex
148+
logout()
149+
break
150+
}
137151
} catch (ex: Exception) {
138-
context.logger.info(ex, "workspace polling error encountered")
152+
context.logger.error(ex, "workspace polling error encountered")
139153
pollError = ex
140154
logout()
141155
break
142156
}
157+
143158
// TODO: Listening on a web socket might be better?
144159
select<Unit> {
145-
onTimeout(5.seconds) {
146-
context.logger.trace("workspace poller waked up by the 5 seconds timeout")
160+
onTimeout(POLL_INTERVAL) {
161+
context.logger.trace("workspace poller waked up by the $POLL_INTERVAL timeout")
147162
}
148163
triggerSshConfig.onReceive { shouldTrigger ->
149164
if (shouldTrigger) {
@@ -152,6 +167,7 @@ class CoderRemoteProvider(
152167
}
153168
}
154169
}
170+
lastPollTime = TimeSource.Monotonic.markNow()
155171
}
156172
}
157173

@@ -329,7 +345,6 @@ class CoderRemoteProvider(
329345
context,
330346
deploymentURL,
331347
token,
332-
httpClient,
333348
::goToEnvironmentsPage,
334349
) { client, cli ->
335350
// Store the URL and token for use next time.

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

+1-3
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette
1515
import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager
1616
import com.jetbrains.toolbox.api.ui.ToolboxUi
1717
import kotlinx.coroutines.CoroutineScope
18-
import okhttp3.OkHttpClient
1918

2019
/**
2120
* Entry point into the extension.
@@ -35,8 +34,7 @@ class CoderToolboxExtension : RemoteDevExtension {
3534
serviceLocator.getService(LocalizableStringFactory::class.java),
3635
CoderSettingsStore(serviceLocator.getService(PluginSettingsStore::class.java), Environment(), logger),
3736
CoderSecretsStore(serviceLocator.getService(PluginSecretStore::class.java)),
38-
),
39-
OkHttpClient(),
37+
)
4038
)
4139
}
4240
}

src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt

+8-4
Original file line numberDiff line numberDiff line change
@@ -53,16 +53,19 @@ open class CoderRestClient(
5353
val token: String?,
5454
private val proxyValues: ProxyValues? = null,
5555
private val pluginVersion: String = "development",
56-
existingHttpClient: OkHttpClient? = null,
5756
) {
5857
private val settings = context.settingsStore.readOnly()
59-
private val httpClient: OkHttpClient
60-
private val retroRestClient: CoderV2RestFacade
58+
private lateinit var httpClient: OkHttpClient
59+
private lateinit var retroRestClient: CoderV2RestFacade
6160

6261
lateinit var me: User
6362
lateinit var buildVersion: String
6463

6564
init {
65+
setupSession()
66+
}
67+
68+
fun setupSession() {
6669
val moshi =
6770
Moshi.Builder()
6871
.add(ArchConverter())
@@ -73,7 +76,7 @@ open class CoderRestClient(
7376

7477
val socketFactory = coderSocketFactory(settings.tls)
7578
val trustManagers = coderTrustManagers(settings.tls.caPath)
76-
var builder = existingHttpClient?.newBuilder() ?: OkHttpClient.Builder()
79+
var builder = OkHttpClient.Builder()
7780

7881
if (proxyValues != null) {
7982
builder =
@@ -103,6 +106,7 @@ open class CoderRestClient(
103106
builder
104107
.sslSocketFactory(socketFactory, trustManagers[0] as X509TrustManager)
105108
.hostnameVerifier(CoderHostnameVerifier(settings.tls.altHostname))
109+
.retryOnConnectionFailure(true)
106110
.addInterceptor {
107111
it.proceed(
108112
it.request().newBuilder().addHeader(

src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt

+1-4
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import kotlinx.coroutines.flow.StateFlow
1616
import kotlinx.coroutines.flow.first
1717
import kotlinx.coroutines.launch
1818
import kotlinx.coroutines.time.withTimeout
19-
import okhttp3.OkHttpClient
2019
import java.net.HttpURLConnection
2120
import java.net.URI
2221
import java.net.URL
@@ -26,7 +25,6 @@ import kotlin.time.toJavaDuration
2625

2726
open class CoderProtocolHandler(
2827
private val context: CoderToolboxContext,
29-
private val httpClient: OkHttpClient?,
3028
private val dialogUi: DialogUi,
3129
private val isInitialized: StateFlow<Boolean>,
3230
) {
@@ -230,8 +228,7 @@ open class CoderProtocolHandler(
230228
deploymentURL.toURL(),
231229
token,
232230
proxyValues = null, // TODO - not sure the above comment applies as we are creating our own http client
233-
PluginManager.pluginInfo.version,
234-
httpClient
231+
PluginManager.pluginInfo.version
235232
)
236233
client.authenticate()
237234
return client

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

-3
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import kotlinx.coroutines.Job
1414
import kotlinx.coroutines.flow.MutableStateFlow
1515
import kotlinx.coroutines.flow.StateFlow
1616
import kotlinx.coroutines.launch
17-
import okhttp3.OkHttpClient
1817
import java.net.URL
1918

2019
/**
@@ -24,7 +23,6 @@ class ConnectPage(
2423
private val context: CoderToolboxContext,
2524
private val url: URL,
2625
private val token: String?,
27-
private val httpClient: OkHttpClient,
2826
private val onCancel: () -> Unit,
2927
private val onConnect: (
3028
client: CoderRestClient,
@@ -95,7 +93,6 @@ class ConnectPage(
9593
token,
9694
proxyValues = null,
9795
PluginManager.pluginInfo.version,
98-
httpClient
9996
)
10097
client.authenticate()
10198
updateStatus(context.i18n.ptrl("Checking Coder binary..."), error = null)

0 commit comments

Comments
 (0)