

















@@ -109,6 +109,13 @@ const IGNORED_MEMORY_WATCH_DIR_NAMES = new Set([
109109110110const log = createSubsystemLogger("memory");
111111const TEST_MEMORY_WATCH_FACTORY_KEY = Symbol.for("openclaw.test.memoryWatchFactory");
112+const TEST_MEMORY_NATIVE_WATCH_FACTORY_KEY = Symbol.for("openclaw.test.memoryNativeWatchFactory");
113+114+type NativeMemoryWatchPair = {
115+dir: string;
116+main: fsSync.FSWatcher;
117+parent: fsSync.FSWatcher | null;
118+};
112119113120function resolveMemoryWatchFactory(): typeof chokidar.watch {
114121if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") {
@@ -120,6 +127,18 @@ function resolveMemoryWatchFactory(): typeof chokidar.watch {
120127return chokidar.watch.bind(chokidar);
121128}
122129130+function resolveMemoryNativeWatchFactory(): typeof fsSync.watch {
131+if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") {
132+const override = (globalThis as Record<PropertyKey, unknown>)[
133+TEST_MEMORY_NATIVE_WATCH_FACTORY_KEY
134+];
135+if (typeof override === "function") {
136+return override as typeof fsSync.watch;
137+}
138+}
139+return fsSync.watch.bind(fsSync);
140+}
141+123142function shouldIgnoreMemoryWatchPath(
124143watchPath: string,
125144stats?: { isDirectory?: () => boolean },
@@ -200,6 +219,7 @@ export abstract class MemoryManagerSyncOps {
200219} = { enabled: false, available: false };
201220protected vectorReady: Promise<boolean> | null = null;
202221protected watcher: FSWatcher | null = null;
222+private nativeMemoryWatchPairs: NativeMemoryWatchPair[] = [];
203223protected watchTimer: NodeJS.Timeout | null = null;
204224protected sessionWatchTimer: NodeJS.Timeout | null = null;
205225protected sessionUnsubscribe: (() => void) | null = null;
@@ -434,13 +454,18 @@ export abstract class MemoryManagerSyncOps {
434454}
435455436456protected ensureWatcher() {
437-if (!this.sources.has("memory") || !this.settings.sync.watch || this.watcher) {
457+if (!this.sources.has("memory") || !this.settings.sync.watch) {
438458return;
439459}
440-const watchPaths = new Set<string>([
441-path.join(this.workspaceDir, "MEMORY.md"),
442-path.join(this.workspaceDir, "memory"),
443-]);
460+if (this.watcher || this.nativeMemoryWatchPairs.length > 0) {
461+// Already initialized — preserve idempotence.
462+return;
463+}
464+// Core paths preserve original symlink-follow behavior (chokidar/fs.watch
465+// resolve through symlinks by default); extraPaths preserves the original
466+// explicit symlink-skip policy.
467+const fileWatchPaths = new Set<string>([path.join(this.workspaceDir, "MEMORY.md")]);
468+const dirWatchPaths = new Set<string>([path.join(this.workspaceDir, "memory")]);
444469const additionalPaths = normalizeExtraMemoryPaths(this.workspaceDir, this.settings.extraPaths);
445470for (const entry of additionalPaths) {
446471try {
@@ -449,40 +474,301 @@ export abstract class MemoryManagerSyncOps {
449474continue;
450475}
451476if (stat.isDirectory()) {
452-watchPaths.add(entry);
477+dirWatchPaths.add(entry);
453478continue;
454479}
455480if (
456481stat.isFile() &&
457482(normalizeLowercaseStringOrEmpty(entry).endsWith(".md") ||
458483classifyMemoryMultimodalPath(entry, this.settings.multimodal) !== null)
459484) {
460-watchPaths.add(entry);
485+fileWatchPaths.add(entry);
461486}
462487} catch {
463488// Skip missing/unreadable additional paths.
464489}
465490}
466-this.watcher = resolveMemoryWatchFactory()(Array.from(watchPaths), {
467-ignoreInitial: true,
468-ignored: (watchPath, stats) =>
469-shouldIgnoreMemoryWatchPath(watchPath, stats, this.settings.multimodal),
470-});
471491const markDirty = (watchPath?: string, stats?: MemoryWatchEventStats) => {
472492recordMemoryWatchEventPath(this.pendingWatchPaths, watchPath, stats);
473493this.dirty = true;
474494this.scheduleWatchSync();
475495};
476-this.watcher.on("add", markDirty);
477-this.watcher.on("change", markDirty);
478-this.watcher.on("unlink", markDirty);
479-this.watcher.on("unlinkDir", markDirty);
480-this.watcher.on("error", (err) => {
481-// File watcher errors (e.g., ENOSPC) should not crash the gateway.
482-// Log the error and continue - memory search still works without auto-sync.
496+// Native recursive fs.watch for directory paths — one watcher per
497+// directory on macOS (FSEvents) and Windows (ReadDirectoryChangesW).
498+// Avoids chokidar's per-file fs.watch fan-out that opened ~12k REG FDs
499+// on multi-thousand-`.md` memory trees (issue #86613).
500+//
501+// Linux is intentionally NOT in the native set: Node's
502+// `fs.watch(dir, { recursive: true })` on non-macOS/non-Windows routes
503+// through `internal/fs/recursive_watch`, which walks the tree and
504+// attaches one watcher per entry under the hood. That defeats the
505+// constant-watcher-profile goal of this fix without throwing (so the
506+// creation-failure fallback below would not catch it). Linux paths
507+// therefore go straight to chokidar, matching pre-PR behavior on that
508+// platform.
509+//
510+// On any other native creation failure (e.g. unsupported filesystem,
511+// ERR_FEATURE_UNAVAILABLE_ON_PLATFORM) the directory also falls back to
512+// chokidar so freshness is preserved on the degraded path.
513+const nativeRecursiveSupported = process.platform === "darwin" || process.platform === "win32";
514+for (const dir of dirWatchPaths) {
515+if (!nativeRecursiveSupported) {
516+fileWatchPaths.add(dir);
517+continue;
518+}
519+if (!this.attachNativeMemoryWatchForDir(dir, markDirty)) {
520+// Native creation failed (dir missing, unsupported FS, throw) —
521+// fall back to chokidar so directory coverage isn't dropped.
522+fileWatchPaths.add(dir);
523+}
524+}
525+if (fileWatchPaths.size > 0) {
526+this.watcher = resolveMemoryWatchFactory()(Array.from(fileWatchPaths), {
527+ignoreInitial: true,
528+ignored: (watchPath, stats) =>
529+shouldIgnoreMemoryWatchPath(watchPath, stats, this.settings.multimodal),
530+});
531+this.watcher.on("add", markDirty);
532+this.watcher.on("change", markDirty);
533+this.watcher.on("unlink", markDirty);
534+this.watcher.on("unlinkDir", markDirty);
535+this.watcher.on("error", (err) => {
536+// File watcher errors (e.g., ENOSPC) should not crash the gateway.
537+// Log the error and continue - memory search still works without auto-sync.
538+const message = err instanceof Error ? err.message : String(err);
539+log.warn(`memory watcher error: ${message}`);
540+});
541+}
542+}
543+544+// Attach a native recursive `fs.watch` to `dir` plus a non-recursive
545+// parent-directory watch that detects root-replacement
546+// (`rm -rf memory && mkdir memory`) by inode comparison. Returns true if
547+// the main native watcher attached. Called from ensureWatcher(); also
548+// re-entered from the parent-watch handler on detected replacement.
549+protected attachNativeMemoryWatchForDir(
550+dir: string,
551+markDirty: (watchPath?: string, stats?: MemoryWatchEventStats) => void,
552+): boolean {
553+if (this.closed) {
554+return false;
555+}
556+let recordedInode: number | null;
557+try {
558+recordedInode = fsSync.statSync(dir).ino;
559+} catch {
560+// Dir doesn't exist; caller will fall back to chokidar.
561+return false;
562+}
563+let mainWatcher: fsSync.FSWatcher;
564+try {
565+mainWatcher = resolveMemoryNativeWatchFactory()(
566+dir,
567+{ recursive: true },
568+(_eventType, filename) => {
569+if (filename == null) {
570+// Node docs: filename may be null on some platforms even when
571+// recursive watching is otherwise supported. Be conservative
572+// and mark broadly dirty rather than dropping the event.
573+markDirty();
574+return;
575+}
576+const full = path.join(dir, filename);
577+let stats: fsSync.Stats | undefined;
578+try {
579+const s = fsSync.lstatSync(full, { throwIfNoEntry: false });
580+stats = s ?? undefined;
581+} catch {
582+stats = undefined;
583+}
584+if (shouldIgnoreMemoryWatchPath(full, stats, this.settings.multimodal)) {
585+return;
586+}
587+// Pass stats so the watch-settle queue can debounce rapid
588+// writes; without a snapshot the queue cannot detect stability.
589+markDirty(full, stats);
590+},
591+);
592+} catch (err) {
593+log.warn(
594+`failed to start native recursive watcher on ${dir}: ${String(err)}; falling back to chokidar`,
595+);
596+return false;
597+}
598+const pair: NativeMemoryWatchPair = { dir, main: mainWatcher, parent: null };
599+mainWatcher.on("error", (err) => {
483600const message = err instanceof Error ? err.message : String(err);
484-log.warn(`memory watcher error: ${message}`);
601+log.warn(`memory native watcher error on ${dir}: ${message}`);
602+// Per Node docs the FSWatcher is no longer usable after an error.
603+this.closeNativeMemoryWatchPair(pair);
604+if (this.closed) {
605+return;
606+}
607+// Force a broad re-sync to cover the gap, then restore directory
608+// coverage by reattaching to chokidar so subsequent file changes
609+// still drive watch sync (intervalMinutes defaults to 0; without
610+// a watcher the directory would stop being indexed).
611+markDirty();
612+this.attachMemoryChokidarFallback(dir, markDirty);
485613});
614+this.nativeMemoryWatchPairs.push(pair);
615+// Non-recursive parent watcher: catches root-directory replacement so
616+// we can reattach the main watcher on the new inode. Without this,
617+// `rm -rf memory && mkdir memory` would leave the main watcher bound
618+// to the dead inode and silently miss subsequent file changes.
619+try {
620+const parentDir = path.dirname(dir);
621+const baseName = path.basename(dir);
622+const parentWatcher = resolveMemoryNativeWatchFactory()(
623+parentDir,
624+{ recursive: false },
625+(_eventType, filename) => {
626+// Per Node docs `filename` can be null on some platforms even
627+// when the parent watcher is otherwise supported. Treat null
628+// as an unknown event and re-check the watched directory's
629+// inode (clawsweeper review [P2] 5df68c…); otherwise filter
630+// by basename so sibling events don't trigger reattach.
631+if (filename !== null && filename !== baseName) {
632+return;
633+}
634+let currentInode: number | null;
635+try {
636+currentInode = fsSync.statSync(dir).ino;
637+} catch {
638+currentInode = null;
639+}
640+if (currentInode === recordedInode) {
641+return;
642+}
643+// Root was replaced (or removed). Tear down the existing pair
644+// and either reattach (if dir still exists) or fall back to
645+// chokidar (if dir is gone).
646+this.closeNativeMemoryWatchPair(pair);
647+if (this.closed) {
648+return;
649+}
650+markDirty();
651+if (currentInode !== null) {
652+// Re-attach on the new inode (this also installs a fresh
653+// parent watcher closed over the new recordedInode). If the
654+// helper's own statSync races with the dir disappearing
655+// between our inode check and its own check, it returns
656+// false — fall back to chokidar so coverage isn't lost.
657+if (!this.attachNativeMemoryWatchForDir(dir, markDirty)) {
658+this.attachMemoryChokidarFallback(dir, markDirty);
659+}
660+} else {
661+this.attachMemoryChokidarFallback(dir, markDirty);
662+}
663+},
664+);
665+parentWatcher.on("error", (err) => {
666+const message = err instanceof Error ? err.message : String(err);
667+log.warn(`memory native parent watcher error on ${path.dirname(dir)}: ${message}`);
668+try {
669+parentWatcher.close();
670+} catch {
671+// ignore
672+}
673+this.removeNativeMemoryParentWatch(parentWatcher);
674+if (pair.parent === parentWatcher) {
675+pair.parent = null;
676+}
677+// Main watcher still alive — root-replacement detection is lost
678+// but normal events still flow. No fallback needed.
679+});
680+pair.parent = parentWatcher;
681+} catch (err) {
682+// Parent watcher couldn't start (e.g. parentDir not accessible).
683+// The main watcher still works for non-replacement events; just
684+// log and continue.
685+log.warn(
686+`memory native parent watcher could not start on ${path.dirname(dir)}: ${String(err)}`,
687+);
688+}
689+return true;
690+}
691+692+private closeNativeMemoryWatchPair(pair: NativeMemoryWatchPair): void {
693+try {
694+pair.main.close();
695+} catch {
696+// ignore close failures
697+}
698+if (pair.parent) {
699+try {
700+pair.parent.close();
701+} catch {
702+// ignore close failures
703+}
704+pair.parent = null;
705+}
706+this.removeNativeMemoryWatchPair(pair);
707+}
708+709+protected closeNativeMemoryWatchPairs(): void {
710+while (this.nativeMemoryWatchPairs.length > 0) {
711+const pair = this.nativeMemoryWatchPairs[0];
712+if (!pair) {
713+return;
714+}
715+this.closeNativeMemoryWatchPair(pair);
716+}
717+}
718+719+private removeNativeMemoryParentWatch(w: fsSync.FSWatcher): void {
720+for (const pair of this.nativeMemoryWatchPairs) {
721+if (pair.parent === w) {
722+pair.parent = null;
723+return;
724+}
725+}
726+}
727+728+private removeNativeMemoryWatchPair(pair: NativeMemoryWatchPair): void {
729+const idx = this.nativeMemoryWatchPairs.indexOf(pair);
730+if (idx >= 0) {
731+this.nativeMemoryWatchPairs.splice(idx, 1);
732+}
733+}
734+735+// Reattach `dir` to chokidar after a native recursive watcher dies, so
736+// subsequent memory changes under `dir` continue to drive watch sync.
737+// Called from the native watcher `error` handler in ensureWatcher();
738+// factored out so the fallback shape can be unit-tested in isolation.
739+protected attachMemoryChokidarFallback(
740+dir: string,
741+markDirty: (watchPath?: string, stats?: MemoryWatchEventStats) => void,
742+): void {
743+if (this.closed) {
744+// Manager teardown started — don't create new watcher resources.
745+return;
746+}
747+try {
748+if (this.watcher) {
749+// Existing chokidar watcher (handling MEMORY.md and/or other file
750+// paths) — extend it to cover this directory too.
751+this.watcher.add(dir);
752+return;
753+}
754+// No chokidar watcher exists yet. Spin one up just for this directory
755+// so the periodic-sync gap is closed.
756+this.watcher = resolveMemoryWatchFactory()([dir], {
757+ignoreInitial: true,
758+ignored: (watchPath, stats) =>
759+shouldIgnoreMemoryWatchPath(watchPath, stats, this.settings.multimodal),
760+});
761+this.watcher.on("add", markDirty);
762+this.watcher.on("change", markDirty);
763+this.watcher.on("unlink", markDirty);
764+this.watcher.on("unlinkDir", markDirty);
765+this.watcher.on("error", (err) => {
766+const message = err instanceof Error ? err.message : String(err);
767+log.warn(`memory watcher error: ${message}`);
768+});
769+} catch (err) {
770+log.warn(`failed to attach chokidar fallback for ${dir}: ${String(err)}`);
771+}
486772}
487773488774protected ensureSessionListener() {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。