fix(memory-core): avoid per-file watcher FD fan-out for memory directories#86701
Conversation
|
Codex review: needs changes before merge. Reviewed May 26, 2026, 12:13 PM ET / 16:13 UTC. Summary PR surface: Source +287, Tests +477, Config +1, Other +553. Total +1318 across 5 files. Reproducibility: yes. The linked issue and PR body provide a high-confidence clean-main macOS reproduction using an authorized Review metrics: 1 noteworthy metric.
Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Risk before merge
Maintainer options:
Copy recommended automerge instructionNext step before merge Security Review findings
Review detailsBest possible solution: Fix the repro-helper timeout false-negative, then land the watcher-backend change once maintainers accept the macOS-live and Windows-contract proof boundary; keep broader Do we have a high-confidence way to reproduce the issue? Yes. The linked issue and PR body provide a high-confidence clean-main macOS reproduction using an authorized Is this the best way to solve the issue? Mostly yes. The runtime fix preserves the existing Full review comments:
Overall correctness: patch is correct AGENTS.md: found and applied where relevant. Codex review notes: model gpt-5.5, reasoning high; reviewed against 0973a7e4e4cc. Label changesLabel changes:
Label justifications:
Evidence reviewedPR surface: Source +287, Tests +477, Config +1, Other +553. Total +1318 across 5 files. View PR surface stats
Acceptance criteria:
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ff5e61a4f8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
ClawSweeper PR egg ✨ Hatched: 🥚 common Mossy Proofling Hatch commandComment Hatchability rules:
Rarity: 🥚 common. What is this egg doing here?
|
…e watcher error Addresses clawsweeper PR review on openclaw#86701: when the native recursive fs.watch emits an error and the watcher is closed/removed, no replacement watcher is attached. Because intervalMinutes defaults to 0, subsequent memory changes under that directory would stop driving watch sync until process restart. After this commit, the native watcher error handler closes and removes the dead FSWatcher (as before), forces one broad markDirty to cover the gap, AND reattaches the directory to chokidar via the new attachMemoryChokidarFallback helper. If the existing this.watcher slot holds a chokidar instance (handling MEMORY.md and/or other file paths), the directory is added via this.watcher.add(dir). If no chokidar watcher exists yet, one is spun up specifically for that directory. The fallback is intentional: it accepts chokidar's per-file FD cost for the failed directory only (and only after the native watcher already died once), rather than dropping watcher coverage entirely. The FD-leak fix property for the healthy-watcher path is unchanged. Tests: - existing "runtime error" test in manager.watcher-config.test.ts now asserts the directory is attached to chokidar after the error and that a subsequent chokidar event on the fallback path schedules sync - new test exercises the "no chokidar yet" branch where the fallback creates a fresh chokidar watcher for the directory - all 16 tests in manager.watcher-config.test.ts pass; lab probe still shows 12,392 → 1 REG FD reduction on the synthetic workspace Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
@clawsweeper review Addressed the P2 finding from your earlier review (native watcher error path losing directory coverage). New commit
A separate codex review surfaced a different P2 concerning watched-directory replacement ( |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e153c2fc24
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…d fallback against shutdown race Two follow-up findings from PR review on openclaw#86701: 1. Linux gating (clawsweeper [P2], also codex round-1 #1). On non-macOS/non-Windows platforms, Node routes `fs.watch(dir, { recursive: true })` through `internal/fs/recursive_watch`, which walks the tree and attaches one watcher per discovered entry. That defeats the constant-watcher- profile goal of the parent commit's fix without throwing, so the creation-failure fallback never fires. Restrict native attachment to `process.platform === "darwin" || "win32"`; Linux and others go straight to the chokidar fallback, matching pre-PR behavior on those platforms. 2. Shutdown-race guard (codex-connector inline reviews 4359699784 + 4359929223 [P2]). The native watcher `error` handler called `attachMemoryChokidarFallback` unconditionally, including after `close()` had set `this.closed = true` and begun teardown. That left a window where an error fired mid-shutdown could spin up a new chokidar watcher after the normal watcher-close step had already run, leaking watch handles past manager close. Both the error handler and the `attachMemoryChokidarFallback` helper now short-circuit when `this.closed` is true. Tests: - new case in manager.watcher-config.test.ts verifies that `process.platform === "linux"` routes both `memory/` and dir-typed `extraPaths` directly to chokidar (`nativeWatchMock` not called at all) — covers the Linux gate - all 17 tests in manager.watcher-config.test.ts pass Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
Pushed 1. Linux gating — addresses clawsweeper's 2. Shutdown-race guard — addresses codex-connector inline reviews Validation:
The remaining dir-replacement edge case from the earlier codex final review (watched root deleted/recreated mid-process) stays as a deferred follow-up in the PR body — happy to file as a separate issue if maintainers want it merge-blocking. @clawsweeper review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
…e watcher error Addresses clawsweeper PR review on openclaw#86701: when the native recursive fs.watch emits an error and the watcher is closed/removed, no replacement watcher is attached. Because intervalMinutes defaults to 0, subsequent memory changes under that directory would stop driving watch sync until process restart. After this commit, the native watcher error handler closes and removes the dead FSWatcher (as before), forces one broad markDirty to cover the gap, AND reattaches the directory to chokidar via the new attachMemoryChokidarFallback helper. If the existing this.watcher slot holds a chokidar instance (handling MEMORY.md and/or other file paths), the directory is added via this.watcher.add(dir). If no chokidar watcher exists yet, one is spun up specifically for that directory. The fallback is intentional: it accepts chokidar's per-file FD cost for the failed directory only (and only after the native watcher already died once), rather than dropping watcher coverage entirely. The FD-leak fix property for the healthy-watcher path is unchanged. Tests: - existing "runtime error" test in manager.watcher-config.test.ts now asserts the directory is attached to chokidar after the error and that a subsequent chokidar event on the fallback path schedules sync - new test exercises the "no chokidar yet" branch where the fallback creates a fresh chokidar watcher for the directory - all 16 tests in manager.watcher-config.test.ts pass; lab probe still shows 12,392 → 1 REG FD reduction on the synthetic workspace Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…d fallback against shutdown race Two follow-up findings from PR review on openclaw#86701: 1. Linux gating (clawsweeper [P2], also codex round-1 #1). On non-macOS/non-Windows platforms, Node routes `fs.watch(dir, { recursive: true })` through `internal/fs/recursive_watch`, which walks the tree and attaches one watcher per discovered entry. That defeats the constant-watcher- profile goal of the parent commit's fix without throwing, so the creation-failure fallback never fires. Restrict native attachment to `process.platform === "darwin" || "win32"`; Linux and others go straight to the chokidar fallback, matching pre-PR behavior on those platforms. 2. Shutdown-race guard (codex-connector inline reviews 4359699784 + 4359929223 [P2]). The native watcher `error` handler called `attachMemoryChokidarFallback` unconditionally, including after `close()` had set `this.closed = true` and begun teardown. That left a window where an error fired mid-shutdown could spin up a new chokidar watcher after the normal watcher-close step had already run, leaking watch handles past manager close. Both the error handler and the `attachMemoryChokidarFallback` helper now short-circuit when `this.closed` is true. Tests: - new case in manager.watcher-config.test.ts verifies that `process.platform === "linux"` routes both `memory/` and dir-typed `extraPaths` directly to chokidar (`nativeWatchMock` not called at all) — covers the Linux gate - all 17 tests in manager.watcher-config.test.ts pass Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
803df6c to
baa8161
Compare
…atisfy check-test-types clawsweeper review on PR openclaw#86701 (P2 finding) reported the `check-test-types` lane failing on `extensions/memory-core/src/memory/manager.watcher-config.test.ts` because several assertions index `watchMock.mock.calls[0][...]` directly. TypeScript treats vi.fn's `.mock.calls` as a possibly-empty tuple, so direct positional indexing fails the strict type-check lane. Apply the same `as unknown as [string[], Record<string, unknown>]` cast pattern already used elsewhere in this file for the first chokidar destructure, to all remaining `mock.calls[N]` accesses in the new tests added by the previous commit. No runtime behavior change; tests still pass (16/16 in the focused file). `pnpm check:test-types` now clean. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…ment Two rank-up moves identified by clawsweeper review on PR openclaw#86701: 1. **Windows native-watcher coverage in tests.** Add a mocked `process.platform === "win32"` case that mirrors the existing Linux gate test: assert that BOTH `memory/` and dir-typed `extraPaths` route to native recursive fs.watch (Windows uses ReadDirectoryChangesW for the recursive option, like macOS uses FSEvents), and that chokidar receives only the file path (`MEMORY.md`). Verifies the `process.platform === "darwin" || "win32"` gate keeps Windows on the native path. 2. **Detect memory-root replacement and reattach.** When the native recursive watcher is bound to a directory inode and that directory is replaced mid-process (`rm -rf memory && mkdir memory`), Node's fs.watch silently keeps watching the dead inode and never emits an error, so the existing creation-failure + runtime-error fallbacks cannot recover. Codex inline reviews 4360224380 (and codex final) flagged this as a freshness risk because `intervalMinutes` defaults to `0`, leaving the directory unindexed until restart. This commit pairs every native memory directory watch with a non-recursive `fs.watch(parentDir, { recursive: false })`. When that parent watcher fires for the watched basename AND the current inode of the dir differs from the recorded inode, the existing main+parent pair is closed and a fresh `attachNativeMemoryWatchForDir` invocation reattaches on the new inode. If the dir was removed (not replaced), the chokidar fallback takes over to preserve coverage. Implementation: - New `nativeMemoryParentWatchers: fsSync.FSWatcher[]` paired with the existing `nativeMemoryWatchers`. - New helper `attachNativeMemoryWatchForDir(dir, markDirty)` factored out of `ensureWatcher()` so the parent-event handler can re-enter cleanly for reattach. - Both arrays are torn down in `manager.ts` `close()`. - All existing close-time / error-path guards preserved (the parent handler also respects `this.closed`). Tests: - "routes directories through native recursive watch on Windows" — mocked-platform mirror of the existing Linux gate test. - "attaches a non-recursive parent-directory watcher for root-replacement detection" — asserts the main+parent pair is created with the correct recursive flags. - "ignores parent-directory events for unrelated basenames" — asserts the parent handler only reacts to the watched basename and leaves the main watcher untouched for sibling events. - Existing tests updated for the new call shape: each watched dir now produces 2 nativeWatchMock calls (1 main recursive + 1 parent non-recursive). Assertions filter by `recursive: true` to identify main watchers. - Updated `createMockNativeWatcher` mock to expose the `options` argument so tests can distinguish main vs parent watchers. Local validation: - `pnpm tsgo:core:test`: clean - `pnpm check:test-types`: clean - `pnpm vitest run manager.watcher-config.test.ts`: 20/20 pass - `codex review --base origin/main`: no actionable regressions - Lab probe unchanged: 12,392 → 1 REG FD on macOS Closes most of the "merge-risk: 🚨 availability" exposure clawsweeper flagged. Windows live proof (real Windows runner, not mock) and Linux live proof remain available as separate live-validation steps that need real VMs. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…ter root replacement codex-connector inline review 4360397128 on PR openclaw#86701 found a small race in the root-replacement handler: after tearing down the old main+parent native watcher pair, we attempt to reattach with `attachNativeMemoryWatchForDir(dir, markDirty)` if the inode-check reported a non-null inode. But that helper runs its OWN statSync internally — if the directory disappears between our inode check and the helper's check (e.g., `rm -rf memory && mkdir memory && rm -rf memory` racing), the helper returns `false` and we silently lose watcher coverage instead of falling back to chokidar. Check the boolean return from `attachNativeMemoryWatchForDir` and invoke `attachMemoryChokidarFallback` on failure, matching the behavior of the corresponding initial-attach path in `ensureWatcher()`. Local validation: - `pnpm tsgo:core:test`: clean - `pnpm vitest run manager.watcher-config.test.ts`: 20/20 pass - `codex review --base origin/main`: no actionable regressions Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…ories Closes openclaw#86613. A single authorized POST /tools/invoke memory_search call against a workspace with multi-thousand .md files under <workspace>/memory/ opens one read-only FD per file (~12,400 in our captures), never released until process exit. Root cause: chokidar v5 calls fs.watch(path) per filesystem node (handler.js:126), no recursive option. On macOS each per-file fs.watch opens a kqueue VNODE FD that lsof reports as a regular-file REG FD. So one watcher per .md file = one FD per file = 12k+ FDs. Switch directory watch paths to Node >= 22 native recursive fs.watch(dir, { recursive: true }) — one watcher per directory via FSEvents on macOS / inotify recursive on Linux / ReadDirectoryChangesW on Windows. Individual file paths (MEMORY.md, file-typed extraPaths) continue through chokidar. Same dirty-event semantics, different backend, small constant FD profile regardless of tree size. Regression introduced by openclaw#64711 (jasonxargs-boop, 2026-04-13) which changed the chokidar watch target from glob memory/**/*.md to bare directory memory/, making the recursive walk implicit. PR openclaw#81802 (frankekn, 2026-05-15) removed awaitWriteFinish and added watch-settle on the same path; that reduced write-polling pressure but did not address the per-node fs.watch allocation (verification used 1,000 files, well under the storm-producing scale). Lab verification on clean upstream-main + synthetic 12,391-file workspace: 0 → 1 REG FD (MEMORY.md only; 12,391 file events covered by one recursive kqueue/FSEvents watcher), flat plateau, second memory_search returns 200 OK with zero additional FDs. Complementary to openclaw#86345 which attacks the same failure class by bounding INDEX_CACHE lifetime; this PR is non-overlapping in manager-sync-ops.ts and touches a disjoint region of manager.ts. Changes: - manager-sync-ops.ts: split watchPaths by dir/file; native recursive fs.watch for dirs with shouldIgnoreMemoryWatchPath filter and per-event lstat; chokidar retained for files. New TEST_MEMORY_NATIVE_WATCH_FACTORY_KEY for test injection. Native creation failure falls back into chokidar set. Null filename (Node platform caveat) triggers broad markDirty. Runtime error closes + removes + marks dirty. Idempotence guard expanded. Symlink policy preserved (extraPaths-only skip). - manager.ts: close() tears down nativeMemoryWatchers in addition to chokidar watcher. - manager.watcher-config.test.ts: nativeWatchMock paired with new factory key; assertions split between chokidar (files) and native (dirs); new cases for rename/change dispatch, null filename, native creation failure fallback, runtime error close, ensureWatcher re-entrancy guard. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…e watcher error Addresses clawsweeper PR review on openclaw#86701: when the native recursive fs.watch emits an error and the watcher is closed/removed, no replacement watcher is attached. Because intervalMinutes defaults to 0, subsequent memory changes under that directory would stop driving watch sync until process restart. After this commit, the native watcher error handler closes and removes the dead FSWatcher (as before), forces one broad markDirty to cover the gap, AND reattaches the directory to chokidar via the new attachMemoryChokidarFallback helper. If the existing this.watcher slot holds a chokidar instance (handling MEMORY.md and/or other file paths), the directory is added via this.watcher.add(dir). If no chokidar watcher exists yet, one is spun up specifically for that directory. The fallback is intentional: it accepts chokidar's per-file FD cost for the failed directory only (and only after the native watcher already died once), rather than dropping watcher coverage entirely. The FD-leak fix property for the healthy-watcher path is unchanged. Tests: - existing "runtime error" test in manager.watcher-config.test.ts now asserts the directory is attached to chokidar after the error and that a subsequent chokidar event on the fallback path schedules sync - new test exercises the "no chokidar yet" branch where the fallback creates a fresh chokidar watcher for the directory - all 16 tests in manager.watcher-config.test.ts pass; lab probe still shows 12,392 → 1 REG FD reduction on the synthetic workspace Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…d fallback against shutdown race Two follow-up findings from PR review on openclaw#86701: 1. Linux gating (clawsweeper [P2], also codex round-1 #1). On non-macOS/non-Windows platforms, Node routes `fs.watch(dir, { recursive: true })` through `internal/fs/recursive_watch`, which walks the tree and attaches one watcher per discovered entry. That defeats the constant-watcher- profile goal of the parent commit's fix without throwing, so the creation-failure fallback never fires. Restrict native attachment to `process.platform === "darwin" || "win32"`; Linux and others go straight to the chokidar fallback, matching pre-PR behavior on those platforms. 2. Shutdown-race guard (codex-connector inline reviews 4359699784 + 4359929223 [P2]). The native watcher `error` handler called `attachMemoryChokidarFallback` unconditionally, including after `close()` had set `this.closed = true` and begun teardown. That left a window where an error fired mid-shutdown could spin up a new chokidar watcher after the normal watcher-close step had already run, leaking watch handles past manager close. Both the error handler and the `attachMemoryChokidarFallback` helper now short-circuit when `this.closed` is true. Tests: - new case in manager.watcher-config.test.ts verifies that `process.platform === "linux"` routes both `memory/` and dir-typed `extraPaths` directly to chokidar (`nativeWatchMock` not called at all) — covers the Linux gate - all 17 tests in manager.watcher-config.test.ts pass Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…atisfy check-test-types clawsweeper review on PR openclaw#86701 (P2 finding) reported the `check-test-types` lane failing on `extensions/memory-core/src/memory/manager.watcher-config.test.ts` because several assertions index `watchMock.mock.calls[0][...]` directly. TypeScript treats vi.fn's `.mock.calls` as a possibly-empty tuple, so direct positional indexing fails the strict type-check lane. Apply the same `as unknown as [string[], Record<string, unknown>]` cast pattern already used elsewhere in this file for the first chokidar destructure, to all remaining `mock.calls[N]` accesses in the new tests added by the previous commit. No runtime behavior change; tests still pass (16/16 in the focused file). `pnpm check:test-types` now clean. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…ment Two rank-up moves identified by clawsweeper review on PR openclaw#86701: 1. **Windows native-watcher coverage in tests.** Add a mocked `process.platform === "win32"` case that mirrors the existing Linux gate test: assert that BOTH `memory/` and dir-typed `extraPaths` route to native recursive fs.watch (Windows uses ReadDirectoryChangesW for the recursive option, like macOS uses FSEvents), and that chokidar receives only the file path (`MEMORY.md`). Verifies the `process.platform === "darwin" || "win32"` gate keeps Windows on the native path. 2. **Detect memory-root replacement and reattach.** When the native recursive watcher is bound to a directory inode and that directory is replaced mid-process (`rm -rf memory && mkdir memory`), Node's fs.watch silently keeps watching the dead inode and never emits an error, so the existing creation-failure + runtime-error fallbacks cannot recover. Codex inline reviews 4360224380 (and codex final) flagged this as a freshness risk because `intervalMinutes` defaults to `0`, leaving the directory unindexed until restart. This commit pairs every native memory directory watch with a non-recursive `fs.watch(parentDir, { recursive: false })`. When that parent watcher fires for the watched basename AND the current inode of the dir differs from the recorded inode, the existing main+parent pair is closed and a fresh `attachNativeMemoryWatchForDir` invocation reattaches on the new inode. If the dir was removed (not replaced), the chokidar fallback takes over to preserve coverage. Implementation: - New `nativeMemoryParentWatchers: fsSync.FSWatcher[]` paired with the existing `nativeMemoryWatchers`. - New helper `attachNativeMemoryWatchForDir(dir, markDirty)` factored out of `ensureWatcher()` so the parent-event handler can re-enter cleanly for reattach. - Both arrays are torn down in `manager.ts` `close()`. - All existing close-time / error-path guards preserved (the parent handler also respects `this.closed`). Tests: - "routes directories through native recursive watch on Windows" — mocked-platform mirror of the existing Linux gate test. - "attaches a non-recursive parent-directory watcher for root-replacement detection" — asserts the main+parent pair is created with the correct recursive flags. - "ignores parent-directory events for unrelated basenames" — asserts the parent handler only reacts to the watched basename and leaves the main watcher untouched for sibling events. - Existing tests updated for the new call shape: each watched dir now produces 2 nativeWatchMock calls (1 main recursive + 1 parent non-recursive). Assertions filter by `recursive: true` to identify main watchers. - Updated `createMockNativeWatcher` mock to expose the `options` argument so tests can distinguish main vs parent watchers. Local validation: - `pnpm tsgo:core:test`: clean - `pnpm check:test-types`: clean - `pnpm vitest run manager.watcher-config.test.ts`: 20/20 pass - `codex review --base origin/main`: no actionable regressions - Lab probe unchanged: 12,392 → 1 REG FD on macOS Closes most of the "merge-risk: 🚨 availability" exposure clawsweeper flagged. Windows live proof (real Windows runner, not mock) and Linux live proof remain available as separate live-validation steps that need real VMs. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…ter root replacement codex-connector inline review 4360397128 on PR openclaw#86701 found a small race in the root-replacement handler: after tearing down the old main+parent native watcher pair, we attempt to reattach with `attachNativeMemoryWatchForDir(dir, markDirty)` if the inode-check reported a non-null inode. But that helper runs its OWN statSync internally — if the directory disappears between our inode check and the helper's check (e.g., `rm -rf memory && mkdir memory && rm -rf memory` racing), the helper returns `false` and we silently lose watcher coverage instead of falling back to chokidar. Check the boolean return from `attachNativeMemoryWatchForDir` and invoke `attachMemoryChokidarFallback` on failure, matching the behavior of the corresponding initial-attach path in `ensureWatcher()`. Local validation: - `pnpm tsgo:core:test`: clean - `pnpm vitest run manager.watcher-config.test.ts`: 20/20 pass - `codex review --base origin/main`: no actionable regressions Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…parent watcher on native errors clawsweeper review on commit 5df68c5 surfaced two parent-watcher lifecycle issues introduced by the dir-replacement fix: 1. **[P2] Null parent-watcher filename** (line 623). Node fs.watch may call its listener with `filename === null` on some platforms even when the parent watcher is otherwise supported. The previous handler returned silently on `filename !== baseName`, so a root replacement reported with a null filename would leave the native watcher bound to the dead inode. Now treat null as an unknown event and fall through to the inode check; sibling events (non-null, non-baseName) still return early. 2. **[P3] Paired parent watcher leaks on native error** (line 600). When the main native watcher emits `error`, the previous handler closed only the main watcher and added the chokidar fallback, leaving the paired parent watcher alive. A later root-replacement event would then reattach native coverage on top of the already-installed chokidar watcher, creating duplicate handles and event paths. Forward-declared `parentWatcherRef` so the main error handler can close + remove + null-out the paired parent watcher before the chokidar fallback runs. Parent watcher's own error handler also clears the ref so a stale reference doesn't persist after parent-side teardown. Tests added (22 total now, up from 20): - "treats null parent-watcher filename as an unknown event and re-checks the inode" — verifies null filename falls through to the inode check and that no spurious teardown happens when the inode hasn't changed. - "closes the paired parent watcher when the native main watcher errors" — verifies the paired parent watcher's `close()` is invoked when the main watcher errors out (preventing double-attach if a later root replacement occurs). Local validation: - `pnpm tsgo:core:test`: clean - `pnpm vitest run manager.watcher-config.test.ts`: 22/22 pass - `codex review --base origin/main`: no actionable regressions Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
baa8161 to
e27c28a
Compare
|
Merged via squash.
Thanks @lukeboyett! |
Summary
Fixes the gateway FD-leak regression reported in #86613.
A single authorized
POST /tools/invoke memory_searchcall against a workspace with multi-thousand.mdfiles under<workspace>/memory/opens one read-only FD per file (~12,400 in our captures), never released until process exit. The root cause is chokidar v5's per-filefs.watch(path, {persistent: true})fan-out (node_modules/chokidar/handler.js:126) when configured against a recursive directory tree — each node in the tree gets its ownfs.watchinstance, which on macOS opens a kqueue VNODE FD thatlsofreports as a regular-file REG FD.This change uses Node ≥22's native recursive
fs.watch(dir, { recursive: true })for directory watch paths (one watcher per directory via FSEvents on macOS / inotify recursive on Linux /ReadDirectoryChangesWon Windows), and keeps chokidar for individual file paths (e.g.MEMORY.md, file-typedextraPaths). Same dirty-event semantics; different watcher backend; small constant FD profile regardless of tree size.Regression history
This is the third intervention on the memory watcher fan-out path:
2204753b6214, @jasonxargs-boop, 2026-04-13) changed the chokidar watch target inmanager-sync-ops.tsfrom globmemory/**/*.mdto bare directorymemory/. This made the recursive walk implicit and is where the per-file fan-out began to dominate large memory trees.b04e42812e, @frankekn, 2026-05-15) removedawaitWriteFinishfrom the same chokidar setup and addedwatch-settle.tsto handle event-stability. That PR reduced write-polling FD pressure but did not address the per-nodefs.watchallocation; the verification used a 1,000-file tree, well under the storm-producing scale.fs.watchinstead of chokidar.Related open PRs
MemoryIndexManager(and therefore its watchers) can live in the module-levelINDEX_CACHEbefore being evicted, so the FD leak is time-bounded. My PR attacks the per-allocation: each manager opens a small constant number of FDs rather than ~N where N is the workspace memory tree size. The two PRs are intentionally non-overlapping inmanager-sync-ops.ts(this PR), and touch disjoint regions ofmanager.ts(their changes are in the imports/constants and search wrapper near line 715; this PR's only change there is inclose()near line 957). If both land, the combined effect is defense in depth: smaller per-manager FD footprint AND bounded manager lifetime.memory/**/*.mdinstead ofmemory/*.md. No code overlap; both PRs are mutually consistent.Real behavior proof
.mdfiles under<workspace>/memory/whenevermemory_searchis invoked against a multi-thousand-file memory tree (issue [Bug]: Every memory_search call leaks ~N FDs (one per .md file in workspace memory tree) on macOS; long-lived gateways degrade toward FD exhaustion #86613). One FD per file, never released until process exit; on macOS this is chokidar v5's per-filefs.watchopening a kqueue VNODE descriptor per node in the watched tree. Long-running gateways degrade toward FD exhaustion / EBADF in child-process spawns.2026.5.19onupstream/main+ 14 local patches (none inextensions/memory-core/), running under launchd. Workspace memory tree has 12,591.mdfiles acrosstranscripts/,transcripts.archived/, andstructured-md/{lessons,procedures,decisions,projects}/plus their.archived/siblings.01c5ab8d13, macOS 14.7.6, Node 22.19, dedicatedOPENCLAW_HOME, isolated gateway runtime on port 28789, no channels, no provider keys,memorySearch.sync.watchdefaulting to true. Synthetic workspace with 12,391.mdfiles mirroring the per-subdirectory composition of the original storms (9,394 / 1,695 / 268 / 215 / 214 / 213 / 151 / 126 / 81 / 34).pnpm buildin this checkout). Launch the isolated lab gateway against the synthetic workspace. Run one authorized HTTP call, sample FDs vialsof -p $GATEWAY_PIDat intervals 0/5/15/30/45/60/90/120 seconds, then send a second invocation and re-sample. Probe command:~/.openclaw/.env.--max-time 30timed out; call never returned)Mid-storm V8 heap snapshot at t+17s (341 MB, retainer chain
WatchHelper → fsw → FSWatcher → _closers → Map) available on request. Full setup, sample lines, and second-call behaviour in #86613 comment 4537621029.Environment 2 — clean upstream-main lab, before fix: HEAD
01c5ab8d13, dedicatedOPENCLAW_HOME, isolated gateway runtime on port 28789, no channels, no provider keys,memorySearch.sync.watchdefaulting to true. Synthetic workspace with 12,391.mdfiles mirroring the per-subdirectory composition of the original storms (9,394 / 1,695 / 268 / 215 / 214 / 213 / 151 / 126 / 81 / 34).memory_searchcall; curl 30s timeout fired)12,392=12,391(memory/**/*.mdcount) +1(MEMORY.md).Second invocation:
200 OKin <30s, zero additional FDs. Confirms the fan-out cost is paid once per (gateway, workspace) and reused thereafter — but never released for the process lifetime.Environment 2 — clean upstream-main lab, WITH this fix applied (same
OPENCLAW_HOME, same synthetic workspace, same probe):memory_search; curl 30s timeout fired again — see below)The remaining
1workspace REG isMEMORY.md, opened by chokidar (single-file path; chokidar's per-nodefs.watchhere costs one FD which is correct). The memory tree (12,391 files) is now covered by one native recursivefs.watch, visible as 1 of the 4 KQUEUE FDs (the other 3 are Node libuv internals present at baseline). 99.99% reduction in REG FDs.The HTTP timeout at 30s is unrelated to the leak — it's first-time memory indexing on a 12k-file workspace. The second invocation returns
200 OKin <30s and does not add any FDs.memory_searchcall opens 1 REG FD (MEMORY.md, via chokidar single-file path) instead of 12,392, and that count holds flat through the 120-second observation window. The memory tree itself is now covered by exactly one native recursivefs.watch(visible as 1 of 4 total KQUEUE FDs; the other 3 are Node libuv internals present at baseline). Second invocation returns200 OKin <30s with zero additional FDs, confirming the fan-out cost is paid once per (gateway, workspace) lifetime via FSEvents on macOS. 99.99% reduction in REG FDs versus before-fix baseline.process.platform === "darwin" || "win32"and routes Linux + others through chokidar fallback to match pre-PR behavior on those platforms).watch-settle.tsqueue should handle this; not exercised in lab.What changes
extensions/memory-core/src/memory/manager-sync-ops.ts(+128/-15):ensureWatcher()splitswatchPathsinto file vs directory sets.MEMORY.mdstays a file path;memory/and dir-typedextraPathsbecome directory paths.fsSync.watch(dir, { recursive: true })via a new test-injectable factoryTEST_MEMORY_NATIVE_WATCH_FACTORY_KEY, parallel to the existingTEST_MEMORY_WATCH_FACTORY_KEY.shouldIgnoreMemoryWatchPathfor filtering (no duplicated filter logic), with anlstatSync({ throwIfNoEntry: false })for stats.nullfilename(Node platform caveat) triggers a broadmarkDirty()rather than a silent drop.fsSync.watchthrows at creation (e.g. unsupported filesystem,ERR_FEATURE_UNAVAILABLE_ON_PLATFORM), the directory falls back into the chokidar set — coverage preserved, FD cost degraded to the previous behavior for that path only.errorevent the native watcher is closed, removed from the array, and a broadmarkDirty()is issued; the existing interval sync continues to maintain freshness.extraPathsentries are lstat-skipped (matching pre-PR behavior);memory/andMEMORY.mdcontinue to follow symlinks.this.watcher || this.nativeMemoryWatchers.length > 0so a re-entrantensureWatcher()doesn't double-allocate native watchers.extensions/memory-core/src/memory/manager.ts(+10/-0):close()tears down the native watchers in addition to the chokidar watcher.extensions/memory-core/src/memory/manager.watcher-config.test.ts(+230/-67):nativeWatchMockpaired with the new factory key.MEMORY.md); native mock receives directories (memory/, dir-typedextraPaths) withrecursive: true.rename/changeevent →markDirty.markDirty(Node platform caveat).markDirtyfires.ensureWatcher()re-entrant call → no duplicate native watcher allocation.Compatibility expectations
markDirtycontinues to trigger it. The sharedshouldIgnoreMemoryWatchPathpredicate is the sole filter for both watcher backends.sync.watchresolution atsrc/agents/memory-search.tsis unchanged; this is purely an internal watcher backend swap. The "watcher policy choice" raised in clawsweeper's review on [Bug]: Every memory_search call leaks ~N FDs (one per .md file in workspace memory tree) on macOS; long-lived gateways degrade toward FD exhaustion #86613 (disable / cap / lifetime change) is intentionally sidestepped — this PR is a transparent infrastructure swap.node:fsrecursive watch, available in ourengines: node >= 22.19.fs.watchis supported in Node ≥22.19 on macOS (FSEvents), Linux (since v19.1, refined through v20+), and Windows (always supported). On any path where the native call throws or the FSWatcher emits an error, the chokidar fallback preserves coverage. Real lab verification was done on macOS only (see "What I did not test" above).Acceptance criteria from clawsweeper review on #86613
/tools/invoke memory_searchrepro that samples gateway FD counts before and after the call — done in lab (Environment 2 above)manager.watcher-config.test.tsupdated for the watcher split + new behavior casestools.test.tsruns clean against the patch (untouched file; verified locally)tools-invoke-http.test.tsruns clean against the patch (untouched file; verified locally)Test plan
pnpm tsgo:core:testclean on the patched sourcepnpm test:extension memory-corecleanpnpm checkcleanpnpm vitest run extensions/memory-core/src/memory/manager.watcher-config.test.tscleanpnpm vitest run extensions/memory-core/src/tools.test.tscleanpnpm vitest run src/gateway/tools-invoke-http.test.tscleancodex review --base origin/mainrun; findings addressed (see "AI assistance" below)12,392 → 1REG FD reduction confirmed, second-call returns200 OKwith zero FD delta (numbers above)AI assistance
curl POST /tools/invokeandlsofsampling done by hand on a real Mac).codex review --base origin/mainrun before opening.Notes for review
sync.watchdefault, no per-tree cap, no manager lifetime change. The "maintainer policy direction" called out by clawsweeper is sidestepped because freshness semantics are preserved.memorySearch.sync.watch: falseshould still be the safer default for very large workspaces as defense-in-depth (separately from this PR). That's Feature: sync.watch should default to false in gateway mode #71335's domain.Codex review findings deferred to follow-up
close()/ sync race (pre-existing).codex review --base origin/mainflagged a P2 race inextensions/memory-core/src/memory/manager.tsaroundclose()lines 940–941: whenclose()races with a firstsync()that's still awaitingensureProviderInitialized(), thependingSyncsnapshot taken before the await can be stale (this.syncingmay not yet be assigned). After provider init resolves, that sync continues and can run against the just-closed provider/DB. This race is in pre-existing code; my changes in this file are limited to the native watcher teardown block at lines 958–967 (a disjoint region). Tracked as separate follow-up in MemoryIndexManager.close() races with in-flight sync — provider/DB closed before sync settles #86702.Watched directory replacement (edge case, surfaced by post-fallback-fix codex review). When an existing
memory/directory is deleted and recreated mid-process (e.g.rm -rf memory && mkdir memory), nativefs.watchremains bound to the old inode and subsequent changes in the recreated directory are not observed. WithintervalMinutesdefaulting to0, auto-sync would stay stale until restart. The previous chokidar-on-bare-directory setup may handle this case via parent-dir or rediscovery semantics (not fully verified). Fixing this here would require either parent-directory watching, periodic inode-stat checks, or readdirp-style root rediscovery — meaningful scope expansion beyond the FD-leak fix. The scenario is uncommon at runtime (live gateways rarely have their memory roots replaced) and is also relevant to Feature: sync.watch should default to false in gateway mode #71335 (sync.watch policy: the safer default for high-churn workspaces issync.watch: falseplus a non-zerointervalMinutes). Happy to file as a separate follow-up issue if maintainers consider it merge-blocking.Update — clawsweeper review addressed
clawsweeper's PR review (2026-05-26 01:32 UTC) requested one fix before merge: the native watcher
errorhandler closed and removed the failed watcher but did not restore coverage, leaving the directory un-watched. This is now fixed in the follow-up commit on this branch:manager-sync-ops.ts— addedattachMemoryChokidarFallback(dir, markDirty). On a native watcher runtime error, the directory is reattached to the existing chokidar watcher viathis.watcher.add(dir); if no chokidar watcher exists yet, one is spun up for that directory.manager.watcher-config.test.ts— the existing runtime-error test now asserts the directory is added to chokidar after the error, AND that a subsequent chokidar event on the fallback path schedulessync. New test added for the "no chokidar yet → spin one up" branch.All 16 tests in
manager.watcher-config.test.tspass; lab probe still shows 12,392 → 1 REG FD on the synthetic workspace.Closes #86613.