fix(core): fence lock renewal and release by generation - #93
Conversation
There was a problem hiding this comment.
All reported issues were addressed across 2 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
2754324 to
f425bd4
Compare
|
Updated to Cubic found two real bugs after this PR had already passed independent cross-family review, and one of them was a liveness regression this branch introduced — worth stating plainly rather than burying in a thread.
The fix inverts the default rather than patching each path: continuation now happens unless we deliberately decided to stop. All ten exits of the renewal attempt were enumerated and classified — four stop, six continue — because the bug existed precisely because one exit silently skipped the continuation. Also fixed: Declined, with reasoning in-thread: an "atomic generation-conditional mutation" for the residual pre-write window. That primitive does not exist on a plain filesystem — it is the constraint this module is built around. The window is now bounded and documented in-source instead: two winners require both a Gate: 1072 pass / 0 fail (1064 on |
|
Verified the bug and the direction; two things to resolve before this can merge. The gap is real. 1. The new stress test times out (blocking)
It is a timeout, not an invariant violation — 2238 of 3000 assertions passed before the cutoff, all single-winner. Raising the limit to 60s passes with all 3000: So the fix holds; the test is simply sized past bun's default 5s. 12.9s of wall clock for one test is also worth a second look on its own — this repo already had a 3,000-round lock stress test that had to be cut to 512 rounds because it destabilized the release gate on a loaded machine. I would rather not reintroduce that shape at 2.5x the default timeout. Either drop the round count until it fits comfortably inside the default, or keep the count and state the raised timeout explicitly with a comment saying why the volume is load-bearing. 2. The open cubic P1 needs an answer, not a dismissal
That is the same class as the bug being fixed, one level in: renewal re-checks ownership and then writes, and the window between those two steps is exactly what a generation fence has to close. If the generation is compared and then written non-atomically, the fence moves the race rather than removing it. Please either show the interleaving is impossible on this implementation or close it — it is the highest-severity finding on any PR in this batch, and this is the one file where "probably fine" is not good enough. Everything else is green: 1071 pass with only that timeout failing, typecheck clean. Worth noting for calibration: this batch's findings have again been valid but severity-ordered oddly. Read them as claims to test, not as a queue to burn down — that has been the pattern across #87 and now here. |
Eviction was already fenced, but renewal and release both read the owner record, awaited, then acted on the lock by pathname. A holder stalled past the expiry could overwrite a successor's record or delete a successor's live lock, admitting two concurrent holders — the outcome the eviction fencing exists to rule out. Fence both paths by owner generation and serialize release against an in-flight renewal, so a stale operation can only affect its own record.
f425bd4 to
18798e1
Compare
|
Both blockers addressed — 1. Stress testCut to 512 rounds — 118ms, well inside the default. Thank you for the history; we did not know about the earlier 3,000-round test that had to be cut for the same reason, and we reintroduced the identical shape. The round count was inherited from a rule written when high-round stress was the only instrument available, and it false-greened on roughly 1-in-100 races. This branch now has deterministic seam-driven fault injection that forces each interleaving exactly, so those tests carry the proof and the stress test is an ordinary-contention smoke check. 512 rounds × 2 contenders still does 1024 acquires; a zero-or-two-winner regression fails in the first round. 2. The cubic P1 — closed, not boundedYou were right to reject the documented-residual answer, and the reason is worse than "we should have tried harder": this module already contained the pattern that closes it, and we did not apply it. The eviction path re-checks marker ownership after its claiming act and undoes it when the marker was stolen, turning a would-be two-winner outcome into zero winners. Renewal was not doing the same. It does now. After Every ordering around write / marker theft / successor write / relinquish:
No interleaving reaches two winners. The last row needs a ≥5s stall and persistent filesystem I/O failure, and even then leaves a single record. Also worth flagging, since it is a behavioural change
On the review that got us hereOur own cross-family reviewer approved this change and then, when asked specifically to attack the new code, found that the ownership condition inside the relinquish — the single line preventing us from deleting a healthy successor's lock — was not pinned by any test. It removed the condition and nothing reddened. Worst case without it is still zero winners, so it was not a correctness break, but it was exactly the kind of unguarded invariant that gets tidied away later by someone who cannot see why it is there. It is now pinned by a seam-driven test that pauses between the read and the delete, writes a successor's record, and asserts the lock survives; deleting the condition reddens it with Your calibration note matches ours, for what it is worth. These came from an automated sweep of 44 raw findings, and we filed only the three we had verified by hand — the aggregate severity ordering was not trustworthy (one HIGH required an operator to configure fallback on a success status, i.e. misconfiguration rather than a defect). Claims to test, not a queue to burn down. |
|
Verified at Stress test512 rounds, 118ms, comfortably inside the default. Your reasoning for why the count could drop is better than the fix itself: the high-round shape existed because stress was the only instrument, and it false-greened on roughly 1-in-100 races. Deterministic seam-driven injection carries the proof now, so the stress test can go back to being a smoke check. That is the right direction — a test that only catches a race 99 times out of 100 is a test that reports green while the bug ships. The P1Closed properly. Reusing the eviction path's own re-check-after-acting pattern is the answer I was hoping for rather than a documented residual, and your interleaving table covers the orderings I would have constructed. I did not take the pinning claim on trust. Both guards mutation-tested individually:
So the single line your reviewer found unpinned is now pinned, and the branch that reaches it is pinned separately. 1074 pass / 0 fail, The unpinned-line story is the most valuable thing in this thread. Worst case without that condition was still zero winners, so no correctness break and no test would ever have caught it — but it is exactly the line a future reader deletes as redundant, and the interleaving it prevents would then be silent and rare. An invariant that is load-bearing only in an interleaving nobody reproduces is precisely what needs a test pinning it, and "the failure mode is benign" is the argument that gets such lines removed. Asking a reviewer to attack new code specifically, after it had already been approved, is what surfaced it. The fail-closed timing change on Merging. |
|
Thank you for mutation-testing both guards rather than taking the pinning claim on trust — and for testing the branch separately from the line. That distinction matters and we did not make it explicitly: the post-write re-check reddening two tests while the ownership condition reddens one is the evidence that the guard and the path to the guard are independently pinned. We had verified the line; you verified the route to it. On the unpinned line, one correction to the credit: our reviewer had already approved this change before finding it. It surfaced only because the round after approval asked it to attack the code the fix itself introduced, on the grounds that a relinquish is new destructive machinery — code that deletes a lock where previously nothing happened. That reframing is what found it, not the reviewer being thorough on a second pass. The general shape is worth stating plainly, since this thread is where it is best evidenced: our reviewers verify that a change is correct; they are much weaker at catching what a change makes worse elsewhere. This branch produced two instances. The fencing fix closed a real race and introduced a liveness regression — renewal died permanently on transient marker contention, two owners from a transient condition, in the file written to prevent exactly that. The relinquish then closed the P1 and introduced a new destructive path whose only safeguard was unpinned. Both were "correct fix, new failure mode", and neither was visible to the review that approved the fix on its own terms. That is also why your "benign failure mode is the argument that gets such lines removed" framing landed. A test suite records what someone thought to assert; a line whose absence still yields an acceptable outcome is invisible to it forever, and the next reader has strictly less context than the one who wrote it. For the record on the sweep that started this: 44 raw findings, 3 filed after hand-verification, 3 merged. The other ~38 stay unfiled until someone tests them individually — your read of the severity ordering matched ours independently, which is the main reason we are treating the remainder as claims rather than a backlog. |
Fixes #90.
Eviction was already fenced. Renewal and release were its unswept twins: both read the owner record, awaited, then acted on the lock by pathname. Branched from
9bf8f4c, independent of #87.The three interleavings
writeOwner()lands unconditionally over P2's record. Two processes now believe they hold the lock.rmremoves P2's live lock and admits a third holder.release()can land its write afterwards, recreating an orphan lock nobody owns that blocks contenders until TTL.All three violate the invariant the eviction fencing exists to hold: the filesystem offers atomic claim but no atomic conditional-delete, so the acceptable worst case is zero winners, never two.
Approach
Both paths now go through the existing eviction-marker primitive as a generation fence, and release awaits any in-flight renewal.
ownerIdremains the generation and the on-disk format is unchanged, so older builds and other processes reading the same file are unaffected.The diff is large relative to the fix because the marker helpers were hoisted for reuse. Eviction semantics are untouched — the reviewer verified the four eviction fences survive 1-for-1 (old 236/240/243/249 → new 274/278/281/287, same checks, same order) and that
src/tests/review-fixes.test.tsis unmodified.Honest severity
Each interleaving needs the holder to stall past the 120s TTL between the check and the act — realistically severe event-loop starvation. Worth fixing because it breaks a stated invariant, not because it is likely to fire.
A testability seam, called out deliberately
Forcing the post-check/pre-write race required a new
onStep('renewal-write-fenced')seam in production code. It is guarded (if (options.onStep)), so nothing is awaited when unhooked.Review flagged that as a footgun rather than a bug: anyone later installing a hook there for telemetry would reopen the two-winner race. So the fence is now re-checked after the seam, immediately before the write — the invariant is structural rather than dependent on nobody hooking that point. A regression test pins that re-check specifically.
The extra
ownsEvictionMarker()call is one more filesystem read per renewal (interval ≥1s). It is fail-closed: a transient read error aborts the renewal, the lock expires naturally, and the result is zero-winner-then-one-winner — the degradation this design already accepts.Tests
1069 pass / 0 fail(1064 on9bf8f4c),tscclean.An earlier revision's proof was invalid and was redone. It red-first'd against
9bf8f4c, where the tests hung at 1s because that commit lacks the seams — a missing-API red, not a behavioural one. The proof is now fault injection on the current fencing code, with each RED a concrete assertion about observed state:ownerIdownerIdoverwrittenPlus 3,000 plain contended rounds electing exactly one owner. That stress seeds a stale lock only, so it exercises happy-path eviction under contention rather than the crashed-evictor branch — that branch stays covered by the existing unchanged tests.
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Summary by cubic
Fences refresh-lock renewal and release by generation under the existing eviction marker and serializes release behind an in‑flight renewal. Previously both validated ownership then acted by pathname; a stalled holder could overwrite a successor or delete a live lock.
withEvictionMarker,ownsEvictionMarker,recoverStaleEvictionMarker); eviction semantics unchanged.Written for commit 18798e1. Summary will update on new commits.