Merge pull request #10783 from Kilo-Org/gray-jet

fix(vscode): surface backend crash recovery
This commit is contained in:
Marius
2026-06-01 11:56:37 +02:00
committed by GitHub
10 changed files with 140 additions and 24 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Show a retryable connection error and preserve unsent prompts when the VS Code background CLI process exits.
+14 -4
View File
@@ -347,6 +347,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
}
private postConnectionState(error = this.connectionService.getConnectionError()): void {
this.postMessage({
type: "connectionState",
state: this.connectionState,
...(this.connectionState === "error" && {
error: getErrorMessage(error) || "Connection to CLI backend lost. Retry to reconnect.",
}),
})
}
// Strip edit-tool metadata.filediff.before/after (multi-MB for edit-heavy
// sessions) to keep session switches fast. Logic in kilo-provider/slim-metadata.ts.
private slimPart<T>(part: T): T {
@@ -386,7 +396,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
// Always push connection state first so the UI can render appropriately.
this.postMessage({ type: "connectionState", state: this.connectionState })
this.postConnectionState()
pushTelemetryState((m) => this.postMessage(m))
// Re-send ready so the webview can recover after refresh.
@@ -1228,9 +1238,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
)
// Subscribe to connection state changes
this.unsubscribeState = this.connectionService.onStateChange(async (state) => {
this.unsubscribeState = this.connectionService.onStateChange(async (state, error) => {
this.connectionState = state
this.postMessage({ type: "connectionState", state })
this.postConnectionState(error)
if (state === "connected") {
// Fire config warnings independently so a failure in the
@@ -1309,7 +1319,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
workspaceDirectory: this.getProjectDirectory(this.currentSession?.id),
})
}
this.postMessage({ type: "connectionState", state: this.connectionState })
this.postConnectionState()
// connect() can resolve after SSE reaches "connected" but before this
// provider subscribes to onStateChange(). In that case the initial
@@ -7,7 +7,7 @@ import { resolveEventSessionId as resolveEventSessionIdPure } from "./connection
export type ConnectionState = "connecting" | "connected" | "disconnected" | "error"
type SSEEventListener = (event: Event, directory?: string) => void
type StateListener = (state: ConnectionState) => void
type StateListener = (state: ConnectionState, error?: Error) => void
type SSEEventFilter = (event: Event, directory?: string) => boolean
type NotificationDismissListener = (notificationId: string) => void
type LanguageChangeListener = (locale: string) => void
@@ -65,6 +65,7 @@ export class KiloConnectionService {
private info: { port: number } | null = null
private config: ServerConfig | null = null
private state: ConnectionState = "disconnected"
private error: Error | null = null
private connectPromise: Promise<void> | null = null
private healthPollTimer: ReturnType<typeof setInterval> | null = null
private remoteService: import("../RemoteStatusService").RemoteStatusService | null = null
@@ -93,7 +94,7 @@ export class KiloConnectionService {
private unsubRemote: (() => void) | null = null
constructor(context: vscode.ExtensionContext) {
this.serverManager = new ServerManager(context)
this.serverManager = new ServerManager(context, (code) => this.handleServerExit(code))
}
/**
@@ -115,7 +116,7 @@ export class KiloConnectionService {
await this.connectPromise
} catch (error) {
// If doConnect() fails before SSE can emit a state transition, avoid leaving consumers stuck in "connecting".
this.setState("error")
this.setState("error", this.error ?? (error instanceof Error ? error : new Error(String(error))))
throw error
} finally {
this.connectPromise = null
@@ -126,7 +127,7 @@ export class KiloConnectionService {
* Get the shared SDK client. Throws if not connected.
*/
getClient(): KiloClient {
if (!this.client) {
if (!this.client || this.state !== "connected") {
throw new Error("Not connected — call connect() first")
}
return this.client
@@ -139,11 +140,11 @@ export class KiloConnectionService {
* or if the connection fails.
*/
async getClientAsync(dir?: string): Promise<KiloClient> {
if (this.client) return this.client
if (this.client && this.state === "connected") return this.client
const root = dir ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
if (!root) throw new Error("No workspace folder open")
await this.connect(root)
return this.client!
return this.getClient()
}
/**
@@ -189,6 +190,13 @@ export class KiloConnectionService {
return this.state
}
/**
* Last connection error. Cleared when a new connection attempt begins.
*/
getConnectionError(): Error | null {
return this.error
}
/**
* Subscribe to SSE events. Returns unsubscribe function.
*/
@@ -503,12 +511,14 @@ export class KiloConnectionService {
this.config = null
this.info = null
this.state = "disconnected"
this.error = null
}
private setState(state: ConnectionState): void {
private setState(state: ConnectionState, error?: Error): void {
this.state = state
this.error = state === "error" ? (error ?? this.error) : null
for (const listener of this.stateListeners) {
listener(state)
listener(state, this.error ?? undefined)
}
}
@@ -558,10 +568,28 @@ export class KiloConnectionService {
}
}
private async doConnect(workspaceDir: string): Promise<void> {
// If we reconnect, ensure the previous SSE connection is cleaned up first.
private resetConnection(): void {
this.stopHealthPoll()
this.sseClient?.dispose()
const sse = this.sseClient
this.sseClient = null
sse?.disconnect()
this.client = null
this.config = null
this.info = null
}
private handleServerExit(code: number | null): void {
console.warn("[Kilo New] ConnectionService: CLI background process exited:", code)
this.resetConnection()
this.setState(
"error",
new Error(`CLI background process exited with code ${code ?? "unknown"}. Retry to reconnect.`),
)
}
private async doConnect(workspaceDir: string): Promise<void> {
// Never expose a stale SDK client while its replacement server is starting.
this.resetConnection()
const server = await this.serverManager.getServer()
this.info = { port: server.port }
@@ -575,14 +603,15 @@ export class KiloConnectionService {
// Create SDK client with Basic Auth header
const authHeader = `Basic ${Buffer.from(`kilo:${server.password}`).toString("base64")}`
this.client = createKiloClient({
const client = createKiloClient({
baseUrl: config.baseUrl,
headers: {
Authorization: authHeader,
},
})
this.sseClient = new SdkSSEAdapter(this.client)
const sse = new SdkSSEAdapter(client)
this.client = client
this.sseClient = sse
// Wait until SSE yields its first server event before resolving connect().
// Initial stream failures are handled by the adapter reconnect loop.
@@ -596,18 +625,29 @@ export class KiloConnectionService {
let didConnect = false
// Wire SSE events → broadcast to all registered listeners
this.sseClient.onEvent((event, directory) => {
sse.onEvent((event, directory) => {
if (this.sseClient !== sse) return
for (const listener of this.eventListeners) {
listener(event, directory)
}
})
this.sseClient.onError(() => {
this.setState("error")
sse.onError((error) => {
if (this.sseClient !== sse) return
this.setState("error", error)
})
// Wire SSE state → broadcast to all registered state listeners
this.sseClient.onStateChange((sseState) => {
sse.onStateChange((sseState) => {
if (this.sseClient !== sse) {
if (!didConnect && sseState === "disconnected") {
rejectConnected?.(new Error(`SSE connection ended in state: ${sseState}`))
resolveConnected = null
rejectConnected = null
}
return
}
this.setState(sseState)
if (sseState === "connected") {
@@ -625,7 +665,7 @@ export class KiloConnectionService {
}
})
this.sseClient.connect()
sse.connect()
await connectedPromise
@@ -17,6 +17,7 @@ export interface ServerInstance {
const STARTUP_TIMEOUT_SECONDS = 30
type WorkspaceFolderLike = { uri: { fsPath: string } }
type ServerExitListener = (code: number | null) => void
export function resolveServerCwd(folders: readonly WorkspaceFolderLike[] | undefined, storage: string): string {
return folders?.[0]?.uri.fsPath ?? storage
@@ -31,7 +32,10 @@ export class ServerManager {
private instance: ServerInstance | null = null
private startupPromise: Promise<ServerInstance> | null = null
constructor(private readonly context: vscode.ExtensionContext) {}
constructor(
private readonly context: vscode.ExtensionContext,
private readonly onExit?: ServerExitListener,
) {}
/**
* Get or start the server instance
@@ -171,6 +175,7 @@ export class ServerManager {
console.log("[Kilo New] ServerManager: 🛑 Process exited with code:", code)
if (this.instance?.process === serverProcess) {
this.instance = null
this.onExit?.(code)
}
if (!resolved) {
const { userMessage, userDetails } = toErrorMessage(
@@ -69,6 +69,7 @@ function connection() {
getServerInfo: () => ({ port: 12345 }),
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:12345", password: "test" }),
getConnectionState: () => "connected" as const,
getConnectionError: () => null,
resolveEventSessionId: (event: Event) => (event.type === "session.created" ? event.properties.info.id : undefined),
recordMessageSessionId: () => undefined,
notifyNotificationDismissed: () => undefined,
@@ -98,6 +98,7 @@ function createConnection(client: ReturnType<typeof createClient>) {
registerDirectoryProvider: () => () => undefined,
getServerInfo: () => ({ port: 12345 }),
getConnectionState: () => "connected" as const,
getConnectionError: () => null,
resolveEventSessionId: () => undefined,
recordMessageSessionId: () => undefined,
notifyNotificationDismissed: () => undefined,
@@ -91,6 +91,7 @@ function createConnection(client: ReturnType<typeof createClient>) {
getServerInfo: () => ({ port: 12345 }),
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:12345", password: "test" }),
getConnectionState: () => "connected" as const,
getConnectionError: () => null,
resolveEventSessionId: () => undefined,
recordMessageSessionId: () => undefined,
notifyNotificationDismissed: () => undefined,
@@ -0,0 +1,20 @@
import { describe, expect, it } from "bun:test"
import { readFileSync } from "node:fs"
import { join } from "node:path"
describe("PromptInput connection guard", () => {
const path = join(__dirname, "..", "..", "webview-ui", "src", "components", "chat", "PromptInput.tsx")
const src = readFileSync(path, "utf8")
it("rechecks the connection after resolving async attachments and before clearing the draft", () => {
const attachments = src.indexOf("const gitFile = await git.resolveAttachment")
const guard = src.indexOf("if (isDisabled()) return", attachments)
const send = src.indexOf("session.sendMessage(message", guard)
const clear = src.indexOf("drafts.delete(key)", send)
expect(attachments).toBeGreaterThan(-1)
expect(guard).toBeGreaterThan(attachments)
expect(send).toBeGreaterThan(guard)
expect(clear).toBeGreaterThan(send)
})
})
@@ -110,6 +110,38 @@ describe("SdkSSEAdapter", () => {
})
})
describe("KiloConnectionService backend crash", () => {
it("invalidates the stale SDK client and reports a retryable error", () => {
const service = new KiloConnectionService({} as any)
const states: Array<{ state: string; error?: string }> = []
;(service as any).client = {}
;(service as any).config = { baseUrl: "http://127.0.0.1:52512", password: "secret" }
;(service as any).info = { port: 52512 }
;(service as any).state = "connected"
service.onStateChange((state, error) => states.push({ state, error: error?.message }))
;(service as any).handleServerExit(9)
expect(service.getConnectionState()).toBe("error")
expect(service.getConnectionError()?.message).toContain("CLI background process exited with code 9")
expect(service.getServerConfig()).toBeNull()
expect(service.getServerInfo()).toBeNull()
expect(() => service.getClient()).toThrow("Not connected")
expect(states).toEqual([
{ state: "error", error: "CLI background process exited with code 9. Retry to reconnect." },
])
service.dispose()
})
it("does not expose an SDK client while a replacement server is connecting", () => {
const service = new KiloConnectionService({} as any)
;(service as any).client = {}
;(service as any).state = "connecting"
expect(() => service.getClient()).toThrow("Not connected")
service.dispose()
})
})
describe("KiloConnectionService SSE startup", () => {
it("waits through an initial SSE fetch failure until the stream opens", async () => {
const original = globalThis.fetch
@@ -801,6 +801,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return undefined
})
if (hasGit() && hasGitChangesMention(message) && !gitFile) return
if (isDisabled()) return
const allFiles = [
...mentionFiles,