fix(memory): stop watcher write-polling fd pressure#81802
Conversation
|
@codex review |
|
Codex review: needs maintainer review before merge. Summary Reproducibility: yes. The large Real behavior proof Next step before merge Security Review detailsBest possible solution: Land the narrow watcher-polling removal after maintainer CI/Testbox proof confirms large watched Markdown trees no longer grow REG file descriptors and delayed sync still indexes settled content. Do we have a high-confidence way to reproduce the issue? Yes. The large Is this the best way to solve the issue? Yes. Removing chokidar's whole-tree write polling while settling only changed event paths is the narrowest maintainable fix for the identified FD pressure and addresses the earlier chunked-write concern without adding a new config surface. Acceptance criteria:
What I checked:
Likely related people:
Remaining risk / open question:
Codex review notes: model gpt-5.5, reasoning high; reviewed against 83d7ab0d362f. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3bb6d0ea13
ℹ️ 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".
|
@codex review |
|
Codex Review: Didn't find any major issues. Can't wait for the next one! ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
hxy91819
left a comment
There was a problem hiding this comment.
LGTM.
This PR resolves issues in scenarios with a large number of MD documents: the original implementation, designed to detect when file writing was complete, continuously held FDs for every file in the watch tree to perform size polling, which led to FD exhaustion. The fix removes this mechanism, switching to performing temporary stat checks (settle) only on files that have triggered events after a debounce. This reduces FD consumption from O(N) to 0. The only trade-off is that index refreshing might be delayed by one cycle in extreme cases, but the benefits are very significant.
ae4e97c to
f1ceab5
Compare
f1ceab5 to
6238746
Compare
|
Merged via squash.
Thanks @frankekn! |
Merged via squash. Prepared head SHA: 6238746 Co-authored-by: frankekn <[email protected]> Co-authored-by: frankekn <[email protected]> Reviewed-by: @frankekn
…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]>
…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]>
…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]>
Merged via squash. Prepared head SHA: 6238746 Co-authored-by: frankekn <[email protected]> Co-authored-by: frankekn <[email protected]> Reviewed-by: @frankekn
Merged via squash. Prepared head SHA: 6238746 Co-authored-by: frankekn <[email protected]> Co-authored-by: frankekn <[email protected]> Reviewed-by: @frankekn
Merged via squash. Prepared head SHA: 6238746 Co-authored-by: frankekn <[email protected]> Co-authored-by: frankekn <[email protected]> Reviewed-by: @frankekn
Summary
awaitWriteFinishpolling from built-in memory and QMD watchersWhy
Issue #78224 gives the concrete reproduction for the same failure class behind #77327: large Markdown
extraPathstrees withmemorySearch.sync.watch: truebuild up thousands of regular-file descriptors, then gateway child-process spawns fail withEBADF.The linked PR #78029 only changes bounded QMD read-stream cleanup. I could not reproduce a leak from that narrow path on Node v25.9.0, so this PR targets the watcher polling surface called out by the large-tree report instead.
The follow-up keeps chokidar from polling the whole tree while still avoiding obvious partial-write indexing: the watcher queue compares only paths that emitted events, and reschedules the watch sync if a changed file is still moving.
Fixes #77327.
Fixes #78224.
Alternative to #78029.
Verification
/opt/homebrew/bin/nodev25.9.0 standalone bounded QMD read loop, 1,000 iterations: target file FDs stayed at 0/opt/homebrew/bin/nodev25.9.0 standalone chokidar watch over 500 wiki Markdown files + 500SKILL.mdfiles: targetREGFDs stayed at 0 before/after closeOPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD=1 OPENCLAW_TEST_WORKERS=1 pnpm test extensions/memory-core/src/memory/manager.watcher-config.test.ts extensions/memory-core/src/memory/qmd-manager.test.tspnpm exec oxfmt --check CHANGELOG.md extensions/memory-core/src/memory/watch-settle.ts extensions/memory-core/src/memory/manager-sync-ops.ts extensions/memory-core/src/memory/qmd-manager.ts extensions/memory-core/src/memory/manager.watcher-config.test.ts extensions/memory-core/src/memory/qmd-manager.test.tsgit diff --checkOPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD=1 OPENCLAW_TEST_WORKERS=1 pnpm check:changed