Repository navigation
fix(hostlock): a host that cannot take the lock must say UNUSABLE, not FREE - #1989
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1989 +/- ##
==========================================
+ Coverage 80.66% 81.04% +0.37%
==========================================
Files 424 425 +1
Lines 207766 208150 +384
Branches 207766 208150 +384
==========================================
+ Hits 167601 168688 +1087
+ Misses 34533 33819 -714
- Partials 5632 5643 +11
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
…t FREE
FREE is not a fact about a directory, it is a promise: the host is yours,
go ahead. Where the lock cannot be CREATED that promise is false. Measured
against origin/main on the two fixtures this adds, rather than reasoned
about:
parent chmod 500:
status FREE (runnable=4) rc 0 fail open
status --porcelain state=FREE rc 0 fail open
wait hostlock: free (runnable=4) rc 0 fail open
acquire / run mkdir: Permission denied rc 1 misclassified
lock path is a plain file:
acquire --wait timed out after 12s waiting for the lock, while
printing FREE underneath it rc 3 fail open
The first three tell an unattended harness to proceed on the one host where
the lock is unavailable: `status` is the README's opening "is anybody
benchmarking?" and `wait` exists to be followed by a run. The fourth is a
correct refusal with the wrong classification -- exit 1 is documented as
usage/error, so a caller cannot tell a broken host from its own typo. The
fifth is the worst: an idle box, the whole --timeout spent, and then peers
blamed for contention that does not exist.
Adds a fifth state and one place that decides it. lock_dir_problem() tests
the PARENT, never the leaf, because publish_lock stages at a sibling and
renames it into place -- so an existing-but-unwritable lock dir is still
publishable (and a second agent's honest answer there is BUSY, not
UNUSABLE), while a deep path under a writable ancestor is usable because
mkdir -p builds the rest. Both -w and -x matter: a writable but unsearchable
directory cannot host entries either. status reports it with the reason;
--porcelain gains lock_dir_problem=, emitted unconditionally and empty when
there is none, because a consumer that learns the state from a key's
presence reads absence as "fine". acquire/wait/run refuse with exit 7 --
distinct from 1 because a misconfigured host is not a bad argument, and from
2 and 3 because those assert a peer holds the box -- and the refusal
precedes the wait loop, since a correct code that arrives 900s late is still
the defect.
What this does NOT do, stated because the first draft implied otherwise:
it detects access(2) denials, not a sandbox that mediates only the mutating
syscalls. /tmp here is mode 1777, so `test -w` is true for every user, and
an agent forbidden /tmp by policy still gets FREE. A script cannot detect a
policy; that case is the machine-local lock_dir= config (#1953).
Suite 340/340 (pin 321 -> 340, 19 new cells). Mutation battery 9/9 killed.
M5 (test the leaf, not the parent) and M9 (drop the unsearchable half,
raised by review) were both found SURVIVING, and are why the BUSY cell and
the 0600-parent cell exist. Rust reader divergence filed as #1997.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
6886421 to
1338bdf
Compare
|
Review addressed. All three SHOULD FIXes landed, and two of them changed what this PR claims, not just what it tests — so the body and the commit message were rewritten rather than patched. SHOULD FIX 1 — the SHOULD FIX 2 — the promptness cell was not an independent guard. Also correct, and the interesting part is why: on the The staging dir is a sibling, so with an unwritable parent That measurement also falsified two claims in my own PR body, which are now gone: base SHOULD FIX 3 — the guard measures It remains a real fix for DAC denials, read-only mounts, NIT (f) — the Rust reader now disagrees. Agreed, and filed as #1997 rather than widened into this PR: NIT — root. Noted in the issue rather than fixed. Under Suite 340/340 (pin 321 → 340, 19 new cells). Mutation battery 9/9 killed — M5 and M9 both found surviving first, which is the only reason I trust the rest of it. |
Review named the one mutation the M1-M9 battery missed: swap the UNUSABLE preflight with `refuse_if_legacy_held` in cmd_acquire and the whole suite stays green. Every cell in the new section sets HOSTLOCK_DIR, so LOCK_DIR_SOURCE is `env`, and refuse_if_legacy_held returns immediately for any source but `config` -- the ordering it asserts is invisible to all of them. It is not cosmetic. On a config-source host with a live legacy holder and an unusable configured dir, the swapped order answers 2 (busy) instead of 7: the agent is told a peer has the box and goes away to wait for a lock it could never have acquired, and the reason it cannot is never printed. Fixture: a config pointing lock_dir at the unwritable parent plus the same live legacy holder the migration section uses. 341/341, and the mutation is killed by exactly one cell. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
M10 closed rather than accepted, because the comment above it was making the claim ("Refuse BEFORE anything else, including the legacy consult") and nothing was holding it to that. You are right that every cell in the section sets Exactly one cell reddens it, which is the property I wanted. And the severity is not quite as low as it looks from the fixture's exoticism: that combination — a config-source host, a live holder on the old path, a configured dir the host cannot create — is precisely the state the Also took your Not taking the root-caveat note as a code change either: under 341/341, mutation battery 10/10 killed. Three of the ten (M5, M9, M10) were found surviving first — two of those by you — which is most of why I trust the other seven. |
#2026) ## What `onnx_runtime_hostmon::hostlock::read` reported **`Free`** on a host where the lock directory does not exist *and cannot be created*. Every benchmark row taken there was stamped `host_lock=free`. That is a fail-open, and it is the same one #1989 fixed in `hostlock.sh` — reintroduced one layer down, where it is the more durable half: | | on an unusable host, before this PR | |---|---| | `hostlock.sh` | `UNUSABLE`, and `acquire`/`run` **refuse** with exit 7 | | `hostlock::read` | `Free` → `host_lock=free` on every emitted row | The script at least stops. A false `free` is a *claim about conditions* that outlives the run: it says "nobody had declared the machine", when the true statement is "nobody could have — including whoever was running beside this measurement". Weeks later it reads as reassurance. Closes #1997. ## Measured, on `origin/main` The reader resolves the lock dir correctly (#1942) and then opens `<dir>/meta`. Path resolution succeeds *into* a mode-500 parent (it has `x`), finds no `hl` entry, and returns `ENOENT` — which `classify_io` maps to `Free`. So the arm that produces the wrong answer is the one whose comment says *"Absent is a measurement: nobody has taken the lock."* It is a measurement only when absence was a choice. ## What changed - **`LockState::Unusable` / `LockField::Unusable`**, printed `unusable`. Never `is_protected()`. `Unusable` vs `Free` across the window is `changed` — a host that becomes usable mid-run did change custody in the only sense that matters. - **`dir_problem`** ports `lock_dir_problem` from the script, including the two things that were got wrong there first and would have been got wrong again here: - it asks the **nearest existing ancestor**, not the lock dir, because publishing stages at a *sibling* and `mv -T`s it into place; - it requires `W_OK` **and** `X_OK`. A mode-0600 directory is writable and still cannot hold an entry (`mkdir 0600-parent/sub` → `EACCES`). A `-w`-only check passes a host on which nothing can be created. - It calls **`access(2)`** rather than reading `st_mode`, because the script's `test -w` resolves to `faccessat`. Reimplementing the rule from mode bits would disagree with the writer on uid, supplementary groups, ACLs and read-only mounts — on precisely the hosts where the answer is interesting. - **`state_at`** keeps the script's ordering: **an existing lock directory outranks any question about writability.** Reporting `unusable` for a directory a peer has already published into would relabel real contention as a broken config, and send an agent off to fix its own machine while somebody else's benchmark runs. (This is the mutant that survived the first battery on the shell side.) - `scripts/ort_ab/README.md` gains the `unusable` row in the `host_lock=` value table. ## Anti-vacuity Six mutations, each killed by at least one named cell: | mutation | killed by | |---|---| | absence is always `Free` | `a_host_that_cannot_take_the_lock_reads_unusable_in_both_implementations` | | `W_OK` only, no `X_OK` | same (the mode-0600 cell) | | dir problem outranks a published holder | `a_published_lock_outranks_a_host_that_could_not_have_created_it` | | "exists and is not a directory" arm removed | the plain-file cell | | `field` maps `Unusable` → `Free` | `an_unusable_host_does_not_print_as_a_free_one` | | `Display` prints `unusable` as `free` | same | The differential test compares against the **script's real porcelain** (`state=UNUSABLE`, `state=FREE`, `state=HELD`), not against my expectation of it — a reader that agreed with a comment and not with the writer is the failure that whole file exists to prevent. It carries a creatable-path control, so a guard that degenerated into a blanket refusal cannot pass, and it asserts the `acquire` refusal alongside the state. ## Validation - `cargo test -p onnx-runtime-hostmon` — 29 + 10 + 31 + 1 + 2 pass, 0 fail. - `cargo clippy -p onnx-runtime-hostmon --all-targets` clean; `cargo fmt --check` clean. - `scripts/hostlock_test.sh` — **341/341**, unchanged (the script is not touched). - No benchmark was run for this PR, so it needed no host lock and took none. Scope: one crate plus one README row. No EP, kernel or scheduling behaviour. --------- Co-authored-by: Leon <leon@squad.local> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Why
FREEis not a fact about a directory, it is a promise to the caller: the host is yours, go ahead. On a host where the lock cannot be created, that promise is false — and the tool made it anyway.Measured against
origin/main, on the two fixtures this PR adds, rather than reasoned about:origin/mainchmod 500statusFREE (runnable=4), rc 0chmod 500status --porcelainstate=FREE, rc 0 — bytes identical to a genuinely free hostchmod 500waithostlock: free (runnable=4), rc 0chmod 500acquire/runmkdir: … Permission denied, rc 1acquire --wait --timeout 12timed out after 12s waiting for the lockand printsFREEin the same breath, rc 3, elapsed 15 sThe first three tell an unattended harness to proceed on the one host where the lock is unavailable.
statusis the README's own opening move (scripts/hostlock.sh status # is anybody benchmarking?) andwaitexists to be followed by a run. The fourth is a correct refusal with the wrong classification: exit 1 is documented as usage/error, so a caller cannot tell a broken host from its own typo. The fifth is the worst of them — the box is idle, and the tool spends the whole--timeoutbefore blaming peers for contention that does not exist, while printingFREEunderneath the complaint.Correction to my first draft of this PR, which claimed
run"ran the benchmark unlocked" and thatacquire --waithung on the permission fixture. Neither is true and the reviewer was right to push: on thechmod 500fixturepublish_lockdies immediately, so baserunexits 1 in 0 s and does not run the command. The hang is real but needs the plain-file fixture, where the staging sibling is created fine and only the finalmv -Tfails — indistinguishable, to the loop, from "somebody got there first". The table above is what I measured; the narrower claim is the true one.What
A fifth state,
UNUSABLE, and one place that decides it.lock_dir_problem()— echoes the reason and returns 0 when no lock can be created, 1 when one can. It tests the parent, never the leaf:publish_lockstages at${LOCK_DIR}.stage.$$, a sibling, and renames it into place, so the real question is "can we create entries next to it", answered at the nearest ancestor that exists (mkdir -pbuilds the rest). Both halves matter — a directory that is writable but not searchable (mode 0600) cannot host entries either.statusreportsUNUSABLEwith the reason;--porcelaingainslock_dir_problem=, emitted unconditionally and empty when there is none — a consumer that learns the state from a key's presence reads absence as "fine", which is the failure the field exists to report.acquire/wait/runrefuse with a new exit code 7: distinct from 1 because a misconfigured host is not a bad argument and must not be retried as one, and distinct from 2 and 3 because both of those assert a peer holds the box.UNUSABLE; the old branch tested-dand fell through toFREE.HELDlock readsHELDwhatever the directory's mode says, or the state that stops a second agent taking the box would be answerable with achmod.Evidence
scripts/hostlock_test.sh— 341/341, count pin 321 → 341 (20 new cells), full suite, on this host.bash -nandshellcheck -S warningclean on both files.Mutation battery, each mutant run against the whole suite:
Three of these were found surviving and are the reason four cells exist:
M5 survived the first round. With
p=$LOCK_DIRthe ancestor walk climbs to the parent anyway, so for an absent lock dir the mutant is behaviourally identical. The difference appears only when the lock dir exists and is unwritable, where the mutant reportsUNUSABLEfor a lock a peer is legitimately holding — relabelling real contention as a broken host, which is the expensive direction: it sends the agent off to fix its config while somebody's benchmark is running. Cell:a held-but-unwritable lock dir is BUSY, not UNUSABLE.M9 was raised by review, not by me: every unusable fixture I had written was
chmod 500(not writable, but executable), so the[ ! -x "$p" ]clause was untested and deleting it was a one-line fail-open the whole suite stayed green through. Cell:a writable but unsearchable parent is UNUSABLE too.M10 was raised by the re-review: swapping the preflight with
refuse_if_legacy_heldleft the whole suite green, because every cell in the section setsHOSTLOCK_DIRand the legacy consult is a no-op for any source butconfig. Not cosmetic — on a config-source host with a live legacy holder and an unusable configured dir the swapped order answers 2 (busy) instead of 7, sending the agent away to wait for a lock it could never have acquired, with the reason never printed. Cell:an unusable configured dir refuses as 7 even while the legacy path is live.M1 is worth a note: it kills the porcelain cell and the file cell but not the human
statuscell, becausecmd_status's human branch andlock_statedecide independently. That is why both cells exist rather than one.Negative controls, so the new cells cannot pass vacuously: a usable free host must still print
FREE, must emit the samelock_dir_problem=key empty, and a deep path under a writable ancestor must be acquirable for real (M5 and M6 kill that pair).Known limits, stated rather than implied
access(2)denials — DAC modes and read-only mounts — not a sandbox that mediates only the mutating syscalls. For a1777 /tmp,test -wis true for every user, so an agent forbidden/tmpby policy rather than by permission still getsFREE. A script cannot detect a policy; the honest fix for that case is the machine-locallock_dir=config (see the README section, and Decide when to move this box's benchmark lock off /tmp (needs every worktree past #1942 first) #1953), and this PR does not claim otherwise.onnx_runtime_hostmon::hostlock) still classifies an absent-because-uncreatable lock asfree, so on such a host the script and the reader now disagree. The harm is pre-existing — both said free before — but the divergence is new and I have filed it as hostmon reader still reportsfreefor a lock dir that cannot exist, now that hostlock.sh says UNUSABLE #1997 rather than widen this PR into Rust.Scope
scripts/hostlock.sh, its self-test, and the two paragraphs ofscripts/ort_ab/README.mdthat enumerate the states. No behaviour change on any host where the lock directory can be created:lock_dir_problemreturns 1 and every path is exactly as before.agrees_with_hostlock_sh.rsmatches porcelain withcontains, not a strict key set, so the added key is compatible (8/8 pass).No
--admin, no ruleset bypass. Merged only viascripts/merge_when_green.sh, after every required context has reported SUCCESS on this head.