diff --git a/.changeset/jetbrains-restart-loading.md b/.changeset/jetbrains-restart-loading.md new file mode 100644 index 00000000000..eb87e2bcb68 --- /dev/null +++ b/.changeset/jetbrains-restart-loading.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Recover the JetBrains backend when startup or restart loading is interrupted by stale connection failures. diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index 9d026428b88..52acdbcfdc7 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -136,6 +136,14 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi - For state-driven updates, assert that the component state matches after flushing coroutines and draining the EDT. - For retained Swing components, assert that expand/collapse, update, and no-op paths work correctly without rebuilding the component tree. +### Integration Test Timeouts + +- Prefer deterministic synchronization over timeouts: wait for explicit state transitions, event emissions, fake server hooks, latches, or coroutine completions that prove the system reached the expected condition. +- Use timeouts only when an integration test cannot otherwise protect the suite from a stuck process, external boundary, or coroutine. Treat them as watchdogs, not as the mechanism that makes the test pass. +- When a timeout is necessary, define one named timeout or wait helper near the top of the test file and reuse it. Do not scatter literal timeout values through individual assertions. +- Timeout failures should include the last observed state and useful logs or errors so CI explains what blocked progress. +- Do not use `delay`, sleeps, or repeated polling to guess when asynchronous work is done unless the behavior under test is timing-specific. + ## Dependencies - **Always bundle third-party libraries with the plugin.** Do not rely on libraries bundled with the IntelliJ platform (e.g. OkHttp, Gson, Guava, kotlinx-serialization-json). The IDE's bundled versions change across releases without notice and can cause version collisions, classloader conflicts, or silent API breakage. Declare all third-party dependencies as `implementation` in the relevant `build.gradle.kts` so they ship inside the plugin JAR and load from the plugin's own classloader. 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 22d2fbc6257..c8f358ce8d2 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 @@ -278,7 +278,7 @@ class KiloBackendAppService private constructor( private suspend fun reconnect() { mutex.withLock { val current = _appState.value - if (current is KiloAppState.Ready || current is KiloAppState.Connecting || current is KiloAppState.Loading || current is KiloAppState.MigrationRequired) { + if (current is KiloAppState.Ready || current is KiloAppState.Loading || current is KiloAppState.MigrationRequired) { log.info("reconnect: already ${current::class.simpleName} — skipping") return } @@ -442,6 +442,7 @@ class KiloBackendAppService private constructor( } catch (e: CancellationException) { throw e } catch (e: Exception) { + ensureActive() log.warn("Application start failed: ${e.message}") captureLoad("Backend Load Failed", start, mapOf( "errorCount" to errors.size.toString(), 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 d19eb944e65..2e00da22b2c 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 @@ -2,6 +2,7 @@ package ai.kilocode.backend.app import ai.kilocode.backend.app.KiloAppState import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.cli.CliServer import ai.kilocode.backend.rpc.appStateDto import ai.kilocode.backend.testing.FakeCliServer import ai.kilocode.backend.testing.MockCliServer @@ -14,7 +15,10 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import java.net.ServerSocket import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicInteger import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -40,14 +44,56 @@ class KiloBackendAppServiceTest { private fun create(loadTimeoutMs: Long = 30_000L): KiloBackendAppService = KiloBackendAppService.create(scope, FakeCliServer(mock), log, loadTimeoutMs) + private suspend fun ready(svc: KiloBackendAppService): KiloAppState.Ready { + val state = assertNotNull( + withTimeoutOrNull(35_000) { + svc.appState.first { + it is KiloAppState.Ready || it is KiloAppState.Error || it is KiloAppState.MigrationRequired + } + }, + "App startup timed out in ${svc.appState.value}; logs=${log.messages}", + ) + return assertIs(state, "App startup failed in $state; logs=${log.messages}") + } + + private class StallingServer(private val mock: MockCliServer) : CliServer { + override var forceExtract = false + private val starts = AtomicInteger() + private var srv: ServerSocket? = null + + val count: Int get() = starts.get() + + override fun process(): Process? = null + + override suspend fun init(): CliServer.State { + if (starts.getAndIncrement() == 0) { + val socket = ServerSocket(0) + srv = socket + return CliServer.State.Ready(socket.localPort, mock.password) + } + return CliServer.State.Ready(mock.start(), mock.password) + } + + override fun exited(proc: Process) {} + + override fun stop() { + srv?.close() + srv = null + mock.shutdown() + } + + override fun dispose() { + stop() + mock.close() + } + } + @Test fun `full lifecycle reaches Ready`() = runBlocking { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) val ready = svc.appState.value as KiloAppState.Ready assertNotNull(ready.data.config) @@ -60,9 +106,7 @@ class KiloBackendAppServiceTest { val svc = KiloBackendAppService.create(scope, server, log) svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) svc.shutdownForUnload() svc.shutdownForUnload() @@ -82,9 +126,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) assertNotNull(svc.config) assertEquals("claude-4", svc.config!!.model) @@ -96,9 +138,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) val dto = appStateDto(svc.appState.value) assertEquals("openai/gpt", dto.config?.model) @@ -112,9 +152,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) val ready = svc.appState.value as KiloAppState.Ready assertEquals(1, ready.data.warnings.size) @@ -128,9 +166,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) val before = svc.appState.value as KiloAppState.Ready assertEquals(1, before.data.warnings.size) @@ -155,9 +191,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) val before = mock.requestCount("/global/config") svc.retry() @@ -178,9 +212,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) assertNotNull(svc.profile) assertEquals("alice@test.com", svc.profile!!.profile.email) @@ -191,9 +223,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) svc.setOrganization("org_1") assertEquals("""{"organizationId":"org_1"}""", mock.lastOrganizationSetBody) @@ -208,9 +238,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) // Profile is null but we still reached Ready assertNull(svc.profile) @@ -265,9 +293,7 @@ class KiloBackendAppServiceTest { mock.config = """{"model":"retry/model"}""" svc.retry() - withTimeout(15_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) assertEquals("retry/model", svc.config?.model) assertEquals(4, mock.requestCount("/global/config")) @@ -306,11 +332,8 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { state -> - state is KiloAppState.Ready && state.data.warnings.any { it.path == ".kilo/kilo.json" } - } - } + val state = ready(svc) + assertTrue(state.data.warnings.any { it.path == ".kilo/kilo.json" }) assertTrue(log.awaitMessage { it.contains("App warnings:") && it.contains(".kilo/kilo.json: Invalid JSON") @@ -338,9 +361,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) // Second connect should not change state svc.connect() @@ -352,9 +373,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) val dto = svc.health() assertTrue(dto.healthy) @@ -367,9 +386,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) val dto = svc.health() assertFalse(dto.healthy) @@ -383,9 +400,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(15_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) assertNull(svc.profile) assertIs(svc.appState.value) @@ -396,9 +411,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) svc.dispose() assertEquals(KiloAppState.Disconnected, svc.appState.value) @@ -419,10 +432,7 @@ class KiloBackendAppServiceTest { assertIs(loading) gate.countDown() - val ready = withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } - assertIs(ready) + ready(svc) } finally { gate.countDown() } @@ -461,11 +471,9 @@ class KiloBackendAppServiceTest { try { svc.connect() - val ready = withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } as KiloAppState.Ready + val state = ready(svc) - assertTrue(ready.data.warnings.isEmpty()) + assertTrue(state.data.warnings.isEmpty()) assertTrue(svc.warnings.isEmpty()) } finally { gate.countDown() @@ -488,9 +496,7 @@ class KiloBackendAppServiceTest { gate.countDown() svc.restart() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) assertIs(svc.appState.value) assertFalse(log.messages.any { it.contains("Application start timed out") }) @@ -515,9 +521,7 @@ class KiloBackendAppServiceTest { gate.countDown() svc.reinstall() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) assertIs(svc.appState.value) assertFalse(log.messages.any { it.contains("Application start timed out") }) @@ -532,9 +536,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) assertEquals("initial", svc.config?.model) @@ -560,9 +562,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) assertEquals(1, (svc.appState.value as KiloAppState.Ready).data.warnings.size) @@ -589,9 +589,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) val auth = svc.startLogin(null) assertEquals("https://auth.kilo.ai/device", auth.verificationUrl) @@ -606,9 +604,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) val profile = svc.completeLogin(null) assertNotNull(profile) @@ -623,9 +619,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) mock.awaitSseConnection() @@ -657,17 +651,13 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) // Restart should tear down and reconnect svc.restart() // Should transition back to Ready after restart - withTimeout(15_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) assertIs(svc.appState.value) assertNotNull(svc.config) @@ -678,9 +668,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) // Close SSE to trigger reconnect path mock.closeSse() @@ -696,6 +684,19 @@ class KiloBackendAppServiceTest { assertIs(svc.appState.value) } + @Test + fun `startup SSE timeout reconnects from Connecting`() = runBlocking { + val server = StallingServer(mock) + val svc = KiloBackendAppService.create(scope, server, log) + svc.connect() + + ready(svc) + + assertTrue(server.count >= 2) + assertTrue(log.messages.any { it.contains("SSE: connection timed out") }) + assertIs(svc.appState.value) + } + // ------ Profile DTO mapping tests ------ @Test @@ -712,9 +713,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) val dto = appStateDto(svc.appState.value) assertEquals("alice@test.com", dto.profile?.email) @@ -730,9 +729,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) // Update mock to return different profile mock.profile = """{"profile":{"email":"alice@test.com","name":"Updated Alice"},"balance":{"balance":99.0},"currentOrgId":null}""" @@ -750,9 +747,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) assertNotNull(svc.profile) mock.authRemoveStatus = 200 @@ -769,9 +764,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) val before = svc.profile assertNotNull(before) @@ -793,9 +786,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) mock.authorizeStatus = 500 var thrown = false @@ -814,9 +805,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) val auth = svc.startLogin(null) assertNull(auth.code, "code should be null when instructions have no code: prefix") @@ -828,9 +817,7 @@ class KiloBackendAppServiceTest { val svc = create() svc.connect() - withTimeout(10_000) { - svc.appState.first { it is KiloAppState.Ready } - } + ready(svc) mock.callbackStatus = 500 var thrown = false