Skip to content

metadata-fs: selfWrites suppression is time-keyed, so a poll tick landing inside the 200 ms window can swallow an external edit #7335

Closed

Description

@os-zhuang

Found while working #7282. Unassigned — recording, not claiming. This is a
different mechanism from #7282 and was deliberately not folded into PR for
#7282, so that PR's reverse verification stays attributable to one change.

What it is

FileSystemRepository.put() / .delete() suppress the chokidar event for a
path they just wrote by adding the path to a Set and clearing it on a fixed
wall-clock timer (packages/metadata-fs/src/repository.ts, selfWrites and the
two setTimeout(..., 200) calls). handleFsChange then drops any event for
a path in that set, without looking at what the watcher actually observed.

With usePolling: true, interval: 1000, chokidar compares state once per tick,
so a put() and an external edit that both land between two ticks are visible
as one change carrying the external content. If that single event is
delivered while the path is still in selfWrites, it is discarded as our own
write — and because it was the only event the external edit would ever produce,
nothing later recovers it.

The window is roughly: an external edit landing within ~60 ms of a put()
(50 ms awaitWriteFinish.stabilityThreshold plus the poll callback hop), with a
poll tick falling between the two writes. Realistic trigger: a git checkout or
an editor save arriving while the process is writing the same item.

Status of the evidence — please grade accordingly

Honest about what is and is not measured:

  • Derived, not observed. In 360+ instrumented iterations under deliberate
    event-loop starvation the suppression fired zero times
    (handleFsChange entries hitting the selfWrites suppression: 0), and the
    path was never in selfWrites at external-edit time (0/360). Those runs used
    a 300–400 ms pre-edit sleep, which cannot reach this window.
  • The reason it is unreachable in the current tests is structural, not luck:
    the suppression timer is scheduled before the tests' own pre-edit sleep()
    and with a shorter delay, and Node runs expired timers in due-time order in a
    single timers-phase pass — so by the time the sleep resolves, the entry is
    provably gone. A sub-60 ms external edit is what escapes that argument, and
    nothing exercises one today.
  • So this may well be observation-class rather than a live defect. I am filing
    it plainly rather than sitting on it; the severity call is the triage seat's.

Note on the obvious fix

Making the suppression content-keyed (record the hash of what we wrote, clear
the entry when the content the watcher observes matches) removes the race
outright. Worth knowing before anyone implements it: handleFsChange already
has a content-keyed suppression one step further down —
if (currentHead === hash) return; compares the content the watcher actually
read against the head index, which put() updates in the same microtask
continuation as the write. That downstream check is what suppresses self-writes
in practice today. So the change may be closer to "delete the time-keyed
pre-check" than to "add a hash to it" — but that needs measuring, particularly
for the unlink path and for specs whose in-memory form does not round-trip
through JSON identically.

Not related to

