From 008ad0712076e0c945c68b03b435c514445fe9e5 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 8 Jul 2026 19:05:28 -0400 Subject: [PATCH 1/6] fix(jetbrains): harden CLI startup --- packages/kilo-jetbrains/CHANGELOG.md | 6 + .../backend/cli/KiloBackendCliManager.kt | 196 +++++++++++++++--- .../cli/KiloBackendCliManagerReadyTest.kt | 111 ++++++++++ .../kilo-jetbrains/shared/build.gradle.kts | 6 + .../main/kotlin/ai/kilocode/log/KiloLog.kt | 28 ++- .../kotlin/ai/kilocode/log/KiloLogTest.kt | 42 ++++ 6 files changed, 353 insertions(+), 36 deletions(-) create mode 100644 packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerReadyTest.kt create mode 100644 packages/kilo-jetbrains/shared/src/test/kotlin/ai/kilocode/log/KiloLogTest.kt diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index acb7b083f65..5a64b99ab42 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Fixed + +- Surface a clear error when the Kilo backend fails to start instead of hanging on loading, and write the `kilo-dev.log` diagnostic log in release builds. + ## 7.4.2 ### Patch Changes 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 301142da9f7..3fd3ce8355b 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 @@ -5,16 +5,25 @@ import ai.kilocode.backend.dev.KiloDevMode import ai.kilocode.log.KiloLog import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.application.PathManager +import com.intellij.openapi.util.SystemInfo import com.intellij.util.EnvironmentUtil +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import java.io.BufferedReader import java.io.File +import java.io.InputStream import java.io.InputStreamReader +import java.nio.file.Files +import java.nio.file.Path import java.security.SecureRandom import java.util.UUID import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +private val PORT_REGEX = Regex("""listening on http://[\w.]+:(\d+)""") /** * Manages the Kilo CLI binary lifecycle. @@ -29,12 +38,12 @@ import java.util.concurrent.TimeUnit */ class KiloBackendCliManager( private val log: KiloLog = KiloLog.create(KiloBackendCliManager::class.java), + private val timeoutMs: Long = STARTUP_TIMEOUT_MS, ) : CliServer { companion object { private const val STARTUP_TIMEOUT_MS = 30_000L private const val KILL_TIMEOUT_SECONDS = 5L - private val PORT_REGEX = Regex("""listening on http://[\w.]+:(\d+)""") } @Volatile @@ -43,6 +52,7 @@ class KiloBackendCliManager( private var closing: Process? = null private var hook: Thread? = null private var stderr: Thread? = null + private var stdout: Thread? = null @Volatile override var forceExtract = false @@ -54,9 +64,7 @@ class KiloBackendCliManager( val path = resolveCli(onProgress) onResolved() log.info("CLI binary path: ${path.absolutePath} (size=${path.length()} bytes)") - withTimeout(STARTUP_TIMEOUT_MS) { - spawn(path) - } + spawn(path) } catch (e: Exception) { log.warn("CLI startup failed", e) process?.let { proc -> @@ -100,6 +108,7 @@ class KiloBackendCliManager( val pwd = generatePassword() val env = buildEnv(pwd) + val diag = startupDiagnostics(cli, env, log) val cmd = listOf(cli.absolutePath, "serve", "--port", "0") val builder = ProcessBuilder(cmd) @@ -135,30 +144,26 @@ class KiloBackendCliManager( }, "kilo-cli-stderr").apply { isDaemon = true; start() } this@KiloBackendCliManager.stderr = err - BufferedReader(InputStreamReader(proc.inputStream)).use { reader -> - for (line in reader.lineSequence()) { - log.info("CLI stdout: $line") - val match = PORT_REGEX.find(line) - if (match != null) { - val p = match.groupValues[1].toInt() - log.info("CLI server ready on port $p") - return@withContext CliServer.State.Ready(port = p, password = pwd) - } - - if (!proc.isAlive) break - } - } - - val code = proc.waitFor() - val details = synchronized(stderr) { stderr.toString().trim() } - process = null - uninstall() - this@KiloBackendCliManager.stderr = null - log.warn("CLI process exited with code $code before announcing a port: $details") - CliServer.State.Error( - message = "CLI process exited with code $code before announcing a port", - details = details.ifEmpty { null }, + val state = awaitReady( + stdout = proc.inputStream, + stderr = stderr, + pwd = pwd, + timeoutMs = timeoutMs, + alive = { proc.isAlive }, + pid = { proc.pid() }, + code = { proc.waitFor() }, + onTimeout = { cleanup(proc, "startup timeout") }, + diagnostics = { diag }, + log = log, + onThread = { stdout = it }, ) + if (state is CliServer.State.Error) { + process = null + uninstall() + this@KiloBackendCliManager.stderr = null + this@KiloBackendCliManager.stdout = null + } + state } override fun dispose() { @@ -175,9 +180,14 @@ class KiloBackendCliManager( kill(proc, source) val thread = stderr stderr = null + val out = stdout + stdout = null if (thread != null && thread != Thread.currentThread()) { thread.join(TimeUnit.SECONDS.toMillis(1)) } + if (out != null && out != Thread.currentThread()) { + out.join(TimeUnit.SECONDS.toMillis(1)) + } } finally { closing = null } @@ -234,6 +244,138 @@ class KiloBackendCliManager( } } +internal fun startupDiagnostics(cli: File, env: Map, log: KiloLog): String { + val home = System.getProperty("user.home").orEmpty() + val profile = EnvironmentUtil.getValue("USERPROFILE").orEmpty() + val data = env["XDG_DATA_HOME"] ?: home.takeIf { it.isNotBlank() }?.let { File(it, ".local/share/kilo").absolutePath }.orEmpty() + val lines = mutableListOf() + lines += "CLI binary: ${cli.absolutePath}${pathInfo(cli.absolutePath)}" + lines += "user.home: ${home.ifBlank { "" }}${pathInfo(home)}" + lines += "USERPROFILE: ${profile.ifBlank { "" }}${pathInfo(profile)}" + lines += "CLI data home: ${data.ifBlank { "" }}${pathInfo(data)}" + for (key in listOf("XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME")) { + lines += "$key: ${env[key] ?: ""}" + } + if (data.isNotBlank() && remote(data)) { + lines += "warning: Kilo CLI data dir appears to be on a non-local drive (${root(data)}); SQLite WAL may hang. Set XDG_DATA_HOME/XDG_STATE_HOME/XDG_CONFIG_HOME/XDG_CACHE_HOME to a local disk." + } + val text = lines.joinToString("\n") + log.info("CLI startup diagnostics:\n$text") + if (data.isNotBlank() && remote(data)) { + log.warn("Kilo CLI data dir appears to be on a non-local drive (${root(data)}); SQLite WAL may hang. Set XDG_DATA_HOME/XDG_STATE_HOME/XDG_CONFIG_HOME/XDG_CACHE_HOME to a local disk.") + } + return text +} + +private fun pathInfo(value: String): String { + if (value.isBlank()) return "" + val path = runCatching { Path.of(value) }.getOrNull() ?: return " (fs=, unc=false)" + return " (fs=${store(path)}, attrs=${attrs(path)}, unc=${unc(value)}, root=${root(value)})" +} + +private fun store(path: Path): String = runCatching { + val target = existing(path) + Files.getFileStore(target).type().ifBlank { "" } +}.getOrElse { "" } + +private fun existing(path: Path): Path { + var current = path + while (!Files.exists(current) && current.parent != null) current = current.parent + return current +} + +private fun remote(value: String): Boolean { + if (unc(value)) return true + val path = runCatching { Path.of(value) }.getOrNull() ?: return false + val type = store(path).lowercase() + val flags = attrs(path).lowercase() + if (listOf("remote=true", "removable=true", "cdrom=true").any { flags.contains(it) }) return true + return listOf("smb", "cifs", "nfs", "webdav", "afp", "sshfs", "remote").any { type.contains(it) } +} + +private fun attrs(path: Path): String { + val store = runCatching { Files.getFileStore(existing(path)) }.getOrNull() ?: return "" + val keys = listOf("volume:isRemote" to "remote", "volume:isRemovable" to "removable", "volume:isCdrom" to "cdrom") + return keys.mapNotNull { item -> + runCatching { "${item.second}=${store.getAttribute(item.first)}" }.getOrNull() + }.takeIf { it.isNotEmpty() }?.joinToString(",") ?: "" +} + +private fun unc(value: String): Boolean = value.startsWith("\\\\") + +private fun root(value: String): String { + val path = runCatching { Path.of(value) }.getOrNull() ?: return "" + val root = path.root?.toString() + if (root != null) return root + if (SystemInfo.isWindows && value.length >= 2 && value[1] == ':') return value.take(2) + return value +} + +internal suspend fun awaitReady( + stdout: InputStream, + stderr: StringBuilder, + pwd: String, + timeoutMs: Long, + alive: () -> Boolean, + pid: () -> Long, + code: () -> Int, + onTimeout: () -> Unit, + diagnostics: () -> String, + log: KiloLog = KiloLog.create(KiloBackendCliManager::class.java), + onThread: (Thread) -> Unit = {}, +): CliServer.State { + val done = CompletableDeferred() + val timed = AtomicBoolean(false) + fun complete(state: CliServer.State) { + done.complete(state) + } + val thread = Thread({ + runCatching { + BufferedReader(InputStreamReader(stdout)).use { reader -> + for (line in reader.lineSequence()) { + log.info("CLI stdout: $line") + val match = PORT_REGEX.find(line) + if (match != null) { + val port = match.groupValues[1].toInt() + log.info("CLI server ready on port $port") + complete(CliServer.State.Ready(port = port, password = pwd)) + return@Thread + } + } + } + val value = if (timed.get()) null else runCatching { code() }.getOrNull() + val text = synchronized(stderr) { stderr.toString().trim() } + val extra = diagnostics().trim() + val details = listOf(text, extra).filter { it.isNotEmpty() }.joinToString("\n\n") + val msg = if (value == null) { + "CLI stdout closed before announcing a port" + } else { + "CLI process exited with code $value before announcing a port" + } + log.warn("$msg: $details") + complete(CliServer.State.Error(msg, details.ifEmpty { null })) + }.onFailure { err -> + if (!timed.get()) { + log.warn("CLI stdout reader failed", err) + complete(CliServer.State.Error("CLI stdout reader failed", err.stackTraceToString())) + } + } + }, "kilo-cli-stdout").apply { isDaemon = true; start() } + onThread(thread) + + return try { + withTimeout(timeoutMs) { done.await() } + } catch (_: TimeoutCancellationException) { + timed.set(true) + val message = "CLI did not announce a port within ${timeoutMs}ms (process alive=${alive()}, pid=${pid()})" + log.warn(message) + onTimeout() + val err = synchronized(stderr) { stderr.toString().trim() } + val details = listOf(err, diagnostics().trim()).filter { it.isNotEmpty() }.joinToString("\n\n") + CliServer.State.Error(message, details.ifEmpty { null }) + } +} + private const val DEFAULT_CONFIG = """{"permission":{"edit":"ask","bash":"ask"}}""" // Must be called from a background thread — devStorageEnv() performs blocking I/O (mkdirs). diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerReadyTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerReadyTest.kt new file mode 100644 index 00000000000..8f981b3de5e --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerReadyTest.kt @@ -0,0 +1,111 @@ +package ai.kilocode.backend.cli + +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import java.io.ByteArrayInputStream +import java.io.PipedInputStream +import java.io.PipedOutputStream +import java.util.concurrent.atomic.AtomicInteger + +class KiloBackendCliManagerReadyTest { + + @Test + fun `ready line returns port`() = runBlocking { + val state = awaitReady( + stdout = ByteArrayInputStream("kilo server listening on http://127.0.0.1:12345\n".toByteArray()), + stderr = StringBuilder(), + pwd = "pwd123", + timeoutMs = TIMEOUT_MS, + alive = { false }, + pid = { 123L }, + code = { 0 }, + onTimeout = {}, + diagnostics = { "diag" }, + ) + + val ready = assertIs(state) + assertEquals(12345, ready.port) + assertEquals("pwd123", ready.password) + } + + @Test + fun `timeout invokes cleanup once and returns diagnostics`() = runBlocking { + val input = PipedInputStream() + val output = PipedOutputStream(input) + output.write("not ready yet\n".toByteArray()) + output.flush() + val calls = AtomicInteger(0) + + val state = awaitReady( + stdout = input, + stderr = StringBuilder("stderr line"), + pwd = "pwd123", + timeoutMs = 50, + alive = { true }, + pid = { 456L }, + code = { 0 }, + onTimeout = { + calls.incrementAndGet() + output.close() + }, + diagnostics = { "diag line" }, + ) + + val err = assertIs(state) + assertEquals(1, calls.get()) + assertContains(err.message, "within 50ms") + assertContains(err.message, "process alive=true") + assertContains(err.message, "pid=456") + assertContains(err.details.orEmpty(), "stderr line") + assertContains(err.details.orEmpty(), "diag line") + } + + @Test + fun `early eof without port returns exit code and stderr`() = runBlocking { + val state = awaitReady( + stdout = ByteArrayInputStream("booting\n".toByteArray()), + stderr = StringBuilder("bad db"), + pwd = "pwd123", + timeoutMs = TIMEOUT_MS, + alive = { false }, + pid = { 789L }, + code = { 9 }, + onTimeout = {}, + diagnostics = { "diag line" }, + ) + + val err = assertIs(state) + assertEquals("CLI process exited with code 9 before announcing a port", err.message) + assertContains(err.details.orEmpty(), "bad db") + assertContains(err.details.orEmpty(), "diag line") + } + + @Test + fun `ipv6 bind form remains a known non match`() = runBlocking { + val calls = AtomicInteger(0) + + val state = awaitReady( + stdout = ByteArrayInputStream("kilo server listening on http://[::1]:12345\n".toByteArray()), + stderr = StringBuilder(), + pwd = "pwd123", + timeoutMs = TIMEOUT_MS, + alive = { false }, + pid = { 321L }, + code = { 0 }, + onTimeout = { calls.incrementAndGet() }, + diagnostics = { "diag line" }, + ) + + val err = assertIs(state) + assertEquals(0, calls.get()) + assertTrue(err.message.startsWith("CLI process exited with code 0")) + } + + companion object { + private const val TIMEOUT_MS = 1_000L + } +} diff --git a/packages/kilo-jetbrains/shared/build.gradle.kts b/packages/kilo-jetbrains/shared/build.gradle.kts index cc14cbfdc72..4b47d15e0d9 100644 --- a/packages/kilo-jetbrains/shared/build.gradle.kts +++ b/packages/kilo-jetbrains/shared/build.gradle.kts @@ -12,4 +12,10 @@ dependencies { intellijPlatform { intellijIdea(libs.versions.intellij.platform) } + + testImplementation(kotlin("test")) +} + +tasks.test { + useJUnitPlatform() } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt index 12763663cf3..b448343a5b9 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt @@ -20,12 +20,11 @@ import java.util.logging.LogRecord /** * Logging interface for the Kilo JetBrains plugin. * - * In normal (non-sandbox) mode, all output goes through IntelliJ's own [com.intellij.openapi.diagnostic.Logger], - * which writes to the standard IDE log file. + * In normal (non-sandbox) mode, output goes through IntelliJ's own [com.intellij.openapi.diagnostic.Logger], + * which writes to the standard IDE log file, and to a rotated `kilo-dev.log` file inside the IDE log directory. * * In sandbox mode (i.e. when running via `./gradlew runIde`, detected via the `idea.plugin.in.sandbox.mode` - * system property), output is written only to a `kilo-dev.log` file inside the IDE log directory. RC plugin builds - * write to both IntelliJ's log and `kilo-dev.log`. + * system property), output is written only to `kilo-dev.log`. * * Usage: * ```kotlin @@ -48,10 +47,18 @@ interface KiloLog { companion object { fun create(cls: Class<*>): KiloLog { - if (sandbox()) return FileLog(cls) - val intellij = IntellijLog(cls) - if (!runCatching { KiloPlugin.isRc() }.getOrDefault(false)) return intellij - return CompositeLog(intellij, FileLog(cls)) + return create(cls, sandbox()) + } + + internal fun create(cls: Class<*>, sandbox: Boolean): KiloLog = logger( + sandbox = sandbox, + intellij = { IntellijLog(cls) }, + file = { FileLog(cls) }, + ) + + internal fun logger(sandbox: Boolean, intellij: () -> KiloLog, file: () -> KiloLog): KiloLog { + if (sandbox) return file() + return CompositeLog(intellij(), file()) } fun sandbox(): Boolean = System.getProperty("idea.plugin.in.sandbox.mode", "false").toBoolean() @@ -97,6 +104,8 @@ internal class FileLog(cls: Class<*>) : KiloLog { companion object { private val level: Level by lazy { resolveLevel() } + private const val LIMIT = 5_000_000 + private const val COUNT = 3 private val root: java.util.logging.Logger by lazy { val logger = java.util.logging.Logger.getLogger("ai.kilocode") @@ -111,7 +120,8 @@ internal class FileLog(cls: Class<*>) : KiloLog { private val handler: FileHandler by lazy { val dir = resolveLogDir() val path = dir.resolve("kilo-dev.log") - val h = FileHandler(path.toString(), true) + IntellijLog(FileLog::class.java).info("Kilo diagnostic log directory: $dir") + val h = FileHandler(path.toString(), LIMIT, COUNT, true) h.formatter = KiloFormatter() h } diff --git a/packages/kilo-jetbrains/shared/src/test/kotlin/ai/kilocode/log/KiloLogTest.kt b/packages/kilo-jetbrains/shared/src/test/kotlin/ai/kilocode/log/KiloLogTest.kt new file mode 100644 index 00000000000..b6905048c05 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/test/kotlin/ai/kilocode/log/KiloLogTest.kt @@ -0,0 +1,42 @@ +package ai.kilocode.log + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class KiloLogTest { + + @Test + fun `sandbox uses file log only`() { + val file = FakeLog() + val log = KiloLog.logger( + sandbox = true, + intellij = { error("IntelliJ log should not be created in sandbox") }, + file = { file }, + ) + + assertSame(file, log) + } + + @Test + fun `release uses intellij and file logs`() { + val intellij = FakeLog() + val file = FakeLog() + val log = KiloLog.logger( + sandbox = false, + intellij = { intellij }, + file = { file }, + ) + + val composite = log as CompositeLog + assertEquals(listOf(intellij, file), composite.delegates.toList()) + } + + private class FakeLog : KiloLog { + override val isDebugEnabled = false + override fun debug(block: () -> String) {} + override fun info(msg: String) {} + override fun warn(msg: String, t: Throwable?) {} + override fun error(msg: String, t: Throwable?) {} + } +} From 6609a76fa0dc2b918c6ce20b3ffe350ee3e9327d Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 9 Jul 2026 10:21:52 -0400 Subject: [PATCH 2/6] fix(jetbrains): add CLI install diagnostics --- packages/kilo-jetbrains/CHANGELOG.md | 2 +- .../backend/app/KiloBackendAppService.kt | 24 ++++- .../kilocode/backend/cli/KiloCliDownloader.kt | 91 ++++++++++++++++++- .../backend/app/KiloBackendAppServiceTest.kt | 9 ++ .../backend/cli/KiloCliDownloaderTest.kt | 27 ++++++ 5 files changed, 146 insertions(+), 7 deletions(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index 5a64b99ab42..7ecd10534fd 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Surface a clear error when the Kilo backend fails to start instead of hanging on loading, and write the `kilo-dev.log` diagnostic log in release builds. +- Surface a clear error when the Kilo backend fails to start instead of hanging on loading, write the `kilo-dev.log` diagnostic log in release builds, and add CLI install path diagnostics for relocated JetBrains system folders. ## 7.4.2 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 88cfe1bd64f..5033b312c0c 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 @@ -155,16 +155,32 @@ class KiloBackendAppService private constructor( } suspend fun restart() { + log.info("restart: requested — waiting for lifecycle mutex") mutex.withLock { - clear() - connection.restart() + log.info("restart: acquired lifecycle mutex") + try { + clear() + connection.restart() + log.info("restart: complete") + } catch (e: Exception) { + log.warn("restart: failed", e) + throw e + } } } suspend fun reinstall() { + log.info("reinstall: requested — waiting for lifecycle mutex") mutex.withLock { - clear() - connection.reinstall() + log.info("reinstall: acquired lifecycle mutex") + try { + clear() + connection.reinstall() + log.info("reinstall: complete") + } catch (e: Exception) { + log.warn("reinstall: failed", e) + throw e + } } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt index 181d114efc7..e664c97dd88 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt @@ -3,6 +3,7 @@ package ai.kilocode.backend.cli import ai.kilocode.log.KiloLog import com.intellij.openapi.application.PathManager import com.intellij.openapi.util.SystemInfo +import com.intellij.util.EnvironmentUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json @@ -17,6 +18,10 @@ import org.apache.commons.compress.archivers.tar.TarArchiveInputStream import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream import java.io.File import java.io.RandomAccessFile +import java.nio.channels.FileLock +import java.nio.channels.OverlappingFileLockException +import java.nio.file.Files +import java.nio.file.Path import java.security.MessageDigest import java.time.Instant import java.util.concurrent.ConcurrentHashMap @@ -34,8 +39,11 @@ class KiloCliDownloader( private val root: File = File(PathManager.getSystemPath(), "kilo/cli"), private val baseUrl: String = "https://github.com/Kilo-Org/kilocode/releases/download", private val api: String = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags", + private val lockTimeoutMs: Long = LOCK_TIMEOUT_MS, ) { companion object { + private const val LOCK_TIMEOUT_MS = 30_000L + private const val LOCK_POLL_MS = 100L private val DIGEST = Regex("^sha256:[a-f0-9]{64}$") private val JSON = Json { ignoreUnknownKeys = true } private val LOCKS = ConcurrentHashMap() @@ -43,6 +51,7 @@ class KiloCliDownloader( suspend fun resolve(version: String, force: Boolean = false, onProgress: (CliDownload) -> Unit = {}): File = withContext(Dispatchers.IO) { + logPaths(version, force) locked { val platform = KiloCliPlatform.current() val dir = File(File(root, version), platform) @@ -50,6 +59,11 @@ class KiloCliDownloader( val done = File(dir, ".complete") val ext = KiloCliPlatform.archive(platform) + log.info( + "Kilo CLI cache target: version=$version platform=$platform exe=${exe.absolutePath} " + + "complete=${done.absolutePath} force=$force" + ) + if (!force) { cached(version, platform, exe, done)?.let { return@locked it } } @@ -66,6 +80,7 @@ class KiloCliDownloader( ) onProgress(CliDownload(0, version, platform)) download(version, platform, ext, archive, onProgress) + log.info("Verifying Kilo CLI archive ${archive.absolutePath}") verify(archive, digest) log.info( "Downloaded Kilo CLI $version for $platform to ${archive.absolutePath} (size=${archive.length()} bytes)" @@ -78,6 +93,7 @@ class KiloCliDownloader( if (archive.exists() && !archive.delete()) { log.warn("Failed to delete extracted Kilo CLI archive ${archive.absolutePath}") } + log.info("Writing Kilo CLI cache completion marker ${complete.absolutePath}") complete.writeText("$digest\n") replace(dir, stage) onProgress(CliDownload(100, version, platform)) @@ -93,7 +109,12 @@ class KiloCliDownloader( private fun cached(version: String, platform: String, exe: File, done: File): File? { val digest = done.takeIf { it.isFile }?.readText()?.trim() - if (!exe.isFile || digest == null || !digest.matches(DIGEST)) return null + val valid = digest != null && digest.matches(DIGEST) + log.info( + "Kilo CLI cache check: version=$version platform=$platform exeExists=${exe.isFile} " + + "completeExists=${done.isFile} digestValid=$valid exe=${exe.absolutePath} complete=${done.absolutePath}" + ) + if (!exe.isFile || !valid) return null log.info("Using cached Kilo CLI $version for $platform at ${exe.absolutePath}") if (!SystemInfo.isWindows) exe.setExecutable(true) prune(version) @@ -101,21 +122,48 @@ class KiloCliDownloader( } private fun locked(block: () -> T): T { + log.info("Ensuring Kilo CLI cache root ${root.absolutePath}") if (!root.isDirectory && !root.mkdirs()) { throw IllegalStateException("Failed to create Kilo CLI cache root ${root.absolutePath}") } val file = File(root, ".lock").canonicalFile + log.info("Kilo CLI cache lock path: ${file.absolutePath}") val mutex = LOCKS.computeIfAbsent(file.absolutePath) { Any() } return synchronized(mutex) { RandomAccessFile(file, "rw").channel.use { channel -> - channel.lock().use { block() } + val start = System.currentTimeMillis() + log.info("Waiting for Kilo CLI cache lock: ${file.absolutePath}") + val lock = acquire(file, channel::tryLock, start) + lock.use { + log.info("Acquired Kilo CLI cache lock after ${System.currentTimeMillis() - start}ms: ${file.absolutePath}") + block() + } + } + } + } + + private fun acquire(file: File, attempt: () -> FileLock?, start: Long): FileLock { + while (true) { + val lock = try { + attempt() + } catch (_: OverlappingFileLockException) { + null } + if (lock != null) return lock + val waited = System.currentTimeMillis() - start + if (waited >= lockTimeoutMs) { + val msg = "Timed out waiting for Kilo CLI cache lock after ${waited}ms: ${file.absolutePath}" + log.warn(msg) + throw IllegalStateException(msg) + } + Thread.sleep(LOCK_POLL_MS.coerceAtMost((lockTimeoutMs - waited).coerceAtLeast(1L))) } } private fun stage(version: String, platform: String): File { val tmp = File(root, ".tmp") val dir = File(tmp, "$version-$platform-${System.nanoTime()}") + log.info("Creating Kilo CLI staging directory ${dir.absolutePath}") if (!dir.isDirectory && !dir.mkdirs()) { throw IllegalStateException("Failed to create Kilo CLI staging directory ${dir.absolutePath}") } @@ -123,6 +171,7 @@ class KiloCliDownloader( } private fun replace(dir: File, stage: File) { + log.info("Installing Kilo CLI cache from ${stage.absolutePath} to ${dir.absolutePath}") val parent = dir.parentFile if (!parent.isDirectory && !parent.mkdirs()) { throw IllegalStateException("Failed to create Kilo CLI cache directory ${parent.absolutePath}") @@ -133,6 +182,7 @@ class KiloCliDownloader( throw IllegalStateException("Failed to move existing Kilo CLI cache ${dir.absolutePath} aside") } if (stage.renameTo(dir)) { + log.info("Installed Kilo CLI cache at ${dir.absolutePath}") if (backup.exists() && !backup.deleteRecursively()) { log.warn("Failed to delete previous Kilo CLI cache ${backup.absolutePath}") } @@ -318,4 +368,41 @@ class KiloCliDownloader( private fun url(version: String, platform: String, ext: String) = "${baseUrl.trimEnd('/')}/v$version/kilo-$platform.$ext" + + private fun logPaths(version: String, force: Boolean) { + val text = buildList { + add("version=$version force=$force") + add("configPath=${safe { PathManager.getConfigPath() }}") + add("systemPath=${safe { PathManager.getSystemPath() }}") + add("pluginsPath=${safe { PathManager.getPluginsPath() }}") + add("logPath=${safe { PathManager.getLogPath() }}") + add("logDir=${safe { PathManager.getLogDir().toString() }}") + add("idea.config.path=${System.getProperty("idea.config.path") ?: ""}") + add("idea.system.path=${System.getProperty("idea.system.path") ?: ""}") + add("idea.plugins.path=${System.getProperty("idea.plugins.path") ?: ""}") + add("idea.log.path=${System.getProperty("idea.log.path") ?: ""}") + add("idea.properties.file=${System.getProperty("idea.properties.file") ?: ""}") + add("user.home=${System.getProperty("user.home") ?: ""}") + add("USERPROFILE=${EnvironmentUtil.getValue("USERPROFILE") ?: ""}") + add("TEMP=${EnvironmentUtil.getValue("TEMP") ?: ""}") + add("TMP=${EnvironmentUtil.getValue("TMP") ?: ""}") + add("cacheRoot=${root.absolutePath}${info(root)}") + }.joinToString(" ") + log.info("Kilo CLI path diagnostics: $text") + } + + private fun info(file: File): String = runCatching { + val path = existing(file.toPath()) + val store = Files.getFileStore(path) + " (canonical=${file.canonicalPath} fs=${store.type().ifBlank { "" }} " + + "name=${store.name().ifBlank { "" }} readOnly=${store.isReadOnly})" + }.getOrElse { " (canonical= fs=)" } + + private fun existing(path: Path): Path { + var current = path + while (!Files.exists(current) && current.parent != null) current = current.parent + return current + } + + private fun safe(value: () -> String): String = runCatching { value() }.getOrElse { "" } } 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 d99c80fb556..ae9eadc862a 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 @@ -629,6 +629,9 @@ class KiloBackendAppServiceTest { assertIs(svc.appState.value) assertFalse(log.messages.any { it.contains("Application start timed out") }) + assertTrue(log.messages.any { it.contains("restart: requested") && it.contains("waiting for lifecycle mutex") }) + assertTrue(log.messages.any { it.contains("restart: acquired lifecycle mutex") }) + assertTrue(log.messages.any { it.contains("restart: complete") }) } finally { gate.countDown() } @@ -654,6 +657,9 @@ class KiloBackendAppServiceTest { assertIs(svc.appState.value) assertFalse(log.messages.any { it.contains("Application start timed out") }) + assertTrue(log.messages.any { it.contains("reinstall: requested") && it.contains("waiting for lifecycle mutex") }) + assertTrue(log.messages.any { it.contains("reinstall: acquired lifecycle mutex") }) + assertTrue(log.messages.any { it.contains("reinstall: complete") }) } finally { gate.countDown() } @@ -790,6 +796,9 @@ class KiloBackendAppServiceTest { assertIs(svc.appState.value) assertNotNull(svc.config) + assertTrue(log.messages.any { it.contains("restart: requested") && it.contains("waiting for lifecycle mutex") }) + assertTrue(log.messages.any { it.contains("restart: acquired lifecycle mutex") }) + assertTrue(log.messages.any { it.contains("restart: complete") }) } @Test diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt index 1c4d3131c0b..12d71855d97 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt @@ -11,6 +11,7 @@ import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream import org.junit.jupiter.api.io.TempDir import java.io.ByteArrayOutputStream import java.io.File +import java.io.RandomAccessFile import java.security.MessageDigest import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @@ -54,6 +55,9 @@ class KiloCliDownloaderTest { it.contains("/.tmp/") } ) + assertTrue(log.messages.any { it.contains("Kilo CLI path diagnostics:") && it.contains("cacheRoot=${dir.absolutePath}") }) + assertTrue(log.messages.any { it.contains("Kilo CLI cache target:") && it.contains("exe=${cli.absolutePath}") }) + assertTrue(log.messages.any { it.contains("Kilo CLI cache lock path:") && it.contains(File(dir, ".lock").canonicalPath) }) val cachedProgress = mutableListOf() val cached = KiloCliDownloader( @@ -269,6 +273,29 @@ class KiloCliDownloaderTest { } } + @Test + fun `cache lock times out clearly when held by another process`() = runBlocking { + assertTrue(dir.mkdirs() || dir.isDirectory) + val file = File(dir, ".lock") + val log = TestLog() + RandomAccessFile(file, "rw").channel.use { channel -> + channel.lock().use { + val ex = assertFailsWith { + KiloCliDownloader( + log = log, + root = dir, + lockTimeoutMs = 50, + ).resolve("1.2.3") + } + + assertContains(ex.message.orEmpty(), "Timed out waiting for Kilo CLI cache lock") + assertContains(ex.message.orEmpty(), file.canonicalPath) + assertTrue(log.messages.any { it.contains("Waiting for Kilo CLI cache lock") && it.contains(file.canonicalPath) }) + assertTrue(log.messages.any { it.contains("Timed out waiting for Kilo CLI cache lock") && it.contains(file.canonicalPath) }) + } + } + } + private fun archive(script: String = "#!/bin/sh\n"): ByteArray { val files = mapOf( "bin/${KiloCliPlatform.exe()}" to script.toByteArray(), From 23f89aae7d466677894bfadecdc2783b194ceebc Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 9 Jul 2026 11:45:34 -0400 Subject: [PATCH 3/6] fix(jetbrains): address CLI startup review --- packages/kilo-jetbrains/CHANGELOG.md | 2 +- packages/kilo-jetbrains/README.md | 8 ++--- .../backend/app/KiloBackendAppService.kt | 4 +++ .../backend/cli/KiloBackendCliManager.kt | 34 +++++++++++++++---- .../kilocode/backend/cli/KiloCliDownloader.kt | 28 ++++++++------- .../cli/KiloBackendCliManagerReadyTest.kt | 5 +-- .../backend/cli/KiloCliDownloaderTest.kt | 2 +- .../main/kotlin/ai/kilocode/log/KiloLog.kt | 6 ++-- 8 files changed, 58 insertions(+), 31 deletions(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index 7ecd10534fd..ab2f215be36 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Surface a clear error when the Kilo backend fails to start instead of hanging on loading, write the `kilo-dev.log` diagnostic log in release builds, and add CLI install path diagnostics for relocated JetBrains system folders. +- Surface a clear error when the Kilo backend fails to start instead of hanging on loading, write rotated `kilo-dev.log.*` diagnostic logs in release builds, and add CLI install path diagnostics for relocated JetBrains system folders. ## 7.4.2 diff --git a/packages/kilo-jetbrains/README.md b/packages/kilo-jetbrains/README.md index d35a08a8065..e5452e7ddd0 100644 --- a/packages/kilo-jetbrains/README.md +++ b/packages/kilo-jetbrains/README.md @@ -109,7 +109,7 @@ All properties below are passed with `-P` on the Gradle command line or in the r | `kilo.dev.worktree.root` | monorepo root | Worktree root used to resolve `.kilo-dev/`. Auto-detected from the Gradle project directory; override only when the auto-detection is wrong. | The checked-in IDE run configurations pass `--no-configuration-cache` because the IntelliJ Platform Gradle Plugin run-IDE tasks are not configuration-cache compatible in this setup. -They also pass `--purge-old-log-directories` so stale sandbox logs do not hide the current backend and frontend `kilo-dev.log` files. +They also pass `--purge-old-log-directories` so stale sandbox logs do not hide the current backend and frontend `kilo-dev.log.*` files. Example with a fixed split-mode port: @@ -141,7 +141,7 @@ The checked-in `Run IDE (Backend)`, `Run IDE (Frontend)`, and `Run IDE (Split Mo ### Debug logging properties -The plugin supports a few JVM system properties for local debugging. These are most useful with sandbox runs because the logs are mirrored to `kilo-dev.log` files for frontend and backend. +The plugin supports a few JVM system properties for local debugging. These are most useful with sandbox runs because the logs are mirrored to `kilo-dev.log.*` files for frontend and backend. `kilo.dev.log.level` @@ -167,8 +167,8 @@ The plugin supports a few JVM system properties for local debugging. These are m Where to find the log files: - In sandbox runs, Kilo writes separate dev log files for each side under the IDE sandbox log directory reported by `PathManager.getLogDir()`. -- Frontend log file: `/kilo-frontend/kilo-dev.log` -- Backend log file: `/kilo-backend/kilo-dev.log` +- Frontend log file: `/kilo-frontend/kilo-dev.log.0` +- Backend log file: `/kilo-backend/kilo-dev.log.0` - In practice these sit under the current `log_run*` sandbox logs for the active run. - If you are unsure of the exact sandbox root, open the IDE log directory from the running sandbox instance and then look for the `kilo-frontend/` and `kilo-backend/` subdirectories. 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 5033b312c0c..77aee949b6b 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 @@ -162,6 +162,8 @@ class KiloBackendAppService private constructor( clear() connection.restart() log.info("restart: complete") + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.warn("restart: failed", e) throw e @@ -177,6 +179,8 @@ class KiloBackendAppService private constructor( clear() connection.reinstall() log.info("reinstall: complete") + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.warn("reinstall: failed", e) throw e 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 3fd3ce8355b..6a6b0cd9251 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 @@ -8,6 +8,7 @@ import com.intellij.openapi.application.PathManager import com.intellij.openapi.util.SystemInfo import com.intellij.util.EnvironmentUtil import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.withContext @@ -43,6 +44,7 @@ class KiloBackendCliManager( companion object { private const val STARTUP_TIMEOUT_MS = 30_000L + private const val STARTUP_TIMEOUT_GRACE_MS = 8_000L private const val KILL_TIMEOUT_SECONDS = 5L } @@ -64,7 +66,21 @@ class KiloBackendCliManager( val path = resolveCli(onProgress) onResolved() log.info("CLI binary path: ${path.absolutePath} (size=${path.length()} bytes)") - spawn(path) + withTimeout(timeoutMs + STARTUP_TIMEOUT_GRACE_MS) { spawn(path) } + } catch (e: TimeoutCancellationException) { + val msg = "CLI startup timed out after ${timeoutMs}ms" + log.warn(msg, e) + process?.let { proc -> + log.info("Cleaning up orphaned CLI process (pid=${proc.pid()})") + process = null + cleanup(proc, "startup timeout cleanup") + } + CliServer.State.Error( + message = msg, + details = e.stackTraceToString(), + ) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.warn("CLI startup failed", e) process?.let { proc -> @@ -106,6 +122,7 @@ class KiloBackendCliManager( private suspend fun spawn(cli: File): CliServer.State = withContext(Dispatchers.IO) { val pwd = generatePassword() + val start = System.nanoTime() val env = buildEnv(pwd) val diag = startupDiagnostics(cli, env, log) @@ -148,20 +165,21 @@ class KiloBackendCliManager( stdout = proc.inputStream, stderr = stderr, pwd = pwd, - timeoutMs = timeoutMs, + timeoutMs = (timeoutMs - elapsed(start)).coerceAtLeast(1L), alive = { proc.isAlive }, pid = { proc.pid() }, code = { proc.waitFor() }, - onTimeout = { cleanup(proc, "startup timeout") }, + onTimeout = { + if (process == proc) process = null + cleanup(proc, "startup timeout") + }, diagnostics = { diag }, log = log, onThread = { stdout = it }, ) - if (state is CliServer.State.Error) { + if (state is CliServer.State.Error && process == proc) { process = null - uninstall() - this@KiloBackendCliManager.stderr = null - this@KiloBackendCliManager.stdout = null + cleanup(proc, "startup error") } state } @@ -242,6 +260,8 @@ class KiloBackendCliManager( SecureRandom().nextBytes(bytes) return bytes.joinToString("") { "%02x".format(it) } } + + private fun elapsed(start: Long): Long = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) } internal fun startupDiagnostics(cli: File, env: Map, log: KiloLog): String { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt index e664c97dd88..3c436e3a32c 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt @@ -131,11 +131,11 @@ class KiloCliDownloader( val mutex = LOCKS.computeIfAbsent(file.absolutePath) { Any() } return synchronized(mutex) { RandomAccessFile(file, "rw").channel.use { channel -> - val start = System.currentTimeMillis() + val start = System.nanoTime() log.info("Waiting for Kilo CLI cache lock: ${file.absolutePath}") val lock = acquire(file, channel::tryLock, start) lock.use { - log.info("Acquired Kilo CLI cache lock after ${System.currentTimeMillis() - start}ms: ${file.absolutePath}") + log.info("Acquired Kilo CLI cache lock after ${elapsed(start)}ms: ${file.absolutePath}") block() } } @@ -150,7 +150,7 @@ class KiloCliDownloader( null } if (lock != null) return lock - val waited = System.currentTimeMillis() - start + val waited = elapsed(start) if (waited >= lockTimeoutMs) { val msg = "Timed out waiting for Kilo CLI cache lock after ${waited}ms: ${file.absolutePath}" log.warn(msg) @@ -160,6 +160,8 @@ class KiloCliDownloader( } } + private fun elapsed(start: Long): Long = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) + private fun stage(version: String, platform: String): File { val tmp = File(root, ".tmp") val dir = File(tmp, "$version-$platform-${System.nanoTime()}") @@ -377,16 +379,16 @@ class KiloCliDownloader( add("pluginsPath=${safe { PathManager.getPluginsPath() }}") add("logPath=${safe { PathManager.getLogPath() }}") add("logDir=${safe { PathManager.getLogDir().toString() }}") - add("idea.config.path=${System.getProperty("idea.config.path") ?: ""}") - add("idea.system.path=${System.getProperty("idea.system.path") ?: ""}") - add("idea.plugins.path=${System.getProperty("idea.plugins.path") ?: ""}") - add("idea.log.path=${System.getProperty("idea.log.path") ?: ""}") - add("idea.properties.file=${System.getProperty("idea.properties.file") ?: ""}") - add("user.home=${System.getProperty("user.home") ?: ""}") - add("USERPROFILE=${EnvironmentUtil.getValue("USERPROFILE") ?: ""}") - add("TEMP=${EnvironmentUtil.getValue("TEMP") ?: ""}") - add("TMP=${EnvironmentUtil.getValue("TMP") ?: ""}") - add("cacheRoot=${root.absolutePath}${info(root)}") + add("idea.config.path=${safe { System.getProperty("idea.config.path") ?: "" }}") + add("idea.system.path=${safe { System.getProperty("idea.system.path") ?: "" }}") + add("idea.plugins.path=${safe { System.getProperty("idea.plugins.path") ?: "" }}") + add("idea.log.path=${safe { System.getProperty("idea.log.path") ?: "" }}") + add("idea.properties.file=${safe { System.getProperty("idea.properties.file") ?: "" }}") + add("user.home=${safe { System.getProperty("user.home") ?: "" }}") + add("USERPROFILE=${safe { EnvironmentUtil.getValue("USERPROFILE") ?: "" }}") + add("TEMP=${safe { EnvironmentUtil.getValue("TEMP") ?: "" }}") + add("TMP=${safe { EnvironmentUtil.getValue("TMP") ?: "" }}") + add("cacheRoot=${safe { root.absolutePath + info(root) }}") }.joinToString(" ") log.info("Kilo CLI path diagnostics: $text") } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerReadyTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerReadyTest.kt index 8f981b3de5e..eebcc96eb4d 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerReadyTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerReadyTest.kt @@ -44,7 +44,7 @@ class KiloBackendCliManagerReadyTest { stdout = input, stderr = StringBuilder("stderr line"), pwd = "pwd123", - timeoutMs = 50, + timeoutMs = WATCHDOG_TIMEOUT_MS, alive = { true }, pid = { 456L }, code = { 0 }, @@ -57,7 +57,7 @@ class KiloBackendCliManagerReadyTest { val err = assertIs(state) assertEquals(1, calls.get()) - assertContains(err.message, "within 50ms") + assertContains(err.message, "within ${WATCHDOG_TIMEOUT_MS}ms") assertContains(err.message, "process alive=true") assertContains(err.message, "pid=456") assertContains(err.details.orEmpty(), "stderr line") @@ -107,5 +107,6 @@ class KiloBackendCliManagerReadyTest { companion object { private const val TIMEOUT_MS = 1_000L + private const val WATCHDOG_TIMEOUT_MS = 50L } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt index 12d71855d97..f1971c8d981 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt @@ -274,7 +274,7 @@ class KiloCliDownloaderTest { } @Test - fun `cache lock times out clearly when held by another process`() = runBlocking { + fun `cache lock times out clearly when already held in this process`() = runBlocking { assertTrue(dir.mkdirs() || dir.isDirectory) val file = File(dir, ".lock") val log = TestLog() diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt index b448343a5b9..a14ed12f0e4 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt @@ -21,10 +21,10 @@ import java.util.logging.LogRecord * Logging interface for the Kilo JetBrains plugin. * * In normal (non-sandbox) mode, output goes through IntelliJ's own [com.intellij.openapi.diagnostic.Logger], - * which writes to the standard IDE log file, and to a rotated `kilo-dev.log` file inside the IDE log directory. + * which writes to the standard IDE log file, and to rotated `kilo-dev.log.*` files inside the IDE log directory. * * In sandbox mode (i.e. when running via `./gradlew runIde`, detected via the `idea.plugin.in.sandbox.mode` - * system property), output is written only to `kilo-dev.log`. + * system property), output is written only to `kilo-dev.log.*`. * * Usage: * ```kotlin @@ -119,7 +119,7 @@ internal class FileLog(cls: Class<*>) : KiloLog { private val handler: FileHandler by lazy { val dir = resolveLogDir() - val path = dir.resolve("kilo-dev.log") + val path = dir.resolve("kilo-dev.log.%g") IntellijLog(FileLog::class.java).info("Kilo diagnostic log directory: $dir") val h = FileHandler(path.toString(), LIMIT, COUNT, true) h.formatter = KiloFormatter() From 5a86ca08c517bd1251f3b45d4269e5d267f4c1ec Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 9 Jul 2026 11:47:14 -0400 Subject: [PATCH 4/6] fix(jetbrains): bound full CLI startup --- .../kilocode/backend/cli/KiloBackendCliManager.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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 6a6b0cd9251..ae879d7981e 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 @@ -63,10 +63,13 @@ class KiloBackendCliManager( override suspend fun init(onProgress: (CliDownload) -> Unit, onResolved: () -> Unit): CliServer.State { return try { - val path = resolveCli(onProgress) - onResolved() - log.info("CLI binary path: ${path.absolutePath} (size=${path.length()} bytes)") - withTimeout(timeoutMs + STARTUP_TIMEOUT_GRACE_MS) { spawn(path) } + val start = System.nanoTime() + withTimeout(timeoutMs + STARTUP_TIMEOUT_GRACE_MS) { + val path = resolveCli(onProgress) + onResolved() + log.info("CLI binary path: ${path.absolutePath} (size=${path.length()} bytes)") + spawn(path, start) + } } catch (e: TimeoutCancellationException) { val msg = "CLI startup timed out after ${timeoutMs}ms" log.warn(msg, e) @@ -119,10 +122,9 @@ class KiloBackendCliManager( internal fun buildEnv(pwd: String, base: Map = EnvironmentUtil.getEnvironmentMap()): Map = buildKiloCliEnv(pwd, base, log) - private suspend fun spawn(cli: File): CliServer.State = + private suspend fun spawn(cli: File, start: Long): CliServer.State = withContext(Dispatchers.IO) { val pwd = generatePassword() - val start = System.nanoTime() val env = buildEnv(pwd) val diag = startupDiagnostics(cli, env, log) From 585d6ba8eb61d13190fd5f89a421b5cb61fcd838 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 9 Jul 2026 11:58:38 -0400 Subject: [PATCH 5/6] chore(ci): free disk before Kotlin CodeQL --- .github/workflows/codeql-kotlin.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/codeql-kotlin.yml b/.github/workflows/codeql-kotlin.yml index 5f0cec56c87..1390cb1feea 100644 --- a/.github/workflows/codeql-kotlin.yml +++ b/.github/workflows/codeql-kotlin.yml @@ -39,6 +39,12 @@ jobs: distribution: temurin java-version: "21" + - name: Free disk space for CodeQL + shell: bash + run: | + sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/share/boost + docker system prune --all --force || true + - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 with: From 556145c4476e2c09058bd902f83db2a5018d8a59 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 9 Jul 2026 12:48:52 -0400 Subject: [PATCH 6/6] fix(jetbrains): point run configs at rotated log --- packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml | 2 +- packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml | 2 +- packages/kilo-jetbrains/.run/runIdeSplitMode.run.xml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml b/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml index cfa3d9997d4..af1ad2c5507 100644 --- a/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml +++ b/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml @@ -1,6 +1,6 @@ - +