mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
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 on Windows.
|
||||
+3
@@ -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) {
|
||||
|
||||
+28
-11
@@ -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()
|
||||
|
||||
+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) }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+13
@@ -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"}"""
|
||||
|
||||
+90
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user