fix(metadata-fs): suppress self-writes by observed content, not by a 200ms clock (#7335) - #7845
Merged
Merged
Conversation
…200ms clock (#7335) `handleFsChange` opened with `if (this.selfWrites.has(absPath)) return;` — a Set that `put()`/`delete()` added the path to and a `setTimeout(..., 200)` cleared. It discarded every event for a recently-written path without reading what the watcher had observed. Under `usePolling: true, interval: 1000` chokidar compares state once per tick, so our write and an external edit between two ticks arrive as ONE event carrying the external content — dropped on the timer, and it was the only event that edit would ever produce. Measured: the filing's 0/360 was a sampling artefact, not luck. The delivery lag is `(interval - (writeTime mod interval)) + awaitWriteFinish`, so a fixed pre-edit sleep phase-locks the poll (measured 519-585ms over 25 runs, never inside the window). Randomising the phase over 40 runs split perfectly on the wall clock: lag < 200ms => 7 runs, edit swallowed every time; lag > 200ms => 33 runs, edit delivered every time. Same harness after the fix: 40/40 delivered, 0 swallowed, with 5 runs landing at 76-193ms — inside the old window. The pre-check is removed rather than re-keyed, because the content-keyed suppression it shadowed already existed one step down and needs no timer: `currentHead === hash` for add/change, `!currentHead` for unlink. In 40 randomised runs the pre-check was never once observed suppressing a genuine self-write; every observed firing was a swallowed external edit. `delete()` now retires the head before it unlinks rather than after. `awaitWriteFinish` debounces only add/change, so that face has no stability cushion between the disk mutation and its event; ordering the index update first makes the downstream check a total suppression rather than a race against the poll callback. A failed unlink restores the head before rethrowing, so the error path is unchanged. Pinned in `test/self-write-suppression.test.ts`, table-driven over both faces so a future change cannot fix one and silently regress the other. Each face asserts both directions — an external edit coalesced into our write must be published, an undisturbed self-write must not be. Reverse-verified: with the old behaviour restored, both swallow cases fail with an empty change log and both undisturbed cases still pass. One pre-existing limit is now documented, not altered: a spec whose in-memory form does not round-trip (a `Date`, canonicalising to `{}` in memory but to an ISO string once written and re-read) is republished as an external `update`. Measured to already fail `put().version === get().hash` independently of the watcher; the 200ms window never covered it either, expiring ~360ms before the event it would have had to catch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qr7BLLHcVVqfPuEWWurE6u
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 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:
|
huangyiirene
marked this pull request as ready for review
August 11, 2026 20:48
This was referenced Aug 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #7335
The defect
handleFsChangeopened withif (this.selfWrites.has(absPath)) return;— aSetthatput()/delete()added the path to and asetTimeout(…, 200)cleared. It discarded every event for a recently-written path without ever reading what the watcher had observed.Under
usePolling: true, interval: 1000chokidar compares state once per tick, so our own write and an external edit landing between two ticks are delivered as a single event carrying the external content. Dropped on the timer — and it was the only event that edit would ever produce.The card said "measure first". Here is the measurement.
The dispatch left two things open. Both are now answered, and the first answer contradicts the obvious fix.
(a) or (b): does the pre-check carry real load?
(a) — it does not. Delete it. The content-keyed suppression the filer spotted one step down already covers both faces:
put()→add/changecurrentHead === hashrename;awaitWriteFinishholds the event a furtherstabilityThresholddelete()→unlink!currentHeadnullat delete-resolve and at event delivery (measured)Instrumented
handleFsChangeonorigin/main@69fde55:unlinkevents, 0 hit the pre-check, all 6 correctly suppressed downstream.changeevents → 0 inside the window (delivery lag 519–585 ms).The pre-check was never once observed suppressing a genuine self-write. Every observed firing was a swallowed external edit — so its only distinguishable effect was the bug.
The window is not "derived" any more — it is observed
The filing recorded 0/360 and called it structural. It was structural, but the structure was in the harness, not the defect. Delivery lag is
(interval - (writeTime mod interval)) + awaitWriteFinish, so a fixed pre-edit sleep phase-locks the poll and pins the lag outside the window. Randomising the sleep so the lag samples[0, interval)uniformly, 40 runs on69fde55:Zero exceptions on either side of the boundary. The nearest miss was 202 ms — delivered.
The fix
selfWritesSet with it.delete()retires the head before it unlinks, not after.awaitWriteFinishdebounces onlyadd/change; chokidar emitsunlinkwith no stability delay, so that face has no cushion between the disk mutation and its event. Ordering the index update first makes!currentHeada total suppression rather than a race against the poll callback. A failedunlinkrestores the head before rethrowing, so the error path is byte-for-byte the old semantics.Verification record
Reverse-verified, direction predicted before running. With the old behaviour restored on the same tree:
put(): an external change coalesced into our own write is NOT swallowed→ FAIL (expected [] to have a length of 1— nothing published)delete(): …NOT swallowed→ FAIL (same)…undisturbed, is still not republished→ PASS (the old pre-check suppresses those too)Exactly as predicted. The undisturbed cases passing in both directions is the point: they are what stops "publish everything" from being a passing fix.
End-to-end, same randomised harness, after the fix: 40/40 delivered, 0 swallowed (pre-fix: 33/40, 7 swallowed). Five post-fix runs landed at 76, 142, 143, 147, 193 ms — inside the old window, where every pre-fix swallow happened — and all five delivered the edit. The window was genuinely sampled, not merely avoided.
Gates
packages/metadata-fssuitepackages/metadata(downstream consumer)turbo --filter=...@objectstack/metadata-fs)eslinton changed filescheck:nul-bytesCI owns the full
lint.ymlfarm.Card clauses
1. The
unlinkpath has no content to hash. Answered structurally, not by hashing: for a removal the absence is the content, and!currentHeadis the identity comparison. Measured — head isnullboth whendelete()resolves and when theunlinkevent is delivered. The reordering above is what makes that total rather than probabilistic, and the delete face is pinned with its own external-interference case (an external actor recreating the path inside the window — the symmetric data loss).2. Specs that do not round-trip through JSON. Real, and entirely pre-existing — measured, not assumed:
hashSpeccanonicalises aDateto{}in memory, butJSON.stringifywrites an ISO string, so the disk hash differs.put().version === get().hashtoday with no watcher involved (measured:2978612cvse5abe659).Date-bearing secondput()already publishes a spurious{op: update, actor: 'fs'}and already corrupts the head index — because the event arrives ~560 ms in, roughly 360 ms after the window expired.So this class cannot regress: the pre-check was not holding it. Documented in
handleFsChangerather than silently changed. Worth its own card if anyone wantsput()to reject or normalise non-round-tripping specs.3. Shared consistency coverage.
test/self-write-suppression.test.tsis table-driven over aFACESarray (put,delete), each contributing both directions. A future change that fixes one face and regresses the other fails the table.4. Same-day churn. Anchored on symbol names against
origin/main@69fde55(includesab07b5382/ #7282 and #7150 via #7208), not against the card's line numbers.trackWrittenPath(#7282) andisIgnoredWatchPath(#7150) are untouched — three distinct mechanisms in one file, and only the suppression moves.Why the assertions sit at the handler seam
The end-to-end reproduction is ~17% per iteration and costs ~100 s for 40 runs, because the thing being sampled is a poll phase. Committing it would buy a probabilistic test for a property that can be stated exactly — and this package has been ejected from the merge queue twice on wall-clock watcher assertions (#7208, #7255). So the cases drive
handleFsChangedirectly, entered with exactly the arguments a sub-200 ms delivery produces, and assert the contract: an event whose observed content differs from the index must be published, however recently we wrote that path. The probabilistic harness is the evidence above, run in both directions; the committed pin is deterministic. Same split, and same reasoning, aswatch-write-registration.test.ts.Repricing #7408
Completely unaffected — and already closed. #7408 is a negative-assertion soundness problem in
watch-dot-root.test.tscase 2 (a 4 s quiet window shorter than measured delivery latency, so an empty array proves nothing under load). It was fixed by PR #7472 on 2026-08-11 and the issue is closed. Different file, different direction of assertion, no shared mechanism: this change touches neitherwatch-dot-root.test.tsnor the ignore matcher it guards, and adds no new negative-by-timeout assertion — theundisturbedcases assert an unchanged change log after a synchronously delivered event, so they have no quiet window to be too short.Generated by Claude Code