Skip to content

Commit c26a281

Browse files
authored
fix: stop workspace if server process was stopped (#23579) (#203)
* fix: stop workspace if server process was stopped (#23579) * added tests for RemoteIDEServer Signed-off-by: Andre Dietisheim <[email protected]> Assisted-by: gemini-cli --------- Signed-off-by: Andre Dietisheim <[email protected]>
1 parent 898c419 commit c26a281

File tree

5 files changed

+233
-112
lines changed

5 files changed

+233
-112
lines changed

src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt

Lines changed: 32 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,17 @@
1111
*/
1212
package com.redhat.devtools.gateway
1313

14-
import com.intellij.openapi.diagnostic.thisLogger
1514
import com.jetbrains.gateway.thinClientLink.LinkedClientManager
1615
import com.jetbrains.gateway.thinClientLink.ThinClientHandle
1716
import com.jetbrains.rd.util.lifetime.Lifetime
1817
import com.redhat.devtools.gateway.openshift.DevWorkspaces
1918
import com.redhat.devtools.gateway.openshift.Pods
2019
import com.redhat.devtools.gateway.server.RemoteIDEServer
2120
import io.kubernetes.client.openapi.ApiException
21+
import kotlinx.coroutines.CoroutineScope
22+
import kotlinx.coroutines.Dispatchers
23+
import kotlinx.coroutines.launch
24+
import java.io.Closeable
2225
import java.io.IOException
2326
import java.net.ServerSocket
2427
import java.net.URI
@@ -46,21 +49,14 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) {
4649
onDisconnected: () -> Unit
4750
): ThinClientHandle {
4851
val workspace = devSpacesContext.devWorkspace
52+
devSpacesContext.addWorkspace(workspace)
4953

50-
synchronized(devSpacesContext.activeWorkspaces) {
51-
if (devSpacesContext.activeWorkspaces.contains(workspace)) {
52-
throw IllegalStateException("Workspace '${workspace.name}' is already connected.")
53-
}
54-
devSpacesContext.activeWorkspaces.add(workspace)
55-
}
56-
57-
var client: ThinClientHandle? = null
58-
var forwarder: AutoCloseable? = null
54+
var remoteIdeServer: RemoteIDEServer? = null
55+
var forwarder: Closeable? = null
5956

60-
try {
57+
return try {
6158
startAndWaitDevWorkspace()
62-
63-
val remoteIdeServer = RemoteIDEServer(devSpacesContext)
59+
remoteIdeServer = RemoteIDEServer(devSpacesContext)
6460
val remoteIdeServerStatus = remoteIdeServer.getStatus()
6561
val joinLink = remoteIdeServerStatus.joinLink
6662
?: throw IOException("Could not connect, remote IDE is not ready. No join link present.")
@@ -72,7 +68,7 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) {
7268

7369
val effectiveJoinLink = joinLink.replace(":5990", ":$localPort")
7470

75-
client = LinkedClientManager
71+
val client = LinkedClientManager
7672
.getInstance()
7773
.startNewClient(
7874
Lifetime.Eternal,
@@ -82,101 +78,40 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) {
8278
false
8379
)
8480

85-
client.run {
86-
lifetime.onTermination {
87-
try {
88-
forwarder.close()
89-
} catch (_: Exception) {
90-
// ignore cleanup errors
91-
}
92-
}
93-
94-
lifetime.onTermination {
95-
try {
96-
if (remoteIdeServer.waitServerTerminated()) {
97-
DevWorkspaces(devSpacesContext.client)
98-
.stop(
99-
devSpacesContext.devWorkspace.namespace,
100-
devSpacesContext.devWorkspace.name
101-
)
102-
onDevWorkspaceStopped() // UI refresh through callback
103-
}
104-
} finally {
105-
synchronized(devSpacesContext.activeWorkspaces) {
106-
devSpacesContext.activeWorkspaces.remove(workspace)
107-
}
108-
onDisconnected()
109-
}
110-
}
111-
112-
lifetime.onTermination {
113-
onDisconnected() // UI refresh through callback
114-
}
81+
client.clientClosed.advise(client.lifetime) {
82+
onClientClosed(onDisconnected , onDevWorkspaceStopped, remoteIdeServer, forwarder)
11583
}
11684

117-
return client
85+
client
11886
} catch (e: Exception) {
119-
try {
120-
disconnectAndCleanup(client, forwarder, onDevWorkspaceStopped, onDisconnected) // Cancel if started
121-
} catch (_: Exception) {}
122-
123-
try {
124-
forwarder?.close()
125-
} catch (_: Exception) {}
126-
127-
synchronized(devSpacesContext.activeWorkspaces) {
128-
devSpacesContext.activeWorkspaces.remove(workspace)
129-
}
130-
131-
// Make sure UI refresh still happens on failure
132-
onDisconnected()
133-
87+
onClientClosed(onDisconnected, onDevWorkspaceStopped, remoteIdeServer, forwarder)
13488
throw e
13589
}
13690
}
13791

138-
private fun disconnectAndCleanup(
139-
client: ThinClientHandle?,
140-
forwarder: AutoCloseable?,
92+
private fun onClientClosed(
93+
onDisconnected: () -> Unit,
14194
onDevWorkspaceStopped: () -> Unit,
142-
onDisconnected: () -> Unit
95+
remoteIdeServer: RemoteIDEServer?,
96+
forwarder: Closeable?
14397
) {
144-
if (client == null) {
145-
onDisconnected()
146-
return
147-
}
148-
149-
try {
150-
// Close the port forwarder first
98+
CoroutineScope(Dispatchers.IO).launch {
99+
val currentWorkspace = devSpacesContext.devWorkspace
151100
try {
101+
onDisconnected.invoke()
102+
if (true == remoteIdeServer?.waitServerTerminated()) {
103+
DevWorkspaces(devSpacesContext.client)
104+
.stop(
105+
devSpacesContext.devWorkspace.namespace,
106+
devSpacesContext.devWorkspace.name
107+
)
108+
.also { onDevWorkspaceStopped() }
109+
}
152110
forwarder?.close()
153-
} catch (e: Exception) {
154-
thisLogger().debug("Failed to close port forwarder: ${e.message}")
155-
}
156-
157-
// Stop workspace cleanly
158-
val devWorkspaces = DevWorkspaces(devSpacesContext.client)
159-
val workspace = devSpacesContext.devWorkspace
160-
161-
try {
162-
devWorkspaces.stop(workspace.namespace, workspace.name)
163-
onDevWorkspaceStopped()
164-
} catch (e: Exception) {
165-
thisLogger().debug("Workspace stop failed: ${e.message}")
166-
}
167-
168-
// Remove from active list and update state
169-
synchronized(devSpacesContext.activeWorkspaces) {
170-
devSpacesContext.activeWorkspaces.remove(workspace)
171-
}
172-
onDisconnected()
173-
174-
} catch (e: Exception) {
175-
thisLogger().debug("Error while terminating client: ${e.message}")
176-
synchronized(devSpacesContext.activeWorkspaces) {
177-
devSpacesContext.activeWorkspaces.remove(devSpacesContext.devWorkspace)
111+
} finally {
112+
devSpacesContext.removeWorkspace(currentWorkspace)
113+
onDisconnected()
178114
}
179-
onDisconnected()
180115
}
181116
}
182117

src/main/kotlin/com/redhat/devtools/gateway/DevSpacesContext.kt

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,21 @@ import io.kubernetes.client.openapi.ApiClient
1717
class DevSpacesContext {
1818
lateinit var client: ApiClient
1919
lateinit var devWorkspace: DevWorkspace
20-
var activeWorkspaces = mutableSetOf<DevWorkspace>() // Global or companion-level variable
20+
var activeWorkspaces = mutableSetOf<DevWorkspace>()
21+
22+
fun addWorkspace(workspace: DevWorkspace) {
23+
synchronized(activeWorkspaces) {
24+
if (activeWorkspaces.contains(workspace)) {
25+
return
26+
}
27+
activeWorkspaces.add(workspace)
28+
}
29+
}
30+
31+
fun removeWorkspace(currentWorkspace: DevWorkspace) {
32+
synchronized(activeWorkspaces) {
33+
activeWorkspaces.remove(currentWorkspace)
34+
}
35+
}
36+
2137
}

src/main/kotlin/com/redhat/devtools/gateway/openshift/Pods.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ class Pods(private val client: ApiClient) {
170170
logger.info("Attempt #${attempt + 1}: Connecting $localPort -> $remotePort...")
171171
val portForward = PortForward(client)
172172
forwardResult = portForward.forward(pod, listOf(remotePort))
173+
logger.info("forward successful: $localPort -> $remotePort...")
173174
copyStreams(clientSocket, forwardResult, remotePort)
174175
return
175176
} catch (e: Exception) {

src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ import com.redhat.devtools.gateway.DevSpacesContext
1919
import com.redhat.devtools.gateway.openshift.Pods
2020
import io.kubernetes.client.openapi.models.V1Container
2121
import io.kubernetes.client.openapi.models.V1Pod
22-
import org.bouncycastle.util.Arrays
22+
import kotlinx.coroutines.delay
23+
import kotlinx.coroutines.withTimeoutOrNull
2324
import java.io.IOException
25+
import kotlin.time.Duration.Companion.seconds
2426

2527
/**
2628
* Represent an IDE server running in a CDE.
@@ -68,8 +70,8 @@ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) {
6870
}
6971

7072
@Throws(IOException::class)
71-
fun waitServerReady() {
72-
doWaitServerState(true)
73+
suspend fun waitServerReady() {
74+
doWaitServerProjectExists(true)
7375
.also {
7476
if (!it) throw IOException(
7577
"Remote IDE server is not ready after $readyTimeout seconds.",
@@ -78,19 +80,39 @@ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) {
7880
}
7981

8082
@Throws(IOException::class)
81-
fun waitServerTerminated(): Boolean {
82-
return doWaitServerState(false)
83+
suspend fun waitServerTerminated(): Boolean {
84+
return doWaitServerProjectExists(false)
8385
}
8486

85-
@Throws(IOException::class)
86-
fun doWaitServerState(isReadyState: Boolean): Boolean {
87-
return try {
88-
val status = getStatus()
89-
isReadyState == !Arrays.isNullOrEmpty(status.projects)
90-
} catch (e: Exception) {
91-
thisLogger().debug("Failed to check remote IDE server state.", e)
92-
false
93-
}
87+
/**
88+
* Waits for the server to have or not have projects according to the given parameter.
89+
* Times out the wait if the expected state is not reached within 10 seconds.
90+
*
91+
* @param expected True if projects are expected, False otherwise,
92+
* @return True if the expected state is achieved within the timeout, False otherwise.
93+
*/
94+
private suspend fun doWaitServerProjectExists(expected: Boolean): Boolean {
95+
val timeout = 10.seconds
96+
97+
return withTimeoutOrNull(timeout) {
98+
while (true) {
99+
val hasProjects = try {
100+
val status = getStatus()
101+
status.projects.isNotEmpty()
102+
} catch (e: Exception) {
103+
thisLogger().debug("Failed to check remote IDE server state.", e)
104+
null
105+
}
106+
107+
if (expected == hasProjects) {
108+
return@withTimeoutOrNull true
109+
}
110+
111+
delay(500L)
112+
}
113+
@Suppress("UNREACHABLE_CODE")
114+
null
115+
} ?: false
94116
}
95117

96118
@Throws(IOException::class)

0 commit comments

Comments
 (0)