Repository navigation
Conversation
A doctor write made outside the shared SerializedDecision could be undone by a concurrent commit (the warm pool booting a device back) that read the registry before it and landed after it: doctor --fix marked a vanished device deleted and the warm pool's transition wrote it back as ready. Closes #407
…g its body unchanged
…ision section clearRecovery and markRecoveryAttempt wrote the registry outside the shared SerializedDecision, so a sectioned writer's commit in flight at the same time could undo them, or be undone by them, the same lost write as #407. HeldWritesFilesystem moves to core's testing.ts so both regression tests use it.
…rop comments the rule disallows (#407) 1 finding class: untested wrap, stale ARCHITECTURE claim, what-comments
…e shared decision section; drop comments the rule disallows (#407) 2 tests added; each fails with a pass-through section. HeldWritesFilesystem gets its own tests.
…k to the registry; pin the initiator (#407) Kills the 8 mutants the push listed in that fix: the guard repeated the registry's own.
Contributor
Author
Review notesNot blocking, not verified. Each is one reviewer's claim.
Written by an agent. |
V3RON
marked this pull request as ready for review
October 11, 2026 13:46
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.
Root cause
Registry writes can be lost when two of them overlap, and Doctor made its writes outside the shared
SerializedDecision. So did leasing'sLeaseHealthMonitor, which has the same race on leased devices.Registrymutation builds its newdevicesarray fromthis.#devicesbefore it awaits the write.#commitonly setsthis.#devices = devicesafterwriteFileAtomicresolves (src/core/registry.ts:918-947;markDeviceMissingbuilds its array atregistry.ts:608). Suppose writer W reads the registry while writer M's write is in flight, and W's write finishes after M's. Then W sets#devicesto its stale array and undoes M's change. On this architecture that is safe only because callers make each read-decide-commit inside the oneSerializedDecision(COMPONENTS.md: Registry "does not decide a transition: callers do, inside a decision section").ManagedDeviceLifecycle#commit/#commitWithoutRelease, which run insidedecisions.run(src/core/managed-device-lifecycle.ts:306-333).markDeviceMissing(doctor.ts:748on main), theforeign-state-changetransition plusclearForeignStateDetected(doctor.ts:717,805), andmarkForeignStateDetected/markForeignProvenanceDetected(doctor.ts:451-457).doctor --fixfirst corrects deviceA:ready -> shutdown(doctor.ts:805). That emitsdevice.shutdown. The warm pool reacts, callsmakeReadyon deviceA (the call the test already expects), and commitsshutdown -> readyfor deviceA in a decision section. Meanwhile Doctor moves on and runsmarkDeviceMissing(deviceC).transitionDevice(A, "ready")reads#deviceswhilemarkDeviceMissing(C)is still writing, so it reads C asready. M's write finishes and sets C todeleted. Then W's write finishes and sets#devicesback to its array, where A isreadyand C is stillready.list --devicesreads C asready, and the test fails withvanished deviceC should be marked deleted, not recreated: expected 'ready' to be 'deleted'(e2e/doctor-drift.test.ts:217). The other order, where M lands last, undoes A'sreadyinstead. The test accepts that (["shutdown", "ready"]), which is why only C ever showed up as the failure.LeaseHealthMonitorwroteclearRecovery(when a leased device reads running again,lease-health-monitor.ts:240on main, and after a successful reboot,:335) andmarkRecoveryAttempt(:283) outside any decision section. A sectioned commit for another device that overlaps one of these writes (an idle shutdown, a warm-pool boot, Doctor) can undo it, or be undone by it, in the same way: a recovery attempt that is not counted, recovery markers that come back after recovery, or the other device's transition lost.Fix
Doctor takes the shared
decisions(wired increateCore, like every other component that writes the registry) and makes each of its own registry read-decide-commit sections insidedecisions.run:registry-device-missingfix and the legacy fix's final mark-missing (the legacydestroyLegacydriver call stays outside, because a decision section holds no driver work);foreign-state-changefix (the transition and the flag clear, together);markForeignStateDetected/markForeignProvenanceDetected, which run on every reconcile,--fixor not.leaseExpirer.expireandquarantine.enterFromStalledTransitionalready take the decision section themselves, so they are not wrapped. Wrapping them would nestrunand deadlock. Startup anddoctor.runboth callreconcileoutside any decision section, so nothing nests.LeaseHealthMonitorgets the same treatment. It takesdecisions(leasing already gets core's shared gate increateLeasing, where the acquisition, release, request-book and startup components take it too), and runsclearRecovery(both call sites) andmarkRecoveryAttemptinsidedecisions.run. Each of those registry methods re-reads#devicesitself, so the read, the decision and the commit are all inside the section. Nothing nests: the tick and the detached recovery are started by a timer, never from inside a section.recoverLeasedandreleaseDeviceLosttake the section themselves, and they are called outside these wraps.Why this removes the race by construction:
SerializedDecision.runstarts a section only after the previous one has finished. Doctor's read of the snapshot, its write, and the point where#devicesis set now all happen inside one section. The warm pool's commit happens inside another section. So one of them always reads the registry after the other's write has landed, and neither can set#devicesfrom a stale array. Each fix also re-reads the snapshot inside its section, so it decides on current state.Why this is the simplest fix: it is the rule the code already has (callers decide inside a decision section), applied to the two writers that skipped it. Making
Registryitself queue writes would change the commit and emit contract for every caller. The rest of the diff is mechanical:decisionsadded to eachnew Doctor(...)in the tests and to the monitor's test harness, because the option is required, as it is on every other component that takes it.HeldWritesFilesystemmoved fromdoctor.test.tstosrc/core/testing.tsso both regression tests use it.Evidence
Permanent regression test (no production test hooks):
src/core/doctor.test.ts, theit.each"… keeps its write, and another device's transition committed in the decision section while that write is in flight keeps its own". AHeldWritesFilesystemholds every state write until the test releases it. The test starts the reconcile and waits until Doctor's first write is held. Then it starts what the warm pool does,decisions.run(() => registry.transitionDevice(warm, "ready")), and releases the held writes one at a time, oldest first, each after all pending microtasks have run. There are no timers and no randomness: the order is fixed. There is one case per Doctor write: mark missing, the foreign-state fix, the foreign-state flag, and the provenance flag.On the unfixed code (
git show origin/main:src/core/doctor.ts > src/core/doctor.ts; for the e2e build also restoresrc/core/create-core.tsfrom main, or the build fails with TS2353 ondecisions):The first case is the issue's failure exactly: a device
--fixmarkeddeletedreads back asready.With the fix:
Tests 4 passed. The whole file:Tests 83 passed (83).The e2e itself, forced into the interleaving (temporary patch, not committed): this patch makes the warm pool's commit of deviceA read the registry before Doctor marks C missing and land after it, and makes the CLI return after both:
The waits go around the call in
#applySafeFix, outside the decision section. On the unfixed code that line isawait this.#fixMissingDevice(finding.deviceId);, and the same two waits go around it. Do not put them inside#fixMissingDevice: with the fix it runs insidedecisions.runat both call sites (doctor.ts:712and:793), so a wait there holds the section, and the trace below would not happen. The__simlockDelayReadylines and the trace are the same in both runs. Both runs below were made with exactly this patch (and a trace line intransitionDeviceandmarkDeviceMissing).Unfixed + patch:
A trace from the patched registry shows the order:
A ready->shutdownat t,A shutdown->readyread at t+2 ms (it lands at t+1502),C marked missingat t+1004.Fixed + the same patch:
Tests 3 passed (3). The trace showsA shutdown->readyread at t+7 andC marked missingat t+1513 instead. Doctor's section waited for the warm pool's section to finish, then read the registry with A alreadyreadyand marked Cdeletedon top of it.Sanity check, not proof: the unpatched e2e file passes after the fix.
The health monitor (permanent regression test):
src/leasing/lease-health-monitor.test.ts, theit.each"… keeps its write, and another device's transition committed in the decision section while that write is in flight keeps its own", one case per monitor write: recording a recovery attempt, clearing recovery markers on a device observed running, and clearing them after a successful reboot. Same method as Doctor's test: the setup brings the monitor to just before one write (fake clock ticks;failOn/hangMakeReadyon theFakeDriverto stop at the right point), theHeldWritesFilesystemholds that write, the test commitsready -> shutdownfor an idle device indecisions.runwhile it is held, and releases the held writes oldest first. It then checks the leased device'srecoveryAttemptsand that the idle device isshutdown.On the unfixed code (
git stash push src/leasing/lease-health-monitor.ts src/leasing/create-leasing.ts):The idle device's
shutdowncommit, which read the registry while the monitor's write was in flight, landed last and set#devicesback to its array: the attempt is gone, or the cleared markers are back. With the fix: the whole file,Tests 18 passed (18).Mutation testing: the hook counted
#fixMissingDevice's body as changed (the branch's commit pair re-indented it and put it back), and listed its mutants. Fixed in 516e775 and 48b8f13: thestate !== "deleted"guard repeatedmarkDeviceMissing's own early return, so it is gone; the"doctor"initiator is asserted on thedevice.deletedevent; the lease guard's per-device match is pinned by "--fix marks a missing device deleted although another device holds a lease". The last push still listssrc/core/doctor.tslease.deviceId === deviceId->truein that guard, with an emptycoveredByin the report, so it looks like a reused earlier result. Applied by hand, the new test fails on it. The round 3 code review confirmed this: with the mutant applied, that test fails. The wiring ofdecisionsintoDoctorandLeaseHealthMonitoris pinned by one test each increate-core.test.tsandcreate-leasing.test.ts, each of which fails with a pass-through section.The last push (4b02565) ran the hook on every changed range: 48 mutants, 6 alive.
src/core/doctor.ts:717ConditionalExpression: true: the reused result above (26 of 41 results were reused); the lease-guard test kills it.src/core/testing.ts:70-72and:84(five mutants inHeldWritesFilesystem.heldandreleaseUntil): test-only helper code. The loop guards only decide how longheld()polls before the test's ownreleaseUntildrains the held writes in order, and the two strings only appear in the failure of a test that has already hung. A mutant there can make a test start releasing earlier or hang, never pass an assertion it would fail. The helper's own tests were dropped (round 3 found two of their titles unproven) rather than rewritten.Assumptions
clearRecoveryandmarkRecoveryAttemptread#devicesthemselves, andclearRecoverydoes nothing when no markers are left, so the tick's earlier snapshot only decides whether to call it, not what it writes.#giveUpgoes throughreleaseDeviceLost, which takes the section itself (LeaseReleaseCoordinator), andrecoverLeasedtakes it itself as well. Wrapping either would nestrunand deadlock.Review
Spec review: 0 blocking, 0 fixed, 1 notes. Code review: 10 blocking, 5 fixed, 2 notes. Claims review: 8 blocking, 8 fixed, 8 notes. Comment review: 36 blocking, 26 fixed, 0 notes.
Mutate: 48 mutants, 6 alive.
Round 3 confirmed two code findings (both titles in the helper's own test file
src/core/testing.test.ts) and two comment findings. By the maintainer's choice they were fixed in 4b02565 without another review round: the helper test file is deleted and the two comments removed (fixed unreviewed).Rejected:
--fixinstalls nothing; citing a rule does not point at a declarationCloses #407
Written by an agent.