mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
Merge pull request #12105 from Kilo-Org/plan-address-issue-12048
fix(jetbrains): stop CLI on app close
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Stop orphaned Kilo CLI processes when JetBrains IDEs close, including binaries that ignore graceful shutdown.
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
+11
-8
@@ -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(
|
||||
|
||||
+152
-26
@@ -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")
|
||||
|
||||
+17
@@ -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) }
|
||||
}
|
||||
}
|
||||
+1
-4
@@ -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>
|
||||
|
||||
+15
@@ -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"}"""
|
||||
|
||||
+29
@@ -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
|
||||
|
||||
+133
@@ -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
|
||||
}
|
||||
}
|
||||
+13
@@ -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)
|
||||
|
||||
+8
@@ -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,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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user