Skip to content

Commit b04e428

Browse files
authored
fix(memory): stop watcher write-polling fd pressure (#81802)
Merged via squash. Prepared head SHA: 6238746 Co-authored-by: frankekn <[email protected]> Co-authored-by: frankekn <[email protected]> Reviewed-by: @frankekn
1 parent 21b6dcb commit b04e428

6 files changed

Lines changed: 251 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
Docs: https://docs.openclaw.ai
44

5+
## Unreleased
6+
7+
### Changes
8+
9+
### Fixes
10+
11+
- Memory search: stop using chokidar write-stability polling for memory and QMD watchers so large Markdown extraPath trees no longer build up regular file descriptors; changed files now settle through the existing debounced sync queue. Fixes #77327 and #78224. (#81802) Thanks @frankekn, @loyur, and @JanPlessow.
12+
513
## 2026.5.14
614

715
### Changes

extensions/memory-core/src/memory/manager-sync-ops.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ import {
5959
resolveMemorySourceExistingHash,
6060
} from "./manager-source-state.js";
6161
import { runMemoryTargetedSessionSync } from "./manager-targeted-sync.js";
62+
import {
63+
recordMemoryWatchEventPath,
64+
settleMemoryWatchEventPaths,
65+
type MemoryWatchEventStats,
66+
type MemoryWatchSettleQueue,
67+
} from "./watch-settle.js";
6268

6369
type MemorySyncProgressState = {
6470
completed: number;
@@ -192,6 +198,7 @@ export abstract class MemoryManagerSyncOps {
192198
protected intervalTimer: NodeJS.Timeout | null = null;
193199
protected closed = false;
194200
protected dirty = false;
201+
protected pendingWatchPaths: MemoryWatchSettleQueue = new Map();
195202
protected sessionsDirty = false;
196203
protected sessionsDirtyFiles = new Set<string>();
197204
protected sessionPendingFiles = new Set<string>();
@@ -450,12 +457,9 @@ export abstract class MemoryManagerSyncOps {
450457
ignoreInitial: true,
451458
ignored: (watchPath, stats) =>
452459
shouldIgnoreMemoryWatchPath(watchPath, stats, this.settings.multimodal),
453-
awaitWriteFinish: {
454-
stabilityThreshold: this.settings.sync.watchDebounceMs,
455-
pollInterval: 100,
456-
},
457460
});
458-
const markDirty = () => {
461+
const markDirty = (watchPath?: string, stats?: MemoryWatchEventStats) => {
462+
recordMemoryWatchEventPath(this.pendingWatchPaths, watchPath, stats);
459463
this.dirty = true;
460464
this.scheduleWatchSync();
461465
};
@@ -709,7 +713,21 @@ export abstract class MemoryManagerSyncOps {
709713
}
710714
this.watchTimer = setTimeout(() => {
711715
this.watchTimer = null;
712-
runDetachedMemorySync(() => this.sync({ reason: "watch" }), "watch");
716+
runDetachedMemorySync(async () => {
717+
if (this.closed) {
718+
return;
719+
}
720+
if (!(await settleMemoryWatchEventPaths(this.pendingWatchPaths))) {
721+
if (!this.closed) {
722+
this.scheduleWatchSync();
723+
}
724+
return;
725+
}
726+
if (this.closed) {
727+
return;
728+
}
729+
await this.sync({ reason: "watch" });
730+
}, "watch");
713731
}, this.settings.sync.watchDebounceMs);
714732
}
715733

extensions/memory-core/src/memory/manager.watcher-config.test.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ type WatchIgnoredFn = (watchPath: string, stats?: { isDirectory?: () => boolean
1111

1212
const { createdWatchers, memoryLoggerWarn, watchMock } = vi.hoisted(() => {
1313
type WatchEvent = "add" | "change" | "unlink" | "unlinkDir" | "error";
14-
type WatchCallback = (value?: unknown) => void;
14+
type WatchCallback = (...args: unknown[]) => void;
1515
function createMockWatcher() {
1616
const handlers = new Map<WatchEvent, WatchCallback[]>();
1717
const watcher = {
@@ -20,9 +20,9 @@ const { createdWatchers, memoryLoggerWarn, watchMock } = vi.hoisted(() => {
2020
return watcher;
2121
}),
2222
close: vi.fn(async () => undefined),
23-
emit: (event: WatchEvent, value?: unknown) => {
23+
emit: (event: WatchEvent, ...args: unknown[]) => {
2424
for (const callback of handlers.get(event) ?? []) {
25-
callback(value);
25+
callback(...args);
2626
}
2727
},
2828
};
@@ -172,7 +172,7 @@ describe("memory watcher config", () => {
172172
]);
173173
expect(watchedPaths.filter((watchedPath) => watchedPath.includes("*"))).toEqual([]);
174174
expect(options.ignoreInitial).toBe(true);
175-
expect(options.awaitWriteFinish).toEqual({ stabilityThreshold: 25, pollInterval: 100 });
175+
expect(options).not.toHaveProperty("awaitWriteFinish");
176176

177177
const ignored = options.ignored as WatchIgnoredFn | undefined;
178178
expect(ignored).toBeTypeOf("function");
@@ -261,6 +261,37 @@ describe("memory watcher config", () => {
261261
},
262262
);
263263

264+
it("settles changed file stats before running watch sync", async () => {
265+
await setupWatcherWorkspace({ name: "notes.md", contents: "hello" });
266+
const cfg = createWatcherConfig();
267+
268+
await expectWatcherManager(cfg);
269+
vi.useFakeTimers();
270+
const notesPath = path.join(extraDir, "notes.md");
271+
const initialStats = await fs.stat(notesPath);
272+
const syncSpy = vi
273+
.spyOn(
274+
manager as unknown as {
275+
sync: (params?: { reason?: string }) => Promise<void>;
276+
},
277+
"sync",
278+
)
279+
.mockResolvedValue(undefined);
280+
281+
createdWatchers[0]?.emit("change", notesPath, {
282+
size: initialStats.size,
283+
mtimeMs: initialStats.mtimeMs,
284+
isDirectory: () => false,
285+
});
286+
await fs.writeFile(notesPath, "hello updated");
287+
288+
await vi.advanceTimersByTimeAsync(25);
289+
expect(syncSpy).not.toHaveBeenCalled();
290+
291+
await vi.advanceTimersByTimeAsync(25);
292+
expect(syncSpy).toHaveBeenCalledWith({ reason: "watch" });
293+
});
294+
264295
it("attaches a logging non-throwing watcher error listener", async () => {
265296
await setupWatcherWorkspace({ name: "notes.md", contents: "hello" });
266297
const cfg = createWatcherConfig();

extensions/memory-core/src/memory/qmd-manager.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,7 @@ describe("QmdMemoryManager", () => {
493493
const initialUpdateCalls = spawnMock.mock.calls.filter((call) => call[1]?.[0] === "update");
494494
expect(initialUpdateCalls).toHaveLength(0);
495495
const watchOptions = firstWatchOptions();
496+
expect(watchOptions).not.toHaveProperty("awaitWriteFinish");
496497
expect(watchOptions.ignored?.(path.join(workspaceDir, "node_modules", "pkg", "note.md"))).toBe(
497498
true,
498499
);
@@ -502,7 +503,14 @@ describe("QmdMemoryManager", () => {
502503
expect(watchOptions.ignored?.(path.join(workspaceDir, "build", "note.md"))).toBe(true);
503504
expect(watchOptions.ignored?.(path.join(workspaceDir, "notes.md"))).toBe(false);
504505

505-
watcher.emit("change", path.join(workspaceDir, "notes.md"));
506+
const notesPath = path.join(workspaceDir, "notes.md");
507+
await fs.writeFile(notesPath, "hello");
508+
const initialStats = await fs.stat(notesPath);
509+
watcher.emit("change", notesPath, {
510+
size: initialStats.size,
511+
mtimeMs: initialStats.mtimeMs,
512+
isDirectory: () => false,
513+
});
506514
expect(manager.status().dirty).toBe(true);
507515

508516
await vi.advanceTimersByTimeAsync(25);
@@ -514,6 +522,55 @@ describe("QmdMemoryManager", () => {
514522
await manager.close();
515523
});
516524

525+
it("delays qmd watch sync until changed file stats settle", async () => {
526+
vi.useFakeTimers();
527+
cfg = {
528+
agents: {
529+
defaults: {
530+
workspace: workspaceDir,
531+
memorySearch: {
532+
provider: "openai",
533+
model: "mock-embed",
534+
store: { path: path.join(workspaceDir, "index.sqlite"), vector: { enabled: false } },
535+
sync: { watch: true, watchDebounceMs: 25, onSessionStart: false, onSearch: false },
536+
},
537+
},
538+
list: [{ id: agentId, default: true, workspace: workspaceDir }],
539+
},
540+
memory: {
541+
backend: "qmd",
542+
qmd: {
543+
includeDefaultMemory: false,
544+
update: { interval: "0s", debounceMs: 0, onBoot: false },
545+
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
546+
},
547+
},
548+
} as OpenClawConfig;
549+
550+
const notesPath = path.join(workspaceDir, "notes.md");
551+
await fs.writeFile(notesPath, "hello");
552+
const initialStats = await fs.stat(notesPath);
553+
const { manager } = await createManager({ mode: "full" });
554+
const watcher = watchMock.mock.results[0]?.value as {
555+
emit: (event: string, ...args: unknown[]) => boolean;
556+
};
557+
558+
watcher.emit("change", notesPath, {
559+
size: initialStats.size,
560+
mtimeMs: initialStats.mtimeMs,
561+
isDirectory: () => false,
562+
});
563+
await fs.writeFile(notesPath, "hello updated");
564+
565+
await vi.advanceTimersByTimeAsync(25);
566+
expect(spawnMock.mock.calls.filter((call) => call[1]?.[0] === "update")).toHaveLength(0);
567+
568+
await vi.advanceTimersByTimeAsync(25);
569+
expect(spawnMock.mock.calls.filter((call) => call[1]?.[0] === "update")).toHaveLength(1);
570+
571+
await manager.close();
572+
});
573+
517574
it("runs boot update in background by default", async () => {
518575
cfg = {
519576
...cfg,

extensions/memory-core/src/memory/qmd-manager.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,19 @@ import {
5555
} from "openclaw/plugin-sdk/string-coerce-runtime";
5656
import { asRecord } from "../dreaming-shared.js";
5757
import { resolveQmdCollectionPatternFlags, type QmdCollectionPatternFlag } from "./qmd-compat.js";
58+
import {
59+
recordMemoryWatchEventPath,
60+
settleMemoryWatchEventPaths,
61+
type MemoryWatchEventStats,
62+
type MemoryWatchSettleQueue,
63+
} from "./watch-settle.js";
5864

5965
type SqliteDatabase = import("node:sqlite").DatabaseSync;
6066

6167
const log = createSubsystemLogger("memory");
6268

6369
const SNIPPET_HEADER_RE = /@@\s*-([0-9]+),([0-9]+)/;
6470
const SEARCH_PENDING_UPDATE_WAIT_MS = 500;
65-
const QMD_WATCH_STABILITY_MS = 200;
6671
const MAX_QMD_OUTPUT_CHARS = 200_000;
6772
const NUL_MARKER_RE = /(?:\^@|\\0|\\x00|\\u0000|null\s*byte|nul\s*byte)/i;
6873
const QMD_EMBED_BACKOFF_BASE_MS = 60_000;
@@ -324,6 +329,7 @@ export class QmdMemoryManager implements MemorySearchManager {
324329
private embedTimer: NodeJS.Timeout | null = null;
325330
private watcher: FSWatcher | null = null;
326331
private watchTimer: NodeJS.Timeout | null = null;
332+
private readonly pendingWatchPaths: MemoryWatchSettleQueue = new Map();
327333
private pendingUpdate: Promise<void> | null = null;
328334
private queuedForcedUpdate: Promise<void> | null = null;
329335
private queuedForcedRuns = 0;
@@ -1560,12 +1566,9 @@ export class QmdMemoryManager implements MemorySearchManager {
15601566
this.watcher = chokidar.watch(watchPathList, {
15611567
ignoreInitial: true,
15621568
ignored: (watchPath) => shouldIgnoreMemoryWatchPath(watchPath),
1563-
awaitWriteFinish: {
1564-
stabilityThreshold: QMD_WATCH_STABILITY_MS,
1565-
pollInterval: 100,
1566-
},
15671569
});
1568-
const markDirty = () => {
1570+
const markDirty = (watchPath?: string, stats?: MemoryWatchEventStats) => {
1571+
recordMemoryWatchEventPath(this.pendingWatchPaths, watchPath, stats);
15691572
this.dirty = true;
15701573
this.scheduleWatchSync();
15711574
};
@@ -1592,7 +1595,21 @@ export class QmdMemoryManager implements MemorySearchManager {
15921595
}
15931596
this.watchTimer = setTimeout(() => {
15941597
this.watchTimer = null;
1595-
void this.sync({ reason: "watch" }).catch((err) => {
1598+
void (async () => {
1599+
if (this.closed) {
1600+
return;
1601+
}
1602+
if (!(await settleMemoryWatchEventPaths(this.pendingWatchPaths))) {
1603+
if (!this.closed) {
1604+
this.scheduleWatchSync();
1605+
}
1606+
return;
1607+
}
1608+
if (this.closed) {
1609+
return;
1610+
}
1611+
await this.sync({ reason: "watch" });
1612+
})().catch((err) => {
15961613
log.warn(`qmd watch sync failed: ${String(err)}`);
15971614
});
15981615
}, this.syncSettings.watchDebounceMs);
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import fsSync from "node:fs";
2+
import path from "node:path";
3+
4+
export type MemoryWatchEventStats = {
5+
isDirectory?: () => boolean;
6+
size?: number;
7+
mtimeMs?: number;
8+
};
9+
10+
type WatchPathSnapshot = {
11+
size: number;
12+
mtimeMs: number;
13+
};
14+
15+
export type MemoryWatchSettleQueue = Map<string, WatchPathSnapshot | null>;
16+
17+
const MEMORY_WATCH_SETTLE_RECHECK_MS = 100;
18+
19+
function snapshotFromStats(stats?: MemoryWatchEventStats): WatchPathSnapshot | null {
20+
if (!stats || stats.isDirectory?.()) {
21+
return null;
22+
}
23+
if (typeof stats.size !== "number" || typeof stats.mtimeMs !== "number") {
24+
return null;
25+
}
26+
return { size: stats.size, mtimeMs: stats.mtimeMs };
27+
}
28+
29+
function snapshotsMatch(left: WatchPathSnapshot | null, right: WatchPathSnapshot | null): boolean {
30+
if (left === null || right === null) {
31+
return left === right;
32+
}
33+
return left.size === right.size && left.mtimeMs === right.mtimeMs;
34+
}
35+
36+
function snapshotPath(filePath: string): WatchPathSnapshot | null {
37+
try {
38+
const stats = fsSync.statSync(filePath);
39+
if (stats.isDirectory()) {
40+
return null;
41+
}
42+
return { size: stats.size, mtimeMs: stats.mtimeMs };
43+
} catch {
44+
return null;
45+
}
46+
}
47+
48+
async function delay(ms: number): Promise<void> {
49+
await new Promise<void>((resolve) => {
50+
setTimeout(resolve, ms);
51+
});
52+
}
53+
54+
export function recordMemoryWatchEventPath(
55+
queue: MemoryWatchSettleQueue,
56+
watchPath?: string,
57+
stats?: MemoryWatchEventStats,
58+
): void {
59+
if (!watchPath) {
60+
return;
61+
}
62+
const trimmed = watchPath.trim();
63+
if (!trimmed) {
64+
return;
65+
}
66+
queue.set(path.resolve(trimmed), snapshotFromStats(stats));
67+
}
68+
69+
export async function settleMemoryWatchEventPaths(queue: MemoryWatchSettleQueue): Promise<boolean> {
70+
if (queue.size === 0) {
71+
return true;
72+
}
73+
74+
const entries = Array.from(queue.entries());
75+
queue.clear();
76+
const missingBaseline: Array<{ filePath: string; snapshot: WatchPathSnapshot }> = [];
77+
78+
for (const [filePath, previousSnapshot] of entries) {
79+
const currentSnapshot = snapshotPath(filePath);
80+
if (previousSnapshot === null) {
81+
if (currentSnapshot !== null) {
82+
missingBaseline.push({ filePath, snapshot: currentSnapshot });
83+
}
84+
continue;
85+
}
86+
if (!snapshotsMatch(previousSnapshot, currentSnapshot)) {
87+
queue.set(filePath, currentSnapshot);
88+
}
89+
}
90+
91+
if (missingBaseline.length > 0) {
92+
await delay(MEMORY_WATCH_SETTLE_RECHECK_MS);
93+
for (const entry of missingBaseline) {
94+
const currentSnapshot = snapshotPath(entry.filePath);
95+
if (!snapshotsMatch(entry.snapshot, currentSnapshot)) {
96+
queue.set(entry.filePath, currentSnapshot);
97+
}
98+
}
99+
}
100+
101+
return queue.size === 0;
102+
}

0 commit comments

Comments
 (0)