Skip to content

Commit 3e8f5b0

Browse files
claude[bot]os-zhuangclaude
authored
fix(metadata-fs): confirm absence on disk before publishing an external delete (#12695)
A watcher unlink is a claim of absence, not absence. chokidar reaches its removal path from failed stats as well as from real removals, so under filesystem pressure it retires files that are still there; publishing those claims produced a durable delete/create pair for an item nobody removed. Confirm against the disk under the same per-key lock the reconciliation sweep already used for this, and fall through to the content path when the file is still present, so a spurious unlink alongside a real external edit surfaces as the update it always was. Part of #7369 Claude-Session: https://claude.ai/code/session_01DKWDdUJ2XNRESVVWUvcpnh Co-authored-by: Claude <jack@objectstack.ai> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a8c00e2 commit 3e8f5b0

3 files changed

Lines changed: 311 additions & 1 deletion

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/metadata-fs': patch
3+
---
4+
5+
The file watcher no longer publishes a `delete` for an item that is still on disk.
6+
7+
A watcher `unlink` is a claim of absence, not absence: chokidar reaches its removal path from failed stats as well as from real removals, so under filesystem pressure it can retire a file that is still there. `FileSystemRepository` published those claims straight through as `delete` events — appended to the change log and broadcast to every subscriber, which drops the item from the metadata registry and the `list()` cache — and the reconciliation sweep then republished the untouched file as a `create`. A failed stat therefore produced a durable delete/create pair for an item nobody removed, with a window in between where live metadata had disappeared.
8+
9+
The removal face now confirms the absence against the disk under the same per-key lock the reconciliation sweep already used for this, and an `unlink` for a path that still exists falls through to the content comparison — so a spurious unlink that arrived alongside a real external edit surfaces as the `update` it always was. Genuine external removals are unaffected and are still published on the first delivery.

packages/metadata-fs/src/repository.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -901,9 +901,36 @@ export class FileSystemRepository implements MetadataRepository {
901901
* - `unlink` — `!currentHead` drops the event when the index already
902902
* agrees the item is gone. `delete()` retires the head *before* it
903903
* unlinks, precisely because this face gets no `awaitWriteFinish` delay.
904+
* This is the whole of the *self-write* answer on this face, and it is
905+
* not the whole of the face — see the section below it.
904906
*
905907
* Both faces are pinned together in `test/self-write-suppression.test.ts`.
906908
*
909+
* ## A removal is confirmed against the disk before it is published (#7369)
910+
*
911+
* Those two checks answer "is this event OURS". Neither answers "did this
912+
* happen at all", and the `unlink` face needs that second question asked
913+
* because its input is a third party's inference: chokidar decides a file is
914+
* gone from a *stat that failed*, not only from a file that went away, and
915+
* `!currentHead` cannot tell the two apart because a spurious unlink leaves
916+
* the index exactly as valid as it was.
917+
*
918+
* The cost of getting it wrong is not a dropped notification, which the
919+
* sweep would repair. A `delete` is appended to the change log and broadcast
920+
* to every subscriber, and `MetadataManager` drops the item from the
921+
* registry and the `list()` cache on receipt. The sweep then finds the file
922+
* still on disk and republishes it as a `create` — so a failed stat becomes
923+
* a durable, permanently recorded delete/create pair for an item that never
924+
* changed, and every consumer sees the item disappear in between. That is
925+
* the shape ADR-0008's log is least able to walk back.
926+
*
927+
* So `existsSync` under the same per-key lock the sweep uses, and the same
928+
* decision it makes: absent ⇒ publish the removal; present ⇒ this was a
929+
* change, answered by the content path below. Genuine removals pay nothing —
930+
* `delete()` is still suppressed by `!currentHead`, and an external `rm` is
931+
* still published on the first delivery, because for those the file really
932+
* is gone. Pinned in `test/external-delete-requires-absence.test.ts`.
933+
*
907934
* Note the deliberate limit: identity is judged on what round-trips through
908935
* the file, so a spec whose in-memory form does not (a `Date`, which
909936
* canonicalises to `{}` in memory but to an ISO string once written and
@@ -922,7 +949,36 @@ export class FileSystemRepository implements MetadataRepository {
922949
};
923950
const key = refKey(ref);
924951
await this.mutex.run(key, async () => {
925-
if (kind === 'unlink') {
952+
// A watcher `unlink` is a CLAIM of absence, not absence — so it is
953+
// confirmed against the disk before a `delete` is published (#7369).
954+
// `!currentHead` inside `publishExternalDelete` cannot do this job: it
955+
// compares against the INDEX, which is exactly what a spurious unlink
956+
// leaves intact. The reconciliation sweep already re-checks disk truth
957+
// under this same lock before retiring a key, for a reason it states in
958+
// place; the watcher face was the one path that published a removal on
959+
// the observer's word alone.
960+
//
961+
// chokidar reaches its removal path from failed *stats* as well as from
962+
// real removals, and says so at both sites (chokidar 5 `handler.js`):
963+
// `_handleFile`'s poll listener re-stats a file whose watched stat came
964+
// back zeroed and calls `_remove` from the catch — under the comment
965+
// "Fix issues where mtime is null but file is still present" — with no
966+
// discrimination on errno, so EMFILE/ENFILE retires a file that is
967+
// there; and `_handleRead`'s snapshot diff `_remove`s every previously
968+
// tracked entry its readdirp pass did not enumerate, which includes the
969+
// entries whose per-entry `lstat` failed rather than only the ones that
970+
// are gone. Both faults are load-shaped, which is why the merge queue —
971+
// the only context that runs the FULL suite — is where this surfaced,
972+
// twice, on a case that touches nothing else.
973+
//
974+
// Falling through is the repair, not just skipping: when the path is
975+
// still there the honest reading of the event is "something happened to
976+
// this file", which is the content path's question. It answers with the
977+
// same `currentHead === hash` comparison used everywhere else, so a
978+
// spurious unlink that accompanied a real in-place edit still surfaces
979+
// as the `update` it always was, in the same tick, rather than as the
980+
// `delete` + `create` pair the index-only check produced.
981+
if (kind === 'unlink' && !existsSync(absPath)) {
926982
await this.publishExternalDelete(ref, key);
927983
return;
928984
}
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #7369 — a watcher `unlink` is a CLAIM of absence. Only the disk settles it.
5+
*
6+
* ## The defect
7+
*
8+
* `handleFsChange`'s removal face used to publish on the observer's word:
9+
*
10+
* if (kind === 'unlink') { await this.publishExternalDelete(ref, key); return; }
11+
*
12+
* and the only suppression inside `publishExternalDelete` is `!currentHead` —
13+
* a comparison against the **index**, which a spurious unlink leaves exactly
14+
* as valid as it was. So an event that merely *claimed* a file was gone became
15+
* a `delete`: appended to the change log, broadcast to every subscriber, and
16+
* acted on by `MetadataManager`, which drops the item from the registry and
17+
* the `list()` cache on receipt. The reconciliation sweep then found the file
18+
* still on disk and republished it as a `create`. A failed stat therefore
19+
* produced a durable delete/create pair for an item that was never removed,
20+
* with a window in between where live metadata had vanished.
21+
*
22+
* The repository already held the opposite discipline one method away: the
23+
* sweep's delete pass re-checks `existsSync` under the per-key lock before
24+
* retiring a key, and `external-write-resync.test.ts` pins that a false
25+
* absence handed to the sweep must produce **zero** `delete` events. The
26+
* watcher face was the one path that skipped the check.
27+
*
28+
* ## Why chokidar's unlink is not evidence of removal
29+
*
30+
* chokidar reaches its removal path from failed **stats** as well as from real
31+
* removals, and both sites are load-shaped (chokidar 5, `handler.js`):
32+
*
33+
* - `_handleFile`'s poll listener runs when `fs.watchFile` reports a zeroed
34+
* stat, re-stats the file, and calls `_remove` from the `catch` — under
35+
* the comment "Fix issues where mtime is null but file is still present",
36+
* with no discrimination on errno. EMFILE/ENFILE retires a file that is
37+
* there.
38+
* - `_handleRead`'s snapshot diff `_remove`s every previously tracked entry
39+
* that its readdirp pass did not enumerate — which includes entries whose
40+
* per-entry `lstat` failed, not only entries that are gone.
41+
*
42+
* That is why this surfaced in the merge queue and nowhere else: the queue is
43+
* the only context that runs the FULL suite, and `watch-dot-root.test.ts` case
44+
* 1 has been ejected from it twice by a delivery fault it did not cause. The
45+
* second ejection (2026-08-27, run 33057527457, `Test Core (2/6)`) failed with
46+
* `expected 'delete' to be 'update'` at `watch-dot-root.test.ts:268` — the
47+
* exact-count assertion on the line above it PASSED, so exactly one event
48+
* arrived, typed `delete`, for a file the case never removes.
49+
*
50+
* ## Why these cases assert at the handler seam
51+
*
52+
* The mechanism upstream is a failed stat under resource pressure, which
53+
* cannot be summoned on demand and would be a wall-clock race to wait for.
54+
* This package has now been ejected from the merge queue three times by
55+
* wall-clock watcher assertions, so the cases follow the discipline
56+
* `self-write-suppression.test.ts` set for the same reason: enter at the seam
57+
* immediately below chokidar, with exactly the arguments a spurious unlink
58+
* delivers, and assert the contract rather than the race.
59+
*
60+
* The contract, stated once:
61+
*
62+
* **a removal is published only for an item that is actually absent from
63+
* disk; an `unlink` for a path that is still there is a change.**
64+
*
65+
* Both directions are asserted, because each alone has a trivial wrong fix: a
66+
* repository that published nothing on `unlink` would pass the first two cases
67+
* and fail the third, and today's code passes the third and fails the first
68+
* two.
69+
*/
70+
71+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
72+
import fs from 'node:fs/promises';
73+
import path from 'node:path';
74+
import os from 'node:os';
75+
import type { MetaRef, MetadataEvent } from '@objectstack/metadata-core';
76+
import { FileSystemRepository } from '../src/index.js';
77+
78+
const ref = (name: string): MetaRef => ({ org: 'system', type: 'view', name });
79+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
80+
81+
/**
82+
* Enter the watcher handler exactly as chokidar's `unlink` listener does —
83+
* `w.on('unlink', (p) => void this.handleFsChange(p, 'unlink'))` in
84+
* `startWatcher`. The same reach is made by `self-write-suppression.test.ts`.
85+
*/
86+
type Kind = 'add' | 'change' | 'unlink';
87+
const deliver = (repo: FileSystemRepository, file: string, kind: Kind): Promise<void> =>
88+
(repo as unknown as {
89+
handleFsChange(p: string, k: Kind): Promise<void>;
90+
}).handleFsChange(file, kind);
91+
92+
/**
93+
* Enough turns for anything the delivery above would publish to reach the
94+
* subscriber. It is not a race budget: `deliver` is awaited, so the publish has
95+
* already happened or is never going to — this only lets the broker's queue
96+
* drain into the array.
97+
*/
98+
const drain = () => sleep(50);
99+
100+
describe('#7369 an external delete is published only for an item that is really gone', () => {
101+
let root: string;
102+
let repo: FileSystemRepository | null = null;
103+
let events: MetadataEvent[] = [];
104+
let stop: (() => Promise<void>) | null = null;
105+
106+
/**
107+
* The watcher is disabled throughout: these cases supply the event
108+
* themselves, and a live poller would race them. It also retires the
109+
* reconciliation sweep (armed inside `startWatcher`), so every event
110+
* observed here came from the delivery the case made — which is the whole
111+
* point, since the sweep is precisely what used to paper over the defect by
112+
* republishing the item as a `create` two seconds later.
113+
*/
114+
async function start(): Promise<void> {
115+
repo = new FileSystemRepository({ root, org: 'system', disableWatch: true });
116+
await repo.start();
117+
const iter = repo.watch({ org: 'system' }, 999)[Symbol.asyncIterator]();
118+
let stopped = false;
119+
void (async () => {
120+
while (!stopped) {
121+
const next = await iter.next();
122+
if (next.done) return;
123+
events.push(next.value as MetadataEvent);
124+
}
125+
})();
126+
stop = async () => {
127+
stopped = true;
128+
await iter.return?.(undefined);
129+
};
130+
}
131+
132+
beforeEach(async () => {
133+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'os-unlink-claim-'));
134+
events = [];
135+
});
136+
137+
afterEach(async () => {
138+
if (stop) await stop();
139+
stop = null;
140+
if (repo) await repo.close();
141+
repo = null;
142+
await fs.rm(root, { recursive: true, force: true });
143+
});
144+
145+
it('an unlink for a path that still exists, edited externally, is the update it always was', async () => {
146+
await start();
147+
const r = ref('case_grid');
148+
const file = path.join(root, 'view', 'case_grid.json');
149+
const first = await repo!.put(r, { label: 'original' }, {
150+
parentVersion: null,
151+
actor: 'tester',
152+
});
153+
// Let `put()`'s own event land before clearing, so it cannot arrive
154+
// afterwards and be read as something the delivery below produced.
155+
await drain();
156+
events.length = 0;
157+
158+
// The queue failure, reproduced at the seam: an external in-place edit
159+
// lands, and the delivery chokidar makes for it is `unlink`.
160+
await fs.writeFile(file, JSON.stringify({ label: 'externally edited' }, null, 2) + '\n');
161+
await deliver(repo!, file, 'unlink');
162+
await drain();
163+
164+
expect(events).toHaveLength(1);
165+
// Pre-fix this was 'delete'. That is the assertion that ejected two
166+
// unrelated PRs from the merge queue.
167+
expect(events[0]!.op).toBe('update');
168+
expect(events[0]!.ref.name).toBe('case_grid');
169+
expect(events[0]!.source).toBe('fs');
170+
expect(events[0]!.actor).toBe('fs');
171+
expect(events[0]!.parentHash).toBe(first.version);
172+
expect(events[0]!.hash).not.toBeNull();
173+
174+
// And the item is still there — the index was never retired behind the
175+
// subscriber's back.
176+
const item = await repo!.get(r);
177+
expect(item).not.toBeNull();
178+
expect(item!.body).toEqual({ label: 'externally edited' });
179+
});
180+
181+
it('a purely spurious unlink — nothing on disk changed — publishes nothing at all', async () => {
182+
await start();
183+
const r = ref('untouched');
184+
const file = path.join(root, 'view', 'untouched.json');
185+
const first = await repo!.put(r, { label: 'stable' }, {
186+
parentVersion: null,
187+
actor: 'tester',
188+
});
189+
// Let `put()`'s own event land before clearing, so it cannot arrive
190+
// afterwards and be read as something the delivery below produced.
191+
await drain();
192+
events.length = 0;
193+
194+
// The pure fault: chokidar's stat failed, the file never moved.
195+
await deliver(repo!, file, 'unlink');
196+
await drain();
197+
198+
// Not "a delete then a create". Nothing happened, so nothing is published,
199+
// and no phantom pair is written to the change log for a consumer to
200+
// replay forever.
201+
expect(events).toEqual([]);
202+
const item = await repo!.get(r);
203+
expect(item).not.toBeNull();
204+
expect(item!.hash).toBe(first.version);
205+
206+
// The change log must be equally clean: `delete` is durable, and the
207+
// history is what a consumer rebuilding from disk reads.
208+
const history: MetadataEvent[] = [];
209+
for await (const evt of repo!.history(r, {})) history.push(evt);
210+
expect(history.map((h) => h.op)).toEqual(['create']);
211+
});
212+
213+
it('a genuine external removal is still published on the first delivery', async () => {
214+
await start();
215+
const r = ref('really_gone');
216+
const file = path.join(root, 'view', 'really_gone.json');
217+
const first = await repo!.put(r, { label: 'here' }, {
218+
parentVersion: null,
219+
actor: 'tester',
220+
});
221+
// Let `put()`'s own event land before clearing, so it cannot arrive
222+
// afterwards and be read as something the delivery below produced.
223+
await drain();
224+
events.length = 0;
225+
226+
// Somebody ran `rm`. The file is absent, so the claim is true.
227+
await fs.rm(file);
228+
await deliver(repo!, file, 'unlink');
229+
await drain();
230+
231+
// ⚠️ The complementary direction, and the reason the fix is a disk check
232+
// rather than "ignore unlink": without this case, dropping every unlink
233+
// would pass the two above. External removals are a real capability —
234+
// `external-write-resync.test.ts` pins the sweep's recovery of one.
235+
expect(events).toHaveLength(1);
236+
expect(events[0]!.op).toBe('delete');
237+
expect(events[0]!.ref.name).toBe('really_gone');
238+
expect(events[0]!.hash).toBeNull();
239+
expect(events[0]!.parentHash).toBe(first.version);
240+
expect(events[0]!.source).toBe('fs');
241+
expect(events[0]!.actor).toBe('fs');
242+
243+
expect(await repo!.get(r)).toBeNull();
244+
});
245+
});

0 commit comments

Comments
 (0)