fix(watcher.node): Normalize paths and improve Node watcher robustness: restart/rescan on errors (including ENOSPC), clear stale state, and remove legacy throttler
This commit is contained in:
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-12-11 - 6.2.5 - fix(watcher.node)
|
||||||
|
Normalize paths and improve Node watcher robustness: restart/rescan on errors (including ENOSPC), clear stale state, and remove legacy throttler
|
||||||
|
|
||||||
|
- Normalize all paths to absolute at watcher entry points (watchPath, handleFsEvent, scanDirectory) to avoid relative/absolute mismatch bugs
|
||||||
|
- On watcher restart: clear pending unlink timeouts, dispose stale DirEntry data, and perform a rescan to catch files created during the restart window
|
||||||
|
- Trigger watcher restart on ENOSPC (inotify limit) errors instead of only logging the error
|
||||||
|
- Remove the previous Throttler implementation and rely on the existing debounce + event-sequence tracking to handle rapid events
|
||||||
|
- Atomic write handling and queued unlink behavior preserved; pending unlinks are cleared for restarted base paths to avoid stale events
|
||||||
|
|
||||||
## 2025-12-11 - 6.2.4 - fix(tests)
|
## 2025-12-11 - 6.2.4 - fix(tests)
|
||||||
Stabilize tests and document chokidar-inspired Node watcher architecture
|
Stabilize tests and document chokidar-inspired Node watcher architecture
|
||||||
|
|
||||||
|
|||||||
@@ -80,11 +80,20 @@ The Node.js watcher has been refactored with elegant patterns inspired by [choki
|
|||||||
- Encapsulates file tracking and inode management
|
- Encapsulates file tracking and inode management
|
||||||
- `dispose()` method freezes object to catch use-after-cleanup bugs
|
- `dispose()` method freezes object to catch use-after-cleanup bugs
|
||||||
|
|
||||||
**Throttler Pattern:**
|
**Path Normalization (v6.3.1+):**
|
||||||
- More sophisticated than simple debounce
|
- ALL paths are normalized to absolute at entry points
|
||||||
- Tracks count of suppressed events
|
- Prevents relative/absolute path mismatch bugs
|
||||||
- Returns `false` if already throttled, `Throttler` object otherwise
|
- `watchPath()`, `handleFsEvent()`, `scanDirectory()` all resolve paths
|
||||||
- Used for change events to prevent duplicate emissions
|
|
||||||
|
**Restart Rescan (v6.3.1+):**
|
||||||
|
- When watcher restarts, it rescans the directory
|
||||||
|
- Catches files created during the restart window
|
||||||
|
- Clears stale DirEntry data before rescan
|
||||||
|
- Clears pending unlink timeouts to prevent stale events
|
||||||
|
|
||||||
|
**ENOSPC Handling (v6.3.1+):**
|
||||||
|
- inotify limit errors now trigger watcher restart
|
||||||
|
- Previously only logged error without recovery
|
||||||
|
|
||||||
**Atomic Write Handling:**
|
**Atomic Write Handling:**
|
||||||
- Unlink events are queued with 100ms delay
|
- Unlink events are queued with 100ms delay
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartwatch',
|
name: '@push.rocks/smartwatch',
|
||||||
version: '6.2.4',
|
version: '6.2.5',
|
||||||
description: 'A cross-runtime file watcher with glob pattern support for Node.js, Deno, and Bun.'
|
description: 'A cross-runtime file watcher with glob pattern support for Node.js, Deno, and Bun.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,16 +18,6 @@ const EV = {
|
|||||||
ERROR: 'error',
|
ERROR: 'error',
|
||||||
} as const;
|
} 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 */
|
/** Configuration constants */
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
MAX_RETRIES: 3,
|
MAX_RETRIES: 3,
|
||||||
@@ -127,9 +117,6 @@ export class NodeWatcher implements IWatcher {
|
|||||||
// Event stream
|
// Event stream
|
||||||
public readonly events$ = new smartrx.rxjs.Subject<IWatchEvent>();
|
public readonly events$ = new smartrx.rxjs.Subject<IWatchEvent>();
|
||||||
|
|
||||||
// Throttling - inspired by chokidar's _throttle pattern
|
|
||||||
private throttled: Map<ThrottleType, Map<string, Throttler>> = new Map();
|
|
||||||
|
|
||||||
// Atomic write handling - pending unlinks that may become changes
|
// Atomic write handling - pending unlinks that may become changes
|
||||||
private pendingUnlinks: Map<string, { timeout: NodeJS.Timeout; event: IWatchEvent }> = new Map();
|
private pendingUnlinks: Map<string, { timeout: NodeJS.Timeout; event: IWatchEvent }> = new Map();
|
||||||
|
|
||||||
@@ -156,12 +143,7 @@ export class NodeWatcher implements IWatcher {
|
|||||||
// Closer registry - inspired by chokidar for clean resource management
|
// Closer registry - inspired by chokidar for clean resource management
|
||||||
private closers: Map<string, Array<() => void>> = new Map();
|
private closers: Map<string, Array<() => 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());
|
|
||||||
}
|
|
||||||
|
|
||||||
get isWatching(): boolean {
|
get isWatching(): boolean {
|
||||||
return this._isWatching;
|
return this._isWatching;
|
||||||
@@ -228,14 +210,6 @@ export class NodeWatcher implements IWatcher {
|
|||||||
}
|
}
|
||||||
this.pendingUnlinks.clear();
|
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
|
// Now set flag
|
||||||
this._isWatching = false;
|
this._isWatching = false;
|
||||||
|
|
||||||
@@ -285,41 +259,6 @@ export class NodeWatcher implements IWatcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// 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
|
// Closer Registry - Clean resource management
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
@@ -424,6 +363,9 @@ export class NodeWatcher implements IWatcher {
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
private async watchPath(watchPath: string): Promise<void> {
|
private async watchPath(watchPath: string): Promise<void> {
|
||||||
|
// Normalize path to absolute - critical for consistent lookups
|
||||||
|
watchPath = path.resolve(watchPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stats = await this.statSafe(watchPath);
|
const stats = await this.statSafe(watchPath);
|
||||||
if (!stats?.isDirectory()) return;
|
if (!stats?.isDirectory()) return;
|
||||||
@@ -494,7 +436,8 @@ export class NodeWatcher implements IWatcher {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullPath = path.join(basePath, filename);
|
// Normalize to absolute path - critical for consistent lookups
|
||||||
|
const fullPath = path.resolve(path.join(basePath, filename));
|
||||||
|
|
||||||
// Handle temp files from atomic writes
|
// Handle temp files from atomic writes
|
||||||
if (this.isTemporaryFile(fullPath)) {
|
if (this.isTemporaryFile(fullPath)) {
|
||||||
@@ -602,10 +545,7 @@ export class NodeWatcher implements IWatcher {
|
|||||||
this.safeEmit({ type: EV.CHANGE, path: fullPath, stats });
|
this.safeEmit({ type: EV.CHANGE, path: fullPath, stats });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Apply throttle for change events
|
// Debounce already handles rapid events - no extra throttle needed
|
||||||
if (!this.throttle('change', fullPath, 50)) {
|
|
||||||
return; // Throttled
|
|
||||||
}
|
|
||||||
this.safeEmit({ type: EV.CHANGE, path: fullPath, stats });
|
this.safeEmit({ type: EV.CHANGE, path: fullPath, stats });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -667,6 +607,9 @@ export class NodeWatcher implements IWatcher {
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
private async scanDirectory(dirPath: string, depth: number): Promise<void> {
|
private async scanDirectory(dirPath: string, depth: number): Promise<void> {
|
||||||
|
// Normalize path to absolute - critical for consistent lookups
|
||||||
|
dirPath = path.resolve(dirPath);
|
||||||
|
|
||||||
if (depth > this.options.depth) return;
|
if (depth > this.options.depth) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -724,6 +667,8 @@ export class NodeWatcher implements IWatcher {
|
|||||||
console.error('[smartwatch] ENOSPC: inotify watch limit exceeded!');
|
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');
|
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 });
|
this.safeEmit({ type: EV.ERROR, path: basePath, error });
|
||||||
|
// Trigger restart - watcher may be broken after ENOSPC
|
||||||
|
this.restartWatcher(basePath, error);
|
||||||
} else {
|
} else {
|
||||||
console.error(`[smartwatch] Health check error for ${basePath}:`, error);
|
console.error(`[smartwatch] Health check error for ${basePath}:`, error);
|
||||||
}
|
}
|
||||||
@@ -772,6 +717,22 @@ export class NodeWatcher implements IWatcher {
|
|||||||
this.watchers.delete(basePath);
|
this.watchers.delete(basePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear pending unlinks for this base path (prevent stale events)
|
||||||
|
for (const [unlinkedPath, pending] of this.pendingUnlinks) {
|
||||||
|
if (unlinkedPath.startsWith(basePath)) {
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
this.pendingUnlinks.delete(unlinkedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear stale DirEntry data (will be repopulated by rescan)
|
||||||
|
for (const [dirPath, entry] of this.watched) {
|
||||||
|
if (dirPath === basePath || dirPath.startsWith(basePath + path.sep)) {
|
||||||
|
entry.dispose();
|
||||||
|
this.watched.delete(dirPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Exponential backoff with abort support
|
// Exponential backoff with abort support
|
||||||
const delay = this.restartDelays.get(basePath) || CONFIG.INITIAL_RESTART_DELAY;
|
const delay = this.restartDelays.get(basePath) || CONFIG.INITIAL_RESTART_DELAY;
|
||||||
console.log(`[smartwatch] Waiting ${delay}ms before restart...`);
|
console.log(`[smartwatch] Waiting ${delay}ms before restart...`);
|
||||||
@@ -803,6 +764,8 @@ export class NodeWatcher implements IWatcher {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await this.watchPath(basePath);
|
await this.watchPath(basePath);
|
||||||
|
// Rescan to catch files created during restart window
|
||||||
|
await this.scanDirectory(basePath, 0);
|
||||||
console.log(`[smartwatch] Successfully restarted watcher for ${basePath}`);
|
console.log(`[smartwatch] Successfully restarted watcher for ${basePath}`);
|
||||||
this.restartDelays.set(basePath, CONFIG.INITIAL_RESTART_DELAY);
|
this.restartDelays.set(basePath, CONFIG.INITIAL_RESTART_DELAY);
|
||||||
this.restartAttempts.set(basePath, 0);
|
this.restartAttempts.set(basePath, 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user