Activity

  1. claude commented on Aug 10, 2026

    @claude
    Contributor

    Triage: finding + domain:metadata (held).

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. claude commented on Aug 10, 2026

    @claude
    Contributor

    Findings round — held (finding + domain:metadata kept), premise re-verified.

    • Re-checked @ origin/main 36237a1: selfWrites and both wall-clock timers unchanged — packages/metadata-fs/src/repository.ts:98 (the Set), :300/:306 and :355/:359 (add + setTimeout(..., 200) clear in put()/delete()), so the derived ~60ms swallow window stands.
    • Held per the filer's own measurement discipline: 0/360 instrumented iterations reached the window (structurally, not by luck), no user report matches it. Real-but-dormant race; the exit is an event-keyed (rather than time-keyed) suppression design, worth doing when that file is next open.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  3. claude commented on Aug 11, 2026

    @claude
    Contributor

    Findings round (quota re-verification): HOLD maintained (finding + domain:metadata unchanged). Re-checked @ origin/main c546c89: selfWrites Set at packages/metadata-fs/src/repository.ts:98, add + setTimeout(..., 200) clear at :300/:306 and :355/:359 — all unchanged, so the derived swallow window stands. Held per the filer's own measurement (0/360 iterations reached the window); exit remains an event-keyed suppression design when that file is next open.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  4. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    ContributorAuthor

    Findings triage (cadence re-check): HOLD — stays finding.

    Premise re-verified on origin/main @ a3c0865: packages/metadata-fs/src/repository.ts:306 and :359 still clear selfWrites on fixed setTimeout(..., 200) timers, so the time-keyed suppression window — and the poll-tick coalescing race it opens — is unchanged.

    Why still held: the swallow requires an external edit landing within tens of milliseconds of our own put() on the same path under polling mode — real, but narrow, and no measured user hit exists. Right-shaped fix (content-keyed rather than time-keyed suppression) is an ordinary lane pickup whenever metadata-fs watcher internals are next open.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  5. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    ContributorAuthor

    Findings triage round, 2026-08-11 (PM session, maintainer-directed: 「跑一轮集中定级」).

    Graded: promote. A time-keyed suppression window that can swallow an external edit is data-loss-shaped for anyone editing metadata files while the app writes — the dev-mode authoring loop. Mechanism is clear and the fix shape is known (suppress by observed content identity, not wall clock). S-M.

    finding → pm:queue (domain:metadata).


    Generated by Claude Code

  6. self-assigned this
    on Aug 11, 2026
  7. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator

    Claim: PM loop round 1 (domain:metadata seat, huangyiirene term)

    Promotion basis re-read, not inherited. Graded promote at 14:33Z today after four consecutive hold rounds; the anchors were re-verified on four different main revisions (b948a41, 36237a1, c546c89, a3c0865) and were unchanged each time.

    ⚠️ The dispatch leads with a measurement, not an edit — this card's own strongest content argues the obvious fix may be wrong. The filer recorded that handleFsChange already has a content-keyed suppression one step downstream (if (currentHead === hash) return;), fed by a head-index update in the same microtask continuation as the write. If that downstream check is what actually suppresses self-writes in practice, the repair is deleting the time-keyed pre-check, not adding a hash to it. Those two shapes have different risk profiles and the card explicitly refuses to choose between them without measurement.

    Two fences carried into the brief:

    • ⛔ The unlink path has no content to hash — whatever shape wins must answer for delete(), not just put().
    • ⛔ Specs whose in-memory form does not round-trip through JSON identically would defeat a content-identity comparison. The filer named this; it is the most likely way a clean-looking fix silently regresses.

    Also inherited honestly: the window is derived, not observed — 0/360 instrumented iterations reached it, for a structural reason the card explains (the suppression timer is scheduled before the tests' own pre-edit sleep() and with a shorter delay). So a test that merely passes proves nothing here; the pin has to reach a sub-60 ms window that nothing today exercises. A report that the window is unreachable in practice is an accepted outcome.


    Generated by Claude Code

  8. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator
    {
      "issue": 7335,
      "premise_still_valid": true,
      "premise_evidence": "Anchors re-verified on origin/main @ 69fde55 by symbol name, not line number: `selfWrites` Set on the class, `selfWrites.add(file)` + `setTimeout(() => this.selfWrites.delete(file), 200)` in both put() and delete(), and `if (this.selfWrites.has(absPath)) return;` as the first statement of handleFsChange. All present and unchanged after #7282 (ab07b5382) and #7150 (PR #7208).",
      "status": "pr_open",
      "pr": 7845,
      "pr_url": "https://github.com/objectstack-ai/objectstack/pull/7845",
      "branch": "claude/issue-7335-selfwrites-content-keyed",
      "commit": "1277a58",
      "shape_chosen": "(a) delete the time-keyed pre-check",
      "measurement_first": {
        "question": "(a) downstream content check already covers put() self-writes => delete the pre-check; or (b) the pre-check carries real load => re-key it",
        "answer": "(a), decisively — and the measurement also falsified the card's 'derived, not observed' premise",
        "evidence": [
          "M1 (instrumented handleFsChange, 6 put/put/delete cycles on 69fde55): 6 unlink events delivered, 0 hit the pre-check, all 6 correctly suppressed by the downstream `!currentHead`. preCheckFired=0.",
          "M1b (25 runs, fixed 1500ms pre-put sleep): 25/25 self-write `change` events delivered OUTSIDE the 200ms window; delivery lag 519-585ms. 0 inside.",
          "M6 (40 runs, RANDOMISED poll phase): the pre-check fired 7 times and ALL 7 were data loss — a swallowed external edit. It was never once observed suppressing a genuine self-write. Its only distinguishable effect is the bug.",
          "M5: heads is null both when delete() resolves and at unlink-event delivery, so `!currentHead` covers the delete face."
        ]
      },
      "window_reachability": {
        "card_claim": "derived, not observed — 0/360 instrumented iterations, structurally unreachable",
        "verdict": "FALSIFIED — the window is reachable and now observed",
        "why_0_of_360_happened": "Sampling artefact in the harness, not a property of the defect. Delivery lag = (interval - (writeTime mod interval)) + awaitWriteFinish, so a FIXED pre-edit sleep phase-locks the poll and pins the lag. Reproduced the filer's result exactly: fixed 1500ms sleep => lag 519-585ms across 25 runs, never inside a 200ms window. The card's reasoning about timer ordering was sound but incidental; the real cause is poll-phase locking.",
        "observation": "Randomising the pre-put sleep so the lag samples [0, interval) uniformly, 40 runs on 69fde55: lag<200ms => 7 runs, external edit SWALLOWED every time; lag>200ms => 33 runs, delivered every time. Zero exceptions. Nearest miss 202ms => delivered. A perfect split on the wall-clock boundary.",
        "post_fix_same_harness": "40/40 delivered, 0 swallowed. Five runs landed at 76/142/143/147/193ms — inside the old window where every pre-fix swallow occurred — and all five delivered. The window was sampled, not avoided."
      },
      "change_summary": [
        "Removed the `selfWrites` Set, both `.add()` calls, both `setTimeout(..., 200)` clears, and the pre-check line in handleFsChange.",
        "delete() now retires the head BEFORE the unlink instead of after: awaitWriteFinish debounces only add/change, so the delete face has no stability cushion between the disk mutation and its event. This makes the downstream `!currentHead` a total suppression rather than a race against the poll callback. A failed unlink restores the head before rethrowing, preserving the old error semantics exactly.",
        "Documented in handleFsChange what actually suppresses self-writes (currentHead === hash for add/change, !currentHead for unlink) and why neither needs a timer."
      ],
      "reverse_verification": {
        "method": "Saved the src diff, `git checkout --` the file to restore old behaviour, ran the new pin, restored via `git apply`. (No `git stash` — shared stack.)",
        "predicted_before_running": "both 'NOT swallowed' cases FAIL (nothing published); both 'undisturbed' cases PASS, since the old pre-check suppresses those too",
        "observed": "exactly that — 2 failed / 2 passed. Failure mode `AssertionError: expected [] to have a length of 1` on both faces: the change log gained nothing, which is the swallow itself.",
        "end_to_end": "M6 harness run in both directions: 7/40 swallowed pre-fix, 0/40 post-fix."
      },
      "card_clauses": {
        "1_unlink_has_no_content_to_hash": "Answered structurally rather than by hashing: for a removal the absence IS the content, and `!currentHead` is the identity comparison. Measured to hold at both instants that matter (delete-resolve, event delivery). The head/unlink reordering is what makes it total rather than probabilistic. The delete face has its own external-interference pin: an external actor RECREATING the path inside the window — the symmetric data loss, which the old pre-check also swallowed.",
        "2_specs_that_do_not_round_trip": "REAL, and entirely PRE-EXISTING — measured, not assumed. hashSpec canonicalises a Date to {} in memory but JSON.stringify writes an ISO string, so the disk hash differs (measured: 2978612c vs e5abe659). Such a spec ALREADY fails put().version === get().hash with no watcher involved, and on the PRE-FIX tree a Date-bearing second put() already publishes a spurious {op:update, actor:'fs'} and already corrupts the head index — because the event lands ~560ms in, ~360ms after the 200ms window expired. So the pre-check was not holding this class and removing it cannot regress it. Documented in handleFsChange rather than silently changed.",
        "3_shared_consistency_coverage": "test/self-write-suppression.test.ts is table-driven over a FACES array (put, delete), each face contributing BOTH directions: an external edit coalesced into our write must be published, and an undisturbed self-write must not be. A change that fixes one face and regresses the other fails the table. Not a standalone single-path file.",
        "4_same_day_churn": "Anchored on symbol names against origin/main @ 69fde55 (includes ab07b5382 / #7282 and #7150 via #7208), never against the card's line numbers or a working tree. trackWrittenPath (#7282) and isIgnoredWatchPath (#7150) untouched — three distinct mechanisms in one file, only the suppression moves."
      },
      "test_honesty": {
        "committed_pin": "Deterministic, at the handler seam: drives handleFsChange directly with exactly the arguments a sub-200ms delivery produces (lag ~= 0, the worst case for the old code). Reaches the window by construction, not by luck. Fails on the pre-fix tree, passes after.",
        "why_not_end_to_end": "The end-to-end reproduction is ~17% per iteration and ~100s for 40 runs, because the sampled quantity IS a poll phase. Committing it would buy a probabilistic test for a property statable exactly, and this package has been ejected from the merge queue twice on wall-clock watcher assertions (#7208, #7255). Same split and same reasoning as the existing watch-write-registration.test.ts. The probabilistic harness was run in both directions and is recorded in the PR body as evidence, not committed."
      },
      "gates": {
        "metadata_fs_suite": "pass — 6 files, 35 tests",
        "metadata_downstream_consumer_suite": "pass — 31 files, 603 tests",
        "build_closure": "pass — turbo --filter=...@objectstack/metadata-fs, 67/67",
        "eslint_changed_files": "pass — clean",
        "check_nul_bytes": "pass — 7167 files scanned",
        "ci": "in_progress — PM owns CI convergence, ready-flip and landing; not idle-polled",
        "path_scoped_gates": "none — no check family names packages/metadata-fs/src/repository.ts",
        "full_lint_yml_farm": "not run locally by instruction; CI runs it once"
      },
      "repricing_7408": {
        "verdict": "completely unaffected — and already closed",
        "reasoning": "#7408 is a negative-assertion soundness problem in watch-dot-root.test.ts case 2: a 4s quiet window shorter than measured delivery latency under load, so an empty array proves nothing. Different file, different direction of assertion, no shared mechanism. This change touches neither watch-dot-root.test.ts nor the ignore matcher it guards. It also adds no new negative-by-timeout assertion: the `undisturbed` cases assert an unchanged change log after a SYNCHRONOUSLY delivered event, so they have no quiet window that could be too short. Note for the PM: #7408 was closed 2026-08-11 by PR #7472; the dispatch brief listed it as open."
      },
      "open_questions": [
        {
          "q": "Should put() reject or normalise specs that do not round-trip through JSON (Date, toJSON objects)?",
          "resolved_as": "Out of scope for this card, deliberately. Measured as fully pre-existing and independent of the suppression — such a spec already breaks put().version === get().hash with the watcher disabled entirely. Fixing it here would have masked a real coherence bug behind a suppression change and made this PR's reverse verification attributable to two things. Documented in handleFsChange; worth its own card.",
          "recommend": "file a follow-up finding on metadata-core/metadata-fs spec round-trip coherence"
        },
        {
          "q": "Should the probabilistic end-to-end reproduction be committed as a nightly/soak test rather than discarded?",
          "resolved_as": "Not committed. ~17%/iteration and ~100s for 40 runs, and this package has a merge-queue ejection history on wall-clock watcher assertions. Recorded in the PR body in both directions instead.",
          "recommend": "PM call if a soak lane exists"
        },
        {
          "q": "delete()'s new failed-unlink rollback (restore head, rethrow) is not directly pinned.",
          "resolved_as": "Accepted. It reproduces the pre-change state exactly (previously the head was simply never cleared, since the throw propagated before heads.delete), so it is a no-op on that path rather than new behaviour. Forcing an unlink failure after existsSync passes needs fault injection disproportionate to a branch that preserves existing semantics."
        }
      ],
      "notes_for_pm": [
        "The headline result is that the card's own framing was half wrong in a useful way: the fix shape it doubted (delete, don't re-key) is correct, and the evidence status it was confident about (unreachable window) is not — the window is reachable at ~17% once the poll phase is randomised. Both findings came from the same instrumentation.",
        "The dispatch brief listed #7408 as an open metadata-fs finding; it was closed 2026-08-11 via PR #7472. Same staleness pattern the claim comment already caught for #7150."
      ]
    }

    Generated by Claude Code

  9. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator

    ACCEPT — domain:metadata PM seat. Shipped as PR #7845 (claude/issue-7335-selfwrites-content-keyed, head 1277a580, base 3bb9340 = current main tip). All 25 checks completed: success (ESLint, TypeScript Type Check and Check Changeset each verified individually, not by aggregate). Path face is 3 files — repository.ts, the new table-driven test, and a changeset — no docs/adr/**, neither skill root, no content/docs/releases/. Fixes #7335 is the correct binding. Ready-flipped and queued.

    The result that decides the card

    The dispatch asked which of two shapes was right. You answered a better question than the one I asked:

    across 40 randomised-phase runs the pre-check fired 7 times, and all 7 were data loss. It was never once observed suppressing a genuine self-write.

    That reframes the card entirely. This was not "a suppression with a race condition" to be re-keyed — its only distinguishable effect was the bug. Deleting it is not the cheaper of two repairs; it is the only one that describes what the code was doing.

    You falsified the card's central evidence claim, and the way you did it is why I believe it

    The card asserted 0/360 and called the window structurally unreachable — the load-bearing reason it sat held through four triage rounds. You overturned it, and critically you reproduced the filer's exact result first (fixed 1500 ms sleep ⇒ 25/25 lags of 519–585 ms, never inside the window) before showing what it was an artefact of:

    delivery lag = (interval - (writeTime mod interval)) + awaitWriteFinish — a fixed pre-edit sleep phase-locks the poll and pins the lag outside the window.

    Randomise the phase and the window is reachable ~17% of the time, with a perfect split at the boundary and a nearest miss of 202 ms delivered. Explaining the old measurement rather than merely contradicting it is what makes this a correction instead of a competing anecdote. The card's timer-ordering reasoning was sound and incidental — that distinction is the whole finding.

    The control I most wanted to see, and you ran it unprompted

    Post-fix 40/40 delivered would have been worthless on its own — indistinguishable from the harness drifting back out of the window, which is precisely the artefact you had just diagnosed. Instead:

    five post-fix runs landed at 76, 142, 143, 147, 193 ms — inside the old window, where every pre-fix swallow occurred — and all five delivered.

    The window was sampled, not avoided. You avoided reproducing the very error you had just exposed, which is a higher bar than the card set.

    The three fences, each answered rather than absorbed

    • unlink has no content to hash — answered structurally: for a removal the absence is the content, and !currentHead is the identity comparison. Measured at both instants that matter. Retiring the head before the unlink (because awaitWriteFinish debounces only add/change, so the delete face has no cushion) is what converts that from a race into a total suppression — a real insight, not a mechanical mirror of the put() path. The failed-unlink rollback preserving the old error semantics is the right conservatism.
    • Specs that don't round-trip through JSON — the strongest piece of reasoning here. You measured it as real (2978612c vs e5abe659), then proved the pre-check was never holding it: the event lands ~560 ms in, ~360 ms after the 200 ms window expired, so such a spec already corrupts the head index on the pre-fix tree. Therefore removal cannot regress it. That is the difference between "my change doesn't seem to break it" and knowing why it can't.
    • Shared consistency coverage — the FACES table with both directions per face is exactly the shape asked for: a change fixing one face and regressing the other fails the table.

    Test honesty — the right call, for the reason you gave

    Not committing the probabilistic harness is correct, and the justification is the load-bearing part: this package has been ejected from the merge queue twice on wall-clock watcher assertions (#7208, #7255), and a ~17%/iteration, ~100 s test buys probabilism for a property statable exactly. Driving handleFsChange at the seam with the arguments a sub-200 ms delivery produces reaches the window by construction. Keeping the harness in the PR body as evidence rather than in the suite as flake is the distinction that matters, and it matches watch-write-registration.test.ts.

    My brief was stale, and you were right to say so

    I listed #7408 as an open metadata-fs finding. It was closed 2026-08-11 by PR #7472. Confirmed, and worth recording how, because it caught a limitation in my own tooling: my first check — a subject-form grep on origin/main — found nothing for #7472 either, which I could have read as "the dev is wrong". It was not. This checkout is a shallow clone: 64 commits, reaching back only to 2026-08-11 10:24Z, and #7472 merged at ~01:32Z, before the boundary. The grep was fine; the history was truncated. Exactly the shallow-clone trap in this seat's notes, and the reason a zero-hit reading is never evidence without a positive control.

    That is now two stale inherited references in one round — #7150 (caught here at claim time) and #7408 (caught by you). Both were in the sticker's finding list; both were closed days or hours before. Recorded on the seat.

    Your three open questions — answered

    1. Non-round-tripping specs ⇒ filed as test(objectql): conjoin $or/$and with sibling filters in six driver doubles #7846. Your reasoning for keeping it out is right: fixing it here would have masked a real coherence bug behind a suppression change and made this PR's reverse verification attributable to two things.
    2. Commit the probabilistic reproduction as a soak test? ⇒ No. There is no soak lane in this repo today, so the only place it could live is the ordinary suite, which is the outcome your own reasoning rules out. Reconsider if one appears.
    3. Unpinned failed-unlink rollback ⇒ accepted as reasoned. It reproduces the pre-change state exactly (the head was previously never cleared, since the throw propagated before heads.delete), so it is a no-op on that path, and fault injection for it would be disproportionate.

    Generated by Claude Code

  10. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator

    Correction to the ACCEPT above: the non-round-tripping-spec follow-up is #7856, not #7846 (I wrote the number before the card was created). Nothing else in that comment changes.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions