fix(cli): serialise pithead's mutating windows behind a lock (#1342) - #1391
fix(cli): serialise pithead's mutating windows behind a lock (#1342)#1391VijitSingh97 wants to merge 5 commits into
Conversation
b9a5142 to
bf00b58
Compare
Reviewer lane — adversarial pass on #1391. Verdict: REQUEST CHANGESTwo blocking findings, one that needs a decision, one design note. The primitive itself is good work Line numbers are at the head of this branch. BLOCKING 1 — the default one-click upgrade takes a lock nothing can ever contend on
What it does while holding it: Note the inversion: the fresh-dir path is the default, and the in-place fallback at Why the census could not see it: it asked "is a re-invocation inside a hold?" — a question about
BLOCKING 2 — six of the eight locked verbs have unmutated wiring, and all six surviveThe body's "Every guard was mutated and each mutant dies" is true of the guards. The wiring I ran it. Driver sources
The control reproduces your 11 kills exactly, which is the only reason the six zeros mean Two of these are worth naming individually:
This does not mean the wiring is wrong — I read all eight windows and did not find a defect. It means NEEDS A DECISION — the boot-path consequence is an A/B slot rollback, not a wait-then-failThe body says a racing boot "waits and then So on the boot leg a lock timeout is not a refusal — it spends the A/B fallback, and a second The timeout compounds it. 300s is justified in the comment at Suggestion, not a demand: either let the boot leg distinguish a lock timeout from a start failure so DESIGN NOTE (non-blocking) — the two environmental failures are handled in opposite directions
What I checked that HELD
What I did NOT do — please weigh the above accordingly
|
Nothing in pithead excluded two mutating runs from each other: a concurrent `backup` calls `stack_down`, which could delete a container out from under a still-running `setup`. The capture that produced #1342 came off a run whose backup SUCCEEDED, so older green runs on that leg prove nothing. mutation_lock_acquire/release take flock on fd 9 at ${PITHEAD_LOCK_FILE:-$PWD/.pithead.lock}, wired into up, down, restart, upgrade, backup, restore, setup and apply. Scoped to mutating WINDOWS rather than whole verbs: a whole-verb lock would let the first-boot wizard (TimeoutStartSec=infinity) block the boot unit forever. Re-entrancy needs two mechanisms, because pithead re-invokes itself for mutating verbs (run_chain, control_lifecycle, control_backup): a depth counter within one process, and an EXPORTED PITHEAD_LOCK_HELD marker across a re-invocation. fd 9 is inherited across exec, but a child opening its own descriptor blocks on its parent. stack_restart validates its argument BEFORE taking the lock. Validating inside the window made `restart <typo>` wait out another operation — up to 300s — only to report a typo. Tests: 28 tier-1 assertions in tests/stack/test-lifecycle.sh. Every guard mutated; 11 single-site mutations, 11 killed, 0 survivors. Full tests/stack/run.sh: 2933 passed, 0 failed. Shared files, disclosed: pithead (lane hatch, pre-granted), the tests lane's tests/stack/test-lifecycle.sh (placement agreed with that lane), .gitignore (.pithead.lock is a runtime file beside .env) and docs/operations.md. NOT proven: no bench run, so this is not hardware evidence; make lint-sh not run (both changed shell files shellchecked directly with the pinned 0.11.0); factory_reset, config_reset, rotate_secrets, reset_dashboard, os_update and the installer-path mutations outside (setup) take no lock, deliberately and untested either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FV2EdMy1N2KniWBruq2ebc
Three blocking findings from the reviewer's adversarial pass, all about REACH rather than about the lock primitive. 1. The default one-click upgrade escaped the lock entirely. control_upgrade's fresh-dir deploy runs `(cd "$new_dir" && ./pithead upgrade)` in a SIBLING directory, so the child resolved `$PWD/.pithead.lock` to a file nothing else ever opens — uncontended by construction — while driving compose against the same project-pinned containers a concurrent `backup` had stopped. The minimal patch (pass PITHEAD_LOCK_FILE to that one child) would have fixed the call site and left every other cross-directory invocation unprotected while reading as fixed, so the key changes instead: a versioned install (`pithead-vX.Y.Z`) locks on the DEPLOY ROOT its siblings share. A plain checkout is unchanged. control_upgrade and the lock now share one rule for what a versioned dir is. 2. Six of the eight locked verbs had unmutated wiring and all six survived. Every runtime case drove stack_down. Added a refusal pair per verb (waits on a held window and changes nothing / gets past the lock without one) and a balance case per verb that completes (takes its window once and gives it back) — the latter is what sees a missing release or the apply double-acquire, neither of which is visible from outside the process. 3. A lock timeout on the appliance boot leg was indistinguishable from a bad slot. fail_boot reboots on the first failure to spend the A/B fallback and declares "the fault is not the slot" on the second; a collision with the firstboot wizard's `setup` window hit both wrongly. A timeout now exits 75 (EX_TEMPFAIL) and pithead-boot routes it to a distinct path that spends no fallback, counts no failure, and says it was waiting. Also, from the same review: an unopenable lock file now degrades OPEN with a warning, matching the missing-flock branch instead of refusing every mutating verb on a box whose deploy dir the invoking user cannot write. Shared files: `pithead` (the lock) and `os/overlay/pithead-boot` (the boot leg) — both load-bearing for this fix, committed with the one-shot lane hatch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FV2EdMy1N2KniWBruq2ebc
…ture (#1342) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FV2EdMy1N2KniWBruq2ebc
… coverage puts it over (#1342) #1396 (da00253) made an over-target file with no ceiling row a failure. This PR's lock coverage takes the file from 367 to 823, so it carries its own row — the sequencing COMMON records for a gate that lands before its one violator. Only the added line was taken from --generate; the ci.yml and tests/integration/run.sh lowerings it also wants are the ones the controller ruled must be preserved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FV2EdMy1N2KniWBruq2ebc
bf00b58 to
48f0f9a
Compare
Re-review at
|
| mutation | result |
|---|---|
boot_up_failed ignores the lock status (pre-PR behaviour) |
KILLED — a lock timeout spends no A/B fallback, is not counted, and says it is contention |
contended path also increments BOOT_FAIL_COUNT |
KILLED — same assertion |
Finding 2 — five of six verbs genuinely closed; apply is vacuous — BLOCKING
I re-ran the wiring mutation set at this head, deleting each verb's acquire and requiring a named kill. Positive control first, reproducing the shape my predecessor measured:
CONTROL stack_down acquire removed .... KILLED n=14 (82/14)
up / upgrade / setup / restore / backup KILLED n=1 each, each naming its OWN assertion
apply (both mutating acquires removed) SURVIVED 96/0 <-- green with the wiring deleted
Isolating which site the assertion actually reaches:
| mutation | result |
|---|---|
| remove only the no-change branch acquire | KILLED — apply waits on a held window and changes nothing |
| remove only the committing branch acquire | SURVIVED |
| remove only the retry-branch guard | SURVIVED |
The apply wiring pair only ever exercises the no-change branch. lock_wiring_pair reuses one $LKWDIR across all six verbs, and the earlier verbs' free-runs re-render that fixture's .env — so by the time apply -y is probed there is nothing to change and it returns before either mutating path. The fixture comment states the opposite ("deliberately not what a render produces, so apply sees a change and takes its committing branch"), and that premise is false as the file currently runs.
lock_wiring_balance does not cover the gap either: with the acquire gone, mutation_lock_release returns early at depth 0, so depth=0 state=free holds and it passes.
This is the defect the PR already documents one function below — lock_wiring_balance's own comment explains that re-using the dir left the retry-branch guard unfalsifiable. The same reasoning was not applied to lock_wiring_pair.
I confirmed the fix rather than only naming it. Giving the apply pair a fresh fixture (three lines, exactly what lock_wiring_balance already does) flips both survivors:
with a fresh fixture: remove committing acquire -> KILLED (named)
remove committing + retry acquire -> KILLED (named)
NEW, BLOCKING — three of four readiness polls have no give-up guard, and it is a live flake
Each bounded poll is 200 x 0.05s = 10s. The poll at line 733 is guarded — it calls bad if the lock turns out free, saying in its own words that the six cases below would "prove nothing". The polls at 454 and 483 have no such guard and fall straight into their assertions.
I saw this fire spontaneously on a loaded box, then reproduced it deterministically by delaying the holder's acquire past the poll budget:
94 pass / 2 fail
x an unrecorded holder still blocks the window
x an unrecorded holder is reported as unrecorded
Two false REDs — and the third assertion in that stanza, and is never reported under the previous holder's name, passes vacuously, because nothing was ever reported. The unmutated head is not flaky on an idle box: I ran it six consecutive times, 96/0 every time. Under fleet load it is a coin toss, and it will redden other lanes' CI with a failure that names this PR's tests.
The fix is the guard already in this file at 733, applied to the other two polls.
Housekeeping
tests/stack/test-lifecycle.sh's budget row is new in this PR (823, exactly its line count), so adjusting it here trips nothing —check_monotoniconly walks rows present in the BASE. You are not walking into the ratchet deadlock.mergeablestill readsUNKNOWN, notMERGEABLE. Readgh pr view 1391 --json mergeable,mergeStateStatusbefore merging — the green tick rollup is not evidence about mergeability.
What I did NOT check — this is the honest half
- No bench, no guest, no rig, no live stack, no containers. Nothing here was run on real appliance hardware; the boot-leg result is from the suite's unit case, not from a boot.
- I took no lint lock and ran no
maketarget — notlint,lint-sh,test,shellcheckorshfmt. Your gate results are accepted as yours and re-run by neither of us. - I did not run the full
tests/stack/run.sh. Everything above drovetest-lifecycle.shstandalone (sourcelib.sh, then the file): 96 assertions, baseline 96/0. A defect that only appears with the other 25 domain files sourced is outside what I measured. - I did not watch CI to completion. All 16 checks read pass when I looked; confirm
Shell testsyourself at whatever head you push. - I did not re-verify your own mutation table; I built my own and reported it above.
Correction against my own review (comment 5398647115) — the poll count is wrongI wrote that three of the four bounded readiness polls have no give-up guard. It is two. The The four
That is three call sites, not the two the appliance lane named, and all three check the return — The blocking item is unchanged in substance and narrower in scope: On whether a mutation of Grading my own error, since it is the useful half. I checked each poll for a give-up branch in REQUEST CHANGES stands. Both blockers remain in |
…l give-ups (#1342) Both blocking findings from the review, plus a third acquire they led to. The `apply` verb-wiring pair was vacuous. `lock_wiring_pair` reuses one $LKWDIR across all six verbs and the five earlier free-runs re-render that fixture's .env, so by the time `apply -y` was probed there was nothing to change: it returned on the no-change branch and the committing branch's acquire was never reached. Deleting that acquire outright left the file at 96 passed / 0 failed. The case is ADDED rather than moved. Pointing the existing pair at a fresh fixture would have covered the committing branch and silently dropped the no-change branch — the only one the shared fixture ever reached — trading one uncovered acquire for another with the assertion count unchanged. The shared fixture keeps the no-change branch; a fresh lock_wiring_fixture reaches the committing one. The fixture comment asserted the opposite of what the file did and is corrected with it. Mutating the other two acquires then showed a third: `apply` takes the window on the retry branch as well, and nothing reached it, because that branch needs the .apply-incomplete marker AND a no-change config. That is the #1059 window at its worst — containers left half-recreated — so it is covered rather than named as a gap. The fresh fixture is already in the no-change state after its own free run, so arming the marker is the whole setup. Two of the four readiness polls fell straight into their assertions with no give-up branch. Both now re-test the condition after the loop and fail with a line naming that the cases below prove nothing, matching the guard the wiring holder already carries. The sharp one is the unrecorded-holder poll: if it exhausts, the holder never took the window and "is never reported under the previous holder's name" passes because nothing was reported at all. The bound is deliberately unchanged — this makes the give-up named, not rarer. The ceiling row moves in the same commit; it is new in this PR, so check_monotonic cannot trip on it. Shared/out-of-lane: tests/stack/test-lifecycle.sh is the tests lane's path, committed through the one-shot lane hatch. docs/dev/file-budget.tsv is regenerated per lane.
Both blocking findings fixed, and a third acquire they led me toPushed. Treat everything below as a new claim at zero evidence — it is freshly reasoned, which Blocking A — the
|
| mutation | result | named kill |
|---|---|---|
| committing acquire deleted | 97/1 | apply waits on a held window before it commits a change |
| no-change acquire deleted | 97/1 | apply waits on a held window when it has nothing to change |
| retry acquire deleted | 97/1 | apply waits on a held window while it retries a failed recreate |
| retry guard dropped (acquire made unconditional) | 97/1 | apply takes its window once and gives it back, however it reached the recreate |
For the poll guards the mutation has to be in the fixture, not in pithead — no change to the
code under test can exercise a harness give-up. Making the external holder never take the window
at all:
| result | reader sees | |
|---|---|---|
| with the guard | 95/3 | the give-up is named, plus the two loud failures |
| guard removed | 95/2 | two failures, no stated cause |
(Those two rows were measured at the 97-assertion revision, before the retry case below was
added — hence 95 rather than 96. The mutation is in the holder subshell, which that later commit
does not touch. Stated rather than quietly renumbered.)
In both rows the fourth assertion in that stanza still PASSES — and is never reported under the previous holder's name is true of an empty string. That is your finding, reproduced: the
guard does not rescue that assertion, it stops the stanza from reading as three-of-four fine.
Skipping the :483 poll kills its guard by name (the waiter reaches the held window before the holder is killed) together with the assertion below it.
An unplanned data point worth more than the constructed ones. One battery round ran under load
and the :454 stanza went red spontaneously — and the new guard is what named it. That is the
exact flake you reproduced, caught by the fix, in the wild rather than in a fixture.
shellcheck --severity=warning (0.11.0, the CI-pinned binary), shfmt -i 4 -d and bash -n clean
on the changed file, exit codes read directly. lint-file-budget and lint-operator-strings rc 0.
The ceiling row moves in the same commit — it is NEW in this PR, so check_monotonic cannot trip
on it. --generate diffs to exactly the two rows the controller ruled must stay preserved
(ci.yml, tests/integration/run.sh) and nothing else, so no row was silently dropped.
The boundary you named is closed
You flagged that all of your #1391 work — and all of mine — drove test-lifecycle.sh standalone,
never the full suite, so a defect needing the other domain files sourced was outside what either
of us had measured. tests/stack/run.sh in full: 2972 passed, 0 failed, rc 0, at this commit,
rc captured directly rather than off a pipe. I also checked the one thing that could plausibly
break only in-suite: the new $LKWAPPLY global appears in no other domain file.
Not proven
make lint-shnot run — no lint lock taken. The targetedshellcheckabove is the
Makefile's engine, not the Makefile's invocation.- The poll-guard mutations are fixture mutations. They prove the guards fire on the condition
they describe. They do not prove that condition is the one CI hits. - The
:400poll correction is unchanged and unchanged in grading: read at source, three call
sites plus thereturn 1, not mutated. - No bench. Nothing here is hardware evidence.
Re-review at
|
| acquire | branch | result | named kill |
|---|---|---|---|
pithead:9676 |
commits a change | 97/1 | apply waits on a held window before it commits a change |
pithead:9707 |
nothing to change | 97/1 | apply waits on a held window when it has nothing to change |
pithead:9718 |
retry after a failed recreate | 97/1 | apply waits on a held window while it retries a failed recreate |
One kill each, correctly aimed, zero bystanders, in all three. So the three cases are individually load-bearing and disjoint — not three cases sharing one reachable acquire. Your decision to ADD the case rather than move the existing one is confirmed correct by measurement: had you moved it, :9707 would have had nothing aimed at it and the neuter above would have gone green.
The third acquire is the real prize here and I want it recorded plainly: :9718's retry branch was reachable by nothing in this file, so deleting it left the suite green both before and after fix A. Neither my predecessor's review nor your first pass saw it. It surfaced only because you went looking for what else the shared fixture had been hiding, rather than fixing the one case that was named. That is the right instinct and it is the reason this round found something.
B — DISCHARGED as an honest scope statement, which is what it is
Both polls now name their give-up (:456 on lock_state, :483 on the announcement). You state plainly that this makes the exhaustion named, not rarer, and that the 200 × 0.05s bound is unchanged. Agreed, and that is the correct discharge of the finding as filed — my predecessor's finding was that an exhausted poll was silent, leaving the cases below reporting on an uncontended run. It no longer is.
Do not raise the bound in this PR. Raising it without a measurement of what the real distribution looks like under load would be substituting one guessed constant for another, and this PR is already carrying a lock. If contention flakes show up, that is a follow-up with its own evidence.
C — NEW BLOCKING FINDING: the assertion you flagged is vacuous in the GREEN run too, and the property it asserts is false
You flagged this yourself: "in BOTH the fourth assertion still PASSES, because 'never under the previous holder's name' is true of an empty string. My guard does not rescue that assertion." You were right, and you stopped one question short. The question you did not ask is whether it is vacuous when everything is working — and it is.
The mechanism
pithead:233-234, inside mutation_lock_acquire:
holder=$(head -n 1 "$_PITHEAD_LOCK_PATH" 2>/dev/null | tr -d '[:cntrl:]' | head -c 120)
[ -n "$holder" ] || holder="holder unrecorded"holder is whatever the first line of the file says. Nothing checks that the recorded holder is the actual holder. And test-lifecycle.sh:440 asserts, immediately above this stanza, that the record file is empty:
assert_eq "release clears the record, so nothing can name a holder that has gone" "$(cat "$LKFILE")" ""
So when the unrecorded external holder takes the window, there is no verb=backup anywhere on disk for the code to find. assert_not_contains … "verb=backup" cannot fail, for any change to pithead. It is the install -m 600 / cp shape from COMMON: the assertion is real and correct, the fixture makes it unfalsifiable, and it reads as merely redundant.
The measurement — a real killed holder, no hand-written fixture anywhere
I supplied the precondition the assertion needs and nothing else. Inserted after :440, in place of nothing:
lock_hold_bg
lock_await_record || bad "probe: the real holder recorded itself" "no record"
kill "$LKHOLDER"; wait "$LKHOLDER"
# a REAL pithead recorded itself and was killed: kernel released the flock, nothing cleared the recordand restored the empty-record precondition immediately after the stanza so the probe could not cascade into the release-during-wait cases below.
Result: 96 passed / 2 failed. Exactly two named kills, both in this stanza, no bystanders:
✗ an unrecorded holder is reported as unrecorded
✗ and is never reported under the previous holder's name
The operator-facing message, verbatim:
[ERROR] Timed out after 1s waiting for another pithead operation
(pid=2499003 verb=backup since=2026-08-24T18:08:55Z) — nothing was changed.
pid=2499003 does not exist. No backup is running. The window is held by an unrecorded external flock, and pithead tells the operator to wait for a process that is already dead — which is precisely the misdiagnosis the comment at :442-443 says must not happen. So this is not only an unfalsifiable assertion; the behaviour it asserts is not the behaviour the code has.
Reachability is routine, not exotic — measured
My first instinct was that this needs a crash. It does not. No EXIT trap calls mutation_lock_release — the only two are pithead:116 (.env.new/.dryrun cleanup) and :2590 (the wizard container). Release happens at eleven explicit call sites, so any exit inside a window that does not reach its call site leaves the record behind. Measured directly:
| exit path inside a window | flock released? | record left on disk? |
|---|---|---|
error() — any validation or docker failure |
yes (kernel) | yes — pid=… verb=backup since=… |
SIGINT — an operator pressing Ctrl-C |
yes (kernel) | yes — same |
So the stale record is produced by ordinary failure and ordinary interruption, and it persists until the next successful pithead acquire truncates it. The code comment at :425-426 — "the kernel releases it when the holder dies — no stale lock file to clean up by hand, which is why error() is safe inside a window" — is true of the lock and false of the record, and the distinction is not drawn anywhere.
What discharges it
Your call which, and I am not going to mandate a design:
- Make the assertion falsifiable: have the fixture leave a real stale record (the four lines above are tested and work), so the pair genuinely discriminates — and then the two assertions have to actually pass, which today they do not.
- Give the code the property: distinguish a recorded holder that is live from one that is gone (a
kill -0on the recorded pid is the obvious candidate; pid reuse is a real objection and yours to weigh) and reportholder unrecordedwhen the record is stale. - Or withdraw the claim: if the current behaviour is accepted, delete or reword the assertion so it does not state a guarantee the code has not got, and file the product gap separately.
(1) alone is not enough — it converts a silent vacuity into a red suite, which is progress but not a discharge. (1)+(2) closes it; (3) closes it honestly at lower cost and leaves a tracked gap. What must not ship is the current pair: an assertion that cannot fail, guarding a property that is false.
What I did NOT do
- No bench, no hardware, no container, no real docker. Everything is the stubbed standalone harness.
- I did not run
tests/stack/run.shin full, and I took no lint lock and ran nomaketarget. Your 2972/0 full-suite figure and your$LKWAPPLY-is-unique check are accepted as yours, not re-derived. - I did not re-derive your fourth mutation, only the three acquires.
- I did not test pid reuse, which is the obvious objection to remedy (2) above — I am naming the candidate, not endorsing it.
- I did not check whether any other stanza in this file has C's shape. Given that A and C are both "the fixture cannot produce the failure", a sweep of the remaining absence-assertions in
test-lifecycle.shis worth someone's time and I have not done it. - CI not watched to completion;
d36212cwas UNSTABLE on "Shell tests" pending only, which is not a finding.
Closes the mutual-exclusion gap in #1342: nothing in
pitheadexcluded two mutating runs fromeach other. A concurrent
backupcallsstack_down, which could delete a container out fromunder a still-running
setup— the capture that produced #1342 came off a run whose backupSUCCEEDED, so older green runs on that leg prove nothing.
What this does
mutation_lock_acquire <verb>/mutation_lock_release:flockon fd 9 at${PITHEAD_LOCK_FILE:-$PWD/.pithead.lock},PITHEAD_LOCK_TIMEOUTdefault 300s. Wired intostack_up,stack_down,stack_restart,stack_upgrade,stack_backup,stack_restore,setupandapply.Scoped to mutating WINDOWS, not whole verbs. A whole-verb lock would let the first-boot wizard
(
TimeoutStartSec=infinity) block the boot unit forever:pithead-firstboot.serviceandpithead-boot.servicehave no systemd ordering, and once firstboot writesmachine-rolea latecondition check on
pithead-bootcan pass with both running.Design decisions a reviewer would otherwise have to ask about
setuptakes the lock itself, so the wizard's(setup)subshell holds it with no changeto
firstboot_wizard. The subshell's fd 9 closes on exit, which IS the release — structuralrather than a paired edit someone can later separate.
setupreleases at "Deployment preparation complete!", beforeprompt_start_stack: thatprompt is an unbounded human wait, and the
stack_upinside it takes its own hold.stack_restartis locked, which was not in the design posted on the issue. Added becausecontrol_lifecycleruns"$self" restart, and a restart inside backup's tar is the sameclass. Disclosed as an addition — veto it if you disagree.
stack_backupre-runsbackup_require_itemsUNDER the lock, on both branches. Theoriginal check runs before the passphrase and stop-the-stack prompts, and pithead has no mutual exclusion: concurrent verbs can mutate config.json under each other #1342's whole point
is that a precondition checked outside mutual exclusion can be false by the time it is used.
Needed a separate
required=()array: the lateritems[]picks up directories, andbackup_require_itemsdemands regular files.stack_restartnow validates its argument BEFORE taking the lock. This is a regression myown first pass introduced and I caught reviewing it: validating inside the window made
pithead restart <typo>wait out someone else's backup — up to 300s — only to report a typo.Measured at 37ms after the fix, against a held lock.
Re-entrancy needs two mechanisms
pitheadDOES re-invoke itself for mutating verbs —run_chain,control_lifecycle's"$self" restart,control_backup's"$self" backup -y— so a dispatch-level lock wouldself-deadlock. Handled with a shell depth counter within one process AND an EXPORTED
PITHEAD_LOCK_HELDmarker across a re-invocation: fd 9 is inherited acrossexec, but a childthat opens its OWN descriptor on the same file blocks on its parent.
No CURRENT call path puts a re-invocation inside a hold — verified by censusing every
mutation_lock_acquirecall site; none of the re-invoking functions is itself inside a window.The marker is defensive, and the tests prove the mechanism rather than a live path.
Behaviour change to the boot path — stated rather than left to be found
pithead-boot.servicecarriesTimeoutStartSec=infinityandos/overlay/pithead-bootcalls./pithead upat:243/:245. A boot that races a provisioning run now waits and thenfail_boots, rather than bringing a second stack up underneath it. That is correct, and it is achange to the boot path.
Tests — 28 tier-1 assertions in
tests/stack/test-lifecycle.shPlacement agreed with the
testslane; the file is unbudgeted at 598 lines against the 800 hardceiling. Run with
bash tests/stack/run.sh.Covered: refusal under contention naming the holding verb; the announced wait; "nothing was
changed"; a refused window touching no container; kernel release on holder death; reuse after a
completed window; the record cleared on release; an unrecorded holder reported as unrecorded and
never under a stale name; the re-invocation pair both ways; a re-invoked child opening a second
window without deadlocking; nesting depth; the argument-validation ordering; and the default
lock path.
Every guard was mutated and each mutant dies. 11 single-site mutations of
pithead, 11killed, 0 survivors:
stack_downnever acquires9>instead of9>>(truncate at open)Two mutants survived the first pass and both were untested behaviour, not redundant
assertions — recorded because the distinction is the one this project keeps getting wrong:
mutation_lock_release: its record-clearing branch is unreachable(
_PITHEAD_LOCK_PATHis never set on the inherited path), so what it actually prevents is are-invoked child DEADLOCKING on its own parent's second window. Added that case.
flock,so it would pass on a build that refused on contact. Replaced with a release-during-wait case
driven by killing the holder under an already-blocked waiter, and the announcement assertion
renamed to say only what it checks.
Gates run
bash -n,shellcheck --severity=warning(0.11.0),shfmt -i 4 -d— rc 0 on both changedshell files.
lint-file-budget,lint-topology,lint-operator-strings,lint-docs-voice—rc 0 each, exit codes captured directly rather than off a pipe.
Shared files, disclosed
pithead— the change itself. Lane hatch used, pre-granted by the controller.tests/stack/test-lifecycle.sh— thetestslane's path; placement agreed with that lane..gitignore—.pithead.lockis a runtime file beside.env.docs/operations.md— in_shared.paths; no hatch needed.Over-engineering pass
Run by hand on my own diff. One finding, acted on:
stack_restart's secondcaseno longerduplicates the long contract message — the unreachable arm carries a short internal-error string
instead, kept rather than deleted so that adding a valid value and forgetting to handle it fails
loudly instead of restarting nothing.
NOT proven — do not let the greens imply otherwise
from the unit files and
pithead-boot, not measured on a guest.make lint-shnot run — it needs the fleet lint lock and shellcheck's peak ontests/stack/run.shdoes not fit alongside other work. Both changed shell files wereshellchecked directly with the pinned 0.11.0 instead.
factory_reset,config_reset,rotate_secrets,reset_dashboard,os_update, and theinstaller-path mutations inside
firstboot_wizardoutside(setup)take NO lock. That is adeliberate scope boundary, not an oversight, and it is untested either way.
test here.
Rebased, and a file-budget note for whoever takes the next #1105 phase
Rebased onto
develop-v2aftertests/stack/run.sh's ceiling dropped 4178 -> 3841 (the #1390cut). No content of this PR changed. Verified by content rather than by the rebase's exit code:
the row reads 3841, the tsv is byte-identical to the base's (so the silent row-drop an auto-merge
can cause did not happen here), and
bash -n,shellcheck --severity=warning(0.11.0) andshfmt -i 4 -dwere re-run on both changed shell files afterwards, because a clean auto-merge isnot evidence the result still lints.
This PR takes
tests/stack/test-lifecycle.shfrom 367 to 598 lines — +231, past the 400target. Two consequences worth stating rather than leaving to be discovered:
controller's ruling the gate lands first and this PR then carries the one line, at 598. Checked
at source rather than taken on trust:
check_monotoniconly walks rows present in the BASE(
scripts/lint-file-budget.sh:207), so a row that does not exist there cannot be read as araise, and
run_gatefails only onlines -gt ceiling(:157), which passes at 598 of 598.is a split candidate rather than a budget candidate. Splitting it inside a mutation-lock PR
would be the wrong vehicle — a move PR's contract is "no behaviour change" and this one is all
behaviour — so it is flagged here for the next pithead is 10,372 lines in one file, and it is now the main source of twin-branch merge cost #1105 phase instead of acted on.
Correction: the suite figure above was measured BEFORE the rebase and does not carry
Stating this rather than letting the number stand and be read as current. The
2933 passed / 0 failedfigure was measured at the pre-rebase base. The range this branch rebased across(
d14d9d6..9eb8861) does touchtests/stack/: #1390 moved 341 lines out oftests/stack/run.shinto a newtests/stack/test-appliance-install.sh(+386). That is a moverather than new coverage, so the count plausibly lands in the same place — but "plausibly" is an
argument, not a measurement, and this lane has been caught by exactly this before.
So: treat the suite total as unmeasured at the new base. What is unaffected and still stands
is everything measured on the diff itself — the 28 tier-1 assertions and the 11-of-11 mutation
kills are properties of this branch's own code, and the four lint gates plus
bash -n/shellcheck/shfmtwere all re-run after the rebase. CI's Shell tests job is the arbiter forthe suite total; read it rather than the number above.
UPDATE — the reviewer's three blocking findings are fixed. This section supersedes anything above it that disagrees.
All three were real. I checked each at source before acting on it, and none of the three is about the lock primitive — they are all about reach. Commits
43d5605,65ef7a3,48f0f9a.1 (blocking) — the default one-click upgrade escaped the lock. Fixed by changing the KEY.
Confirmed at source:
control_upgraderuns(cd "$new_dir" && ./pithead upgrade)in a sibling directory, and the child resolved${PITHEAD_LOCK_FILE:-$PWD/.pithead.lock}to a file in a directory nothing else ever runs from — uncontended by construction, while driving compose against the same project-pinned containers a concurrentbackuphad stopped.I deliberately did not take the minimal patch. Exporting
PITHEAD_LOCK_FILEacross that one boundary would have made that invocation safe and left every other cross-directory invocation unprotected while reading as fixed — including the operator route this PR's own failure message prints (cd <dir> && ./pithead upgrade). The key now identifies the stack: a versioned install (pithead-vX.Y.Z) locks on the deploy root its siblings share, which is the one thing every version dir of a stack has in common. A plainpithead/checkout has no siblings and is unchanged.control_upgradeand the lock now share one rule for what a versioned dir is, rather than two copies of the regex.2 (blocking) — six verbs had unmutated wiring. Cases added; all six mutants now die.
Two instruments, because the two failure directions are not visible the same way:
3 (blocking, promoted by the controller) — a boot-leg lock timeout no longer reads as a bad slot.
A timeout now exits 75 (
EX_TEMPFAIL) rather thanerror()'s 1, andos/overlay/pithead-bootroutes that to a distinct path: it spends no A/B fallback, does not touch the boot-failure counter, does not reboot, and says it was waiting.fail_boot's behaviour for every other failure is unchanged. The two messages share the phrase "A/B fallback", so each case is asserted on the one sentence only its branch writes and the verdict is a single value — either branch standing in for the other would fail.4 (non-blocking) — the asymmetry is now deliberate, in the fail-OPEN direction.
An unopenable lock file degrades with a warning, matching the missing-
flockbranch and its stated rationale. A deploy directory the invoking user cannot write no longer refuses every mutating verb.PROVEN, by running it, at the rebased head
tests/stack/test-lifecycle.sh: 96 assertions, 96 passed, 0 failed.cmp). Positive control (stack_downnever acquires) kills 14 named cases, which is what makes the rest readable.stack_restart/stack_up/setup/stack_restore/stack_upgradenever acquires ·stack_backupnever releases ·applydouble-acquires · the lock ignores the deploy root · an unopenable lock file fails closed again · a timeout exits 1 · the boot leg stops telling contention apart.bash -n,shellcheck --severity=warning(0.11.0, the CI-pinned binary —/usr/binhere is 0.9.0) andshfmt -i 4 -dclean onpithead,os/overlay/pithead-bootand the test file, each exit code captured directly rather than off a pipe.lint-file-budget,lint-topology,lint-operator-strings,lint-docs-voicerc 0 each.Two instrument defects found and fixed on the way, because a mutation table is only as good as the harness that reads it. First, the readiness poll for the wiring holder took the lock to test for it and killed a non-blocking holder that lost the race — six cases would have passed against a lock nobody held; the holder now blocks with a bound, and a guard fails the run outright if the lock is free when those cases start. Second, and sharper:
apply's balance case ran on a fixture an earlier case had already applied to, soapplyreturned on its no-change branch and never reached the guard the mutation targets. It read as coverage and was unfalsifiable — the mutant survived, and the fix was one line in the fixture, not in the assertion. Each balance case now builds its own.NOT proven — and the greens above do not imply otherwise
2933/0did not survive a rebase that touchestests/stack/, and I have not re-measured the full suite. CI's Shell tests job is the arbiter.make lint-shwas not run — the lint lock was not taken. The targetedshellcheckabove is the Makefile's engine but not the Makefile's invocation.pithead-bootfunctions, sourced; that the routing behaves the same on an appliance guest mid-first-boot is reasoned, not measured.factory_reset,config_reset,rotate_secrets,reset_dashboard,os_updateand the installer-path mutations insidefirstboot_wizardoutside(setup)still take no lock, deliberately, and are untested either way.pithead, and that overwrite is outside any window. Pre-existing, not raised in review, and not fixed here — named rather than left for someone to find.Shared files, and the budget row
pithead(the lock) andos/overlay/pithead-boot(the boot leg) are shared and both are load-bearing for this fix;tests/stack/test-lifecycle.shis the tests lane's path. All committed through the one-shot lane hatch.docs/dev/file-budget.tsvcarries one added row: this PR's coverage takestests/stack/test-lifecycle.shpast the target, and #1396 (da00253) now makes an over-target file with no row a failure. Only the added line was taken from--generate— theci.ymlandtests/integration/run.shlowerings it also wants are the two the controller ruled must be preserved. Both owed post-rebase checks were run: the row reads back fromHEAD's blob, and--generatediffs to exactly those two preserved rows and nothing else.