From f4243f190b5d0237947c70ba12c9858815c9f2f0 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 11 Dec 2025 11:35:45 +0000 Subject: [PATCH] fix(tests): Stabilize tests and document chokidar-inspired Node watcher architecture --- changelog.md | 8 + readme.hints.md | 63 +- test/test.basic.ts | 34 +- ts/00_commitinfo_data.ts | 2 +- ts/watchers/watcher.node.ts | 1301 ++++++++++++++++++++--------------- 5 files changed, 830 insertions(+), 578 deletions(-) diff --git a/changelog.md b/changelog.md index 43fd270..e750318 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2025-12-11 - 6.2.4 - fix(tests) +Stabilize tests and document chokidar-inspired Node watcher architecture + +- test: add waitForFileEvent helper to wait for events for a specific file (reduces test flakiness) +- test: add small delays after unlink cleanup to account for atomic/temp-file debounce windows +- docs: expand readme.hints.md with a detailed Node watcher architecture section (DirEntry, Throttler, atomic write handling, closer registry, constants and config) +- docs: list updated test files and coverage scenarios (inode detection, atomic writes, stress tests) + ## 2025-12-11 - 6.2.3 - fix(watcher.node) Improve handling of temporary files from atomic editor writes in Node watcher diff --git a/readme.hints.md b/readme.hints.md index 45908e9..79e9bb1 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -71,6 +71,57 @@ The `WriteStabilizer` class replaces chokidar's built-in write stabilization: - **Deno**: Works on all versions with `Deno.watchFs()` - **Bun**: Uses Node.js compatibility layer +### Architecture (v6.3.0+) - Chokidar-Inspired + +The Node.js watcher has been refactored with elegant patterns inspired by [chokidar](https://github.com/paulmillr/chokidar): + +**DirEntry Class:** +- Tracks directory contents with proper disposal +- Encapsulates file tracking and inode management +- `dispose()` method freezes object to catch use-after-cleanup bugs + +**Throttler Pattern:** +- More sophisticated than simple debounce +- Tracks count of suppressed events +- Returns `false` if already throttled, `Throttler` object otherwise +- Used for change events to prevent duplicate emissions + +**Atomic Write Handling:** +- Unlink events are queued with 100ms delay +- If add event arrives for same path within delay, unlink is cancelled +- Emits single `change` event instead of `unlink` + `add` +- Handles editor atomic saves elegantly + +**Closer Registry:** +- Maps watch paths to cleanup functions +- Ensures proper resource cleanup on stop +- `addCloser()` / `runClosers()` pattern + +**Event Constants Object:** +```typescript +const EV = { + ADD: 'add', + CHANGE: 'change', + UNLINK: 'unlink', + ADD_DIR: 'addDir', + UNLINK_DIR: 'unlinkDir', + READY: 'ready', + ERROR: 'error', +} as const; +``` + +**Configuration Constants:** +```typescript +const CONFIG = { + MAX_RETRIES: 3, + INITIAL_RESTART_DELAY: 1000, + MAX_RESTART_DELAY: 30000, + HEALTH_CHECK_INTERVAL: 30000, + ATOMIC_DELAY: 100, + TEMP_FILE_DELAY: 50, +} as const; +``` + ### Robustness Features (v6.1.0+) The Node.js watcher includes automatic recovery mechanisms based on learnings from [chokidar](https://github.com/paulmillr/chokidar) and known [fs.watch issues](https://github.com/nodejs/node/issues/47058): @@ -155,10 +206,20 @@ Example log output: pnpm test ``` +Test files: +- **test.basic.ts** - Core functionality (add, change, unlink events) +- **test.inode.ts** - Inode change detection, atomic writes +- **test.stress.ts** - Rapid modifications, many files, interleaved operations + Tests verify: - Creating Smartwatch instance - Adding glob patterns -- Receiving 'add' events for new files +- Receiving 'add', 'change', 'unlink' events +- Inode change detection (delete+recreate pattern) +- Atomic write pattern (temp file + rename) +- Rapid file modifications (debouncing) +- Many files created rapidly +- Interleaved add/change/delete operations - Graceful shutdown ## Dev Dependencies diff --git a/test/test.basic.ts b/test/test.basic.ts index 018864a..cc6edb5 100644 --- a/test/test.basic.ts +++ b/test/test.basic.ts @@ -35,6 +35,30 @@ async function waitForEvent( }); } +// Helper to wait for a specific file's event (filters by filename) +async function waitForFileEvent( + observable: smartrx.rxjs.Observable, + expectedFile: string, + timeoutMs: number = 5000 +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + subscription.unsubscribe(); + reject(new Error(`Timeout waiting for event on ${expectedFile} after ${timeoutMs}ms`)); + }, timeoutMs); + + const subscription = observable.subscribe((value) => { + const [filePath] = value; + if (filePath.includes(expectedFile)) { + clearTimeout(timeout); + subscription.unsubscribe(); + resolve(value); + } + // Otherwise keep waiting for the right file + }); + }); +} + let testSmartwatch: smartwatch.Smartwatch; // =========================================== @@ -63,8 +87,9 @@ tap.test('should detect ADD event for new files', async () => { const [filePath] = await eventPromise; expect(filePath).toInclude('add-test.txt'); - // Cleanup + // Cleanup - wait for atomic delay to complete (100ms debounce + 100ms atomic) await fs.promises.unlink(testFile); + await delay(250); }); tap.test('should detect CHANGE event for modified files', async () => { @@ -84,8 +109,9 @@ tap.test('should detect CHANGE event for modified files', async () => { const [filePath] = await eventPromise; expect(filePath).toInclude('change-test.txt'); - // Cleanup + // Cleanup - wait for atomic delay to complete await fs.promises.unlink(testFile); + await delay(250); }); tap.test('should detect UNLINK event for deleted files', async () => { @@ -97,7 +123,9 @@ tap.test('should detect UNLINK event for deleted files', async () => { await delay(200); const unlinkObservable = await testSmartwatch.getObservableFor('unlink'); - const eventPromise = waitForEvent(unlinkObservable); + + // Use file-specific wait to handle any pending unlinks from other tests + const eventPromise = waitForFileEvent(unlinkObservable, 'unlink-test.txt'); // Delete the file await fs.promises.unlink(testFile); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index fefad67..e7bab4f 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartwatch', - version: '6.2.3', + version: '6.2.4', description: 'A cross-runtime file watcher with glob pattern support for Node.js, Deno, and Bun.' } diff --git a/ts/watchers/watcher.node.ts b/ts/watchers/watcher.node.ts index 92bec1f..be68427 100644 --- a/ts/watchers/watcher.node.ts +++ b/ts/watchers/watcher.node.ts @@ -3,77 +3,745 @@ import * as path from 'path'; import * as smartrx from '@push.rocks/smartrx'; import type { IWatcher, IWatcherOptions, IWatchEvent, TWatchEventType } from './interfaces.js'; +// ============================================================================= +// Constants +// ============================================================================= + +/** Event type constants - inspired by chokidar's pattern */ +const EV = { + ADD: 'add', + CHANGE: 'change', + UNLINK: 'unlink', + ADD_DIR: 'addDir', + UNLINK_DIR: 'unlinkDir', + READY: 'ready', + ERROR: 'error', +} as const; + +/** Throttle action types */ +type ThrottleType = 'change' | 'unlink' | 'add'; + +/** Throttler state for an action */ +interface Throttler { + timeout: NodeJS.Timeout; + count: number; + clear: () => number; +} + +/** Configuration constants */ +const CONFIG = { + MAX_RETRIES: 3, + INITIAL_RESTART_DELAY: 1000, + MAX_RESTART_DELAY: 30000, + HEALTH_CHECK_INTERVAL: 30000, + ATOMIC_DELAY: 100, + TEMP_FILE_DELAY: 50, +} as const; + +// ============================================================================= +// DirEntry Class - Elegant directory content tracking (inspired by chokidar) +// ============================================================================= + + +/** + * Tracks contents of a watched directory with proper disposal + */ +class DirEntry { + private _path: string; + private _items: Set; + private _inodes: Map; + + constructor(dirPath: string) { + this._path = dirPath; + this._items = new Set(); + this._inodes = new Map(); + } + + get path(): string { + return this._path; + } + + add(item: string, inode?: bigint): void { + if (item === '.' || item === '..') return; + this._items.add(item); + if (inode !== undefined) { + this._inodes.set(item, inode); + } + } + + remove(item: string): void { + this._items.delete(item); + this._inodes.delete(item); + } + + has(item: string): boolean { + return this._items.has(item); + } + + getInode(item: string): bigint | undefined { + return this._inodes.get(item); + } + + setInode(item: string, inode: bigint): void { + this._inodes.set(item, inode); + } + + getChildren(): string[] { + return [...this._items]; + } + + get size(): number { + return this._items.size; + } + + dispose(): void { + this._items.clear(); + this._inodes.clear(); + this._path = ''; + // Freeze to catch accidental use after disposal + Object.freeze(this); + } +} + +// ============================================================================= +// NodeWatcher Class +// ============================================================================= + /** * Node.js/Bun file watcher using native fs.watch API + * + * Architecture inspired by chokidar with additional robustness features: + * - Event deferral during initial scan + * - Event sequence tracking for debounce + * - Atomic write handling (unlink→add becomes change) + * - Inode tracking for delete+recreate detection + * - Health check monitoring + * - Auto-restart with exponential backoff */ export class NodeWatcher implements IWatcher { + // Core state private watchers: Map = new Map(); - private watchedFiles: Set = new Set(); + private watched: Map = new Map(); private _isWatching = false; - // Debounce: pending emits per file path - // Fix 2: Track event sequence instead of just last event type - // This prevents losing intermediate events (add→change→delete should not lose add) + // Event stream + public readonly events$ = new smartrx.rxjs.Subject(); + + // Throttling - inspired by chokidar's _throttle pattern + private throttled: Map> = new Map(); + + // Atomic write handling - pending unlinks that may become changes + private pendingUnlinks: Map = new Map(); + + // Debounce with event sequence tracking private pendingEmits: Map; }> = new Map(); - // Restart tracking + // Restart management private restartDelays: Map = new Map(); private restartAttempts: Map = new Map(); - private healthCheckInterval: NodeJS.Timeout | null = null; - - // Inode tracking - detect when directories are replaced (atomic saves, etc.) - // fs.watch watches the inode, not the path. If inode changes, we need to restart. - private watchedInodes: Map = new Map(); - - // File inode tracking - detect when individual files are deleted and recreated - // This is critical: editors delete+recreate files, fs.watch watches OLD inode! - // See: https://github.com/paulmillr/chokidar/issues/972 - private fileInodes: Map = new Map(); - - // Abort controllers for pending restart delays - prevents orphan watchers on stop() private restartAbortControllers: Map = new Map(); - - // Prevent concurrent restarts for the same path (health check + error can race) private restartingPaths: Set = new Set(); - // Initial scan state - events are deferred until scan completes to avoid race conditions - // Without this, events can arrive before watchedFiles is populated, causing inconsistent state - private initialScanComplete: boolean = false; - private deferredEvents: Array<{basePath: string; filename: string; eventType: string}> = []; + // Health monitoring + private healthCheckInterval: NodeJS.Timeout | null = null; + private watchedInodes: Map = new Map(); - // Configuration constants - private static readonly MAX_RETRIES = 3; - private static readonly INITIAL_RESTART_DELAY = 1000; - private static readonly MAX_RESTART_DELAY = 30000; - private static readonly HEALTH_CHECK_INTERVAL = 30000; + // Initial scan state + private initialScanComplete = false; + private deferredEvents: Array<{ basePath: string; filename: string; eventType: string }> = []; - public readonly events$ = new smartrx.rxjs.Subject(); + // Closer registry - inspired by chokidar for clean resource management + private closers: Map void>> = new Map(); - constructor(private options: IWatcherOptions) {} + constructor(private options: IWatcherOptions) { + // Initialize throttle maps + this.throttled.set('change', new Map()); + this.throttled.set('unlink', new Map()); + this.throttled.set('add', new Map()); + } - /** - * Safely emit an event, catching any subscriber errors - */ + get isWatching(): boolean { + return this._isWatching; + } + + // =========================================================================== + // Public API + // =========================================================================== + + async start(): Promise { + if (this._isWatching) return; + + console.log(`[smartwatch] Starting watcher for ${this.options.basePaths.length} base path(s)...`); + + try { + // Reset state + this.initialScanComplete = false; + this.deferredEvents = []; + + // Start watching each base path (events will be deferred) + for (const basePath of this.options.basePaths) { + await this.watchPath(basePath); + } + + this._isWatching = true; + this.startHealthCheck(); + + // Initial scan populates watched entries + for (const basePath of this.options.basePaths) { + await this.scanDirectory(basePath, 0); + } + + // Process deferred events + this.initialScanComplete = true; + if (this.deferredEvents.length > 0) { + console.log(`[smartwatch] Processing ${this.deferredEvents.length} deferred events`); + for (const event of this.deferredEvents) { + this.handleFsEvent(event.basePath, event.filename, event.eventType); + } + this.deferredEvents = []; + } + + this.safeEmit({ type: EV.READY, path: '' }); + console.log(`[smartwatch] Watcher started with ${this.watchers.size} active watcher(s)`); + } catch (error: any) { + console.error('[smartwatch] Failed to start watcher:', error); + this.safeEmit({ type: EV.ERROR, path: '', error }); + throw error; + } + } + + async stop(): Promise { + console.log('[smartwatch] Stopping watcher...'); + + // Cancel pending emits first (before flag changes) + for (const pending of this.pendingEmits.values()) { + clearTimeout(pending.timeout); + } + this.pendingEmits.clear(); + + // Cancel pending unlinks + for (const pending of this.pendingUnlinks.values()) { + clearTimeout(pending.timeout); + } + this.pendingUnlinks.clear(); + + // Clear throttles + for (const actionMap of this.throttled.values()) { + for (const throttler of actionMap.values()) { + clearTimeout(throttler.timeout); + } + actionMap.clear(); + } + + // Now set flag + this._isWatching = false; + + this.stopHealthCheck(); + + // Abort pending restarts + for (const [watchPath, controller] of this.restartAbortControllers) { + console.log(`[smartwatch] Aborting pending restart for: ${watchPath}`); + controller.abort(); + } + this.restartAbortControllers.clear(); + + // Close all watchers and run closers + for (const [watchPath, watcher] of this.watchers) { + console.log(`[smartwatch] Closing watcher for: ${watchPath}`); + watcher.close(); + this.runClosers(watchPath); + } + + // Clear all state + this.watchers.clear(); + this.watched.forEach(entry => entry.dispose()); + this.watched.clear(); + this.restartDelays.clear(); + this.restartAttempts.clear(); + this.watchedInodes.clear(); + this.restartingPaths.clear(); + this.closers.clear(); + + // Reset scan state + this.initialScanComplete = false; + this.deferredEvents = []; + + console.log('[smartwatch] Watcher stopped'); + } + + // =========================================================================== + // Event Emission + // =========================================================================== + + /** Safely emit an event, isolating subscriber errors */ private safeEmit(event: IWatchEvent): void { try { this.events$.next(event); } catch (error) { console.error('[smartwatch] Subscriber threw error (isolated):', error); - // Don't let subscriber errors kill the watcher + } + } + + // =========================================================================== + // Throttling - Inspired by chokidar's elegant _throttle pattern + // =========================================================================== + + /** + * Throttle an action for a path. Returns false if already throttled. + * Unlike simple debounce, this tracks how many events were suppressed. + */ + private throttle(actionType: ThrottleType, filePath: string, timeout: number): Throttler | false { + const actionMap = this.throttled.get(actionType); + if (!actionMap) return false; + + const existing = actionMap.get(filePath); + if (existing) { + existing.count++; + return false; + } + + const clear = (): number => { + const item = actionMap.get(filePath); + const count = item?.count ?? 0; + actionMap.delete(filePath); + return count; + }; + + const throttler: Throttler = { + timeout: setTimeout(clear, timeout), + count: 0, + clear, + }; + + actionMap.set(filePath, throttler); + return throttler; + } + + // =========================================================================== + // Closer Registry - Clean resource management + // =========================================================================== + + private addCloser(watchPath: string, closer: () => void): void { + let list = this.closers.get(watchPath); + if (!list) { + list = []; + this.closers.set(watchPath, list); + } + list.push(closer); + } + + private runClosers(watchPath: string): void { + const list = this.closers.get(watchPath); + if (list) { + list.forEach(closer => closer()); + this.closers.delete(watchPath); + } + } + + // =========================================================================== + // Directory Entry Management + // =========================================================================== + + private getWatchedDir(dirPath: string): DirEntry { + const resolved = path.resolve(dirPath); + let entry = this.watched.get(resolved); + if (!entry) { + entry = new DirEntry(resolved); + this.watched.set(resolved, entry); + } + return entry; + } + + private isTracked(filePath: string): boolean { + const dir = path.dirname(filePath); + const base = path.basename(filePath); + const entry = this.watched.get(path.resolve(dir)); + return entry?.has(base) ?? false; + } + + private trackFile(filePath: string, inode?: bigint): void { + const dir = path.dirname(filePath); + const base = path.basename(filePath); + this.getWatchedDir(dir).add(base, inode); + } + + private untrackFile(filePath: string): void { + const dir = path.dirname(filePath); + const base = path.basename(filePath); + const entry = this.watched.get(path.resolve(dir)); + entry?.remove(base); + } + + private getFileInode(filePath: string): bigint | undefined { + const dir = path.dirname(filePath); + const base = path.basename(filePath); + const entry = this.watched.get(path.resolve(dir)); + return entry?.getInode(base); + } + + // =========================================================================== + // Temp File Handling + // =========================================================================== + + private isTemporaryFile(filePath: string): boolean { + const basename = path.basename(filePath); + return ( + basename.includes('.tmp.') || + basename.endsWith('.swp') || + basename.endsWith('.swx') || + basename.endsWith('~') || + basename.startsWith('.#') + ); + } + + /** + * Extract real file path from temp file (Claude Code atomic writes) + * Pattern: file.ts.tmp.PID.TIMESTAMP -> file.ts + */ + private getTempFileTarget(tempPath: string): string | null { + const basename = path.basename(tempPath); + + // Claude Code: file.ts.tmp.PID.TIMESTAMP + const claudeMatch = basename.match(/^(.+)\.tmp\.\d+\.\d+$/); + if (claudeMatch) { + return path.join(path.dirname(tempPath), claudeMatch[1]); + } + + // Generic: file.ts.tmp.something + const genericMatch = basename.match(/^(.+)\.tmp\.[^.]+$/); + if (genericMatch) { + return path.join(path.dirname(tempPath), genericMatch[1]); + } + + return null; + } + + // =========================================================================== + // Watch Path Setup + // =========================================================================== + + private async watchPath(watchPath: string): Promise { + try { + const stats = await this.statSafe(watchPath); + if (!stats?.isDirectory()) return; + + // Store inode for health check (fs.watch watches inode, not path!) + this.watchedInodes.set(watchPath, BigInt(stats.ino)); + + const watcher = fs.watch( + watchPath, + { recursive: true, persistent: true }, + (eventType, filename) => { + if (filename) { + this.handleFsEvent(watchPath, filename, eventType); + } + } + ); + + watcher.on('error', (error: NodeJS.ErrnoException) => { + console.error(`[smartwatch] FSWatcher error on ${watchPath}:`, error); + + if (error.code === 'ENOSPC') { + console.error('[smartwatch] CRITICAL: inotify watch limit exceeded!'); + console.error('[smartwatch] Fix: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p'); + } + + this.safeEmit({ type: EV.ERROR, path: watchPath, error }); + if (this._isWatching) { + this.restartWatcher(watchPath, error); + } + }); + + watcher.on('close', () => { + if (this._isWatching) { + console.warn(`[smartwatch] FSWatcher closed unexpectedly for ${watchPath}`); + this.restartWatcher(watchPath, new Error('Watcher closed unexpectedly')); + } + }); + + this.watchers.set(watchPath, watcher); + + // Register closer + this.addCloser(watchPath, () => { + try { watcher.close(); } catch {} + }); + + console.log(`[smartwatch] Started watching: ${watchPath}`); + } catch (error: any) { + console.error(`[smartwatch] Failed to watch path ${watchPath}:`, error); + this.safeEmit({ type: EV.ERROR, path: watchPath, error }); + } + } + + // =========================================================================== + // Event Handling + // =========================================================================== + + private handleFsEvent( + basePath: string, + filename: string, + eventType: 'rename' | 'change' | string + ): void { + // Guard against post-stop events + if (!this._isWatching) return; + + // Defer events until initial scan completes + if (!this.initialScanComplete) { + this.deferredEvents.push({ basePath, filename, eventType }); + return; + } + + const fullPath = path.join(basePath, filename); + + // Handle temp files from atomic writes + if (this.isTemporaryFile(fullPath)) { + console.log(`[smartwatch] Detected temp file event: ${filename}`); + const realPath = this.getTempFileTarget(fullPath); + if (realPath) { + console.log(`[smartwatch] Checking corresponding real file: ${realPath}`); + setTimeout(() => { + if (this._isWatching) { + this.handleFsEvent(basePath, path.relative(basePath, realPath), 'change'); + } + }, CONFIG.TEMP_FILE_DELAY); + } + return; + } + + // Track event sequence for intelligent debouncing + const existing = this.pendingEmits.get(fullPath); + if (existing) { + clearTimeout(existing.timeout); + existing.events.push(eventType as 'rename' | 'change'); + existing.timeout = setTimeout(() => { + const pending = this.pendingEmits.get(fullPath); + if (pending) { + this.pendingEmits.delete(fullPath); + this.emitFileEvent(fullPath, pending.events); + } + }, this.options.debounceMs); + } else { + const timeout = setTimeout(() => { + const pending = this.pendingEmits.get(fullPath); + if (pending) { + this.pendingEmits.delete(fullPath); + this.emitFileEvent(fullPath, pending.events); + } + }, this.options.debounceMs); + + this.pendingEmits.set(fullPath, { + timeout, + events: [eventType as 'rename' | 'change'], + }); } } /** - * Restart a watcher after an error with exponential backoff - * Includes guards against: - * - Dual restart race condition (health check + error handler calling simultaneously) - * - Orphan watchers when stop() is called during restart delay + * Emit file event after debounce with atomic write handling + * + * Atomic write pattern (inspired by chokidar): + * - unlink event queued with delay + * - if add arrives for same path, transform to change */ + private async emitFileEvent( + fullPath: string, + eventSequence: Array<'rename' | 'change'> + ): Promise { + try { + const stats = await this.statSafe(fullPath); + const wasTracked = this.isTracked(fullPath); + const previousInode = this.getFileInode(fullPath); + + // Analyze event sequence + const hasRename = eventSequence.includes('rename'); + const renameCount = eventSequence.filter(e => e === 'rename').length; + + if (eventSequence.length > 1) { + console.log(`[smartwatch] Processing event sequence for ${fullPath}: [${eventSequence.join(', ')}]`); + } + + if (stats) { + // File EXISTS + const currentInode = BigInt(stats.ino); + const inodeChanged = previousInode !== undefined && previousInode !== currentInode; + + if (stats.isDirectory()) { + if (!wasTracked) { + this.trackFile(fullPath); + this.safeEmit({ type: EV.ADD_DIR, path: fullPath, stats }); + } + } else { + // Update tracking + this.trackFile(fullPath, currentInode); + + // Check for pending unlink → transform to change (atomic write pattern) + const pendingUnlink = this.pendingUnlinks.get(fullPath); + if (pendingUnlink) { + clearTimeout(pendingUnlink.timeout); + this.pendingUnlinks.delete(fullPath); + console.log(`[smartwatch] Atomic write detected (unlink→add→change): ${fullPath}`); + this.safeEmit({ type: EV.CHANGE, path: fullPath, stats }); + return; + } + + if (!wasTracked) { + this.safeEmit({ type: EV.ADD, path: fullPath, stats }); + } else if (inodeChanged) { + console.log(`[smartwatch] File inode changed (delete+recreate): ${fullPath}`); + console.log(`[smartwatch] Previous inode: ${previousInode}, current: ${currentInode}`); + + if (renameCount >= 2) { + // Multiple renames with inode change = delete+recreate + this.safeEmit({ type: EV.UNLINK, path: fullPath }); + this.safeEmit({ type: EV.ADD, path: fullPath, stats }); + } else { + // Single rename with inode change = atomic save + this.safeEmit({ type: EV.CHANGE, path: fullPath, stats }); + } + } else { + // Apply throttle for change events + if (!this.throttle('change', fullPath, 50)) { + return; // Throttled + } + this.safeEmit({ type: EV.CHANGE, path: fullPath, stats }); + } + } + } else { + // File does NOT exist - handle unlink + const wasDir = this.isKnownDirectory(fullPath); + + if (wasTracked) { + this.untrackFile(fullPath); + + if (renameCount >= 2 && !wasDir) { + // Rapid create+delete + console.log(`[smartwatch] File created and deleted rapidly: ${fullPath}`); + this.safeEmit({ type: EV.ADD, path: fullPath }); + this.safeEmit({ type: EV.UNLINK, path: fullPath }); + } else { + // Queue unlink with delay for atomic write detection + this.queueUnlink(fullPath, wasDir); + } + } else { + if (renameCount >= 2) { + console.log(`[smartwatch] Untracked file created and deleted: ${fullPath}`); + this.safeEmit({ type: EV.ADD, path: fullPath }); + this.safeEmit({ type: EV.UNLINK, path: fullPath }); + } else if (hasRename) { + console.log(`[smartwatch] Untracked file deleted: ${fullPath}`); + this.queueUnlink(fullPath, false); + } + } + } + } catch (error: any) { + this.safeEmit({ type: EV.ERROR, path: fullPath, error }); + } + } + + /** + * Queue an unlink event with delay for atomic write detection + * If add event arrives within delay, unlink is cancelled and change is emitted + */ + private queueUnlink(fullPath: string, isDir: boolean): void { + const event: IWatchEvent = { + type: isDir ? EV.UNLINK_DIR : EV.UNLINK, + path: fullPath, + }; + + const timeout = setTimeout(() => { + const pending = this.pendingUnlinks.get(fullPath); + if (pending) { + this.pendingUnlinks.delete(fullPath); + this.safeEmit(pending.event); + } + }, CONFIG.ATOMIC_DELAY); + + this.pendingUnlinks.set(fullPath, { timeout, event }); + } + + // =========================================================================== + // Directory Scanning + // =========================================================================== + + private async scanDirectory(dirPath: string, depth: number): Promise { + if (depth > this.options.depth) return; + + try { + const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + + if (this.isTemporaryFile(fullPath)) continue; + + const stats = await this.statSafe(fullPath); + if (!stats) continue; + + if (entry.isDirectory()) { + this.trackFile(fullPath); + this.safeEmit({ type: EV.ADD_DIR, path: fullPath, stats }); + await this.scanDirectory(fullPath, depth + 1); + } else if (entry.isFile()) { + this.trackFile(fullPath, BigInt(stats.ino)); + this.safeEmit({ type: EV.ADD, path: fullPath, stats }); + } + } + } catch (error: any) { + if (error.code !== 'ENOENT' && error.code !== 'EACCES') { + this.safeEmit({ type: EV.ERROR, path: dirPath, error }); + } + } + } + + // =========================================================================== + // Health Check & Auto-Restart + // =========================================================================== + + private startHealthCheck(): void { + console.log('[smartwatch] Starting health check (every 30s)'); + this.healthCheckInterval = setInterval(async () => { + console.log(`[smartwatch] Health check: ${this.watchers.size} watchers active`); + + for (const [basePath] of this.watchers) { + try { + const stats = await fs.promises.stat(basePath); + const currentInode = BigInt(stats.ino); + const previousInode = this.watchedInodes.get(basePath); + + if (previousInode !== undefined && currentInode !== previousInode) { + console.warn(`[smartwatch] Inode changed for ${basePath}: ${previousInode} -> ${currentInode}`); + console.warn('[smartwatch] fs.watch watches inode, not path - restarting watcher'); + this.restartWatcher(basePath, new Error('Inode changed - directory was replaced')); + } + } catch (error: any) { + if (error.code === 'ENOENT') { + console.error(`[smartwatch] Health check failed: ${basePath} no longer exists`); + this.restartWatcher(basePath, new Error('Watched path disappeared')); + } else if (error.code === 'ENOSPC') { + console.error('[smartwatch] ENOSPC: inotify watch limit exceeded!'); + console.error('[smartwatch] Fix: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p'); + this.safeEmit({ type: EV.ERROR, path: basePath, error }); + } else { + console.error(`[smartwatch] Health check error for ${basePath}:`, error); + } + } + } + }, CONFIG.HEALTH_CHECK_INTERVAL); + } + + private stopHealthCheck(): void { + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval); + this.healthCheckInterval = null; + console.log('[smartwatch] Stopped health check'); + } + } + private async restartWatcher(basePath: string, error: Error): Promise { - // Guard: Prevent concurrent restarts for the same path + // Guard against concurrent restarts if (this.restartingPaths.has(basePath)) { console.log(`[smartwatch] Restart already in progress for ${basePath}, skipping`); return; @@ -85,31 +753,27 @@ export class NodeWatcher implements IWatcher { this.restartAttempts.set(basePath, attempts); console.log(`[smartwatch] Watcher error for ${basePath}: ${error.message}`); - console.log(`[smartwatch] Restart attempt ${attempts}/${NodeWatcher.MAX_RETRIES}`); + console.log(`[smartwatch] Restart attempt ${attempts}/${CONFIG.MAX_RETRIES}`); - if (attempts > NodeWatcher.MAX_RETRIES) { + if (attempts > CONFIG.MAX_RETRIES) { console.error(`[smartwatch] Max retries exceeded for ${basePath}, giving up`); this.safeEmit({ - type: 'error', + type: EV.ERROR, path: basePath, - error: new Error(`Max restart retries (${NodeWatcher.MAX_RETRIES}) exceeded`) + error: new Error(`Max restart retries (${CONFIG.MAX_RETRIES}) exceeded`), }); return; } - // Close failed watcher + // Close old watcher const oldWatcher = this.watchers.get(basePath); if (oldWatcher) { - try { - oldWatcher.close(); - } catch { - // Ignore close errors - } + try { oldWatcher.close(); } catch {} this.watchers.delete(basePath); } - // Exponential backoff with AbortController (so stop() can cancel) - const delay = this.restartDelays.get(basePath) || NodeWatcher.INITIAL_RESTART_DELAY; + // Exponential backoff with abort support + const delay = this.restartDelays.get(basePath) || CONFIG.INITIAL_RESTART_DELAY; console.log(`[smartwatch] Waiting ${delay}ms before restart...`); const abortController = new AbortController(); @@ -123,565 +787,56 @@ export class NodeWatcher implements IWatcher { reject(new Error('Restart aborted by stop()')); }); }); - } catch (abortError) { + } catch { console.log(`[smartwatch] Restart aborted for ${basePath}`); - return; // stop() was called, don't continue + return; } finally { this.restartAbortControllers.delete(basePath); } - // Double-check we're still watching after the delay if (!this._isWatching) { - console.log(`[smartwatch] Watcher stopped during restart delay, aborting`); + console.log('[smartwatch] Watcher stopped during restart delay, aborting'); return; } - this.restartDelays.set(basePath, Math.min(delay * 2, NodeWatcher.MAX_RESTART_DELAY)); + this.restartDelays.set(basePath, Math.min(delay * 2, CONFIG.MAX_RESTART_DELAY)); try { - await this.watchPath(basePath, 0); + await this.watchPath(basePath); console.log(`[smartwatch] Successfully restarted watcher for ${basePath}`); - this.restartDelays.set(basePath, NodeWatcher.INITIAL_RESTART_DELAY); + this.restartDelays.set(basePath, CONFIG.INITIAL_RESTART_DELAY); this.restartAttempts.set(basePath, 0); } catch (restartError) { console.error(`[smartwatch] Restart failed for ${basePath}:`, restartError); - // Clear restartingPaths before recursive call this.restartingPaths.delete(basePath); - this.restartWatcher(basePath, restartError as Error); // Recursive retry - return; // Don't delete from restartingPaths again in finally + this.restartWatcher(basePath, restartError as Error); + return; } } finally { this.restartingPaths.delete(basePath); } } - /** - * Start periodic health checks to detect silent failures - * Checks for: - * 1. Path no longer exists - * 2. Inode changed (directory was replaced - fs.watch watches inode, not path!) - */ - private startHealthCheck(): void { - console.log('[smartwatch] Starting health check (every 30s)'); - this.healthCheckInterval = setInterval(async () => { - console.log(`[smartwatch] Health check: ${this.watchers.size} watchers active`); - for (const [basePath] of this.watchers) { - try { - const stats = await fs.promises.stat(basePath); - const currentInode = stats.ino; - const previousInode = this.watchedInodes.get(basePath); + // =========================================================================== + // Utilities + // =========================================================================== - if (!stats) { - console.error(`[smartwatch] Health check failed: ${basePath} no longer exists`); - this.safeEmit({ - type: 'error', - path: basePath, - error: new Error('Watched path no longer exists') - }); - this.restartWatcher(basePath, new Error('Watched path disappeared')); - } else if (previousInode !== undefined && BigInt(currentInode) !== previousInode) { - // CRITICAL: Inode changed! fs.watch is now watching a stale inode. - // This happens when the directory is replaced (atomic operations, git checkout, etc.) - console.warn(`[smartwatch] Inode changed for ${basePath}: ${previousInode} -> ${currentInode}`); - console.warn('[smartwatch] fs.watch watches inode, not path - restarting watcher'); - this.restartWatcher(basePath, new Error('Inode changed - directory was replaced')); - } - } catch (error: any) { - if (error.code === 'ENOENT') { - console.error(`[smartwatch] Health check failed: ${basePath} no longer exists`); - this.restartWatcher(basePath, new Error('Watched path disappeared')); - } else if (error.code === 'ENOSPC') { - // inotify watch limit exceeded - critical system issue - console.error(`[smartwatch] ENOSPC: inotify watch limit exceeded!`); - console.error('[smartwatch] Fix: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p'); - this.safeEmit({ type: 'error', path: basePath, error }); - } else { - console.error(`[smartwatch] Health check error for ${basePath}:`, error); - } - } - } - }, NodeWatcher.HEALTH_CHECK_INTERVAL); - } - - /** - * Stop health check interval - */ - private stopHealthCheck(): void { - if (this.healthCheckInterval) { - clearInterval(this.healthCheckInterval); - this.healthCheckInterval = null; - console.log('[smartwatch] Stopped health check'); - } - } - - /** - * Check if a file is a temporary file created by editors - */ - private isTemporaryFile(filePath: string): boolean { - const basename = path.basename(filePath); - // Editor temp files: *.tmp.*, *.swp, *.swx, *~, .#* - if (basename.includes('.tmp.')) return true; - if (basename.endsWith('.swp') || basename.endsWith('.swx')) return true; - if (basename.endsWith('~')) return true; - if (basename.startsWith('.#')) return true; - return false; - } - - /** - * Extract the real file path from a temporary file path - * Used to detect atomic writes where only the temp file event is emitted - * - * Patterns: - * - Claude Code: file.ts.tmp.PID.TIMESTAMP -> file.ts - * - Vim swap: .file.ts.swp -> file.ts (but we don't handle this case) - */ - private getTempFileTarget(tempFilePath: string): string | null { - const basename = path.basename(tempFilePath); - - // Claude Code pattern: file.ts.tmp.PID.TIMESTAMP - // Match: anything.tmp.digits.digits - const claudeMatch = basename.match(/^(.+)\.tmp\.\d+\.\d+$/); - if (claudeMatch) { - const realBasename = claudeMatch[1]; - return path.join(path.dirname(tempFilePath), realBasename); - } - - // Generic .tmp. pattern: file.ts.tmp.something -> file.ts - const tmpMatch = basename.match(/^(.+)\.tmp\.[^.]+$/); - if (tmpMatch) { - const realBasename = tmpMatch[1]; - return path.join(path.dirname(tempFilePath), realBasename); - } - - return null; - } - - get isWatching(): boolean { - return this._isWatching; - } - - async start(): Promise { - if (this._isWatching) { - return; - } - - console.log(`[smartwatch] Starting watcher for ${this.options.basePaths.length} base path(s)...`); - - try { - // Reset initial scan state - this.initialScanComplete = false; - this.deferredEvents = []; - - // Start watching each base path - // NOTE: Events may arrive immediately but will be deferred until scan completes - for (const basePath of this.options.basePaths) { - await this.watchPath(basePath, 0); - } - - this._isWatching = true; - - // Start health check monitoring - this.startHealthCheck(); - - // Perform initial scan to emit 'add' events for existing files - // This populates watchedFiles and fileInodes BEFORE we process events - for (const basePath of this.options.basePaths) { - await this.scanDirectory(basePath, 0); - } - - // Mark scan complete and process any events that arrived during scan - this.initialScanComplete = true; - if (this.deferredEvents.length > 0) { - console.log(`[smartwatch] Processing ${this.deferredEvents.length} deferred events from initial scan window`); - for (const event of this.deferredEvents) { - this.handleFsEvent(event.basePath, event.filename, event.eventType); - } - this.deferredEvents = []; - } - - // Emit ready event - this.safeEmit({ type: 'ready', path: '' }); - console.log(`[smartwatch] Watcher started with ${this.watchers.size} active watcher(s)`); - } catch (error: any) { - console.error('[smartwatch] Failed to start watcher:', error); - this.safeEmit({ type: 'error', path: '', error }); - throw error; - } - } - - async stop(): Promise { - console.log('[smartwatch] Stopping watcher...'); - - // Fix 4: Cancel pending debounced emits FIRST (before flag changes) - // This prevents handleFsEvent from creating new pendingEmits during shutdown - for (const pending of this.pendingEmits.values()) { - clearTimeout(pending.timeout); - } - this.pendingEmits.clear(); - - // NOW set the flag - handleFsEvent will return early after this - this._isWatching = false; - - // Stop health check monitoring - this.stopHealthCheck(); - - // Abort all pending restart delays (prevents orphan watchers) - for (const [path, controller] of this.restartAbortControllers) { - console.log(`[smartwatch] Aborting pending restart for: ${path}`); - controller.abort(); - } - this.restartAbortControllers.clear(); - - // Close all watchers - for (const [watchPath, watcher] of this.watchers) { - console.log(`[smartwatch] Closing watcher for: ${watchPath}`); - watcher.close(); - } - this.watchers.clear(); - this.watchedFiles.clear(); - - // Clear all tracking state - this.restartDelays.clear(); - this.restartAttempts.clear(); - this.watchedInodes.clear(); - this.fileInodes.clear(); - this.restartingPaths.clear(); - - // Fix 5: Reset initial scan state - this.initialScanComplete = false; - this.deferredEvents = []; - - console.log('[smartwatch] Watcher stopped'); - } - - /** - * Start watching a path (file or directory) - */ - private async watchPath(watchPath: string, depth: number): Promise { - if (depth > this.options.depth) { - return; - } - - try { - const stats = await this.statSafe(watchPath); - if (!stats) { - return; - } - - if (stats.isDirectory()) { - // Store inode for health check - fs.watch watches inode, not path! - // If inode changes (directory replaced), watcher becomes stale - this.watchedInodes.set(watchPath, BigInt(stats.ino)); - - // Watch the directory with recursive option (Node.js 20+ supports this on all platforms) - const watcher = fs.watch( - watchPath, - { recursive: true, persistent: true }, - (eventType, filename) => { - if (filename) { - this.handleFsEvent(watchPath, filename, eventType); - } - } - ); - - watcher.on('error', (error: NodeJS.ErrnoException) => { - console.error(`[smartwatch] FSWatcher error event on ${watchPath}:`, error); - - // Detect inotify watch limit exceeded - common cause of "stops working" - if (error.code === 'ENOSPC') { - console.error('[smartwatch] CRITICAL: inotify watch limit exceeded!'); - console.error('[smartwatch] Fix with: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p'); - } - - this.safeEmit({ type: 'error', path: watchPath, error }); - if (this._isWatching) { - this.restartWatcher(watchPath, error); - } - }); - - // Handle 'close' event - fs.watch can close without error - watcher.on('close', () => { - // Only log/restart if we didn't intentionally stop - if (this._isWatching) { - console.warn(`[smartwatch] FSWatcher closed unexpectedly for ${watchPath}`); - this.restartWatcher(watchPath, new Error('Watcher closed unexpectedly')); - } - }); - - this.watchers.set(watchPath, watcher); - console.log(`[smartwatch] Started watching: ${watchPath}`); - } - } catch (error: any) { - console.error(`[smartwatch] Failed to watch path ${watchPath}:`, error); - this.safeEmit({ type: 'error', path: watchPath, error }); - } - } - - /** - * Handle raw fs.watch events - debounce and normalize them - */ - private handleFsEvent( - basePath: string, - filename: string, - eventType: 'rename' | 'change' | string - ): void { - // Fix 3: Guard against post-stop events (events queued before watcher closed) - if (!this._isWatching) { - return; - } - - // Fix 1: Defer events until initial scan completes - // This prevents race conditions where events arrive before watchedFiles is populated - if (!this.initialScanComplete) { - this.deferredEvents.push({ basePath, filename, eventType }); - return; - } - - const fullPath = path.join(basePath, filename); - - // Handle temporary files from atomic writes (Claude Code, editors, etc.) - // Pattern: editor writes to file.tmp.xxx then renames to file - // Problem: fs.watch on Linux may ONLY emit event for the temp file, not the target! - // Solution: When we see a temp file event, also check the corresponding real file - if (this.isTemporaryFile(fullPath)) { - console.log(`[smartwatch] Detected temp file event: ${filename}`); - - // Extract the real file path from the temp file path - // Pattern: file.ts.tmp.PID.TIMESTAMP -> file.ts - const realFilePath = this.getTempFileTarget(fullPath); - if (realFilePath) { - console.log(`[smartwatch] Checking corresponding real file: ${realFilePath}`); - // Queue an event for the REAL file - this is the actual file that changed - // Use a short delay to let the rename complete - setTimeout(() => { - if (this._isWatching) { - this.handleFsEvent(basePath, path.relative(basePath, realFilePath), 'change'); - } - }, 50); - } - return; - } - - // Fix 2: Track event sequence in debounce instead of collapsing to last event - // This ensures we don't lose intermediate events (e.g., add→change→delete) - const existing = this.pendingEmits.get(fullPath); - if (existing) { - // Cancel existing timeout but KEEP the event sequence - clearTimeout(existing.timeout); - // Add this event to the sequence - existing.events.push(eventType as 'rename' | 'change'); - // Reschedule the emit with the accumulated events - existing.timeout = setTimeout(() => { - const pending = this.pendingEmits.get(fullPath); - if (pending) { - this.pendingEmits.delete(fullPath); - this.emitFileEvent(fullPath, pending.events); - } - }, this.options.debounceMs); - } else { - // First event for this file - create new sequence - const timeout = setTimeout(() => { - const pending = this.pendingEmits.get(fullPath); - if (pending) { - this.pendingEmits.delete(fullPath); - this.emitFileEvent(fullPath, pending.events); - } - }, this.options.debounceMs); - - this.pendingEmits.set(fullPath, { - timeout, - events: [eventType as 'rename' | 'change'] - }); - } - } - - /** - * Emit the actual file event after debounce - * - * Fix 2: Now receives event sequence instead of single event type - * This allows intelligent processing of rapid event sequences: - * - add→change→delete: File was created and deleted rapidly - * - rename→rename: File was deleted and recreated (or vice versa) - * - * Also handles file inode tracking to detect delete+recreate scenarios: - * - fs.watch watches the inode, not the path - * - When editors delete+recreate files, the inode changes - * - Without inode tracking, events for the new file would be missed - * - See: https://github.com/paulmillr/chokidar/issues/972 - */ - private async emitFileEvent( - fullPath: string, - eventSequence: Array<'rename' | 'change'> - ): Promise { - try { - const stats = await this.statSafe(fullPath); - const wasWatched = this.watchedFiles.has(fullPath); - const previousInode = this.fileInodes.get(fullPath); - - // Analyze event sequence to understand what happened - const hasRename = eventSequence.includes('rename'); - const hasChange = eventSequence.includes('change'); - const renameCount = eventSequence.filter(e => e === 'rename').length; - - // Log sequence for debugging complex scenarios - if (eventSequence.length > 1) { - console.log(`[smartwatch] Processing event sequence for ${fullPath}: [${eventSequence.join(', ')}]`); - } - - if (stats) { - // File EXISTS now - const currentInode = BigInt(stats.ino); - const inodeChanged = previousInode !== undefined && previousInode !== currentInode; - - if (stats.isDirectory()) { - if (!wasWatched) { - this.watchedFiles.add(fullPath); - this.safeEmit({ type: 'addDir', path: fullPath, stats }); - } - // Directories don't track inodes at file level - } else { - // Update tracking - this.fileInodes.set(fullPath, currentInode); - this.watchedFiles.add(fullPath); - - if (!wasWatched) { - // File wasn't tracked before - this is an add - // Even if there were multiple events, the end result is a new file - this.safeEmit({ type: 'add', path: fullPath, stats }); - } else if (inodeChanged) { - // File was recreated with different inode (delete+recreate) - console.log(`[smartwatch] File inode changed (delete+recreate): ${fullPath}`); - console.log(`[smartwatch] Previous inode: ${previousInode}, current: ${currentInode}`); - // Multiple rename events with inode change = delete+recreate pattern - // Emit unlink for the old file, then add for the new one - if (renameCount >= 2) { - this.safeEmit({ type: 'unlink', path: fullPath }); - this.safeEmit({ type: 'add', path: fullPath, stats }); - } else { - // Single rename with inode change = atomic save (emit as change) - this.safeEmit({ type: 'change', path: fullPath, stats }); - } - } else if (hasChange || hasRename) { - // File exists, was tracked, inode same - content changed - this.safeEmit({ type: 'change', path: fullPath, stats }); - } - } - } else { - // File does NOT exist now - it was deleted - const wasDir = this.isKnownDirectory(fullPath); - - if (wasWatched) { - // File was tracked and is now gone - this.watchedFiles.delete(fullPath); - this.fileInodes.delete(fullPath); - - // If there were multiple events, file may have been created then deleted - if (renameCount >= 2 && !wasDir) { - // add→delete sequence - emit both events - console.log(`[smartwatch] File created and deleted rapidly: ${fullPath}`); - this.safeEmit({ type: 'add', path: fullPath }); - this.safeEmit({ type: 'unlink', path: fullPath }); - } else { - this.safeEmit({ - type: wasDir ? 'unlinkDir' : 'unlink', - path: fullPath - }); - } - } else { - // File wasn't tracked - but events occurred for it - this.fileInodes.delete(fullPath); - - if (renameCount >= 2) { - // Multiple rename events for untracked file that doesn't exist - // Likely: created → deleted rapidly - console.log(`[smartwatch] Untracked file created and deleted: ${fullPath}`); - this.safeEmit({ type: 'add', path: fullPath }); - this.safeEmit({ type: 'unlink', path: fullPath }); - } else if (hasRename) { - // Single event for file that doesn't exist and wasn't tracked - console.log(`[smartwatch] Untracked file deleted: ${fullPath}`); - this.safeEmit({ type: 'unlink', path: fullPath }); - } - // If only 'change' events for non-existent untracked file, ignore - } - } - } catch (error: any) { - this.safeEmit({ type: 'error', path: fullPath, error }); - } - } - - /** - * Scan directory and emit 'add' events for existing files - */ - private async scanDirectory(dirPath: string, depth: number): Promise { - if (depth > this.options.depth) { - return; - } - - try { - const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); - - // Skip temp files during initial scan too - if (this.isTemporaryFile(fullPath)) { - continue; - } - - const stats = await this.statSafe(fullPath); - - if (!stats) { - continue; - } - - if (entry.isDirectory()) { - this.watchedFiles.add(fullPath); - this.safeEmit({ type: 'addDir', path: fullPath, stats }); - await this.scanDirectory(fullPath, depth + 1); - } else if (entry.isFile()) { - this.watchedFiles.add(fullPath); - // Track file inode for delete+recreate detection - this.fileInodes.set(fullPath, BigInt(stats.ino)); - this.safeEmit({ type: 'add', path: fullPath, stats }); - } - } - } catch (error: any) { - if (error.code !== 'ENOENT' && error.code !== 'EACCES') { - this.safeEmit({ type: 'error', path: dirPath, error }); - } - } - } - - /** - * Safely stat a path, returning null if it doesn't exist - */ private async statSafe(filePath: string): Promise { try { return await (this.options.followSymlinks ? fs.promises.stat(filePath) : fs.promises.lstat(filePath)); } catch (error: any) { - // Only silently return null for expected "file doesn't exist" errors if (error.code === 'ENOENT' || error.code === 'ENOTDIR') { return null; } - // Log other errors (permission, I/O) but still return null console.warn(`[smartwatch] statSafe warning for ${filePath}: ${error.code} - ${error.message}`); return null; } } - /** - * Check if a path was known to be a directory (for proper unlink event type) - */ private isKnownDirectory(filePath: string): boolean { - // Check if any watched files are children of this path - for (const watched of this.watchedFiles) { - if (watched.startsWith(filePath + path.sep)) { - return true; - } - } - return false; + const resolved = path.resolve(filePath); + return this.watched.has(resolved); } }