fix(metadata-fs): register written paths with the watcher instead of trusting its scan (#7282) - #7336
Conversation
…trusting its scan (#7282) `FileSystemRepository`'s watcher could go permanently blind to a single item: external edits to that file produced no `MetadataEvent` for the life of the process. The window is a race between chokidar's asynchronous initial scan and the repository's own first write, which both `start()` and `ensureRoot()` can open. Measured on chokidar 5 with this repository's options (`usePolling`, `interval: 1000`): chokidar reads `<root>/<type>/` while the atomic `rename` in `writeJsonAtomic` has not landed, then takes the directory's polling baseline stat *after* it lands. The directory's stat never changes again, so it is never re-read, the item is never registered, no per-file watcher is created, and neither `add` nor `change` is ever emitted for that path — `getWatched()` reports the type directory as `[]` while the file sits in it. That is why the two time-based mitigations tried on this family could not have worked: the event is never delivered, not late. This fix widens no timer — the only writer that can be inside the window is us, so `put()` now tells the watcher explicitly about the path it created. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6bLax4KMrSfnE1ydFU8Dw
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
ACCEPT — PM review (step 7), anchored to head
|
| file | status | matches declaration |
|---|---|---|
packages/metadata-fs/src/repository.ts |
modified | yes — one call in put() + the new private trackWrittenPath; watcher options untouched |
packages/metadata-fs/test/watch-write-registration.test.ts |
added | yes — the pin |
.changeset/metadata-fs-watch-write-registration.md |
added | yes |
selfWrites is verifiably untouched (its setTimeout(… delete) is context, not a changed line). The chokidar ignored matcher (#7150) is untouched. No content/docs/releases/ change, no docs/adr/** change, no quarantine, no consumer-side tolerance widened anywhere.
CI, per job
25 check runs, all completed: 23 success, 2 skipped (Build Docs, Console Pin Gate — both path-filtered and correctly inert for a non-docs PR). No failure, no neutral, nothing still running. Named gates: ESLint success, TypeScript Type Check success, Build Core success, Test Core 3/3 + rollup success, Dogfood Regression Gate 3/3 + rollup success, Dogfood Verify CLI success, Temporal Conformance (live PG + MySQL) success, Check Changeset success, ADR maintainer approval success.
What I am accepting, stated plainly
The dev did not implement the direction the card recommended — it falsified the card's mechanism first. The card proposed that a put() and a later external edit coalesce into one poll event which the fixed 200 ms selfWrites timer then swallows, and named content-keying that suppression as direction 1. Measurement says that never happens: 0/360 iterations had a selfWrites entry alive at external-edit time and the suppression branch was entered 0 times, in failing and passing iterations alike — with an argument for why it cannot fire here (the timer is scheduled before the test's own pre-edit sleep() and with a shorter delay, and Node drains expired timers in due-time order). In the failures handleFsChange is never entered at all. The branch name claude/issue-7282-selfwrites-content-keyed is therefore a self-declared misnomer; it is left as-is because renaming a branch mid-PR costs more than the confusion it saves, and this comment plus the PR body are the record.
The mechanism it found instead is strictly worse than the card's, and I accept the evidence for it. chokidar's asynchronous initial scan races the repository's own first write: the type directory is read while writeJsonAtomic's rename has not landed, then watchFile() takes that directory's polling baseline after it lands, so the stat never changes again, the directory is never re-read, parent.has(basename) stays false, and neither add nor change is ever emitted for that path for the life of the process. getWatched() reporting the type directory as [] for 7.5 s with the file sitting in it is the observation that settles it. This explains the two mitigations already spent on this family rather than merely agreeing with them — a 20 s deadline and a 25541 ms deadline both waited on an event that was never coming — and it converts direction 3 from "narrows the window" to effect exactly zero, since a shorter poll re-compares the same unchanged stat. Two claims about the codebase I had assumed and that this corrects: I treated the flake as bounded by the test harness, and I treated the poll interval as a tunable that would at least help. Both wrong.
It is a production defect, not only a test flake. MetadataPlugin attaches the repository and the first put() can race the same scan, after which MetadataManager.subscribe() silently never sees out-of-process edits to that item — a hand edit, or a git checkout bringing metadata JSON in. The changeset carries that as the user-visible effect, at patch, which is the right framing and the right bump.
Verification was done under load, as I required, not in isolation. Base origin/main @ 62b6a2fb2 failed 7/168 probe iterations with the CI signature; with the fix, 0/360 under identical harness and identical load, plus six concurrent rounds of the real metadata-fs and metadata suites (31/31, 593/593, zero failed rounds) with the box saturated. The control is the dev's own base, not a moving origin/main.
Reverse verification is honest in all four categories. Predicted red, and predicted it would be deterministic rather than load-dependent because the pin phase-anchors the poll — measured 5/5 red on the same assertion and the same message. One missed prediction reported rather than buried: the pin's first draft anchored immediately after start() and saw nothing, which the dev initially read as a second manifestation of the defect; it is not — a file landing during the initial walk is treated as pre-existing and under ignoreInitial correctly emits nothing (3/3 at delay 0, 3/3 the other way at 5 ms or after ready), so the anchor now waits for ready and the finding is written into the test header. One assertion green in both directions — the closing external-edit liveness check — is labelled in the file as a control, not as evidence. One item left unmeasured and said so: the failure was not reproduced inside the merge queue itself; the local reproduction is a stated proxy.
Splitting #7335 out was the right call and I am endorsing it explicitly. The selfWrites window is time-keyed and genuinely can lose an event when an external edit lands within ~60 ms of a put() with a poll tick in between — but it is a different defect, still unobserved in the wild, and folding it in would have left this PR's reverse verification attributable to two changes instead of one. Filed, not fixed here.
Ruling
ACCEPT. Flipping out of draft and enabling auto-merge (SQUASH); it lands through the merge queue in the normal way. This closes the head-of-queue flake that ejected verified-green PRs four times across three PRs in roughly two hours.
Generated by Claude Code
Fixes #7282
The proposed mechanism is falsified; the real one is worse
The card's mechanism — a
put()and a later external edit coalescing into onepoll event that is then swallowed by the fixed 200 ms
selfWritestimer — doesnot happen. Measured, instrumented, over 360+ iterations under deliberate
event-loop starvation:
The suppression never fired once, in passing or failing iterations alike. It
cannot fire in these cases: the suppression timer is scheduled before the
test's own pre-edit
sleep()and with a shorter delay, and Node runs expiredtimers in due-time order in a single timers-phase pass — so the moment the
sleep resolves, the suppression entry is provably already gone.
What the failing iterations actually show is that
handleFsChangeis neverentered at all, because chokidar never emits anything for the path.
The confirmed mechanism
Reproduced locally at ~4% (7 failures in 168 iterations) by running six probe
processes concurrently on a 4-core box with a busy-loop starving each event
loop. The instrumented timeline of a failure, taken from chokidar's own
internals:
chokidar's initial scan is asynchronous, and every write path in this file can
run while it is still walking the tree —
start()arms the watcher and thecaller may
put()on the next tick, andensureRoot()arms it in the middle ofthe very first write (#7000). With
usePollingthat produces apermanently-blinding interleaving:
renameinwriteJsonAtomichas not landed yet;watchFile()on the directory and libuv takes its pollingbaseline stat, which already reflects step 2.
The directory's stat then never changes again. No poll ever fires for it,
_handleReadnever re-runs, the item file is never added to the watched set,no per-file watcher is created, and
parent.has(basename)stays false — which_handleFile's listener requires before it will emitchangeat all. chokidaremits neither
addnorchangefor that path for the life of the process.This fits every observation on the card, and explains the two spent routes
rather than merely agreeing with them:
wait for.
watcher-arm time, before the sleep starts.
interval(direction 3) would change nothing either — a shorterpoll re-compares against the same unchanged directory stat. This is stronger
than the card's "narrows the window without closing it": for this mechanism
the effect is exactly zero.
between a
readdirpstream end and awatchFile()call, a gap that onlyopens up under scheduling pressure.
This is not only a test flake. In production
MetadataPluginattaches therepository and the first
put()can race the same scan, after whichMetadataManager.subscribe()silently never sees out-of-process edits to thatitem — a hand edit, or a
git checkoutbringing metadata JSON in.The change
The window is exactly "files that exist at baseline time but were absent from
the snapshot read a moment earlier", and the only writer that can be inside it
is the repository itself. So
put()now tells the watcher explicitly about thepath it created, instead of depending on a directory scan that may never notice
it. No timer is widened, and no consumer-side tolerance is added.
add()is idempotent here (_handleFilereturns early when the parent alreadytracks the basename) and emits nothing (chokidar treats an explicit
add()asan initial add, and
ignoreInitialis set). Its effect is the one needed:_watchWithNodeFsregisters the basename with the parent directory and startsthe per-file poll.
File surface (the real one)
packages/metadata-fs/src/repository.ts— one call input()plus the newprivate
trackWrittenPath. The watcher options are unchanged.packages/metadata-fs/test/watch-write-registration.test.ts— new pin..changeset/metadata-fs-watch-write-registration.md.selfWritesand the chokidarignoredmatcher (#7150) are untouched. Noquarantine was needed, so
packages/metadata's suite is unchanged too.Verification under load, not in isolation
A green local run proves nothing here, so the fix was measured against the
reproduction rather than against a quiet checkout. Identical harness, identical
load, my own base as the control:
origin/main@62b6a2fb2)Plus the real suites:
packages/metadata-fsandpackages/metadatarunconcurrently, six rounds, with three CPU burners saturating the box —
31/31and593/593every round, 0 failed rounds.Reverse verification
Predicted: reverting the fix turns the new pin red on the registration
assertion, and does so deterministically rather than under load — the pin
measures registration latency, and the base's only route to registration is a
1000 ms poll tick that the case deliberately phase-anchors away from.
Result, 5 consecutive runs on base:
5/5 red, same assertion, same message. So this red is not load-dependent,
unlike the defect it stands for — that difference is the point of the design and
is written into the test's header.
Honest categories:
after
start()and the event never arrived — I read that as a secondmanifestation of the defect. It is not: a file that lands while chokidar is
still walking is treated as pre-existing and, under
ignoreInitial, correctlyemits nothing (measured 3/3 with the write at delay 0, seen 3/3 at delay
5 ms or after
ready). The anchor now waits for chokidar'sready, and thefinding is recorded in the test header.
liveness control — an external edit to the freshly written path must reach
subscribers — passes with and without the fix on a quiet machine. It is
labelled as a control in the file.
merge queue itself; the local reproduction is a proxy chosen to match the
described conditions (full-monorepo concurrency), not the queue.
Not folded in
ignoreddotfile regex matches the.objectstacksegment of the root path #7150 (the chokidarignoredpattern) is untouched and unaffected — thedot-root cases pass unchanged.
selfWritesfinding is filed as metadata-fs:selfWritessuppression is time-keyed, so a poll tick landing inside the 200 ms window can swallow an external edit #7335 rather than fixed here: thesuppression is time-keyed, and although it provably cannot lose the events in
these tests, it can lose one when an external edit lands within roughly 60 ms
of a
put()and the poll tick falls in between. That is a different, narrowdefect from the one this PR fixes, and folding it in would have muddied the
reverse verification.
Generated by Claude Code