Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions packages/kilo-indexing/src/indexing/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ export const DEFAULT_MAX_SEARCH_RESULTS = CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH
/**File Watcher */
export const QDRANT_CODE_BLOCK_NAMESPACE = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
export const MAX_FILE_SIZE_BYTES = 1 * 1024 * 1024 // 1MB
// Maximum time to wait for chokidar to finish its initial scan and emit "ready".
// On very large workspaces, or when chokidar's native fsevents backend is
// unavailable (e.g. inside a bundled runtime) and it falls back to a slow
// per-directory walk, "ready" may never fire. Without a bound this hangs the
// entire indexing pipeline indefinitely with no surfaced error.
export const WATCHER_READY_TIMEOUT_MS = 60_000

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: A hard 60s deadline turns a hang into "no indexing at all"

Since orchestrator.runIndexing() awaits _startWatcher() before the vector store init and the scan, a rejection here aborts the whole pipeline and lands in Error state — the user gets zero indexed files instead of an infinite spinner. That is strictly better for diagnosability, but as the PR notes, the watcher is only needed for incremental updates, so a genuinely large monorepo whose initial chokidar walk takes 70s now goes from "eventually works" to "never works".

Given the fix is already touching this path, consider degrading instead of failing: log/telemetry the readiness timeout, skip the watcher (no incremental updates), and let the scan proceed. If the hard failure is intentional for now, a follow-up issue for the degraded mode would be worth linking, and making the bound configurable (as suggested in the PR description) reduces the risk of false failures on slow network filesystems.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.


/**Directory Scanner */
export const MAX_LIST_FILES_LIMIT_CODE_INDEX = 50_000
Expand Down
82 changes: 73 additions & 9 deletions packages/kilo-indexing/src/indexing/processors/file-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
BATCH_SEGMENT_THRESHOLD,
MAX_BATCH_RETRIES,
INITIAL_RETRY_DELAY_MS,
WATCHER_READY_TIMEOUT_MS,
} from "../constants"
import { scannerExtensions } from "../shared/supported-extensions"
import {
Expand Down Expand Up @@ -38,6 +39,53 @@ import { isBinary } from "../shared/is-binary"

const log = Log.create({ service: "file-watcher" })

/**
* Minimal watcher surface needed to await readiness. Declaring our own narrow
* interface (rather than the full chokidar type) keeps {@link waitForWatcherReady}
* unit-testable without spinning up a real filesystem watcher.
*/
export interface WatcherReadySource {
once(event: "ready", listener: () => void): unknown
once(event: "error", listener: (error: unknown) => void): unknown
}

/**
* Waits for a chokidar watcher to finish its initial scan and emit "ready".
*
* Rejects if the watcher emits "error", or if neither "ready" nor "error"
* occurs within `timeoutMs`. The timeout is what prevents a silent, indefinite
* hang on very large workspaces (or when chokidar's native backend is
* unavailable and it falls back to a slow per-directory walk). The caller owns
* the watcher lifecycle and is responsible for closing it on failure.
*/
export function waitForWatcherReady(
watcher: WatcherReadySource,
workspacePath: string,
timeoutMs: number = WATCHER_READY_TIMEOUT_MS,
): Promise<void> {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
reject(
new Error(
`File watcher did not become ready within ${Math.round(timeoutMs / 1000)}s for "${workspacePath}". ` +
`The workspace may be too large or contain problematic symlinks — scope indexing to a single ` +
`repository, or add .gitignore/.kilocodeignore patterns to exclude large directories.`,
),
)
}, timeoutMs)
// Do not let the safety timeout keep the event loop alive on its own.
timer.unref()
watcher.once("ready", () => {
clearTimeout(timer)
resolve()
})
watcher.once("error", (error: unknown) => {
clearTimeout(timer)
reject(error instanceof Error ? error : new Error(String(error)))
})
})
}

