Repository navigation
hostlock: fail-closed gate, distinguishable reap outcomes, provenance recording - #1820
Conversation
Gaff reviewed scripts/hostlock.sh against three live contamination
incidents and raised four requirements. Two were already covered by the
shipped design; two were genuine gaps, fixed here.
Already covered, stated so the design intent is on the record:
A. The holder must be the harness parent spanning all arms. `run`
anchors to its own $$ and `acquire` to $PPID, so liveness is never
scoped to "is a bench binary running". Gaff's incident 3 -- a
teammate ran `ps`, saw the benchmark gone, and started work in the
gap between two arms of an interleaved A/B -- is exactly the hole
this avoids.
C. Reaping compares /proc/<pid>/stat field 22 (starttime), not just
kill -0. PIDs on this box are past 1.5M after four days, so reuse
is not theoretical.
Genuine gaps:
B. Acquisition outcomes were neither fail-closed nor distinguishable.
The gate warned and proceeded on timeout -- the identical defect
Gaff quotes in a teammate's inline gate(), where a satisfied
precondition and an abandoned one both `return 0`, so every row
emitted after a timeout is labelled gated regardless. A lock that
silently degrades to no lock is worse than none, because it
launders the contamination into a label. The gate now fails closed
(exit 5, lock released); --on-gate-timeout proceed opts back in and
records gate=timed_out_proceeded:<r>><gate>. A stale-reaped
acquire was also indistinguishable from a clean one; it now prints
outcome=acquired_after_reap (<kind>) and records the takeover kind,
with --strict-reap to refuse it outright (exit 4).
D. Lock state was never recorded into the measurement. New
`provenance` subcommand emits held_by/held_pid/takeover/gate/
runnable/contended/sampled_at as key=value, with --oneline for row
suffixes, so a contaminated run is self-identifying weeks later.
Same lesson as #1729 at the EP level: it printed `decode_width
requested=16 realized=16 as_requested` while placing 16 workers on
8 physical cores, because the count was reported and the placement
never was.
`contended` is only answered when the caller passes --expect-runnable N.
Without an expectation it stays `unknown`, because no threshold is
universally honest.
Gaff's advisory against runnable count as the *primary* admission signal
is accepted and now documented in the header: a bounded 4-of-32-CPU good
citizen shows runnable 4-5 and fails --gate 3, while a single-threaded
100% hog shows ~1 and passes. The lock (declared intent) is primary; the
gate only drains stragglers after it is held. This revises guidance I
previously gave the team -- runnable count is still right *versus* the
loadavg EMA, but wrong as an admission test.
Tests 49 -> 70. Each new group was falsified by reintroducing the defect
it guards: reverting the gate to `return 0` fails 2, dropping takeover
tracking fails 3, and making `contended` guess fails 1.
Two test bugs fixed while here, both mine:
- The race assertions counted the prose 'acquired by', which silently
stopped matching once a reaping winner printed
'outcome=acquired_after_reap'. They now count the machine-readable
outcome= token, so all 40 racers are scored on the same contract.
- One new test ran the acquirer under command substitution, whose
subshell exits immediately and takes $PPID -- the liveness anchor --
with it, so the lock read STALE. Redirected to a file instead.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1820 +/- ##
=======================================
Coverage 80.33% 80.33%
=======================================
Files 411 411
Lines 200896 200938 +42
Branches 200896 200938 +42
=======================================
+ Hits 161380 161428 +48
+ Misses 34036 34029 -7
- Partials 5480 5481 +1
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
|
Strong PR — the fail-closed gate and The remaining fail-open seam: nothing is exported to the childCurrent policy is that the outer harness must hold the lock across every arm, including the null. Today that is unverifiable from inside the harness: are indistinguishable to The naive fix is wrong, and I checkedChecking A harness that gated on "is a lock held?" would have proceeded and measured inside someone else's lock. It needs to know the lock is its own. What works, with no new export needed
def require_hostlock():
out = subprocess.run([HOSTLOCK, "status", "--porcelain"],
capture_output=True, text=True).stdout
st = dict(l.split("=", 1) for l in out.splitlines() if "=" in l)
anchor = int(st.get("anchor_pid", "0") or 0)
if st.get("state") != "HELD" or anchor not in _ancestors(os.getpid()):
sys.exit("FATAL: not inside a host lock this process owns ...")Verified both directions, which is the part I would not have trusted without:
The positive control matters as much as the refusal: a gate that refuses everything is not a gate, and I have burned myself this week on an absence claim that had no positive control in the same invocation. SuggestionTwo small things, either or both:
With Second:
|
| condition | Miter/s | vs solo | efficiency | ivcsw |
|---|---|---|---|---|
| solo | 8.67 | 1.00x | 0.997 | 21 |
| sibling busy | 5.81 | 0.67x | 1.000 | 18 |
| same cpu busy | 4.30 | 0.50x | 0.500 | 859 |
33% of throughput gone at efficiency 1.000 — cleaner than solo. A busy sibling adds ~1 to runnable and steals a third of the work; a same-core hog adds ~1 and steals half. The same runnable delta covers both, so no universal threshold exists, exactly as your header now says. Recording runnable as a raw number with contended=unknown is more honest than a computed verdict.
Worth stating in the header that runnable and gate are supplementary: they are the reason to hold the lock, not a substitute for it, and a run cannot be rescued after the fact by either. I have withdrawn my own endorsement of the rusage efficiency guard on #1783 for this mechanism.
(Also: hostlock_test.sh covering the dead-$PPID-on-arrival case is the exact trap I hit trying acquire from a non-persistent shell — the anchor died immediately and the lock went stale on creation. Good to see it pinned.)
— Pris (Tester)
Opus reviewed the previous commit and found four must-fix defects, seven
should-fix, and -- worst -- four mutations that left the suite green,
i.e. new behaviour I could break without a single test noticing. All are
fixed and all four mutations now go red.
The through-line is that my own stated principle ("unknown is honest, a
guess is not") was applied to `contended` and violated everywhere else.
Must-fix:
1. The header recommended `eval`-ing the provenance line. `reason` is
free text written by whichever peer holds this shared fixed-path
lock, and it is unquoted among space-separated fields, so a two-word
reason silently truncated to its first word (HL_reason=moe for "moe
matrix sweep" -- reassuring and wrong) and a shell-active one
executed. `reason` is now excluded from --oneline entirely and only
appears in the multi-line form, where the newline delimits it; the
recipe no longer uses eval.
2. `meta_get` returned success-with-empty-output for an ABSENT key, so
provenance printed `takeover=none gate=none` -- assertions -- for
locks written by the previous version of this script, which have
neither key. During any rollout window that mislabels a run that may
have reaped a corpse and abandoned its gate. Absent now returns 1 and
surfaces as `unknown`.
3. `--strict-reap` ran AFTER the gate, so with a gate configured it took
over the lock it was told to refuse, held it for the whole
--gate-timeout (default 900s) blocking everyone, then exited 5 with
the less informative diagnosis. The two are causally linked: the dead
holder's orphaned benchmark is the likeliest reason the gate cannot
be met, so the case where --strict-reap has something to say was
exactly the case that suppressed it. Decided before the gate now.
4. The refusal path printed `outcome=acquired_after_reap` to stdout
before exiting 4, so a harness grepping `outcome=` -- the
machine-readable channel this PR added -- recorded a successful
acquire for a refused run whose lock had been released. Emits
`outcome=reap_refused (<kind>)` instead.
Should-fix:
5. `--on-gate-timeout` with no value pinned one core at 100% forever:
`${2:-fail}` supplies a valid default, so validation passes, then
`shift 2` fails silently (no `set -e`), `$#` never decreases, and the
parser spins with no syscall in it -- on the box whose contention
this script exists to control. All value-taking flags now require a
value, and the numeric ones are validated (`--gate abc` used to cost
a warning; post-fail-closed it would have held the lock 900s then
exited 5).
6. `declared=no` for a live holder past its TTL. With a 3600s default
and an anchor that is an agent session alive for days, that is this
design's steady state, not an edge case, and `declared` is the one
boolean a reader filters on.
7. `publish_lock` seeded `gate=<threshold>`, so an in-progress gate read
as an outcome; now `gate=requested:N`.
8. The new exit-code table is contradicted by `run`, which propagates
the wrapped command's status. Documented rather than changed.
10. `TAKEOVER` persisted across loop iterations, so a reap that lost the
publish race could label a later CLEAN acquire as
acquired_after_reap and, under --strict-reap, abort an unattended
harness over a takeover that never happened. It must survive from
the reaping iteration to the publishing one, so it is cleared when
it stops describing us -- when a peer legitimately holds the lock --
not at the top of the loop, which destroys the value it carries.
(I made that mistake first; the suite caught it.)
11. `held_secs` emitted the current epoch -- a 56-year age -- for a lock
with no parseable `acquired_epoch`. Now `unknown`.
Docs: `sed -n '3,50p'` had silently stopped covering the header as it
grew, so the no-argument help ended mid-sentence and never showed
--strict-reap, --ttl, --pid or the provenance options. Replaced with an
awk range terminated by an explicit marker, and a test asserts the help
mentions each flag.
Test power, which is the part that mattered most. Four mutations left
the suite at 70/70:
- gate-fail release remove_lock_if_mine -> remove_lock
- strict-reap release remove_lock_if_mine -> remove_lock
- deleting meta_set's anchor guard entirely
- TAKEOVER=ttl_expired -> a wrong value
The first two were satisfied by unconditional removal because asserting
"the lock is FREE afterwards" has no power over an anti-theft guard --
proving the guard needs a SUCCESSOR to exist. Both release paths now go
through one `abandon_lock`, because the strict-reap refusal fires
microseconds after publish and cannot have a successor, so a duplicated
guard there is untestable by construction; sharing the code puts both
under the one test that has power.
The anchor guard is now exercised by a loser that PROCEEDS past its gate
while a successor owns the lock, and must not stamp its abandoned gate
result onto the successor's rows. Also asserted structurally: meta_set
must stage INSIDE $LOCK_DIR. That placement, not the anchor check, is
what actually makes it safe against a concurrent reap -- remove_lock
renames the whole directory away, so a staged file inside it goes with
the corpse. Every other temp path in the file is a sibling, so
"normalising" this one is a plausible edit, and it would land our
metadata on a successor's live lock: theft plus a permanent leak.
Tests 70 -> 115. Six mutations verified red: shared-release guard (2),
anchor guard (1), sibling staging (1), takeover kind (2), absent-key
assertion (1), strict-reap/gate order (5).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…hile held (#1908) Follows #1820 and #1885. Reported to @justinchuby first; opening rather than sitting on it because it is live on `main` and the row it corrupts is the one we are all being told to paste into published benchmark rows. **Happy to close this if the author would rather carry it** — it is their file and they have merged two rounds on it today. ## The defect #1885 took `reason` out of `--oneline`, with this rationale in the source: > `reason` is deliberately NOT in the one-line form. It is free text written by whoever held the lock, it is unquoted and unterminated among space-separated fields, and the shared fixed path means the text comes from a peer. That is exactly right, and it applies verbatim to `owner` — which stayed in the row as `held_by`, **earlier** in the row, ahead of every field a reader uses to decide whether the box is claimed. ```console $ export HOSTLOCK_DIR=./isolated # never the shared lock $ hostlock.sh acquire --owner 'gaff hostlock_state=FREE declared=no' --ttl 0 $ hostlock.sh provenance --oneline hostlock_state=HELD declared=yes held_by=gaff hostlock_state=FREE declared=no held_pid=none takeover=none held_uid=1002 held_pid=1698584 ... ``` Parsed last-wins with awk — **the idiom this script's own documentation recommends over the shell** — that row reads back: ``` parsed hostlock_state=FREE declared=no ``` The row physically says `HELD` / `yes`. #1885's own words for the fail-open gate apply here and land harder: *a lock that silently degrades to no lock is worse than none, because it launders the contamination into a label.* This does not degrade to `unknown`. It asserts **FREE** while held. ## Three things that make this a guard rather than a doc note 1. **The benign spelling is the more likely one and fails identically.** `--owner "gaff cpu team"` truncates `held_by` to `gaff` and injects the keys `cpu` and `team`. That is the `HL_reason=moe` truncation again, in the field that was kept. 2. **A newline is worse than a space.** `publish_lock` writes `owner=${OWNER}` into `$META` line by line, and `meta_get` is `sed -n "s/^$1=//p" | head -1` — **first occurrence wins**. So `--owner $'gaff\ntakeover=none'` injects a `takeover` key that *outranks* the real one. That is the field added in #1885 precisely so a row could not assert "no takeover" about a run that may have reaped a corpse. 3. **#1885's point about staleness fields generalises one step further.** Its conclusion was that the fields disclosing staleness must *travel with* `held_by`. True — and they must also be **unforgeable by** `held_by`. Travelling together is not enough if one of them can rewrite the others. ## The fix `require_name`: `[A-Za-z0-9_.-]+`. An owner is a name; restricting it to one is not a limitation, it is what the field already meant. Validated in **two** places, and neither is redundant: | mutation | assertions that fail | |---|---:| | remove both `require_name` calls | **6** | | remove only the post-parse call | **1** — the `$HOSTLOCK_OWNER` one | The flag check gives a precise message at the point of the typo; the post-parse check catches the same text arriving through `$HOSTLOCK_OWNER` or a `$USER` with a space in it, which reach the published row by exactly the same route. ## Tests Nine assertions in `hostlock_test.sh`. **Three assert the parsed row rather than the raw string** — the defect is not that the text appears, it is that a consumer's reading of the row inverts, so asserting on the string would pass a fix that only escaped the display. One is the positive case: `--owner gaff-cpu.2` is still accepted, its row parses back to exactly one `held_by`, and `hostlock_state` parses to the value the row physically carries. This is a validation, not a lockout. The `every assertion in this file ran` pin goes **249 → 258**, and I verified it by running the suite rather than by arithmetic — a pin bumped to make the number match is the defect it exists to catch. **258 passed, 0 failed.** `bash -n` clean on both files. That pin is also what made me count what I had added instead of assuming; it is a good mechanism. ## Note for reviewers `held_uid` (from `/proc/<pid>/status`) is the only field in the row a peer **cannot** forge, and after this change `held_by` is constrained but still self-declared. Worth a header line saying which is corroborating and which is claimed, since a reader will otherwise trust the friendlier-looking name. Not done here — it is prose in someone else's file and this PR is already touching their two most recently merged areas. #1869 is also open against `scripts/hostlock.sh`. This change is confined to a new helper plus two call sites and does not touch `run`/`--ttl`, so it should merge either order, but flagging it. Auto-merge armed, no `--admin`; waiting on required CI. Co-authored-by: Gaff <gaff@squad.local> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Gaff reviewed
scripts/hostlock.sh(merged in73c76458c) against three live contamination incidents and raised four requirements. I checked each against the shipped code rather than assuming coverage: two were already covered, two were genuine gaps. An Opus review of the resulting commit then found 11 more defects and, worse, four mutations that left the suite green — new behaviour I could have broken without a single test noticing. Both rounds are in this PR.Round 1 — Gaff's review
Already covered (A, C).
runanchors to its own$$andacquireto$PPID, so the holder is the harness parent spanning all arms — Gaff's incident 3 was someone samplingpsin the gap betweenpf_beforeandpf_afterand concluding the host was clear. Reaping already compares/proc/<pid>/statfield 22, not justkill -0; with PIDs past ~1.5M after four days, reuse is not theoretical.Gap B1 — the gate was fail-open, the identical defect Gaff quotes in Sebastian's inline
gate(), where a satisfied precondition and an abandoned one bothreturn 0. I had shipped the same shape. It now releases the lock and exits 5;--on-gate-timeout proceedopts back in and is recorded asgate=timed_out_proceeded:<r>><gate>. Gaff's framing is why this matters more than an exit code: a lock that silently degrades to no lock is worse than none, because it launders the contamination into a label.Gap B2 — a stale-reaped acquire was indistinguishable from a clean one. Now
outcome=acquired_after_reap (<kind>)with the kind recorded;--strict-reaprefuses outright (exit 4).Gap D — lock state was never recorded into the measurement. New
provenancesubcommand emitskey=valuefields,--onelinefor row suffixes. Every one of Gaff's three incidents was recoverable only because someone happened to have an A/A null arm, which is luck, not method. Same lesson as #1729 one level up: the EP printeddecode_width requested=16 realized=16 as_requestedwhile placing 16 workers on 8 physical cores — the count was reported, the placement never was.Accepted advisory: the lock is the primary admission signal, the runnable count is secondary. Gaff's counterexample is decisive — a bounded 4-of-32-CPU good citizen shows runnable 4–5 and trips
--gate 3, while a single-threaded 100% hog shows ~1 and sails through. This revises guidance I previously gave the team: runnable count is still right versus the loadavg EMA, and wrong as an admission test.Round 2 — the review of round 1
The through-line: my own stated principle —
unknownis honest, a guess is not — was applied tocontendedand violated everywhere else.Must-fix
eval-ing the provenance line.reasonis free text written by whichever peer holds this shared fixed-path lock, unquoted among space-separated fields."moe matrix sweep"truncated toHL_reason=moe— reassuring and wrong — and a shell-active reason executed.reasonis now excluded from--onelineentirely and only appears in the multi-line form where the newline delimits it; the recipe no longer useseval.meta_getcould not tell an absent key from an empty value, so provenance printedtakeover=none gate=none— assertions — for locks written by the previous version of this script, which have neither key. Guaranteed to mislabel runs during any rollout window. Absent now surfaces asunknown.--strict-reapran after the gate, so with a gate configured it took over the lock it was told to refuse, held it for the full--gate-timeout(default 900s) blocking everyone, and then exited 5 with the less informative diagnosis. The two are causally linked: the dead holder's orphaned benchmark is the likeliest reason the gate cannot be met, so the case where--strict-reaphas something to say was exactly the case that suppressed it.outcome=acquired_after_reapto stdout before exiting 4, so a harness greppingoutcome=— the machine-readable channel this PR introduces — recorded a success for a refused run whose lock had been released. Nowoutcome=reap_refused (<kind>).Should-fix
--on-gate-timeoutwith no value pinned one core at 100% forever:${2:-fail}supplies a valid default so validation passes, thenshift 2fails silently (noset -e),$#never decreases, and the parser spins with no syscall in it — on the box whose contention this script exists to control. All value-taking flags now require a value; numeric ones are validated.declared=nofor a live holder past its TTL. With a 3600s default and an anchor that is an agent session alive for days, that is the design's steady state, anddeclaredis the one boolean a reader filters on.gate=<threshold>was seeded bypublish_lock, so an in-progress gate read as an outcome →gate=requested:N.run, which propagates the wrapped command's status. Documented rather than changed.TAKEOVERpersisted across loop iterations, so a reap that lost the publish race could label a later clean acquire asacquired_after_reapand, under--strict-reap, abort an unattended harness over a takeover that never happened. It must survive from the reaping iteration to the publishing one, so it is cleared when it stops describing us — not at the top of the loop, which destroys the value it carries. (I made that mistake first; the suite caught it.)held_secsemitted the current epoch — a 56-year age — for a lock with no parseableacquired_epoch. Nowunknown.Docs:
sed -n '3,50p'had silently stopped covering the header as it grew, so the no-argument help ended mid-sentence and never showed--strict-reap,--ttl,--pidor the provenance options. Now an awk range terminated by an explicit marker, with a test asserting the help mentions each flag.Test power — the part that mattered most
Four mutations left the suite at 70/70:
remove_lock_if_mine→remove_lockremove_lock_if_mine→remove_lockmeta_set's anchor guardTAKEOVER=ttl_expired→ a wrong valuemeta_setstages beside the lock dirnoneThe first two were satisfied by unconditional removal, because asserting "the lock is FREE afterwards" has no power over an anti-theft guard — proving the guard needs a successor to exist. Both release paths now go through one
abandon_lock: the strict-reap refusal fires microseconds after publish and cannot have a successor, so a duplicated guard there is untestable by construction, and sharing the code puts both under the one test that has power.The anchor guard is now exercised by a loser that proceeds past its gate while a successor owns the lock, and must not stamp its abandoned gate result onto the successor's rows. And
meta_setstaging inside$LOCK_DIRis asserted structurally, because that placement — not the anchor check — is what actually makes it safe against a concurrent reap:remove_lockrenames the whole directory away, so a staged file inside it goes with the corpse. Every other temp path in the file is a sibling, so "normalising" this one is a plausible future edit, and it would land our metadata on a successor's live lock: theft plus a permanent leak.Validation
shellcheckclean. Tests 49 → 115, all passing. Seven mutations verified red.Two test bugs fixed along the way, both mine: the race assertions counted the prose
'acquired by', which silently stopped matching once a reaping winner printedoutcome=acquired_after_reap(RACE B went to zero winners while "reaped exactly once" still passed); and one new test ran the acquirer under$( ), whose subshell exits immediately and takes$PPID— the liveness anchor — with it, so the lock legitimately readSTALE. The code was right and the test was wrong.Scripts only; no crate code touched, so this took no measurable CPU while the host is under measurement by others.