From 462035c27ba7dbcaa8c4dc764604f33c37fedaa3 Mon Sep 17 00:00:00 2001 From: John Tsui <110079544+JohnXu22786@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:42:24 +0800 Subject: [PATCH 1/4] fix(watcher): detect changes in polling fallback Snapshot included files before polling and refresh only when their metadata changes, avoiding refresh loops from unchanged or excluded index files. --- src/watcher.ts | 56 ++++++++++++++++++++++++++++++++++++++++++- tests/watcher.test.ts | 35 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/watcher.ts b/src/watcher.ts index 036f9d2..2baee43 100644 --- a/src/watcher.ts +++ b/src/watcher.ts @@ -9,6 +9,8 @@ */ import { watch, type FSWatcher } from 'node:fs' +import { readdir, stat } from 'node:fs/promises' +import { join, relative } from 'node:path' export interface WatchHandle { close(): void @@ -35,6 +37,8 @@ export function createDirWatcher( let closed = false let debounce: NodeJS.Timeout | undefined let polling: NodeJS.Timeout | undefined + let pollingRun: Promise | undefined + let pollingSnapshot: Map | undefined let watcher: FSWatcher | undefined const isExcluded = (name: string | null): boolean => { @@ -54,11 +58,61 @@ export function createDirWatcher( }, debounceMs) } + const takePollingSnapshot = async (): Promise> => { + const entries = await readdir(root, { recursive: true, withFileTypes: true }) + const snapshot = new Map() + for (const entry of entries) { + const filePath = join(entry.parentPath, entry.name) + const name = relative(root, filePath).replace(/\\/g, '/') + if (entry.isDirectory() || isExcluded(name)) continue + try { + const info = await stat(filePath) + if (!info.isFile()) continue + snapshot.set(name, `${info.size}:${info.mtimeMs}:${info.ctimeMs}`) + } catch { + // Ignore files that disappear while the snapshot is being collected. + } + } + return snapshot + } + + const snapshotsEqual = (left: Map, right: Map): boolean => { + if (left.size !== right.size) return false + for (const [name, signature] of left) { + if (right.get(name) !== signature) return false + } + return true + } + + const poll = async (): Promise => { + if (closed || pollingRun) return + pollingRun = (async () => { + try { + const next = await takePollingSnapshot() + if (closed) return + if (pollingSnapshot && !snapshotsEqual(pollingSnapshot, next)) { + pollingSnapshot = next + fire(null) + } else { + pollingSnapshot = next + } + } catch { + // A transient scan failure should not create a refresh loop. + } finally { + pollingRun = undefined + } + })() + await pollingRun + } + const startPolling = (): void => { if (polling) return - polling = setInterval(() => fire(null), pollingMs) + polling = setInterval(() => { + void poll() + }, pollingMs) // keep the event loop alive only while an index is being watched polling.unref?.() + void poll() } try { diff --git a/tests/watcher.test.ts b/tests/watcher.test.ts index ea0d5fd..c0cd478 100644 --- a/tests/watcher.test.ts +++ b/tests/watcher.test.ts @@ -3,7 +3,10 @@ */ import assert from 'node:assert/strict' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { mock, test } from 'node:test' +import { join } from 'node:path' +import { tmpdir } from 'node:os' type WatchCallback = (event: string, filename: string | Buffer | null) => void interface FakeWatcher { @@ -12,6 +15,7 @@ interface FakeWatcher { } const callbacks: WatchCallback[] = [] +let forcePolling = false const fakeWatcher: FakeWatcher = { on(): typeof fakeWatcher { return fakeWatcher @@ -22,6 +26,7 @@ const fakeWatcher: FakeWatcher = { mock.module('node:fs', { namedExports: { watch: (...args: unknown[]) => { + if (forcePolling) throw new Error('recursive watch unavailable') callbacks.push(args[2] as WatchCallback) return fakeWatcher }, @@ -50,3 +55,33 @@ test('watcher: excludes matching directory names at any depth', async () => { mock.reset() } }) + +test('watcher: polling detects included changes without reacting to excluded changes', async () => { + forcePolling = true + const root = await mkdtemp(join(tmpdir(), 'sema-watcher-')) + await mkdir(join(root, '.sema')) + await writeFile(join(root, 'source.ts'), 'initial source') + await writeFile(join(root, '.sema', 'index.json'), 'initial index') + + let changes = 0 + const handle = createDirWatcher(root, () => { + changes += 1 + }, { debounceMs: 0, pollingMs: 250, exclude: ['.sema'] }) + + try { + await new Promise((resolve) => setTimeout(resolve, 350)) + assert.equal(changes, 0) + + await writeFile(join(root, '.sema', 'index.json'), 'updated index') + await new Promise((resolve) => setTimeout(resolve, 350)) + assert.equal(changes, 0) + + await writeFile(join(root, 'source.ts'), 'updated source') + await new Promise((resolve) => setTimeout(resolve, 350)) + assert.equal(changes, 1) + } finally { + handle.close() + forcePolling = false + await rm(root, { recursive: true, force: true }) + } +}) From f754c35923a3672c1e8e92246daa97794bff8dce Mon Sep 17 00:00:00 2001 From: John Tsui <110079544+JohnXu22786@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:59:49 +0800 Subject: [PATCH 2/4] fix(watcher): reconcile fallback startup changes Trigger a one-time refresh after the first fallback snapshot so edits during watcher loss or scanning are not missed. Normalize trailing data-directory separators before deriving the excluded basename to keep index writes out of polling. --- src/index.ts | 3 ++- src/watcher.ts | 7 +++-- tests/index-watcher.test.ts | 51 +++++++++++++++++++++++++++++++++++++ tests/watcher.test.ts | 8 +++--- 4 files changed, 60 insertions(+), 9 deletions(-) create mode 100644 tests/index-watcher.test.ts diff --git a/src/index.ts b/src/index.ts index e3bb4e5..16508b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,6 +68,7 @@ export function apply(ctx: Context, config: PluginConfig): () => void { let watcher: ReturnType | undefined if (resolved.watch) { + const dataDirName = index.config.dataDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || '.sema' watcher = createDirWatcher(resolved.root, () => { if (!index.ready) return void index.build('refresh').catch((error) => { @@ -76,7 +77,7 @@ export function apply(ctx: Context, config: PluginConfig): () => void { }, { debounceMs: resolved.watchDebounceMs, // never let our own index writes feed back into a rebuild - exclude: [index.config.dataDir.split(/[\\/]/).pop() ?? '.sema'], + exclude: [dataDirName], }) } diff --git a/src/watcher.ts b/src/watcher.ts index 2baee43..22977da 100644 --- a/src/watcher.ts +++ b/src/watcher.ts @@ -90,11 +90,10 @@ export function createDirWatcher( try { const next = await takePollingSnapshot() if (closed) return - if (pollingSnapshot && !snapshotsEqual(pollingSnapshot, next)) { - pollingSnapshot = next + const previous = pollingSnapshot + pollingSnapshot = next + if (!previous || !snapshotsEqual(previous, next)) { fire(null) - } else { - pollingSnapshot = next } } catch { // A transient scan failure should not create a refresh loop. diff --git a/tests/index-watcher.test.ts b/tests/index-watcher.test.ts new file mode 100644 index 0000000..c66860b --- /dev/null +++ b/tests/index-watcher.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' +import { mkdtemp, rm } from 'node:fs/promises' +import { mock, test } from 'node:test' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +const watcherOptions: Array<{ exclude?: string[] }> = [] + +mock.module('../src/watcher.ts', { + namedExports: { + createDirWatcher: (...args: unknown[]) => { + watcherOptions.push(args[2] as { exclude?: string[] }) + return { close(): void {} } + }, + }, +}) + +const { apply } = await import('../src/index.ts') + +test('entry: trims trailing separators before deriving the watcher exclusion name', async () => { + const root = await mkdtemp(join(tmpdir(), 'sema-index-watcher-')) + const registered: unknown[] = [] + const ctx: any = { + tools: { + register: (tool: unknown) => { + registered.push(tool) + return () => undefined + }, + }, + logger: () => ({ info: () => undefined, warn: () => undefined, error: () => undefined, debug: () => undefined }), + } + + try { + const dispose = apply(ctx, { + root, + dataDir: `${join(root, '.sema')}/`, + provider: { kind: 'lexical', dimension: 64 }, + autoIndex: false, + autosave: false, + watch: true, + }) + + try { + assert.deepEqual(watcherOptions.at(-1)?.exclude, ['.sema']) + } finally { + dispose() + } + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/tests/watcher.test.ts b/tests/watcher.test.ts index c0cd478..f2b7be0 100644 --- a/tests/watcher.test.ts +++ b/tests/watcher.test.ts @@ -56,7 +56,7 @@ test('watcher: excludes matching directory names at any depth', async () => { } }) -test('watcher: polling detects included changes without reacting to excluded changes', async () => { +test('watcher: polling reconciles once, then detects included changes without reacting to excluded changes', async () => { forcePolling = true const root = await mkdtemp(join(tmpdir(), 'sema-watcher-')) await mkdir(join(root, '.sema')) @@ -70,15 +70,15 @@ test('watcher: polling detects included changes without reacting to excluded cha try { await new Promise((resolve) => setTimeout(resolve, 350)) - assert.equal(changes, 0) + assert.equal(changes, 1) await writeFile(join(root, '.sema', 'index.json'), 'updated index') await new Promise((resolve) => setTimeout(resolve, 350)) - assert.equal(changes, 0) + assert.equal(changes, 1) await writeFile(join(root, 'source.ts'), 'updated source') await new Promise((resolve) => setTimeout(resolve, 350)) - assert.equal(changes, 1) + assert.equal(changes, 2) } finally { handle.close() forcePolling = false From f3a5860140688399ba7c2c8dc8a5c2a10a1de717 Mon Sep 17 00:00:00 2001 From: John Tsui <110079544+JohnXu22786@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:19:35 +0800 Subject: [PATCH 3/4] fix(plugin): queue watcher refresh until boot Preserve reconciliation events received before the index is ready and replay one pending refresh after boot completes, so startup changes are not lost. --- src/index.ts | 27 +++++++--- tests/index-startup-watcher.test.ts | 76 +++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 tests/index-startup-watcher.test.ts diff --git a/src/index.ts b/src/index.ts index 16508b6..e5cabb1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,6 +45,19 @@ export function apply(ctx: Context, config: PluginConfig): () => void { // autoIndex. Errors are logged, never thrown (the plugin stays usable — the // lazy first-search path will rebuild if the boot failed). let booted = false + let bootComplete = false + let refreshPending = false + let disposed = false + const refresh = (): void => { + if (disposed) return + if (!bootComplete || !index.ready) { + refreshPending = true + return + } + void index.build('refresh').catch((error) => { + logger.warn(`semantic-search refresh failed: ${error instanceof Error ? error.message : String(error)}`) + }) + } const boot = async (): Promise => { if (booted) return booted = true @@ -62,6 +75,12 @@ export function apply(ctx: Context, config: PluginConfig): () => void { } } catch (error) { logger.error(`semantic-search boot failed: ${error instanceof Error ? error.message : String(error)}`) + } finally { + bootComplete = true + if (!disposed && refreshPending && index.ready) { + refreshPending = false + refresh() + } } } void boot() @@ -69,12 +88,7 @@ export function apply(ctx: Context, config: PluginConfig): () => void { let watcher: ReturnType | undefined if (resolved.watch) { const dataDirName = index.config.dataDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || '.sema' - watcher = createDirWatcher(resolved.root, () => { - if (!index.ready) return - void index.build('refresh').catch((error) => { - logger.warn(`semantic-search refresh failed: ${error instanceof Error ? error.message : String(error)}`) - }) - }, { + watcher = createDirWatcher(resolved.root, refresh, { debounceMs: resolved.watchDebounceMs, // never let our own index writes feed back into a rebuild exclude: [dataDirName], @@ -82,6 +96,7 @@ export function apply(ctx: Context, config: PluginConfig): () => void { } return () => { + disposed = true watcher?.close() for (const dispose of disposers) { try { diff --git a/tests/index-startup-watcher.test.ts b/tests/index-startup-watcher.test.ts new file mode 100644 index 0000000..cc131a2 --- /dev/null +++ b/tests/index-startup-watcher.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict' +import { mock, test } from 'node:test' + +let watcherCallback: (() => void) | undefined +let releaseInit!: () => void +let markInitStarted!: () => void +const initStarted = new Promise((resolve) => { + markInitStarted = resolve +}) +const initGate = new Promise((resolve) => { + releaseInit = resolve +}) +const buildModes: string[] = [] + +class FakeSearchIndex { + readonly config: any + readonly providerId = 'test-provider' + private isReady = false + + constructor(config: any) { + this.config = config + } + + get ready(): boolean { + return this.isReady + } + + async init(): Promise<{ status: 'loaded'; meta: null }> { + markInitStarted() + await initGate + this.isReady = true + return { status: 'loaded', meta: null } + } + + build(mode: 'full' | 'refresh'): Promise> { + buildModes.push(mode) + return Promise.resolve({ mode, files: 0, chunks: 0, truncated: false }) + } +} + +mock.module('../src/engine/search.ts', { + namedExports: { SearchIndex: FakeSearchIndex }, +}) +mock.module('../src/watcher.ts', { + namedExports: { + createDirWatcher: (_root: string, onChange: () => void) => { + watcherCallback = onChange + return { close(): void {} } + }, + }, +}) + +const { apply } = await import('../src/index.ts') + +test('entry: queues startup reconciliation until boot completes', async () => { + const ctx: any = { + tools: { register: () => () => undefined }, + logger: () => ({ info: () => undefined, warn: () => undefined, error: () => undefined, debug: () => undefined }), + } + const dispose = apply(ctx, { autoIndex: false, autosave: false, watch: true }) + + try { + await initStarted + const callback = watcherCallback + assert.ok(callback) + callback() + assert.deepEqual(buildModes, []) + + releaseInit() + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(buildModes, ['refresh']) + } finally { + releaseInit() + dispose() + } +}) From c4a096ff77879b53f484656568e7dd2ddf74f8d0 Mon Sep 17 00:00:00 2001 From: John Tsui <110079544+JohnXu22786@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:31:27 +0800 Subject: [PATCH 4/4] fix(plugin): drain pending watcher refreshes Allow queued reconciliation and later watcher events to trigger the first build after an empty or lazily initialized workspace, instead of waiting for index.ready to become true. --- src/index.ts | 5 +++-- tests/index-startup-watcher.test.ts | 10 ++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index e5cabb1..aa597fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,10 +50,11 @@ export function apply(ctx: Context, config: PluginConfig): () => void { let disposed = false const refresh = (): void => { if (disposed) return - if (!bootComplete || !index.ready) { + if (!bootComplete) { refreshPending = true return } + refreshPending = false void index.build('refresh').catch((error) => { logger.warn(`semantic-search refresh failed: ${error instanceof Error ? error.message : String(error)}`) }) @@ -77,7 +78,7 @@ export function apply(ctx: Context, config: PluginConfig): () => void { logger.error(`semantic-search boot failed: ${error instanceof Error ? error.message : String(error)}`) } finally { bootComplete = true - if (!disposed && refreshPending && index.ready) { + if (!disposed && refreshPending) { refreshPending = false refresh() } diff --git a/tests/index-startup-watcher.test.ts b/tests/index-startup-watcher.test.ts index cc131a2..a825144 100644 --- a/tests/index-startup-watcher.test.ts +++ b/tests/index-startup-watcher.test.ts @@ -25,11 +25,10 @@ class FakeSearchIndex { return this.isReady } - async init(): Promise<{ status: 'loaded'; meta: null }> { + async init(): Promise<{ status: 'empty'; meta: null }> { markInitStarted() await initGate - this.isReady = true - return { status: 'loaded', meta: null } + return { status: 'empty', meta: null } } build(mode: 'full' | 'refresh'): Promise> { @@ -52,7 +51,7 @@ mock.module('../src/watcher.ts', { const { apply } = await import('../src/index.ts') -test('entry: queues startup reconciliation until boot completes', async () => { +test('entry: drains queued reconciliation for an initially empty index', async () => { const ctx: any = { tools: { register: () => () => undefined }, logger: () => ({ info: () => undefined, warn: () => undefined, error: () => undefined, debug: () => undefined }), @@ -69,6 +68,9 @@ test('entry: queues startup reconciliation until boot completes', async () => { releaseInit() await new Promise((resolve) => setImmediate(resolve)) assert.deepEqual(buildModes, ['refresh']) + + callback() + assert.deepEqual(buildModes, ['refresh', 'refresh']) } finally { releaseInit() dispose()