/**
* Implementation of the file watcher interface.
*
Expand Down Expand Up @@ -137,7 +185,7 @@ export class FileWatcher implements IFileWatcher {

log.info("initializing file watcher", { workspacePath: this.workspacePath })

this.watcher = chokidarWatch(this.workspacePath, {
const watcher = chokidarWatch(this.workspacePath, {
ignored: (filePath: string) => {
const relativeFilePath = generateRelativeIgnorePath(filePath, this.workspacePath)
if (!relativeFilePath) return false
Expand All @@ -146,16 +194,32 @@ export class FileWatcher implements IFileWatcher {
},
persistent: true,
ignoreInitial: true,
// Match the scanner's glob (which does not traverse symlinks) and git's
// own semantics. Following symlinked directories can cause chokidar to
// re-walk/cycle through link targets, which on symlink-heavy trees can
// stall the initial scan so "ready" never fires.
followSymlinks: false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: followSymlinks: false also stops incremental updates for symlinked files

The comment justifies this by matching the scanner, and for symlinked directories that is accurate (glob defaults to follow: false, so ** does not descend into them). But glob with nodir: true still returns symlinks that point at files, so those paths are indexed by the initial scan. With followSymlinks: false, chokidar watches the link itself rather than the target, so edits to the target no longer emit change — those entries will silently go stale until a full re-index.

Probably an acceptable trade for the hang this fixes, but it's a behavior change beyond "avoid symlink cycles" and worth calling out in the changeset/PR notes (or narrowing, e.g. keep following links but bound the walk).


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

})
this.watcher = watcher

this.watcher.on("add", (filePath) => this.handleFileEvent(filePath, "create"))
this.watcher.on("change", (filePath) => this.handleFileEvent(filePath, "change"))
this.watcher.on("unlink", (filePath) => this.handleFileEvent(filePath, "delete"))
this.ready = new Promise((resolve, reject) => {
this.watcher?.once("ready", resolve)
this.watcher?.once("error", reject)
})
await this.ready
watcher.on("add", (filePath) => this.handleFileEvent(filePath, "create"))
watcher.on("change", (filePath) => this.handleFileEvent(filePath, "change"))
watcher.on("unlink", (filePath) => this.handleFileEvent(filePath, "delete"))

const ready = waitForWatcherReady(watcher, this.workspacePath)
this.ready = ready
try {
await ready
} catch (error) {
// The watcher never became ready (or emitted an error). Surface the
// failure instead of hanging the indexing pipeline forever, and reset
// state so a later initialize() can retry cleanly rather than re-awaiting
// this rejected promise.
if (this.ready === ready) this.ready = undefined
if (this.watcher === watcher) this.watcher = undefined
void watcher.close()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Fire-and-forget close() can leak the failed watcher and produce an unhandled rejection

Two concerns on this line, both specific to the failure path this PR adds:

  1. void watcher.close() discards the promise, so if close() rejects (quite plausible for a watcher that is already unhealthy — this is exactly the case where the readiness timeout fires) Node reports an unhandled rejection. Attaching a logging .catch() keeps the failure visible without changing control flow.
  2. Because this.watcher is cleared just above, this is the last reference the class holds to the chokidar instance. If close() never settles, the underlying fs.watch handles from the partially-completed initial walk stay open forever, and shutdown()/dispose() can no longer reach them. A retry (which the reset intentionally enables) then creates a second full watcher on top of the first, so repeated retries accumulate watchers and file descriptors.

Something like watcher.close().catch((err) => log.error("failed to close watcher after readiness failure", { err })) addresses (1) and at least makes (2) diagnosable. Also worth noting the add/change/unlink handlers registered above remain attached until close() actually completes, so events from the abandoned watcher can still push entries into accumulatedEvents after initialize() has thrown.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

throw error
}
log.info("file watcher ready", { workspacePath: this.workspacePath })
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import type {
PointStruct,
VectorStoreSearchResult,
} from "../../../../src/indexing/interfaces"
import { FileWatcher } from "../../../../src/indexing/processors/file-watcher"
import {
FileWatcher,
waitForWatcherReady,
type WatcherReadySource,
} from "../../../../src/indexing/processors/file-watcher"
import { CodeParser } from "../../../../src/indexing/processors/parser"
import { loadIgnore } from "../../../../src/indexing/shared/load-ignore"
import { WorktreeOverlay } from "../../../../src/indexing/worktree-overlay"
Expand Down Expand Up @@ -384,3 +388,43 @@ describe("FileWatcher", () => {
}
})
})

describe("waitForWatcherReady", () => {
type FakeWatcher = WatcherReadySource & { emit(event: string, arg?: unknown): void }

function createReadyWatcher(): FakeWatcher {
const handlers = new Map<string, (arg?: unknown) => void>()
return {
once(event: string, listener: (arg?: unknown) => void) {
handlers.set(event, listener)
},
emit(event: string, arg?: unknown) {
handlers.get(event)?.(arg)
},
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The riskiest new logic — initialize()'s failure reset — is untested

The three new tests cover waitForWatcherReady in isolation, which is the simple half. The subtler behavior added by this PR is in initialize(): on failure it must clear this.ready and this.watcher, close the watcher, and allow a later initialize() to retry rather than re-awaiting a rejected promise. None of that is exercised, and a regression there reintroduces a stuck state (a retained rejected this.ready makes every subsequent initialize() throw immediately).

A test against a real temp directory with a very small timeout — or injecting the readiness wait — would cover the retry path without a real hang.

Minor note on the fake: once stores handlers in a Map keyed by event name and never removes them, so it does not actually behave like once, and the name createReadyWatcher is misleading given two of the three tests use it for the non-ready cases.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.


test("resolves once the watcher emits ready", async () => {
const watcher = createReadyWatcher()
const ready = waitForWatcherReady(watcher, "/workspace", 1000)
watcher.emit("ready")
await expect(ready).resolves.toBeUndefined()
})

test("rejects with an actionable error when ready never fires within the timeout", async () => {
// A watcher that never emits "ready" simulates a very large tree or a
// missing native backend; this must fail fast instead of hanging forever.
const watcher = createReadyWatcher()
const ready = waitForWatcherReady(watcher, "/huge/workspace", 20)
await expect(ready).rejects.toThrow(/did not become ready/)
await expect(ready).rejects.toThrow(/scope indexing to a single/)
await expect(ready).rejects.toThrow(/\/huge\/workspace/)
})

test("rejects when the watcher emits an error", async () => {
const watcher = createReadyWatcher()
const ready = waitForWatcherReady(watcher, "/workspace", 1000)
watcher.emit("error", new Error("watch boom"))
await expect(ready).rejects.toThrow("watch boom")
})
})