Merge pull request #12105 from Kilo-Org/plan-address-issue-12048

fix(jetbrains): stop CLI on app close
This commit is contained in:
Kirill Kalishev
2026-07-10 14:31:10 -04:00
committed by GitHub
18 changed files with 505 additions and 39 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Stop orphaned Kilo CLI processes when JetBrains IDEs close, including binaries that ignore graceful shutdown.
+6
View File
@@ -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.
+1 -1
View File
@@ -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",
@@ -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()
}
}
@@ -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(
@@ -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<ProcessHandle> =
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<ProcessHandle>, 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<ProcessHandle> =
proc.toHandle().descendants().toList().asReversed()
internal fun startupDiagnostics(cli: File, env: Map<String, String>, log: KiloLog): String {
val home = System.getProperty("user.home").orEmpty()
val profile = EnvironmentUtil.getValue("USERPROFILE").orEmpty()
@@ -414,6 +537,9 @@ internal fun buildKiloCliEnv(
): Map<String, String> = 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")
@@ -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<KiloBackendAppService>()?.shutdownForAppClose()
}.onFailure { log.warn("Failed to stop CLI on app close", it) }
}
}
@@ -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<KiloBackendAppService>().shutdownForUnload()
}
service<KiloBackendAppService>().shutdownForUnload()
}
}
@@ -16,6 +16,8 @@
</extensions>
<applicationListeners>
<listener class="ai.kilocode.backend.plugin.KiloBackendAppLifecycleListener"
topic="com.intellij.ide.AppLifecycleListener"/>
<listener class="ai.kilocode.backend.plugin.KiloBackendDynamicPluginListener"
topic="com.intellij.ide.plugins.DynamicPluginListener"/>
</applicationListeners>
@@ -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"}"""
@@ -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
@@ -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<ProcessHandle> {
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
}
}
@@ -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<CliServer.State.Error>(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)
@@ -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()
}
}
@@ -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",
+4
View File
@@ -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<void>((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)
@@ -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
}
}
@@ -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<void>((resolve) => {
stop = startParentWatchdog(resolve, 10)
})
try {
await Promise.race([
orphaned,
new Promise((_, reject) => setTimeout(() => reject(new Error("watchdog did not fire")), 5000)),
])
} finally {
stop()
}
})
})