mcp-watcher: safety-poll fallback for dropped inotify events
Adds a periodic timer (default 30s) that calls drain() unconditionally, covering the case where chokidar/inotify silently drops an IN_MODIFY event. Observed twice in production: ping appended to inbox, file mtime updated, but no event delivered to the watcher; a sibling-file touch unblocked it. Root cause is Linux inotify under brief idle gaps + atomic writes — not consistently reliable on its own. drain() is already idempotent (HWM comparison short-circuits when nothing's new), so the steady-state overhead is one stat + JSON parse per poll cycle. Event-driven path remains the primary; the poll just masks the rare miss within the cycle interval. - safetyPollMs option: default 30_000, set to 0 to disable - stop() clears the interval before closing chokidar - Two new tests: safety-poll delivers when fs-event never fires; safetyPollMs:0 truly disables the timer Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
39a95ad49f
commit
95ff60ce94
2 changed files with 87 additions and 0 deletions
|
|
@ -130,6 +130,67 @@ describe("InboxWatcher initial drain", () => {
|
|||
await w.stop();
|
||||
});
|
||||
|
||||
it("safety-poll delivers pings appended while no fs-event fires", async () => {
|
||||
// Simulates the dropped-inotify-event case: chokidar mock never fires
|
||||
// onChange. A ping is appended after start(); only the safety-poll
|
||||
// timer can catch it. Asserts the watcher recovers within one poll cycle.
|
||||
const paths = makePaths(dir);
|
||||
mkdirSync(paths.pingsDir, { recursive: true });
|
||||
writeInbox(paths.inbox("bob"), [ev({ id: "before-start", ts: "2026-05-06T10:00:00Z" })]);
|
||||
|
||||
const delivered: PingEvent[] = [];
|
||||
const w = new InboxWatcher({
|
||||
paths,
|
||||
agent: "bob",
|
||||
notify: async (e) => { delivered.push(e); },
|
||||
// Chokidar mock that NEVER fires onChange — simulates a dropped event.
|
||||
startWatcher: () => ({ async close() {} }),
|
||||
safetyPollMs: 20,
|
||||
});
|
||||
await w.start();
|
||||
expect(delivered.map((d) => d.id)).toEqual(["before-start"]);
|
||||
|
||||
// Append a new ping post-start, without notifying chokidar.
|
||||
appendFileSync(
|
||||
paths.inbox("bob"),
|
||||
JSON.stringify(ev({ id: "after-start", ts: "2026-05-06T12:00:00Z" })) + "\n",
|
||||
);
|
||||
|
||||
// Wait long enough for at least one safety-poll cycle.
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
await w.stop();
|
||||
|
||||
expect(delivered.map((d) => d.id)).toEqual(["before-start", "after-start"]);
|
||||
const hwm = JSON.parse(readFileSync(paths.hwm("bob"), "utf8"));
|
||||
expect(hwm.last_delivered_ts).toBe("2026-05-06T12:00:00Z");
|
||||
});
|
||||
|
||||
it("safetyPollMs: 0 disables the safety-poll timer", async () => {
|
||||
const paths = makePaths(dir);
|
||||
mkdirSync(paths.pingsDir, { recursive: true });
|
||||
writeInbox(paths.inbox("bob"), [ev({ id: "a", ts: "2026-05-06T10:00:00Z" })]);
|
||||
|
||||
const delivered: PingEvent[] = [];
|
||||
const w = new InboxWatcher({
|
||||
paths,
|
||||
agent: "bob",
|
||||
notify: async (e) => { delivered.push(e); },
|
||||
startWatcher: () => ({ async close() {} }),
|
||||
safetyPollMs: 0,
|
||||
});
|
||||
await w.start();
|
||||
|
||||
// Append a ping; with no fs-event AND no safety-poll, it must NOT be delivered.
|
||||
appendFileSync(
|
||||
paths.inbox("bob"),
|
||||
JSON.stringify(ev({ id: "missed", ts: "2026-05-06T11:00:00Z" })) + "\n",
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await w.stop();
|
||||
|
||||
expect(delivered.map((d) => d.id)).toEqual(["a"]); // only the initial-drain ping
|
||||
});
|
||||
|
||||
it("skips pings already covered by HWM on restart", async () => {
|
||||
const paths = makePaths(dir);
|
||||
mkdirSync(paths.pingsDir, { recursive: true });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue