cache: strict cross-process gate for the warm session-cache refresh transaction#743
Conversation
verifyStillOwner and the heartbeat tick both take the takeover guard; without in-process serialization the fence could observe its own heartbeat's guard file, read it as displacement, and abort a legitimate publication. Fail-safe, but it discarded the parse the lock exists to protect (~6% of verifies at a 1ms heartbeat in the repro). Owner-side guard operations now run through one serializer; cross-process guard semantics are unchanged. Regression test at heartbeatMs 1, mutation- verified against the unserialized code.
# Conflicts: # src/parser.ts
|
Full adversarial review before merge: spec-fit against the #645 revised design confirmed, all four outcomes exercised, and every concurrency attack survived without corruption or lost updates: SIGKILL mid-transaction, SIGSTOP past staleness with a resumed zombie fenced out of publishing over its successor, racing warm refreshes with exactly one parser, and a real 30s timeout serving the prior snapshot with provably zero cache writes. Both mutations (neutered heartbeat, disabled fence) are caught by tests. Real-data drive: two concurrent runs on a copy of a 109MB cache produced byte-identical totals. Two amendments pushed: (1) the publication fence could race the owner's OWN heartbeat for the takeover guard and abort a legitimate save (reproduced 196/3000 at a 1ms heartbeat; fail-safe but it discards the parse this lock exists to protect) — owner-side guard operations now run through an in-process serializer, with a regression test at heartbeatMs 1 that fails against the unserialized code; (2) merged current main, resolving the readOnly gate against the incremental-append reconcile so readOnly never parses changed files while the append optimization is preserved, verified by both suites. The missing staleness marker on readOnly serves is accepted as bounded and self-healing for this release and tracked in the freshness-marker issue. Windows retry paths reviewed statically only; unverified on Windows. |
Draft on purpose: per the commitment in #645, this stays a draft until you give the go-ahead — it's here so the code is reviewable whenever bandwidth exists, not to jump the queue.
Implements the revised design (third comment on #645): first slice, warm session-cache transaction only.
What changed
hydrating.lockis untouched — still cold-only, advisory, fail-open.src/cache-refresh-lock.ts: a strict cross-process gate (session-refresh.lock) around the warm read→reconcile→parse→save transaction. Four explicit outcomes:acquired,completed-by-other(reload the now-fresh cache),timed-out/unavailable(serve the prior complete snapshot read-only — a correctness lock never fails open into mutation). Exclusive-create + random ownership token, 10s heartbeat, 90s staleness with a takeover-guard sidecar that never steals a heartbeating owner, in-process single-flight ahead of the filesystem lock, monotonic-clock wait deadlines, bounded Windows EPERM/EBUSY retries.saveCachere-verifies ownership under the takeover guard immediately before the payload rename and aborts publication if displaced — a suspended/clock-jumped owner can no longer overwrite its successor's work.parseAllSessionsso timeout/stale serving performs no cache mutation at all.How tested
tests/cache-refresh-lock-process.test.tsis a real child-process lost-update regression — two warm processes parse disjoint changed files; with the lock wiring stashed it fails on stock code (later save drops the other's update), with the wiring it passes. Verified both directions locally.distbuilt (theapp/electron/cli.test.tsfresh-clone failures are app/electron/cli.test.ts fails on a fresh clone: dev-resolution tests require a built dist/cli.js #681, pre-existing).Follow-ups per the design: daily-cache cross-process transaction (lock order
daily → session), then #703's incremental parse runs inside this gate (draft PR opening alongside, stacked on this branch).