Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private projectID: string | undefined
/** Abort controller for the current loadMessages request; aborted when a new session is selected. */
private loadMessagesAbort: AbortController | null = null
/** Set after dispose() so async continuations can stop mutating or posting state. */
private disposed = false
/** Incremented whenever the bound webview lifecycle changes, invalidating older async work. */
private stamp = 0
/** Set when refreshSessions() is called before the client is ready.
* Cleared and retried once the connection transitions to "connected". */
private pendingSessionRefresh = false
Expand Down Expand Up @@ -279,6 +283,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
_token: vscode.CancellationToken,
) {
// Store the webview references
this.disposed = false
this.stamp += 1
this.isWebviewReady = false
this.webview = webviewView.webview

Expand All @@ -303,6 +309,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
*/
public resolveWebviewPanel(panel: vscode.WebviewPanel): void {
// WebviewPanel can be restored/reloaded; ensure we don't treat it as ready prematurely.
this.disposed = false
this.stamp += 1
this.isWebviewReady = false
this.webview = panel.webview

Expand Down Expand Up @@ -386,13 +394,21 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
webview: vscode.Webview,
options?: { onBeforeMessage?: (msg: Record<string, unknown>) => Promise<Record<string, unknown> | null> },
): void {
this.disposed = false
this.stamp += 1
this.isWebviewReady = false
this.webview = webview
this.onBeforeMessage = options?.onBeforeMessage ?? null
this.setupWebviewMessageHandler(webview)
this.initializeConnection()
}

private isAlive(stamp: number, abort?: AbortController): boolean {
if (this.disposed || !this.webview || this.stamp !== stamp) return false
if (!abort) return true
return !abort.signal.aborted
}

/**
* Set up the shared message handler for both sidebar and tab webviews.
* Handles ALL message types so tabs have full functionality.
Expand Down Expand Up @@ -1044,6 +1060,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.loadMessagesAbort?.abort()
const abort = new AbortController()
this.loadMessagesAbort = abort
const stamp = this.stamp

try {
const workspaceDir = this.getWorkspaceDirectory(sessionID)
Expand All @@ -1053,7 +1070,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
)

// If this request was aborted while awaiting, skip posting stale results
if (abort.signal.aborted) return
if (!this.isAlive(stamp, abort)) return

// Update currentSession so fallback logic in handleSendMessage/handleAbort
// references the correct session after switching. loadMessages is the
Expand All @@ -1065,7 +1082,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.client.session
.get({ sessionID, directory: workspaceDir })
.then((result) => {
if (result.data && !abort.signal.aborted) {
if (result.data && this.isAlive(stamp, abort)) {
this.currentSession = result.data
}
})
Expand All @@ -1081,7 +1098,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.client.session
.status({ directory: workspaceDir })
.then((result) => {
if (!result.data) return
if (!this.isAlive(stamp, abort) || !result.data) return
for (const [sid, info] of Object.entries(result.data) as [string, SessionStatus][]) {
if (!this.trackedSessionIds.has(sid)) continue
this.postMessage({
Expand Down Expand Up @@ -1112,16 +1129,21 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper

// Recover any permission.asked events that were missed while the webview
// was loading or during an SSE reconnection (fire-and-forget).
if (!this.isAlive(stamp, abort)) return
void fetchAndSendPendingPermissions(this.permissionCtx)
} catch (error) {
// Silently ignore aborted requests — the user switched to a different session
if (abort.signal.aborted) return
if (abort.signal.aborted || this.disposed) return
console.error("[Kilo New] KiloProvider: Failed to load messages:", error)
this.postMessage({
type: "error",
message: getErrorMessage(error) || "Failed to load messages",
sessionID,
})
} finally {
if (this.loadMessagesAbort === abort) {
this.loadMessagesAbort = null
}
}
}

Expand Down Expand Up @@ -2397,6 +2419,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
/** Post a message to the webview. Public so toolbar button commands can send messages. */
public postMessage(message: unknown): void {
if (this.disposed) {
return
}
if (!this.webview) {
const type =
typeof message === "object" &&
Expand Down Expand Up @@ -2608,16 +2633,26 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
* Does NOT kill the server — that's the connection service's job.
*/
dispose(): void {
this.disposed = true
this.stamp += 1
this.isWebviewReady = false
this.unsubscribeEvent?.()
this.unsubscribeState?.()
this.unsubscribeNotificationDismiss?.()
this.unsubscribeLanguageChange?.()
this.unsubscribeProfileChange?.()
this.webviewMessageDisposable?.dispose()
this.loadMessagesAbort?.abort()
this.loadMessagesAbort = null
this.readyResolvers = []
this.pendingReviewComments = []
this.trackedSessionIds.clear()
this.syncedChildSessions.clear()
this.sessionDirectories.clear()
this.sessionStatusMap.clear()
this.currentSession = null
this.onBeforeMessage = null
this.webview = null
this.ignoreController?.dispose()
this.chatAutocomplete?.dispose()
this.marketplace?.dispose()
Expand Down
191 changes: 191 additions & 0 deletions packages/kilo-vscode/src/__tests__/KiloProvider.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { beforeEach, describe, expect, it, vi } from "vitest"

vi.mock("vscode", () => ({
env: {
appName: "VS Code",
isTelemetryEnabled: true,
machineId: "machine-id",
language: "en",
},
version: "1.0.0",
extensions: {
getExtension: vi.fn().mockReturnValue({
packageJSON: { version: "7.1.3" },
}),
},
workspace: {
getConfiguration: vi.fn().mockReturnValue({
get: vi.fn(),
}),
workspaceFolders: [{ uri: { fsPath: "/repo" } }],
},
commands: {
executeCommand: vi.fn(),
},
window: {},
}))

vi.mock("../image-preview", () => ({
buildPreviewPath: vi.fn(),
getPreviewCommand: vi.fn(),
getPreviewDir: vi.fn(),
parseImage: vi.fn(),
trimEntries: vi.fn((x: unknown) => x),
}))

vi.mock("../path-utils", () => ({
isAbsolutePath: vi.fn(),
}))

vi.mock("../services/cli-backend", () => ({
ServerStartupError: class extends Error {
userMessage = "error"
userDetails = "details"
},
}))

vi.mock("../services/autocomplete/shims/FileIgnoreController", () => ({
FileIgnoreController: class {
dispose() {}
},
}))

vi.mock("../services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete", () => ({
ChatTextAreaAutocomplete: class {
dispose() {}
},
}))

vi.mock("../utils", () => ({
buildWebviewHtml: vi.fn(() => "<html></html>"),
}))

vi.mock("../services/telemetry", () => ({
TelemetryProxy: {
getInstance: () => ({
setProvider: vi.fn(),
}),
},
}))

vi.mock("../kilo-provider-utils", () => ({
sessionToWebview: vi.fn((x: unknown) => x),
indexProvidersById: vi.fn(),
filterVisibleAgents: vi.fn(() => []),
buildSettingPath: vi.fn(),
mapSSEEventToWebviewMessage: vi.fn(),
getErrorMessage: vi.fn((err: unknown) => String(err)),
isEventFromForeignProject: vi.fn(() => false),
loadSessions: vi.fn(),
flushPendingSessionRefresh: vi.fn(),
}))

vi.mock("../services/marketplace", () => ({
MarketplaceService: class {
dispose() {}
},
}))

vi.mock("../project-directory", () => ({
resolveProjectDirectory: vi.fn(),
}))

vi.mock("../session-status", () => ({
getBusySessionCount: vi.fn(() => 0),
seedSessionStatuses: vi.fn(),
}))

vi.mock("../kilo-provider/slim-metadata", () => ({
slimPart: vi.fn((x: unknown) => x),
slimParts: vi.fn((x: unknown) => x),
}))

vi.mock("../kilo-provider/handlers/migration", () => ({
checkAndShowMigrationWizard: vi.fn(),
handleRequestLegacyMigrationData: vi.fn(),
handleStartLegacyMigration: vi.fn(),
handleSkipLegacyMigration: vi.fn(),
handleClearLegacyData: vi.fn(),
}))

vi.mock("../kilo-provider/handlers/auth", () => ({
handleLogin: vi.fn(),
handleLogout: vi.fn(),
handleSetOrganization: vi.fn(),
handleRefreshProfile: vi.fn(),
}))

vi.mock("../kilo-provider/handlers/cloud-session", () => ({
handleRequestCloudSessions: vi.fn(),
handleRequestCloudSessionData: vi.fn(),
handleImportAndSend: vi.fn(),
}))

vi.mock("../kilo-provider/handlers/permission-handler", () => ({
handlePermissionResponse: vi.fn(),
fetchAndSendPendingPermissions: vi.fn(),
}))

vi.mock("../kilo-provider/handlers/question", () => ({
handleQuestionReply: vi.fn(),
handleQuestionReject: vi.fn(),
}))

vi.mock("../provider-actions", () => ({
buildActionContext: vi.fn(),
computeDefaultSelection: vi.fn(),
fetchProviderData: vi.fn(),
validateRecents: vi.fn(),
connectProvider: vi.fn(),
authorizeProviderOAuth: vi.fn(),
completeProviderOAuth: vi.fn(),
disconnectProvider: vi.fn(),
saveCustomProvider: vi.fn(),
}))

import { KiloProvider } from "../KiloProvider"
import type { KiloConnectionService } from "../services/cli-backend"
import type { Uri } from "vscode"

function createConnection() {
return {
getClient: vi.fn(() => {
throw new Error("not connected")
}),
} as unknown as KiloConnectionService
}

describe("KiloProvider", () => {
beforeEach(() => {
vi.clearAllMocks()
})

it("disposes into a terminal state and drops late postMessage calls", () => {
const uri = {} as Uri
const provider = new KiloProvider(uri, createConnection())
const webview = {
postMessage: vi.fn().mockResolvedValue(true),
}
const abort = new AbortController()

;(provider as any).webview = webview
;(provider as any).isWebviewReady = true
;(provider as any).loadMessagesAbort = abort
;(provider as any).onBeforeMessage = vi.fn()
;(provider as any).pendingReviewComments = [{ comments: [], autoSend: false }]
;(provider as any).readyResolvers = [vi.fn()]

provider.dispose()
provider.postMessage({ type: "late" })

expect(abort.signal.aborted).toBe(true)
expect((provider as any).disposed).toBe(true)
expect((provider as any).isWebviewReady).toBe(false)
expect((provider as any).loadMessagesAbort).toBeNull()
expect((provider as any).webview).toBeNull()
expect((provider as any).onBeforeMessage).toBeNull()
expect((provider as any).pendingReviewComments).toEqual([])
expect((provider as any).readyResolvers).toEqual([])
expect(webview.postMessage).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as fs from "fs"
import * as path from "path"
import * as vscode from "vscode"
import { t } from "./i18n"
import { parseServerPort } from "./server-utils"
import { scanServerPort } from "./server-utils"

export interface ServerInstance {
port: number
Expand Down Expand Up @@ -87,13 +87,16 @@ export class ServerManager {
console.log("[Kilo New] ServerManager: 📦 Process spawned with PID:", serverProcess.pid)

let resolved = false
let text = ""
const stderrLines: string[] = []

serverProcess.stdout?.on("data", (data: Buffer) => {
const output = data.toString()
console.log("[Kilo New] ServerManager: 📥 CLI Server stdout:", output)

const port = parseServerPort(output)
const next = scanServerPort(text, output)
text = next.text
const port = next.port
if (port !== null && !resolved) {
resolved = true
console.log("[Kilo New] ServerManager: 🎯 Port detected:", port)
Expand Down
10 changes: 10 additions & 0 deletions packages/kilo-vscode/src/services/cli-backend/server-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,13 @@ export function parseServerPort(output: string): number | null {
if (!match) return null
return parseInt(match[1]!, 10)
}

const MAX_BUFFER = 1024

export function scanServerPort(prev: string, chunk: string): { text: string; port: number | null } {
const text = (prev + chunk).slice(-MAX_BUFFER)
return {
text,
port: parseServerPort(text),
}
}
Loading