Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export default [
"core/tools/**/*.ts",
"core/webview/**/*.ts",
"integrations/**/*.ts",
"services/**/*.ts",
],
languageOptions: {
parserOptions: {
Expand Down
19 changes: 19 additions & 0 deletions src/services/code-index/__tests__/manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,25 @@ describe("CodeIndexManager - handleSettingsChange regression", () => {
})

describe("handleSettingsChange", () => {
it("should log background indexing failures", async () => {
const indexingError = new Error("indexing startup failed")
const startIndexing = vi.fn().mockRejectedValue(indexingError)
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined)
Object.defineProperty(manager, "_orchestrator", {
value: { startIndexing, stopIndexing: vi.fn() },
configurable: true,
})

manager["startIndexingInBackground"]()
await Promise.resolve()

expect(consoleErrorSpy).toHaveBeenCalledWith(
"[CodeIndexManager] Background indexing failed:",
indexingError,
)
consoleErrorSpy.mockRestore()
})

it("should not throw when called on uninitialized manager (regression test)", async () => {
// This is the core regression test: handleSettingsChange() should not throw
// when called before the manager is initialized (during first-time configuration)
Expand Down
8 changes: 7 additions & 1 deletion src/services/code-index/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,13 +227,19 @@ export class CodeIndexManager {
(needsServiceRecreation && (!this._orchestrator || this._orchestrator.state !== "Indexing"))

if (shouldStartOrRestartIndexing) {
this._orchestrator?.startIndexing()
this.startIndexingInBackground()
}
}

return { requiresRestart }
}

private startIndexingInBackground(): void {
void this._orchestrator?.startIndexing().catch((error) => {
console.error("[CodeIndexManager] Background indexing failed:", error)
})
}

/**
* Initiates the indexing process (initial scan and starts watcher).
* Automatically recovers from error state if needed before starting.
Expand Down
30 changes: 30 additions & 0 deletions src/services/code-index/processors/__tests__/scanner.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,36 @@ describe("DirectoryScanner", () => {
expect(mockVectorStore.upsertPoints).toHaveBeenCalled()
})

it("should clean up a threshold-triggered batch", async () => {
const thresholdScanner = new DirectoryScanner(
mockEmbedder,
mockVectorStore,
mockCodeParser,
mockCacheManager,
mockIgnoreInstance,
1,
)
const { listFiles } = await import("../../../glob/list-files")
vi.mocked(listFiles).mockResolvedValue([["test/file1.js"], false])
mockCodeParser.parseFile.mockResolvedValue([
{
file_path: "test/file1.js",
content: "test content",
start_line: 1,
end_line: 5,
identifier: "test",
type: "function",
fileHash: "hash",
segmentHash: "threshold-segment",
},
])

await thresholdScanner.scanDirectory("/test")

expect(mockEmbedder.createEmbeddings).toHaveBeenCalledTimes(1)
expect(mockVectorStore.upsertPoints).toHaveBeenCalledTimes(1)
})

it("should delete points for removed files", async () => {
;(mockCacheManager.getAllHashes as any).mockReturnValue({ "old/file.js": "old-hash" })

Expand Down
10 changes: 6 additions & 4 deletions src/services/code-index/processors/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,10 +216,11 @@ export class DirectoryScanner implements IDirectoryScanner {
activeBatchPromises.add(batchPromise)

// Clean up completed promises to prevent memory accumulation
batchPromise.finally(() => {
const cleanupBatch = () => {
activeBatchPromises.delete(batchPromise)
pendingBatchCount--
})
}
void batchPromise.then(cleanupBatch, cleanupBatch)
}
} finally {
release()
Expand Down Expand Up @@ -306,10 +307,11 @@ export class DirectoryScanner implements IDirectoryScanner {
activeBatchPromises.add(batchPromise)

// Clean up completed promises to prevent memory accumulation
batchPromise.finally(() => {
const cleanupBatch = () => {
activeBatchPromises.delete(batchPromise)
pendingBatchCount--
})
}
void batchPromise.then(cleanupBatch, cleanupBatch)
} finally {
release()
}
Expand Down
4 changes: 3 additions & 1 deletion src/services/mcp/McpHub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,9 @@ export class McpHub {
if (secretStorage) {
this.secretStorage = secretStorage
}
this.watchMcpSettingsFile()
void this.watchMcpSettingsFile().catch((error) => {
console.error("[McpHub] Failed to watch MCP settings file:", error)
})
this.watchProjectMcpFile().catch(console.error)
this.setupWorkspaceFoldersWatcher()
this.initializationPromise = Promise.all([
Expand Down
24 changes: 22 additions & 2 deletions src/services/mcp/__tests__/McpHub.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import { ServerConfigSchema, McpHub } from "../McpHub"
import { OAUTH_FLOW_TIMEOUT_MS } from "../constants"
import { t } from "../../../i18n"

type McpHubPrivate = {
watchMcpSettingsFile: () => Promise<void>
}

// Mock fs/promises before importing anything that uses it.
// Named exports and the default export must share the same vi.fn() instances so that
// `import * as fs` (used by McpHub.ts) and `import fs` (default) both see the same mocks.
Expand Down Expand Up @@ -192,6 +196,22 @@ describe("McpHub", () => {
}
})

it("should log settings watcher startup failures", async () => {
const watcherError = new Error("watcher startup failed")
const watchSpy = vi
.spyOn(McpHub.prototype as unknown as McpHubPrivate, "watchMcpSettingsFile")
.mockRejectedValueOnce(watcherError)
vi.mocked(console.error).mockClear()

const failingHub = new McpHub(mockProvider as ClineProvider)
await failingHub.waitUntilReady()
await Promise.resolve()

expect(console.error).toHaveBeenCalledWith("[McpHub] Failed to watch MCP settings file:", watcherError)
await failingHub.dispose()
watchSpy.mockRestore()
})

describe("Discriminated union type handling", () => {
it("should create connected connections with proper type", async () => {
// Mock StdioClientTransport
Expand Down Expand Up @@ -2526,7 +2546,7 @@ describe("McpHub", () => {
})

mockSecretStorage.onDidChange.mockImplementation((_key: string, cb: () => void) => {
Promise.resolve().then(() => {
queueMicrotask(() => {
mockSecretStorage.getOAuthData.mockResolvedValue({
expires_at: Date.now() + 10 * 60 * 1000,
})
Expand Down Expand Up @@ -2811,7 +2831,7 @@ describe("McpHub", () => {
})

mockSecretStorage.onDidChange.mockImplementation((_key: string, cb: () => void) => {
Promise.resolve().then(() => {
queueMicrotask(() => {
// Dispose the hub before the token callback runs
;(mcpHub as any).isDisposed = true
mockSecretStorage.getOAuthData.mockResolvedValue({
Expand Down
Loading