Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/kilo-indexing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@
"@kilocode/kilo-gateway": "workspace:*",
"@kilocode/plugin": "workspace:*",
"@lancedb/lancedb": "0.26.2",
"@parcel/watcher": "2.5.1",
"@qdrant/js-client-rest": "1.17.0",
"async-mutex": "0.5.0",
"chokidar": "4.0.3",
"effect": "catalog:",
"glob": "13.0.6",
"hono": "catalog:",
Expand Down
2 changes: 2 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,8 @@ 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
// Defensive bound on establishing the native file-watcher subscription.
export const PARCEL_SUBSCRIBE_TIMEOUT_MS = 10_000

/**Directory Scanner */
export const MAX_LIST_FILES_LIMIT_CODE_INDEX = 50_000
Expand Down
175 changes: 140 additions & 35 deletions packages/kilo-indexing/src/indexing/processors/file-watcher.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { watch as chokidarWatch, type FSWatcher as ChokidarFSWatcher } from "chokidar"
import type ParcelWatcher from "@parcel/watcher"
import { stat, readFile } from "fs/promises"
import { createHash } from "crypto"
import path from "path"
import { v5 as uuidv5 } from "uuid"
import { Emitter, type Disposable } from "../runtime"
import { Emitter } from "../runtime"
import {
QDRANT_CODE_BLOCK_NAMESPACE,
MAX_FILE_SIZE_BYTES,
BATCH_SEGMENT_THRESHOLD,
MAX_BATCH_RETRIES,
INITIAL_RETRY_DELAY_MS,
PARCEL_SUBSCRIBE_TIMEOUT_MS,
} from "../constants"
import { scannerExtensions } from "../shared/supported-extensions"
import {
Expand Down Expand Up @@ -38,15 +39,107 @@ import { isBinary } from "../shared/is-binary"

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

/**
* Implementation of the file watcher interface.
*
* RATIONALE: Uses chokidar instead of vscode.workspace.createFileSystemWatcher
* so the watcher works outside VS Code (CLI, tests, headless).
*/
declare const KILO_LIBC: string | undefined

export interface FileWatchEvent {
path: string
type: "create" | "update" | "delete"
}

export interface FileWatchSubscription {
unsubscribe(): Promise<void>
}

// Injectable so tests can supply a fake watcher instead of a real subscription.
export type FileWatchSubscribe = (
directory: string,
onEvents: (events: readonly FileWatchEvent[]) => void,
) => Promise<FileWatchSubscription>

function watcherBackend(): ParcelWatcher.BackendType | undefined {
if (process.platform === "win32") return "windows"
if (process.platform === "darwin") return "fs-events"
if (process.platform === "linux") return "inotify"
return undefined
}

let parcelModule: typeof import("@parcel/watcher") | null | undefined
function loadParcelWatcher(): typeof import("@parcel/watcher") | undefined {
if (parcelModule !== undefined) return parcelModule ?? undefined
try {
// Load the platform binding directly; this is what resolves inside the
// bundled bun-compiled runtime (mirrors @kilocode/core's workspace watcher).
const libc = typeof KILO_LIBC === "undefined" ? undefined : KILO_LIBC
const suffix = process.platform === "linux" ? `-${libc || "glibc"}` : ""
// @ts-ignore - the wrapper subpath ships without type declarations
const { createWrapper } = require("@parcel/watcher/wrapper")
const binding = require(`@parcel/watcher-${process.platform}-${process.arch}${suffix}`)
parcelModule = createWrapper(binding) as typeof import("@parcel/watcher")
} catch {
try {
parcelModule = require("@parcel/watcher") as typeof import("@parcel/watcher") // dev / CLI / test fallback
} catch {
parcelModule = null
}
}
return parcelModule ?? undefined
}

// Prune the largest excluded directories from the native watch. Per-repo
// .gitignore/.kilocodeignore are enforced in shouldIndex(), not pruned here.
function watcherIgnoreGlobs(): string[] {
return FileIgnore.PATTERNS.map((pattern) => (pattern.includes("/") ? pattern : `**/${pattern}`))
}

function withSubscribeTimeout(
pending: Promise<FileWatchSubscription>,
directory: string,
): Promise<FileWatchSubscription> {
let timedOut = false
let timer: NodeJS.Timeout | undefined
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
timedOut = true
reject(new Error(`Timed out establishing the file watcher subscription for "${directory}".`))
}, PARCEL_SUBSCRIBE_TIMEOUT_MS)
timer.unref()
})
// Tear down a subscription that only resolves after we have already given up.
void pending
.then((subscription) => (timedOut ? subscription.unsubscribe() : undefined))
.catch((err) => {
if (timedOut) log.warn("failed to tear down late file watcher subscription", { err, workspacePath: directory })

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 late rejection of pending is logged as a teardown failure, mislabeling the root cause

This .catch handles two different errors: (a) unsubscribe() failing while tearing down a late-arriving subscription, and (b) pending itself rejecting after the timeout already fired. In case (b) the subscribe failed — there was never anything to tear down — yet it is logged as "failed to tear down late file watcher subscription", while initialize() already logged the timeout as the failure reason. The real error is preserved in err, but the message points at the wrong operation; consider distinguishing the cases (e.g. a rejection handler on .then that logs "file watcher subscribe failed after the timeout").


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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@kilocode-bot fix it

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.

To use Kilo from GitHub you first need to link your GitHub account to Kilo. Link your Kilo account to continue. After linking, mention me again in this issue or pull request.

})
return Promise.race([pending, timeout]).finally(() => {
if (timer) clearTimeout(timer)
})
}

