diff --git a/.changeset/jetbrains-orphan-cli-shutdown.md b/.changeset/jetbrains-orphan-cli-shutdown.md new file mode 100644 index 00000000000..850c6063d52 --- /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 on Windows. 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 77aee949b6b..3aea234d57f 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 @@ -194,6 +194,9 @@ class KiloBackendAppService private constructor( } } + /** Best-effort synchronous CLI teardown for IDE shutdown. Safe to call repeatedly; never suspends. */ + fun shutdownForAppClose() = shutdown() + suspend fun retry() { mutex.withLock { when (val current = _appState.value) { 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 b71de7c3ad4..abecec7e738 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 @@ -244,19 +245,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 +263,32 @@ 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) + if (ok) return + proc.toHandle().descendants().toList().asReversed().forEach { it.destroyForcibly() } + proc.destroyForcibly() + return + } + proc.toHandle().descendants().toList().asReversed().forEach { it.destroy() } + proc.destroy() + if (!wait) return + if (!proc.waitFor(timeoutSeconds, TimeUnit.SECONDS)) { + log.warn("CLI process did not exit after SIGTERM, sending SIGKILL") + proc.toHandle().descendants().toList().asReversed().forEach { it.destroyForcibly() } + proc.destroyForcibly() + } +} + internal fun startupDiagnostics(cli: File, env: Map, log: KiloLog): String { val home = System.getProperty("user.home").orEmpty() val profile = EnvironmentUtil.getValue("USERPROFILE").orEmpty() 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 00000000000..3100a8a7a79 --- /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/resources/kilo.jetbrains.backend.xml b/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml index a5cfa7b7e67..99227797f63 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 ae9eadc862a..dbe110ee61c 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,19 @@ class KiloBackendAppServiceTest { assertEquals(1, server.disposeCount) } + @Test + fun `shutdown for app close disposes server once`() { + val server = FakeCliServer(mock) + val svc = KiloBackendAppService.create(scope, server, log) + + svc.shutdownForAppClose() + svc.shutdownForAppClose() + svc.dispose() + + assertEquals(KiloAppState.Disconnected, svc.appState.value) + assertEquals(1, 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/cli/KiloBackendCliKillTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliKillTest.kt new file mode 100644 index 00000000000..4ca13c260e4 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliKillTest.kt @@ -0,0 +1,90 @@ +package ai.kilocode.backend.cli + +import ai.kilocode.backend.testing.TestLog +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() + val proc = process("sh", "-c", "sleep 30 & wait") + try { + val kids = descendants(proc) + assertTrue(kids.isNotEmpty(), "process tree did not spawn a descendant") + + killCliProcessTree(proc, log, windows = false) + + assertTrue(proc.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS), "parent process did not exit") + assertFalse(proc.isAlive) + kids.forEach { child -> assertFalse(child.isAlive, "child process ${child.pid()} is still alive") } + } 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): List { + val end = System.nanoTime() + TimeUnit.SECONDS.toNanos(PROCESS_TIMEOUT_SECONDS) + while (System.nanoTime() < end) { + val kids = proc.toHandle().descendants().toList() + if (kids.isNotEmpty()) return kids + Thread.sleep(25) + } + return proc.toHandle().descendants().toList() + } + + 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 + } +}