diff --git a/.changeset/jetbrains-orphan-cli-shutdown.md b/.changeset/jetbrains-orphan-cli-shutdown.md new file mode 100644 index 0000000000..2d9ba8da78 --- /dev/null +++ b/.changeset/jetbrains-orphan-cli-shutdown.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Stop orphaned Kilo CLI processes when JetBrains IDEs close, including binaries that ignore graceful shutdown. diff --git a/.changeset/serve-parent-watchdog.md b/.changeset/serve-parent-watchdog.md new file mode 100644 index 0000000000..2b2aaec1e0 --- /dev/null +++ b/.changeset/serve-parent-watchdog.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Shut down the headless `kilo serve` process automatically when the editor client that launched it exits without a clean signal, preventing orphaned CLI processes. diff --git a/bun.lock b/bun.lock index 743e53e2a1..91b50523c9 100644 --- a/bun.lock +++ b/bun.lock @@ -250,7 +250,7 @@ }, "packages/kilo-jetbrains": { "name": "@kilocode/kilo-jetbrains", - "version": "7.4.4", + "version": "7.4.5", }, "packages/kilo-memory": { "name": "@kilocode/kilo-memory", diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 77aee949b6..62dddd38e0 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -188,11 +188,14 @@ class KiloBackendAppService private constructor( } } - suspend fun shutdownForUnload() { - mutex.withLock { - shutdown() - } - } + /** + * Synchronous CLI teardown for plugin unload. Confirms process exit but does not wait on the + * lifecycle mutex, so an in-flight download/spawn cannot delay unload. Safe to call repeatedly. + */ + fun shutdownForUnload() = shutdown(fast = false) + + /** Best-effort CLI teardown for IDE app close. Non-blocking; safe to call repeatedly. */ + fun shutdownForAppClose() = shutdown(fast = true) suspend fun retry() { mutex.withLock { @@ -926,17 +929,17 @@ class KiloBackendAppService private constructor( } override fun dispose() { - shutdown() + shutdown(fast = false) } - private fun shutdown() { + private fun shutdown(fast: Boolean) { if (closed) return closed = true watcher?.cancel() watcher = null clearNow() connection.dispose() - server.dispose() + if (fast) server.closeForShutdown() else server.dispose() } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/CliServer.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/CliServer.kt index f0418d926a..c99f8d8abe 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/CliServer.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/CliServer.kt @@ -17,6 +17,12 @@ interface CliServer { fun exited(proc: Process) fun stop() fun dispose() + + /** + * Fast teardown for IDE app close. Implementations must not block the caller — it is often the + * EDT during the IDE shutdown sequence. Defaults to [dispose] for test doubles. + */ + fun closeForShutdown() = dispose() } data class CliDownload( diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt index b71de7c3ad..45616e23b1 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt @@ -3,6 +3,7 @@ package ai.kilocode.backend.cli import ai.kilocode.KiloPlugin import ai.kilocode.backend.dev.KiloDevMode import ai.kilocode.log.KiloLog +import com.intellij.execution.process.OSProcessUtil import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.application.PathManager import com.intellij.openapi.util.SystemInfo @@ -52,6 +53,8 @@ class KiloBackendCliManager( private var process: Process? = null @Volatile private var closing: Process? = null + private val lock = Any() + private var closed = false private var hook: Thread? = null private var stderr: Thread? = null private var stdout: Thread? = null @@ -62,6 +65,7 @@ class KiloBackendCliManager( override fun process(): Process? = process override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit): CliServer.State { + if (closed) return CliServer.State.Error("CLI manager is disposed") return try { val start = System.nanoTime() withTimeout(timeoutMs + STARTUP_TIMEOUT_GRACE_MS) { @@ -73,9 +77,9 @@ class KiloBackendCliManager( } catch (e: TimeoutCancellationException) { val msg = "CLI startup timed out after ${timeoutMs}ms" log.warn(msg, e) - process?.let { proc -> + val proc = take() + if (proc != null) { log.info("Cleaning up orphaned CLI process (pid=${proc.pid()})") - process = null cleanup(proc, "startup timeout cleanup") } CliServer.State.Error( @@ -86,9 +90,9 @@ class KiloBackendCliManager( throw e } catch (e: Exception) { log.warn("CLI startup failed", e) - process?.let { proc -> + val proc = take() + if (proc != null) { log.info("Cleaning up orphaned CLI process (pid=${proc.pid()})") - process = null cleanup(proc, "startup failure cleanup") } CliServer.State.Error( @@ -99,15 +103,19 @@ class KiloBackendCliManager( } override fun exited(proc: Process) { - if (process != proc) return - process = null - uninstall() - stderr = null + val ok = synchronized(lock) { + if (process != proc) return@synchronized false + process = null + uninstall() + stderr = null + true + } + if (!ok) return + log.info("CLI process exited (pid=${proc.pid()}, exitCode=${runCatching { proc.exitValue() }.getOrNull()})") } override fun stop() { - val proc = process ?: return - process = null + val proc = take() ?: return cleanup(proc, "stop()") } @@ -150,8 +158,17 @@ class KiloBackendCliManager( throw e } log.info("CLI process started (pid=${proc.pid()})") - process = proc - install(proc) + val reject = synchronized(lock) { + if (closed) return@synchronized true + process = proc + install(proc) + false + } + if (reject) { + log.info("CLI process started after disposal; killing process tree (pid=${proc.pid()})") + cleanup(proc, "disposed startup cleanup") + return@withContext CliServer.State.Error("CLI startup cancelled because service is disposed") + } val stderr = StringBuilder() @@ -185,19 +202,50 @@ class KiloBackendCliManager( log = log, onThread = { stdout = it }, ) - if (state is CliServer.State.Error && process == proc) { + val current = synchronized(lock) { + if (state !is CliServer.State.Error || process != proc) return@synchronized null process = null + proc + } + if (current != null) { cleanup(proc, "startup error") } state } override fun dispose() { - val proc = process ?: return - process = null + val proc = synchronized(lock) { + closed = true + val current = process + process = null + current + } ?: return cleanup(proc, "Disposing") } + /** + * Fast teardown for IDE app close: send SIGTERM so the CLI can flush state, then return without + * waiting. The JVM shutdown hook stays installed and escalates to SIGKILL when the JVM exits, so + * we neither block the shutdown thread (often the EDT) nor risk orphaning the tree. + */ + override fun closeForShutdown() { + val proc = synchronized(lock) { + closed = true + process + } ?: return + closing = proc + close(proc) + descendants(proc).forEach { it.destroy() } + proc.destroy() + log.info("App close — SIGTERM sent to CLI tree (pid=${proc.pid()}); shutdown hook will confirm exit") + } + + private fun take(): Process? = synchronized(lock) { + val proc = process + process = null + proc + } + private fun cleanup(proc: Process, source: String) { closing = proc try { @@ -244,19 +292,9 @@ class KiloBackendCliManager( private fun kill(proc: Process, source: String, wait: Boolean = true) { log.info("$source — killing CLI process tree (pid ${proc.pid()})") - children(proc).forEach { it.destroy() } - proc.destroy() - if (!wait) return - if (!proc.waitFor(KILL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { - log.warn("CLI process did not exit after SIGTERM, sending SIGKILL") - children(proc).forEach { it.destroyForcibly() } - proc.destroyForcibly() - } + killCliProcessTree(proc, log, wait = wait, timeoutSeconds = KILL_TIMEOUT_SECONDS) } - private fun children(proc: Process): List = - proc.toHandle().descendants().toList().asReversed() - private fun close(proc: Process) { runCatching { proc.errorStream.close() }.onFailure { log.info("CLI stderr stream close skipped: ${it.message}") } runCatching { proc.inputStream.close() }.onFailure { log.info("CLI stdout stream close skipped: ${it.message}") } @@ -272,6 +310,91 @@ class KiloBackendCliManager( private fun elapsed(start: Long): Long = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) } +internal fun killCliProcessTree( + proc: Process, + log: KiloLog, + wait: Boolean = true, + timeoutSeconds: Long = 5L, + windows: Boolean = SystemInfo.isWindows, +) { + if (windows) { + val ok = runCatching { OSProcessUtil.killProcessTree(proc) } + .onFailure { log.warn("killProcessTree failed for pid ${proc.pid()}", it) } + .getOrDefault(false) + // killProcessTree returns after its recursive call but does not wait for or + // re-check the process, so a true result alone does not confirm exit. + if (!wait) { + if (!ok) { + descendants(proc).forEach { it.destroyForcibly() } + proc.destroyForcibly() + } + log.info("CLI process tree kill requested without wait (pid=${proc.pid()}, treeKill=$ok); exit not confirmed") + return + } + if (ok && proc.waitFor(timeoutSeconds, TimeUnit.SECONDS)) { + log.info("CLI process tree exited after kill (pid=${proc.pid()}, exitCode=${runCatching { proc.exitValue() }.getOrNull()})") + return + } + log.info("CLI process tree kill fallback sending SIGKILL (pid=${proc.pid()})") + descendants(proc).forEach { it.destroyForcibly() } + proc.destroyForcibly() + if (proc.waitFor(timeoutSeconds, TimeUnit.SECONDS)) { + log.info("CLI process tree exited after SIGKILL fallback (pid=${proc.pid()})") + } else { + log.warn("CLI process still alive after SIGKILL fallback (pid=${proc.pid()})") + } + return + } + val original = descendants(proc) + original.forEach { it.destroy() } + proc.destroy() + if (!wait) { + // Shutdown-hook backstop: the graceful cleanup path uninstalls this hook before killing, + // so if the hook still fires the CLI was never stopped cleanly. Escalate to SIGKILL right + // away rather than risk orphaning a SIGTERM-ignoring tree on JVM exit; we cannot block here. + original.forEach { it.destroyForcibly() } + proc.destroyForcibly() + log.info("CLI process tree SIGTERM+SIGKILL sent without wait (pid=${proc.pid()}); exit not confirmed") + return + } + val parentExited = proc.waitFor(timeoutSeconds, TimeUnit.SECONDS) + // Re-enumerate before SIGKILL: a tool/shell can fork new descendants during the grace + // period, and killing the known processes can reparent them. Union the fresh scan with + // the original handles so late children are escalated too. + val kids = (original + descendants(proc)).distinctBy { it.pid() } + if (parentExited && kids.none { it.isAlive }) { + log.info("CLI process tree exited after SIGTERM (pid=${proc.pid()}, children=${kids.size})") + return + } + log.warn( + if (parentExited) "CLI child processes did not exit after SIGTERM, sending SIGKILL" + else "CLI process did not exit after SIGTERM, sending SIGKILL" + ) + kids.forEach { it.destroyForcibly() } + proc.destroyForcibly() + confirmKilled(proc, kids, log, timeoutSeconds) +} + +/** + * Confirm the tracked parent has exited after SIGKILL so callers observe a terminal state. The + * parent is our direct child, so [Process.waitFor] reaps it deterministically. Descendants are + * non-child handles: SIGKILL has been delivered, but an orphaned child reparents to init and can + * briefly linger as an unreaped zombie that still reports alive, so we report them best-effort + * rather than block on an exit we cannot observe from here. + */ +private fun confirmKilled(proc: Process, kids: List, log: KiloLog, timeoutSeconds: Long) { + val parentExited = proc.waitFor(timeoutSeconds, TimeUnit.SECONDS) + val alive = kids.count { it.isAlive } + if (parentExited && alive == 0) { + log.info("CLI process tree exited after SIGKILL (pid=${proc.pid()}, children=${kids.size})") + return + } + log.warn("CLI process tree escalated to SIGKILL (pid=${proc.pid()}, parentAlive=${!parentExited}, childrenReportedAlive=$alive)") +} + +private fun descendants(proc: Process): List = + proc.toHandle().descendants().toList().asReversed() + internal fun startupDiagnostics(cli: File, env: Map, log: KiloLog): String { val home = System.getProperty("user.home").orEmpty() val profile = EnvironmentUtil.getValue("USERPROFILE").orEmpty() @@ -414,6 +537,9 @@ internal fun buildKiloCliEnv( ): Map = buildMap { putAll(base) put("KILO_SERVER_PASSWORD", pwd) + // The CLI watches this PID and exits if the IDE process is hard-killed without a chance + // to signal or run the JVM shutdown hook, so it is never orphaned. See parent-watchdog.ts. + put("KILO_PARENT_PID", ProcessHandle.current().pid().toString()) put("KILO_CLIENT", "jetbrains") put("KILO_ENABLE_QUESTION_TOOL", "true") put("KILO_PLATFORM", "jetbrains") diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/plugin/KiloBackendAppLifecycleListener.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/plugin/KiloBackendAppLifecycleListener.kt new file mode 100644 index 0000000000..3100a8a7a7 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/plugin/KiloBackendAppLifecycleListener.kt @@ -0,0 +1,17 @@ +package ai.kilocode.backend.plugin + +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.log.KiloLog +import com.intellij.ide.AppLifecycleListener +import com.intellij.openapi.components.serviceIfCreated + +class KiloBackendAppLifecycleListener : AppLifecycleListener { + private val log = KiloLog.create(KiloBackendAppLifecycleListener::class.java) + + override fun appWillBeClosed(isRestart: Boolean) { + log.info("appWillBeClosed(isRestart=$isRestart) — stopping Kilo CLI") + runCatching { + serviceIfCreated()?.shutdownForAppClose() + }.onFailure { log.warn("Failed to stop CLI on app close", it) } + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/plugin/KiloBackendDynamicPluginListener.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/plugin/KiloBackendDynamicPluginListener.kt index ffcf17f387..fc6a32fd61 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/plugin/KiloBackendDynamicPluginListener.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/plugin/KiloBackendDynamicPluginListener.kt @@ -6,7 +6,6 @@ import ai.kilocode.log.KiloLog import com.intellij.ide.plugins.DynamicPluginListener import com.intellij.ide.plugins.IdeaPluginDescriptor import com.intellij.openapi.components.service -import kotlinx.coroutines.runBlocking class KiloBackendDynamicPluginListener : DynamicPluginListener { private val log = KiloLog.create(KiloBackendDynamicPluginListener::class.java) @@ -14,8 +13,6 @@ class KiloBackendDynamicPluginListener : DynamicPluginListener { override fun beforePluginUnload(pluginDescriptor: IdeaPluginDescriptor, isUpdate: Boolean) { if (pluginDescriptor.pluginId != KiloPlugin.id) return log.info("Shutting down Kilo backend for plugin unload (isUpdate=$isUpdate)") - runBlocking { - service().shutdownForUnload() - } + service().shutdownForUnload() } } diff --git a/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml b/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml index a5cfa7b7e6..99227797f6 100644 --- a/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml +++ b/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml @@ -16,6 +16,8 @@ + diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt index ae9eadc862..f5d613cecb 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt @@ -166,6 +166,21 @@ class KiloBackendAppServiceTest { assertEquals(1, server.disposeCount) } + @Test + fun `shutdown for app close fast-closes server once without blocking dispose`() { + val server = FakeCliServer(mock) + val svc = KiloBackendAppService.create(scope, server, log) + + svc.shutdownForAppClose() + svc.shutdownForAppClose() + svc.dispose() + + assertEquals(KiloAppState.Disconnected, svc.appState.value) + // App close uses the non-blocking fast path, not the confirming dispose path. + assertEquals(1, server.closeCount) + assertEquals(0, server.disposeCount) + } + @Test fun `config is loaded`() = runBlocking { mock.config = """{"model":"claude-4","username":"testuser"}""" diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt index 34cef9fecd..bd9e454a89 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt @@ -277,6 +277,35 @@ class KiloConnectionServiceTest { assertEquals(ConnectionState.Disconnected, svc.state.value) } + @Test + fun `dispose prevents reconnect on late SSE callbacks`() = runBlocking { + val reconnects = AtomicInteger(0) + val svc = KiloConnectionService(scope, fake, { reconnects.incrementAndGet() }, log) + svc.connect() + mock.awaitSseConnection() + + withTimeout(5_000) { + svc.state.first { it is ConnectionState.Connected } + } + + svc.dispose() + + // Simulate a late SSE close/failure arriving after teardown. The stale source must be + // ignored so shutdown neither resurrects the connection nor schedules a reconnect. + val field = KiloConnectionService::class.java.getDeclaredField("listener") + field.isAccessible = true + val listener = field.get(svc) as EventSourceListener + val stale = object : EventSource { + override fun request(): Request = Request.Builder().url("http://127.0.0.1/global/event").build() + override fun cancel() {} + } + listener.onFailure(stale, RuntimeException("late failure"), null) + listener.onClosed(stale) + + assertEquals(ConnectionState.Disconnected, svc.state.value) + assertEquals(0, reconnects.get()) + } + // ------ Reconnect & health ------ @Test diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliKillTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliKillTest.kt new file mode 100644 index 0000000000..bee2384a6e --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliKillTest.kt @@ -0,0 +1,133 @@ +package ai.kilocode.backend.cli + +import ai.kilocode.backend.testing.TestLog +import java.io.File +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class KiloBackendCliKillTest { + + @Test + fun `kills a real process non-windows path`() { + val log = TestLog() + val proc = process("sleep", "30") + try { + killCliProcessTree(proc, log, windows = false) + assertTrue(proc.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS), "process did not exit") + assertFalse(proc.isAlive) + } finally { + cleanup(proc) + } + } + + @Test + fun `kills a real process tree`() { + val log = TestLog() + // The parent backgrounds two children and only `wait`s — it installs no TERM trap + // and does not forward signals. Destroying the parent alone would orphan the + // children, so passing assertions prove killCliProcessTree killed the whole tree. + val proc = process("sh", "-c", "sleep 30 & sleep 30 & wait") + // Wait for both children so the captured set matches what the kill enumerates; otherwise a + // late-forked sleep could be asserted on but never seen by killCliProcessTree. + val kids = descendants(proc, min = 2) + try { + assertTrue(kids.size >= 2, "process tree did not spawn both descendants (found ${kids.size})") + + killCliProcessTree(proc, log, windows = false) + + assertTrue(proc.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS), "parent process did not exit") + assertFalse(proc.isAlive) + kids.forEach { child -> assertTrue(exited(child), "child process ${child.pid()} is still alive") } + } finally { + kids.forEach { it.destroyForcibly() } + cleanup(proc) + } + } + + @Test + fun `no-wait path escalates to SIGKILL for a SIGTERM-ignoring process`() { + val log = TestLog() + // The parent ignores SIGTERM, so only SIGKILL can stop it. This is the shutdown-hook path + // (wait=false); it must still escalate rather than orphan a tree that survives SIGTERM. + val proc = process("sh", "-c", "trap '' TERM; sleep 30") + try { + killCliProcessTree(proc, log, wait = false, windows = false) + assertTrue(proc.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS), "process did not exit after SIGKILL") + assertFalse(proc.isAlive) + } finally { + cleanup(proc) + } + } + + @Test + fun `windows path fallback terminates process on this OS`() { + val log = TestLog() + val proc = process("sleep", "30") + try { + killCliProcessTree(proc, log, windows = true) + assertTrue(proc.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS), "process did not exit") + assertFalse(proc.isAlive) + } finally { + cleanup(proc) + } + } + + @Test + fun `double kill is a no-op`() { + val log = TestLog() + val proc = process("sleep", "30") + try { + killCliProcessTree(proc, log, windows = false) + assertTrue(proc.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS), "process did not exit") + killCliProcessTree(proc, log, windows = false) + assertFalse(proc.isAlive) + } finally { + cleanup(proc) + } + } + + private fun process(vararg cmd: String): Process = ProcessBuilder(*cmd).start() + + private fun descendants(proc: Process, min: Int = 1): List { + val end = System.nanoTime() + TimeUnit.SECONDS.toNanos(PROCESS_TIMEOUT_SECONDS) + while (System.nanoTime() < end) { + val kids = proc.toHandle().descendants().toList() + if (kids.size >= min) return kids + Thread.sleep(25) + } + return proc.toHandle().descendants().toList() + } + + private fun exited(child: ProcessHandle): Boolean { + val end = System.nanoTime() + TimeUnit.SECONDS.toNanos(PROCESS_TIMEOUT_SECONDS) + while (System.nanoTime() < end) { + if (dead(child)) return true + Thread.sleep(25) + } + return dead(child) + } + + // A SIGKILLed orphan reparents to init and can linger as an unreaped zombie: ProcessHandle still + // reports it alive and onExit() never fires for a non-child, though it is functionally dead. On + // Linux, read /proc so a zombie ('Z') or already-reaped (missing) process counts as exited; + // elsewhere fall back to the liveness flag (init reaps promptly on macOS). + private fun dead(child: ProcessHandle): Boolean { + if (!child.isAlive) return true + val stat = File("/proc/${child.pid()}/stat") + if (!stat.isFile) return false + val text = runCatching { stat.readText() }.getOrNull() ?: return true + return text.substringAfterLast(") ").firstOrNull() == 'Z' + } + + private fun cleanup(proc: Process) { + proc.toHandle().descendants().forEach { it.destroyForcibly() } + proc.destroyForcibly() + proc.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } + + companion object { + private const val PROCESS_TIMEOUT_SECONDS = 5L + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerReadyTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerReadyTest.kt index eebcc96eb4..bc2b75b656 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerReadyTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerReadyTest.kt @@ -1,9 +1,11 @@ package ai.kilocode.backend.cli +import ai.kilocode.backend.testing.TestLog import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertTrue import java.io.ByteArrayInputStream @@ -84,6 +86,17 @@ class KiloBackendCliManagerReadyTest { assertContains(err.details.orEmpty(), "diag line") } + @Test + fun `init after dispose is rejected without spawning`() = runBlocking { + val manager = KiloBackendCliManager(log = TestLog()) + manager.dispose() + var resolved = false + val state = manager.init(onProgress = {}, onResolved = { resolved = true }) + val err = assertIs(state) + assertEquals("CLI manager is disposed", err.message) + assertFalse(resolved) + } + @Test fun `ipv6 bind form remains a known non match`() = runBlocking { val calls = AtomicInteger(0) diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/FakeCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/FakeCliServer.kt index e6eeca253d..a639ae926f 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/FakeCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/FakeCliServer.kt @@ -18,6 +18,8 @@ class FakeCliServer(private val mock: MockCliServer) : CliServer { private set var disposeCount = 0 private set + var closeCount = 0 + private set override fun process(): Process? = null @@ -39,4 +41,10 @@ class FakeCliServer(private val mock: MockCliServer) : CliServer { disposeCount++ mock.close() } + + /** Fast app-close teardown — stops the socket but keeps the mock alive (no final dispose). */ + override fun closeForShutdown() { + closeCount++ + mock.shutdown() + } } diff --git a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts index 4280b7094d..9481897cc5 100644 --- a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts +++ b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts @@ -131,6 +131,9 @@ export class ServerManager { // See oven-sh/bun#18265 and Jarred's workaround note in #21560. MIMALLOC_PURGE_DELAY: "0", KILO_SERVER_PASSWORD: password, + // The CLI watches this PID and exits if the extension host is hard-killed without a + // chance to run dispose(), so it is never orphaned. See parent-watchdog.ts. + KILO_PARENT_PID: String(process.pid), KILO_CLIENT: "vscode", KILO_ENABLE_QUESTION_TOOL: "true", KILOCODE_FEATURE: "vscode-extension", diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 3149d31e80..bef75293c0 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -4,6 +4,7 @@ import { effectCmd } from "../effect-cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@opencode-ai/core/flag/flag" import { InstanceRuntime } from "../../project/instance-runtime" // kilocode_change +import { startParentWatchdog } from "../../kilocode/parent-watchdog" // kilocode_change export const ServeCommand = effectCmd({ command: "serve", @@ -32,7 +33,10 @@ export const ServeCommand = effectCmd({ yield* Effect.promise( () => new Promise((resolve) => { + // Exit if the editor client that spawned us is hard-killed (no signal reaches us). + const stopWatchdog = startParentWatchdog(() => process.kill(process.pid, "SIGTERM")) const shutdown = async () => { + stopWatchdog() try { await InstanceRuntime.disposeAllInstances() await server.stop(true) diff --git a/packages/opencode/src/kilocode/parent-watchdog.ts b/packages/opencode/src/kilocode/parent-watchdog.ts new file mode 100644 index 0000000000..c5ca672c88 --- /dev/null +++ b/packages/opencode/src/kilocode/parent-watchdog.ts @@ -0,0 +1,49 @@ +import * as Log from "@opencode-ai/core/util/log" + +const log = Log.create({ service: "parent-watchdog" }) + +/** + * Exit the server when the embedded client that spawned it dies. + * + * Editor clients (VS Code extension, JetBrains plugin) run `kilo serve` as a child + * process. A graceful client shutdown signals the server, but a hard kill (SIGKILL, + * crash, OOM) never gets the chance, orphaning the server. The client passes its own + * PID via `KILO_PARENT_PID`; we poll that PID and re-parenting so the server shuts + * itself down when the client is gone. + * + * No-op unless `KILO_PARENT_PID` is set to a valid PID, so a manually launched + * `kilo serve` (whose parent shell exiting may be intentional) is never affected. + * + * Returns a function that stops the watchdog. + */ +export function startParentWatchdog(onOrphan: () => void, intervalMs = 1000): () => void { + const configured = Number(process.env["KILO_PARENT_PID"]) + if (!Number.isInteger(configured) || configured <= 0) return () => {} + const initial = process.ppid + log.info("watching parent process", { parent: configured, ppid: initial, intervalMs }) + const timer = setInterval(() => { + if (!orphaned(configured, initial)) return + clearInterval(timer) + log.info("parent process gone — shutting down server", { parent: configured }) + onOrphan() + }, intervalMs) + timer.unref() + return () => clearInterval(timer) +} + +function orphaned(parent: number, initial: number): boolean { + // Re-parented away from the spawner (parent already exited on some platforms). + if (initial !== 1 && process.ppid !== initial) return true + if (parent === 1) return false + try { + // Signal 0 probes liveness without delivering a signal. + process.kill(parent, 0) + return false + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + if (code === "ESRCH") return true + // EPERM etc. means the process still exists; treat only "no such process" as dead. + log.debug("parent liveness check inconclusive", { parent, code }) + return false + } +} diff --git a/packages/opencode/test/kilocode/parent-watchdog.test.ts b/packages/opencode/test/kilocode/parent-watchdog.test.ts new file mode 100644 index 0000000000..79909cb561 --- /dev/null +++ b/packages/opencode/test/kilocode/parent-watchdog.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { startParentWatchdog } from "../../src/kilocode/parent-watchdog" + +describe("startParentWatchdog", () => { + afterEach(() => { + delete process.env["KILO_PARENT_PID"] + }) + + test("is a no-op when KILO_PARENT_PID is unset", () => { + delete process.env["KILO_PARENT_PID"] + let called = false + const stop = startParentWatchdog(() => { + called = true + }) + stop() + expect(called).toBe(false) + }) + + test("is a no-op for an invalid KILO_PARENT_PID", () => { + process.env["KILO_PARENT_PID"] = "0" + let called = false + const stop = startParentWatchdog(() => { + called = true + }) + stop() + expect(called).toBe(false) + }) + + test("fires onOrphan once the watched parent process is gone", async () => { + // Spawn a real process, kill it, and wait for it to be reaped so its PID is dead. + const child = Bun.spawn([process.execPath, "-e", "await Bun.sleep(30000)"], { stdout: "ignore", stderr: "ignore" }) + const pid = child.pid + child.kill("SIGKILL") + await child.exited + process.env["KILO_PARENT_PID"] = String(pid) + + let stop = () => {} + const orphaned = new Promise((resolve) => { + stop = startParentWatchdog(resolve, 10) + }) + try { + await Promise.race([ + orphaned, + new Promise((_, reject) => setTimeout(() => reject(new Error("watchdog did not fire")), 5000)), + ]) + } finally { + stop() + } + }) +})