fix(memory): bound INDEX_CACHE lifetime in long-running gateway daemons#86345
fix(memory): bound INDEX_CACHE lifetime in long-running gateway daemons#86345quengh wants to merge 1 commit into
Conversation
|
Thanks for the context here. I swept through the related work, and this is now duplicate or superseded. Keep open. Current main still has no gateway-lifetime eviction for the module-level memory manager cache, so the PR is not superseded by the merged per-call watcher fix; however, the current head has a failing gateway startup test and the default-on sidecar/config behavior needs maintainer approval before merge. Canonical path: Close this PR as superseded by #86701. So I’m closing this here and keeping the remaining discussion on #86701. Review detailsBest possible solution: Close this PR as superseded by #86701. Do we have a high-confidence way to reproduce the issue? Yes. Current main source keeps the memory manager cache alive until explicit close paths, and the PR supplies production macOS before/after soak evidence; I did not run a live repro during this read-only review. Is this the best way to solve the issue? Unclear until maintainer approval. Idle eviction is a plausible complementary fix to the merged per-call watcher fix, but the default-on gateway policy and the failing startup test need resolution before this is the best merge shape. Security review: Security review cleared: The diff does not change dependencies, workflows, package resolution, permissions, secrets handling, or credential surfaces; no concrete security or supply-chain issue was found. AGENTS.md: found and applied where relevant. What I checked:
Likely related people:
Codex review notes: model gpt-5.5, reasoning high; reviewed against 982e88821c0d. |
|
ClawSweeper PR egg ✨ Hatched: 🥚 common Moonlit Signal Puff Hatch commandComment Hatchability rules:
Rarity: 🥚 common. What is this egg doing here?
|
…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]>
Cached MemoryIndexManager instances live in a module-level INDEX_CACHE that grows for the entire gateway daemon lifetime. Each instance owns a chokidar FSWatcher recursively watching the memory directory (under the default sync.watch=true), so every cache entry holds an FSEventWrap native handle plus one awaitWriteFinish file descriptor per touched memory/*.md file. Cached managers are torn down at CLI exit (openclaw#40389), but daemons never reach that exit path until restart. A production daemon (OpenClaw 2026.4.15) measured +1.6 GB RSS over 11 h and 612 accumulated FSEventWrap handles before OOM. This change adds a periodic runtime sweep that closes idle managers during normal daemon operation, so restart is no longer the only way to release these resources. Approach -------- ManagedCache: - Track lastAccessAt per cache key (refreshed on hit and on initial set). - closeIdleManagedCacheEntries walks lastAccessAt, closes stale entries, and isolates close() errors so one bad entry does not stop the rest. Reuses the per-manager close() introduced in openclaw#40389 - no new teardown code path. In-flight protection (always-on safety net): - inflightCount map keyed the same way as cache / lastAccessAt. - acquireManagedCacheKey() increments the count, refreshes lastAccessAt, and returns an idempotent release() function. - isManagedCacheKeyBusy() reports whether the count is > 0. - closeIdleManagedCacheEntries consults isManagedCacheKeyBusy in both the survey loop and immediately before close(); busy entries are skipped, counted, and surfaced via onSkipBusy / skippedBusy. - A revalidation gate (just before close) also re-checks lastAccessAt freshness and cache identity, catching entries that became active or were swapped during the scan-to-close gap. Reported as skippedRevalidated. - Cache metadata cleanup in MemoryIndexManager.close() and closeMemoryIndexManagersForAgent is now scoped to the cache identity check that previously guarded only the INDEX_CACHE delete, so a replacement manager installed in the same key during the close() await window keeps its own lastAccessAt and inflightCount entries. MemoryIndexManager: - Protected withBusy(fn) helper wraps acquire/release in try/finally. - Guards sync(), search(), and ensureProviderInitialized() so the busy ref is held across provider init as well as the manager work itself. - close() and closeMemoryIndexManagersForAgent clear residual cache metadata only when this instance is still the cache occupant. release() is idempotent so a release that fires after close() is a no-op. Gateway sidecar: - scheduleMemoryIndexIdleEvict registers a gateway-lifetime sidecar that calls closeIdleMemorySearchManagers every idleEvictScanMs (default 5 min), evicting managers idle for idleEvictMs (default 15 min). Setting either to 0 disables the sweep entirely. - Resolved from agents.defaults.memorySearch.sync only. The cache is a process-wide singleton and the sweep runs once per cadence over the entire cache; there is no per-agent sweep timer, and no coherent per-agent answer when two agents share a workspace and disagree on the threshold. Per-agent values under agents.list[].memorySearch.sync are accepted by the (shared) schema but ignored at runtime; this is documented on the TS doc, zod comments, UI labels, FIELD_HELP, and in docs/reference/memory-config.md. Configuration ------------- Two new optional fields under agents.defaults.memorySearch.sync: - idleEvictMs (default 900000): idle TTL before a manager is eligible for eviction. 0 disables. - idleEvictScanMs (default 300000): sweep cadence. 0 disables. Documented in: - src/config/zod-schema.agent-runtime.ts (schema with scope contract) - src/config/types.tools.ts (MemorySearchConfig TS doc) - src/config/schema.labels.ts (UI labels, tagged "gateway-wide") - src/config/schema.help.ts (FIELD_HELP user-facing copy) - docs/reference/memory-config.md (reference docs with ParamField blocks) Validation ---------- Live soak on the production daemon (OpenClaw 2026.4.15 + this patch backported, 17 h+ continuous at PR submission, sampling every 10 min): | Metric | Without patch (11 h) | With patch (17 h soak) | |-----------------|-------------------------|-------------------------------| | FSEventWrap | 612 native handles | bounded, returns to ~3 baseline | | memory_fd | 488 (chokidar holds) | 0 between sweeps | | RSS growth rate | ~1 GB/h | ~150 MB/h (~7x improvement) | | Outcome | OOM within 4 to 6 h | 3.16 GB stable, no OOM | Sidecar observed behavior: - fd accumulates during memory_search activity (up to ~600 handles) - next sweep evicts idle managers, fd returns to baseline within 10 min - skippedBusy and skippedRevalidated stayed at 0 throughout the soak - the workload burst rate did not exercise the deferral paths, but the unit tests cover them deterministically Tests ----- - extensions/memory-core/src/memory/manager-cache.test.ts: 21 tests (was 11). New coverage: acquireManagedCacheKey, isManagedCacheKeyBusy, busy deferral, refcount, lastAccessAt refresh, inflightCount cleanup, revalidation gate for cache-hit / busy / identity-swap / slot- disappearance gaps. - extensions/memory-core/src/memory/manager.idle-gc.test.ts: 12 tests (was 5). New coverage: in-flight protection at manager-level entry points (search, sync, ensureProviderInitialized), plus cache-occupant identity-check coverage for the close() metadata-cleanup path. - extensions/memory-core/: 720/720 across 57 files. - src/gateway/server-startup-post-attach.test.ts: 41 tests pass. - src/agents/memory-search.test.ts: 27 tests pass. - src/config/: 1493/1493 across all config schema tests pass. - pnpm run lint, tsgo:core, tsgo:extensions, tsgo:test, build all green. Risk / Compatibility -------------------- - Default idle threshold (15 min) is conservative - comfortably outlives typical bursts of memory_search calls within a session. - Worst-case dwell is idleMs + scanMs + max in-flight op duration (was unbounded; idleMs + scanMs if all ops are short). - Short-lived CLI invocations are unchanged: closeAllMemoryIndexManagers remains the exit path from openclaw#40389, and it clears inflightCount alongside the rest of the cache. - Public MemoryPluginRuntime.closeIdleMemorySearchManagers return type grows two additive fields (skippedBusy, skippedRevalidated). Existing callers reading evicted / remaining continue to work unchanged via TypeScript structural typing. - No new dependencies, no schema migration, no protocol change. Out of scope ------------ - Does NOT change sync.watch default value (openclaw#71335 tracks that separately). - Does NOT refactor the watcher to be singleton-per-workspace (much larger architectural change). - Does NOT introduce an opt-out for the in-flight protection - it's a safety net that should always be on. The only configurable knobs are the sweep cadence and idle threshold. Refs openclaw#71335, openclaw#40389.
bbdef73 to
bf03bff
Compare
|
@osolmaz Thanks for merging #86701 — that landed the per-call watcher fan-out fix. This PR (#86345) tackles a complementary failure mode: even after #86701 caps per-call FD allocations, long-running gateways still accumulate The two PRs touch Would you mind taking a look when you have a chance? The PR adds two default-on knobs ( |
|
ClawSweeper applied the proposed close for this PR.
|
Relationship to #86701
This PR is complementary to #86701 (merged 2026-05-26), not a replacement.
INDEX_CACHE(aMap<key, MemoryIndexManager>) accumulatesMemoryIndexManagerinstances over the lifetime of a long-running gateway. Each cached manager retains its own watcher handles, sync timers, embedding probe state, and other module-scoped retained heap. Nothing evicts idle entries until process exit, so daemons that never restart keep growing.The two PRs touch
extensions/memory-core/src/memory/manager.tsin disjoint regions — #86701 modifiedclose()near line 957, this PR modifies the cache helpers and lifecycle wrappers near line 715 — and lukeboyett's PR body explicitly framed them as defense in depth. The branch has been rebased clean against currentmain(which already includes #86701 and the subsequentfix(memory-core): close providers created during shutdowncleanup).Real behavior proof
Behavior addressed: Long-running OpenClaw gateway daemons accumulate chokidar
FSEventWrapnative handles andmemory/*.mdfile descriptors for the entire process lifetime. RSS grows ~1 GB/h until OOM, typically within 4–6 h on memory-heavy workloads. Root cause:MemoryIndexManagerinstances live in the module-levelINDEX_CACHEand are only torn down at process exit (#40389), which long-running daemons never reach until restart.Real environment tested: Production gateway daemon on macOS 15.7.4 (Apple Silicon), OpenClaw
2026.4.15base, default config (agents.defaults.memorySearch.sync.watch=true), Nodev24.13.0. Patch applied at runtime by replacing the installeddist/directory with a build of this branch (fix/memory-index-manager-daemon-eviction), thenlaunchctl kickstart -k gui/501/ai.openclaw.gatewayto restart the daemon onto the patched dist.Exact steps or command run after this patch: see code block below.
Evidence after fix: real terminal output from the soaked production gateway (PID 93144, 17 h elapsed at PR submission).
Process state on patched daemon (started 2026-05-24 20:37 +0800):
fd breakdown at steady state between sweeps:
Sampler CSV excerpts (
/tmp/soak-v2-samples.csv, 100+ data points, 10 min interval):For comparison, before the patch (2026-05-23, same daemon, 11 h uptime, no sidecar) heap snapshot diff showed:
and
lsofof that pre-patch daemon showed488chokidarawaitWriteFinishread fds on~/.openclaw/workspace/memory/.Observed result after fix:
memory_fd(chokidar read fds onmemory/*.md) oscillates between0baseline and ~557 during bursts, returning to0within one sweep interval — without the patch, this value grew monotonically to 488+ and never returned to baseline.kqueuestays pinned at exactly3for the entire 17 h soak — without the patch,FSEventWrapinstances accumulated by the hundreds (+612 in the pre-patch 11 h heap diff). RSS rose ~150 MB/h (from 1.37 GB at +15 min to 3.16 GB), well under OOM — without the patch the same workload hit OOM at ~4.7 GB within 4–6 h. Sidecar log lines confirm the sweep is running and reportingmemory index manager idle eviction: evicted N (deferred M busy, K revalidated) (remaining R). 33 unit tests cover the in-flight protection deferral and revalidation paths deterministically.What was not tested: Windows / Linux daemons (only macOS validated live; CI covers cross-platform builds, but no live soak on those platforms yet). Multi-agent workloads exercising > 50
MemoryIndexManagerinstances simultaneously (our production setup has ~3 active managers). Workloads where individualsync()batches exceed the 15 min idle threshold (would exercise the busy-defer path live; covered by unit tests but not by soak).Out-of-scope follow-ups
sync.watchdefault fromtruetofalse(tracked in Feature: sync.watch should default to false in gateway mode #71335)Problem
Cached
MemoryIndexManagerinstances live in a module-levelINDEX_CACHEthat grows for the entire gateway daemon lifetime. Each instance owns a chokidarFSWatcherrecursively watching the memory directory (under the defaultsync.watch=true, see #71335), so every cache entry holds anFSEventWrapnative handle plus oneawaitWriteFinishfile descriptor per touchedmemory/*.mdfile.Cached managers are torn down at CLI exit (#40389), but daemons never reach that exit path until restart. This PR adds a periodic runtime sweep that bounds cache lifetime during normal daemon operation, so restart is no longer the only way to release these resources.
Measured impact (production daemon, OpenClaw 2026.4.15 + this patch)
Sampling: 100+ data points at 10 min interval. Sweep cadence: 5 min scan, 15 min idle threshold (both configurable).
Fix
Extend
ManagedCachewith idle tracking and add a gateway-lifetime sidecar:ManagedCache.lastAccessAtis refreshed on every cache hit and on initial set.closeIdleManagedCacheEntrieswalkslastAccessAt, closes stale entries, and isolatesclose()errors so one bad entry does not stop the rest. It reuses the per-managerclose()method introduced in Fix one-shot exit hangs by tearing down cached memory managers #40389 — no new teardown code path.closeIdleMemoryIndexManagers(inmanager.ts) layers in the embedding-probe-cache cleanup that the manager would otherwise miss.scheduleMemoryIndexIdleEvict(inserver-startup-post-attach.ts) registers a gateway-lifetime sidecar that runs the sweep everyidleEvictScanMs(default 5 min), evicting managers idle foridleEvictMs(default 15 min). Setting either to0disables the sweep entirely.In-flight protection
ManagedCachetracks aninflightCountmap keyed the same way ascache/lastAccessAt:acquireManagedCacheKey(cache, key)increments the count, refresheslastAccessAt, and returns an idempotentrelease()function.isManagedCacheKeyBusy(cache, key)reports whether the count is > 0.closeIdleManagedCacheEntriesconsultsisManagedCacheKeyBusyduring both the survey loop and immediately beforeclose(). Busy entries are skipped, counted, and surfaced viaonSkipBusy/skippedBusy.lastAccessAtfreshness and cache identity, catching entries that became active or were swapped during the scan-to-close gap. These are reported asskippedRevalidated.In
MemoryIndexManager, a protectedwithBusy(fn)helper wraps the acquire/release pair in try/finally. It guards:sync(...)— covers batch reindex, targeted session sync, full sync. The refcount is released in the same.finally()that nullsthis.syncing, so they unwind together.search(...)— protects the embedding/vector/FTS work plus the inlinesync({ reason: "search", force: true })bootstrap that runs on the first call after process start.ensureProviderInitialized(...)— coversprobeVectorAvailability,probeEmbeddingAvailability, and the lazy provider hookup invoked bysync/search.close()andcloseMemoryIndexManagersForAgentclear residual cache metadata only when this manager is still the current cache occupant for its key. Therelease()returned byacquireManagedCacheKeyis idempotent, so a release that fires afterclose()is a no-op.Sidecar log lines now report both deferral classes:
Configuration
Two new optional knobs under
agents.defaults.memorySearch.sync:idleEvictMs0disables.idleEvictScanMs0disables.Both fields are gateway-wide (process-wide) by design. The
INDEX_CACHEis a module-level singleton shared by every agent in the gateway process; the sweep runs once per cadence over the entire cache. Per-agent values placed underagents.list[].memorySearch.syncare accepted by the (shared) schema but read only fromagents.defaultsat runtime. This is documented in the zod schema (src/config/zod-schema.agent-runtime.ts), the TS type (src/config/types.tools.ts), UI labels (src/config/schema.labels.ts, tagged "gateway-wide"),FIELD_HELP(src/config/schema.help.ts), anddocs/reference/memory-config.md.Validation
Live soak (production daemon)
sync.watch=true)lsof-derived fd counts +ps-derived RSS, 10 min intervalSidecar observed behavior during the soak:
memory_searchactivity (up to ~600 handles)skippedBusy=0andskippedRevalidated=0throughout — confirms the in-flight and revalidation guards are wired correctly but our workload's burst rate did not exercise them (the unit tests below do)Tests
extensions/memory-core/src/memory/manager-cache.test.ts— 21 tests (was 11). New coverage:acquireManagedCacheKey/isManagedCacheKeyBusy/ busy deferral / refcount /lastAccessAtrefresh /inflightCountcleanup / revalidation gate for cache-hit, busy, identity-swap, and slot-disappearance gaps.extensions/memory-core/src/memory/manager.idle-gc.test.ts— 12 tests (was 5). New coverage: in-flight protection at manager-level entry points (search,sync,ensureProviderInitialized), plus coverage for the cache-occupant identity check in the managerclose()cleanup path.extensions/memory-core/— 720/720 across 57 files.src/gateway/server-startup-post-attach.test.ts— 41 tests pass.src/agents/memory-search.test.ts— 27 tests pass.src/config/— 1493/1493 across all config schema tests pass.pnpm run lint,pnpm run tsgo:core,pnpm run tsgo:extensions,pnpm run tsgo:test, andpnpm run buildall green.Risk / Compatibility
memory_searchcalls within a session.idleMs + scanMs + max in-flight op duration(was unbounded;idleMs + scanMsif all ops are short).closeAllMemoryIndexManagersis still the exit path from Fix one-shot exit hangs by tearing down cached memory managers #40389, and it clearsinflightCountalongside the rest of the cache.MemoryPluginRuntime.closeIdleMemorySearchManagersreturn type grows two additive fields (skippedBusy,skippedRevalidated). Existing callers readingevicted/remainingcontinue to work unchanged via TypeScript structural typing.Out of scope
sync.watchdefault value (Feature: sync.watch should default to false in gateway mode #71335 tracks that separately).Lifecycle phase comparison vs #40389 (for reviewers)
finally)idleEvictScanMsisBusy+withBusy+ revalidation gateclose()close()in the sweepThe two are layered: #40389 handles the moment of exit; this PR handles the time between starts and exits, which is where long-lived daemons spend ~100% of their wall clock.
Refs #71335, #40389, #86701.
Raw production data
Real terminal output from the soaked production gateway (PID 93144 at the time of writing, 17 h elapsed, OpenClaw
2026.4.15base + this patch backported as runtime dist replacement).Before the patch (baseline measured 2026-05-23, no patch, 11 h uptime)
Heap snapshot delta over 11 h (
chrome devtools heap snapshot):lsof -p <gateway>count by location (no patch, 11 h):RSS over 11 h grew from ~700 MB baseline to ~2.3 GB (+1.6 GB, ~150 MB/h);
process subsequently OOM'd at ~4.7 GB.
After the patch (live soak in progress, 17 h elapsed at PR submission)
pssnapshot:lsoffd breakdown (steady state between sweeps):Sidecar in action — sampler CSV excerpts (
/tmp/soak-v2-samples.csv, 100+ lines, 10 min interval):Visible behavior in the CSV:
fd_totalswings between baseline~40and burst~600and reliably returns to baseline within one sweep interval (10 min in this run)memory_fd(chokidarawaitWriteFinishread fds) stays at0between bursts — they are released byclose(), not just dropped from the JS heapkqueuestays at exactly3throughout (native kevent handles) — confirmsFSEventWrapis being destroyed, not orphanedrss_kbrises slowly (~150 MB/h) but does not approach OOM territory; without the patch the same workload OOM'd within 4–6 hRaw CSV available on request.
Maintainer review request
@osolmaz — as you recently merged #86701 from a complementary angle (per-call watcher fan-out), would you mind taking a look at this one when you have a chance? It tackles the long-running daemon side of the same family of leaks (idle
INDEX_CACHEretention rather than per-call FD allocation) and the two PRs were designed to layer.The PR adds two default-on knobs (
idleEvictMs= 15 min,idleEvictScanMs= 5 min). Happy to switch them to opt-in or adjust defaults if you'd prefer a more conservative rollout.