// @parcel/watcher subscribes without a blocking initial walk (reporting only
// future changes), so it does not stall startup the way chokidar did under bun.
const parcelSubscribe: FileWatchSubscribe = (directory, onEvents) => {
const parcel = loadParcelWatcher()
if (!parcel) {
return Promise.reject(new Error("native file watcher backend (@parcel/watcher) is unavailable"))
}
const pending = parcel.subscribe(
directory,
(error, events) => {
if (error) {
log.error("file watcher reported an error", { workspacePath: directory, err: error })
return
}
onEvents(events)
},
{ ignore: watcherIgnoreGlobs(), backend: watcherBackend() },

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 ignore list dropped .gitignore / .kilocodeignore, which were a prune, not just a filter

The old chokidar ignored callback consulted this.ignoreInstance too, and chokidar calls ignored for directories, so gitignored trees were never descended into. ignoreInstance is always supplied here (service-factory.ts:260), so this is a real loss.

With only FileIgnore.PATTERNS, @parcel/watcher's inotify backend will now recursively watch every gitignored directory that isn't in that hardcoded list — .venv, venv, .tox, Pods, Carthage, packages/*/build variants, generated code, large data dirs, etc. On Linux each directory costs an inotify watch descriptor, so this can push a big repo past max_user_watches and make subscribe reject with ENOSPC — which (see the comment on line 151) currently fails the whole pipeline.

So it isn't purely an efficiency prune as the doc comment on line 105 claims. Consider deriving extra globs from this.ignoreInstance (or threading an ignore list into FileWatchSubscribe so parcelSubscribe can receive them) to restore the previous pruning behavior.


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

)
return withSubscribeTimeout(pending, directory)
}

/** Watches the workspace via @parcel/watcher and feeds changes into the incremental indexer. */
export class FileWatcher implements IFileWatcher {
private ignoreInstance?: IgnoreMatcher
private watcher?: ChokidarFSWatcher
private subscription?: FileWatchSubscription
private accumulatedEvents: Map<string, { path: string; type: "create" | "change" | "delete" }> = new Map()
private batchProcessDebounceTimer?: NodeJS.Timeout
private readonly BATCH_DEBOUNCE_DELAY_MS = 500
Expand All @@ -68,6 +161,15 @@ export class FileWatcher implements IFileWatcher {
}>()
public readonly onDidFinishBatchProcessing = new Emitter<BatchProcessingSummary>()

// @parcel/watcher reports "update" where the pipeline expects "change".
private readonly onWatchEvents = (events: readonly FileWatchEvent[]): void => {
for (const event of events) {
const type: "create" | "change" | "delete" =
event.type === "create" ? "create" : event.type === "delete" ? "delete" : "change"
this.handleFileEvent(event.path, type)
}
}

constructor(
private workspacePath: string,
private readonly cacheManager: CacheManager,
Expand All @@ -80,6 +182,7 @@ export class FileWatcher implements IFileWatcher {
private readonly telemetryMeta?: IndexingTelemetryMeta,
extensions: readonly string[] = scannerExtensions,
private readonly parser: ICodeParser = codeParser,
private readonly subscribeFn: FileWatchSubscribe = parcelSubscribe,
) {
if (ignoreInstance) {
this.ignoreInstance = ignoreInstance
Expand Down Expand Up @@ -124,10 +227,10 @@ export class FileWatcher implements IFileWatcher {
}

/**
* Initializes the file watcher using chokidar.
*
* RATIONALE: chokidar watches the filesystem directly using native OS events,
* removing the dependency on VS Code's file system watcher API.
* Subscribes to the workspace for incremental updates. The watcher is only
* needed for incremental updates, so a subscription failure (missing native
* backend, inotify limits, ...) degrades to a warning and lets the full scan
* proceed rather than failing indexing.
*/
async initialize(): Promise<void> {
if (this.ready) {
Expand All @@ -137,26 +240,18 @@ export class FileWatcher implements IFileWatcher {

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

this.watcher = chokidarWatch(this.workspacePath, {
ignored: (filePath: string) => {
const relativeFilePath = generateRelativeIgnorePath(filePath, this.workspacePath)
if (!relativeFilePath) return false
if (FileIgnore.match(relativeFilePath)) return true
return this.ignoreInstance?.ignores(relativeFilePath) ?? false
},
persistent: true,
ignoreInitial: true,
})

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)
})
this.ready = this.subscribeFn(this.workspacePath, this.onWatchEvents)
.then((subscription) => {
this.subscription = subscription

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: A subscription that resolves after shutdown()/dispose() is leaked and keeps feeding accumulatedEvents

Neither shutdown() nor dispose() awaits this.ready, and this.subscription is still undefined while a subscribe is in flight. If indexing is cancelled (or stopWatcher() runs) while subscribeFn is still pending — possible for up to PARCEL_SUBSCRIBE_TIMEOUT_MS on a pathological backend — the late resolution lands here and stores a live native subscription on an already-disposed watcher. Nothing ever unsubscribes it, and its events keep hitting handleFileEvent, which appends to accumulatedEvents (line 322) even when not collecting — unbounded growth in a long-lived extension host. Worse, dispose() resets this.ready, so a later initialize() starts a second subscribe; when both resolve, this assignment overwrites the first and leaks it permanently.

Consider capturing the pending promise (const ready = ...) and only storing the subscription when it is still current (if (this.ready === ready) ... else void subscription.unsubscribe()), or awaiting this.ready in shutdown() before tearing down.


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

log.info("file watcher subscribed", { workspacePath: this.workspacePath })
})
.catch((error) => {

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: Latching the failure into a resolved this.ready removes retry for transient subscribe errors

Because this .catch converts a failed subscribe into a fulfilled ready, every later initialize() (each startIndexing → _startWatcher) no-ops on the early return. A transient failure — e.g. inotify ENOSPC that is freed a minute later — leaves incremental updates disabled until something disposes the watcher (settings change / extension reload). The previous revision reset this.ready = undefined on failure so the next run retried. Consider resetting this.ready in this catch as well (still resolving rather than throwing) so the next indexing run re-attempts the subscription, or call out the trade-off explicitly.


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

log.warn("file watcher unavailable; continuing without incremental updates", {
workspacePath: this.workspacePath,
err: error,
})
})
await this.ready
log.info("file watcher ready", { workspacePath: this.workspacePath })
}

setOverlay(overlay?: WorktreeOverlay): void {
Expand Down Expand Up @@ -191,14 +286,24 @@ export class FileWatcher implements IFileWatcher {
this.collecting = false
if (this.batchProcessDebounceTimer) clearTimeout(this.batchProcessDebounceTimer)
this.batchProcessDebounceTimer = undefined
await this.watcher?.close()
const subscription = this.subscription
this.subscription = undefined
await subscription
?.unsubscribe()
.catch((err) => log.error("failed to unsubscribe file watcher", { err, workspacePath: this.workspacePath }))
await this.drainTask
this.dispose()
}

dispose(): void {
this.collecting = false
void this.watcher?.close()
const subscription = this.subscription
this.subscription = undefined
if (subscription) {
void subscription
.unsubscribe()
.catch((err) => log.error("failed to unsubscribe file watcher", { err, workspacePath: this.workspacePath }))
}
if (this.batchProcessDebounceTimer) clearTimeout(this.batchProcessDebounceTimer)
this.batchProcessDebounceTimer = undefined
this.onDidStartBatchProcessing.dispose()
Expand All @@ -209,7 +314,7 @@ export class FileWatcher implements IFileWatcher {
}

/**
* Handles a file event from chokidar by accumulating it and scheduling batch processing.
* Handles a file event reported by the watcher by accumulating it and scheduling batch processing.
*/
private handleFileEvent(filePath: string, type: "create" | "change" | "delete"): void {
if (!this.shouldIndex(filePath)) return
Expand Down
Loading