fix(jetbrains): stop CLI on app close

This commit is contained in:
kirillk
2026-07-10 11:12:01 -04:00
parent 2031f946b7
commit 8ceeb0fb99
7 changed files with 158 additions and 11 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Stop orphaned Kilo CLI processes when JetBrains IDEs close on Windows.
@@ -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) {
@@ -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<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 +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<String, String>, log: KiloLog): String {
val home = System.getProperty("user.home").orEmpty()
val profile = EnvironmentUtil.getValue("USERPROFILE").orEmpty()
@@ -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) }
}
}
@@ -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,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"}"""
@@ -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<ProcessHandle> {
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
}
}