-
Notifications
You must be signed in to change notification settings - Fork 5
Jetbrains Gateway cannot connect after 3.5h IDLE period #148
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,5 @@ | ||
/* | ||
* Copyright (c) 2024 Red Hat, Inc. | ||
* Copyright (c) 2024-2025 Red Hat, Inc. | ||
* This program and the accompanying materials are made | ||
* available under the terms of the Eclipse Public License 2.0 | ||
* which is available at https://www.eclipse.org/legal/epl-2.0/ | ||
|
@@ -11,81 +11,178 @@ | |
*/ | ||
package com.redhat.devtools.gateway | ||
|
||
import com.redhat.devtools.gateway.openshift.DevWorkspaces | ||
import com.redhat.devtools.gateway.openshift.Pods | ||
import com.redhat.devtools.gateway.server.RemoteIDEServer | ||
import com.intellij.openapi.application.ApplicationManager | ||
import com.intellij.openapi.diagnostic.thisLogger | ||
import com.intellij.openapi.ui.Messages | ||
import com.jetbrains.gateway.thinClientLink.LinkedClientManager | ||
import com.jetbrains.gateway.thinClientLink.ThinClientHandle | ||
import com.jetbrains.rd.util.lifetime.Lifetime | ||
import com.redhat.devtools.gateway.openshift.DevWorkspaces | ||
import com.redhat.devtools.gateway.openshift.Pods | ||
import com.redhat.devtools.gateway.server.RemoteIDEServer | ||
import com.redhat.devtools.gateway.server.RemoteIDEServerStatus | ||
import io.kubernetes.client.openapi.ApiException | ||
import okio.Closeable | ||
import java.io.IOException | ||
import java.net.URI | ||
import java.util.concurrent.CancellationException | ||
import java.util.concurrent.atomic.AtomicInteger | ||
|
||
class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) { | ||
@Throws(Exception::class) | ||
@Throws(Exception::class, CancellationException::class) | ||
@Suppress("UnstableApiUsage") | ||
fun connect( | ||
onConnected: () -> Unit, | ||
onDisconnected: () -> Unit, | ||
onDevWorkspaceStopped: () -> Unit, | ||
onProgress: ((message: String) -> Unit)? = null, | ||
isCancelled: (() -> Boolean)? = null | ||
): ThinClientHandle { | ||
if (devSpacesContext.isConnected) | ||
throw IOException(String.format("Already connected to %s", devSpacesContext.devWorkspace.metadata.name)) | ||
|
||
devSpacesContext.isConnected = true | ||
try { | ||
return doConnection(onConnected, onDevWorkspaceStopped, onDisconnected) | ||
return doConnection(onConnected, onDevWorkspaceStopped, onDisconnected, onProgress, isCancelled) | ||
} catch (e: Exception) { | ||
devSpacesContext.isConnected = false | ||
throw e | ||
} | ||
} | ||
|
||
@Throws(Exception::class) | ||
@Throws(Exception::class, CancellationException::class) | ||
@Suppress("UnstableApiUsage") | ||
private fun doConnection( | ||
onConnected: () -> Unit, | ||
onDevWorkspaceStopped: () -> Unit, | ||
onDisconnected: () -> Unit | ||
onDisconnected: () -> Unit, | ||
onProgress: ((message: String) -> Unit)? = null, | ||
isCancelled: (() -> Boolean)? = null | ||
): ThinClientHandle { | ||
startAndWaitDevWorkspace() | ||
startAndWaitDevWorkspace(onProgress) | ||
if (isCancelled?.invoke() == true) { | ||
throw CancellationException("User cancelled the operation") | ||
} | ||
|
||
onProgress?.invoke("Waiting for the Remote IDE server to get ready...") | ||
val (remoteIdeServer, remoteIdeServerStatus) = | ||
try { | ||
val remoteIdeServer = RemoteIDEServer(devSpacesContext).apply { | ||
waitRemoteIDEServerReady() | ||
} | ||
remoteIdeServer to remoteIdeServer.getStatus() | ||
} catch (_: IOException) { | ||
null to RemoteIDEServerStatus.empty() | ||
} | ||
|
||
if (isCancelled?.invoke() == true) { | ||
throw CancellationException("User cancelled the operation") | ||
} | ||
|
||
if (remoteIdeServer == null || !remoteIdeServerStatus.isReady) { | ||
thisLogger().debug("Remote IDE server is in an invalid state. Please restart the pod and try again. ") | ||
val result = AtomicInteger(-1) | ||
ApplicationManager.getApplication().invokeAndWait { | ||
result.set( | ||
Messages.showDialog( | ||
"The Remote IDE Server is not responding properly.\n" + | ||
"Would you like to try restarting the Pod or cancel the connection?", | ||
"Remote IDE Server Issue", | ||
arrayOf("Cancel Connection", "Restart Pod and try again"), | ||
0, // default selected index | ||
Messages.getWarningIcon() | ||
) | ||
) | ||
} | ||
|
||
when (result.get()) { | ||
1 -> { | ||
// User chose "Restart Pod" | ||
thisLogger().info("User chose to restart the pod.") | ||
stopAndWaitDevWorkspace(onProgress) | ||
if (isCancelled?.invoke() == true) { | ||
throw CancellationException("User cancelled the operation") | ||
} | ||
return doConnection(onConnected, onDevWorkspaceStopped, onDisconnected, onProgress, isCancelled) | ||
} | ||
} | ||
|
||
val remoteIdeServer = RemoteIDEServer(devSpacesContext) | ||
val remoteIdeServerStatus = remoteIdeServer.getStatus() | ||
// User chose "Cancel Connection" | ||
thisLogger().info("User cancelled the remote IDE connection.") | ||
throw IllegalStateException("Remote IDE server is not responding properly. Try restarting the pod and reconnecting.") | ||
} | ||
|
||
val client = LinkedClientManager | ||
.getInstance() | ||
.startNewClient( | ||
Lifetime.Eternal, | ||
URI(remoteIdeServerStatus.joinLink), | ||
URI(remoteIdeServerStatus.joinLink!!), | ||
"", | ||
onConnected, | ||
false | ||
) | ||
|
||
val forwarder = Pods(devSpacesContext.client).forward(remoteIdeServer.pod, 5990, 5990) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. #forward can fail, too ex. if local port is already bound. |
||
|
||
client.run { | ||
lifetime.onTermination { forwarder.close() } | ||
lifetime.onTermination { | ||
if (remoteIdeServer.waitServerTerminated()) | ||
DevWorkspaces(devSpacesContext.client) | ||
.stop( | ||
devSpacesContext.devWorkspace.metadata.namespace, | ||
devSpacesContext.devWorkspace.metadata.name | ||
) | ||
.also { onDevWorkspaceStopped() } | ||
try { | ||
client.run { | ||
lifetime.onTermination { | ||
cleanup(forwarder, remoteIdeServer, devSpacesContext, onDevWorkspaceStopped, onDisconnected) | ||
} | ||
} | ||
lifetime.onTermination { devSpacesContext.isConnected = false } | ||
lifetime.onTermination(onDisconnected) | ||
} catch (e: Exception) { | ||
cleanup(forwarder, remoteIdeServer, devSpacesContext, onDevWorkspaceStopped, onDisconnected) | ||
throw e // rethrow so caller can handle the original problem | ||
} | ||
|
||
return client | ||
} | ||
|
||
@Throws(IOException::class, ApiException::class) | ||
private fun startAndWaitDevWorkspace() { | ||
if (!devSpacesContext.devWorkspace.spec.started) { | ||
private fun cleanup( | ||
forwarder: Closeable?, | ||
remoteIdeServer: RemoteIDEServer?, | ||
devSpacesContext: DevSpacesContext, | ||
onDevWorkspaceStopped: () -> Unit, | ||
onDisconnected: () -> Unit | ||
) { | ||
try { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpicking: I'd create separate methods for all 3 closing operations ( |
||
forwarder?.close() | ||
thisLogger().info("Closed port forwarder") | ||
} catch (e: Exception) { | ||
thisLogger().debug("Failed to close port forwarder", e) | ||
} | ||
|
||
try { | ||
if (remoteIdeServer?.isRemoteIdeServerState(false) == true) { | ||
DevWorkspaces(devSpacesContext.client) | ||
.stop( | ||
devSpacesContext.devWorkspace.metadata.namespace, | ||
devSpacesContext.devWorkspace.metadata.name | ||
) | ||
.also { onDevWorkspaceStopped() } | ||
} | ||
} catch (e: Exception) { | ||
thisLogger().debug("Failed to stop DevWorkspace", e) | ||
} | ||
|
||
devSpacesContext.isConnected = false | ||
|
||
try { | ||
onDisconnected() | ||
} catch (e: Exception) { | ||
thisLogger().debug("onDisconnected handler failed", e) | ||
} | ||
} | ||
|
||
|
||
@Throws(IOException::class, ApiException::class, CancellationException::class) | ||
private fun startAndWaitDevWorkspace(onProgress: ((message: String) -> Unit)? = null, | ||
isCancelled: (() -> Boolean)? = null) { | ||
// We really need a refreshed DevWorkspace here | ||
val devWorkspace = DevWorkspaces(devSpacesContext.client).get( | ||
devSpacesContext.devWorkspace.metadata.namespace, | ||
devSpacesContext.devWorkspace.metadata.name) | ||
|
||
if (!devWorkspace.spec.started) { | ||
DevWorkspaces(devSpacesContext.client) | ||
.start( | ||
devSpacesContext.devWorkspace.metadata.namespace, | ||
|
@@ -94,11 +191,17 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) { | |
} | ||
|
||
if (!DevWorkspaces(devSpacesContext.client) | ||
.waitPhase( | ||
.waitForPhase( | ||
devSpacesContext.devWorkspace.metadata.namespace, | ||
devSpacesContext.devWorkspace.metadata.name, | ||
DevWorkspaces.RUNNING, | ||
DevWorkspaces.RUNNING_TIMEOUT | ||
onProgress = { phase, message -> | ||
onProgress?.invoke(buildString { | ||
append("Phase: $phase") | ||
if (message.isNotBlank()) append(" – $message") | ||
}) | ||
}, | ||
isCancelled = { isCancelled?.invoke() ?: false } | ||
) | ||
) throw IOException( | ||
String.format( | ||
|
@@ -108,4 +211,42 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) { | |
) | ||
) | ||
} | ||
|
||
@Throws(IOException::class, ApiException::class, CancellationException::class) | ||
private fun stopAndWaitDevWorkspace(onProgress: ((message: String) -> Unit)? = null, | ||
isCancelled: (() -> Boolean)? = null) { | ||
// We really need a refreshed DevWorkspace here | ||
val devWorkspace = DevWorkspaces(devSpacesContext.client).get( | ||
devSpacesContext.devWorkspace.metadata.namespace, | ||
devSpacesContext.devWorkspace.metadata.name) | ||
|
||
if (devWorkspace.spec.started) { | ||
DevWorkspaces(devSpacesContext.client) | ||
.stop( | ||
devSpacesContext.devWorkspace.metadata.namespace, | ||
devSpacesContext.devWorkspace.metadata.name | ||
) | ||
} | ||
|
||
if (!DevWorkspaces(devSpacesContext.client) | ||
.waitForPhase( | ||
devSpacesContext.devWorkspace.metadata.namespace, | ||
devSpacesContext.devWorkspace.metadata.name, | ||
DevWorkspaces.STOPPED, | ||
onProgress = { phase, message -> | ||
onProgress?.invoke(buildString { | ||
append("Phase: $phase") | ||
if (message.isNotBlank()) append(" – $message") | ||
}) | ||
}, | ||
isCancelled = { isCancelled?.invoke() ?: false } | ||
) | ||
) throw IOException( | ||
String.format( | ||
"DevWorkspace '%s' is not stopped after %d seconds", | ||
devSpacesContext.devWorkspace.metadata.name, | ||
DevWorkspaces.RUNNING_TIMEOUT | ||
) | ||
) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I saw situations where the joinLink was
null
. I believe that it would be better to check thejoinLink
for being non-empty and throw instead of asserting it to be non-null (!!
)