Skip to content

fix(hostlock): an owner is a name, so a row cannot claim it is free while held - #1908

Merged
justinchuby merged 1 commit into
mainfrom
squad/gaff-hostlock-owner-injection
Aug 24, 2026
Merged

justinchuby merged 1 commit into
mainfrom
squad/gaff-hostlock-owner-injection

Conversation

@justinchuby

Copy link
Copy Markdown
Owner

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.

$ 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 fix(hostlock): give the reaper guard an owner, so a kill cannot wedge the box #1885 precisely so a row could not assert "no takeover" about a run that may have reaped a corpse.

  3. fix(hostlock): give the reaper guard an owner, so a kill cannot wedge the box #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.

…hile held

`reason` was taken out of `--oneline` because it is free text written by
whichever peer holds a shared fixed-path lock. That rationale is 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.

    $ hostlock.sh acquire --owner 'gaff hostlock_state=FREE declared=no'
    $ 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 ...

Parsed last-wins with awk -- the idiom this script's own documentation
recommends over the shell -- that row reads back `hostlock_state=FREE
declared=no`. The row physically says HELD. Reproduced against an
isolated HOSTLOCK_DIR, never the shared lock.

A lock that silently degrades to no lock is worse than none, because it
launders the contamination into a label. This is the stronger case: it
does not degrade to unknown, it asserts FREE while held.

Three things make it worth a guard rather than a doc note:

- 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` -- the HL_reason=moe truncation again,
  reassuring and wrong.
- A newline is worse than a space. publish_lock writes `owner=${OWNER}`
  into the metadata file line by line, and meta_get is
  `sed -n 's/^key=//p' | head -1`, so FIRST occurrence wins: an injected
  `takeover=` outranks the real one. That is the field added so a row
  could not assert "no takeover" about a run that reaped a corpse.
- The fields that disclose staleness were made to travel with `held_by`.
  Travelling together is not enough if one of them can rewrite the
  others.

Restricting an owner to `[A-Za-z0-9_.-]+` is not a limitation; it is what
the field already meant. Validated in two places, and neither is
redundant: the flag check gives a precise message, the post-parse check
catches the same text arriving through $HOSTLOCK_OWNER or a $USER with a
space. Mutation-verified -- removing both fails 6 assertions, removing
only the post-parse check fails exactly 1, the environment one.

Nine assertions added. Three assert the parsed row rather than the raw
string, because the defect is not that the text appears but that a
consumer's reading of the row inverts. One asserts the positive case
(`--owner gaff-cpu.2` still accepted, exactly one `held_by`,
`hostlock_state` parsing to the value the row carries) so this is a
validation and not a lockout.

The `every assertion in this file ran` pin goes 249 -> 258, verified by
running the suite rather than by arithmetic: 258 passed, 0 failed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby enabled auto-merge (squash) August 23, 2026 23:59
@codecov

codecov Bot commented Aug 24, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.33%. Comparing base (fd5756d) to head (12f4747).
⚠️ Report is 8 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1908      +/-   ##
==========================================
+ Coverage   80.15%   80.33%   +0.18%     
==========================================
  Files         413      415       +2     
  Lines      200757   204450    +3693     
  Branches   200757   204450    +3693     
==========================================
+ Hits       160910   164248    +3338     
- Misses      34326    34626     +300     
- Partials     5521     5576      +55     
Flag Coverage Δ
cli-ort-linux 72.51% <ø> (?)
cli-ort-windows 72.10% <ø> (+0.09%) ⬆️
mlas 85.10% <ø> (?)
offline 80.46% <ø> (+0.09%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 23 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@justinchuby
justinchuby merged commit 47ceafd into main Aug 24, 2026
13 of 17 checks passed
@justinchuby
justinchuby deleted the squad/gaff-hostlock-owner-injection branch August 24, 2026 00:45
justinchuby added a commit that referenced this pull request Aug 24, 2026
…fuse-run-ttl

Both sides appended a new assertion block at the same point in the suite, so
git could not tell they were independent. Resolved by keeping BOTH blocks --
this branch's `run --ttl` refusal and #1908's owner-injection refusal -- and
re-deriving the pinned count by running the suite: 265.

Verified per-assertion rather than by the total alone, since a total can be
made to match while a block is missing:

  this branch's 5 TTL assertions   all present, all PASS
  #1908's 3 owner-injection ones   all present, all PASS
  "every assertion in this file ran"  PASS

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 24, 2026
…at produces it

Follow-up to #1903, fixing a defect its review found and I merged anyway
because auto-merge fired on green CI while I was still writing the fix.

#1903 replaced a fixed `--min-efficiency 0.8` with a threshold derived from a
probe run on the same host, because 0.8 is an exclusive-host assumption and
this suite has to pass co-tenanted (#1802). The correct fix, with a defect:
a threshold derived from the measurement it judges is invariant to any
CONSTANT scaling of that measurement. Review demonstrated it rather than
suspecting it:

    eff = c / (w * n) * 0.5

halves every efficiency, every derived threshold and every ratio together,
and all 249 assertions passed. The fixed 0.8 that #1903 removed is precisely
what would have caught it. The two mutations I had run -- `eff` = constant
1.000, and dropping cstime -- both move the numbers RELATIVE to each other,
which is the one class a self-referential threshold can still see, so my
"coverage is preserved, verified by mutation" was a reassuring conclusion
drawn from the wrong experiment.

So pin the number to two anchors that do not come from that code path:

  * the child's own accounting of its own CPU -- os.times() inside the
    command, written to a file, compared against hostlock's cpu= field;
  * the arithmetic relating the three fields on the row it prints,
    efficiency ~= cpu / (wall * cores).

Both hold under any load, because both compare the run against itself rather
than against an expectation of the host, so neither reintroduces the
quiet-host assumption #1903 removed.

Mutation-verified:

  eff = c/(w*n) * 0.5   (constant scaling)     260/1 RED  <- was 249/0 GREEN
  CPU_TICKS=$((cu))     (kernel time dropped)  258/3 RED  (unchanged)

Both re-measured after rebasing onto main at cdc7d93, because #1908 added
nine assertions to this same file in the interim and a mutation count taken
against the older total would be a number nobody measured.

Two robustness fixes to #1903 in the same area, both observed rather than
theorised. The derived-threshold slack goes 0.9 -> 0.5, and the kernel-time
ratio 0.7 -> 0.5: the probe and the run it judges are separate 1.5s arms
taken seconds apart, so a co-tenant arriving between them starves only the
second, and at 0.9 that reddens the pair for a reason that has nothing to do
with the gate under test. That is the same "goes red when somebody starts a
build, so people re-run until green" trap #1903 exists to remove, and it was
observed twice on this box: two assertions failing, then the identical file
passing minutes later with nothing changed but the neighbours. 0.5 still leaves 2x headroom over the ~0.23
that dropping kernel time produces.

And one cell that cannot be made vacuous by load at all: the verdict must
follow the number printed on the same row. That holds at any starvation level
and catches a gate that always says ok, always says contended, or has its
comparison backwards.

Suite 258 -> 261. shellcheck clean. No crate code.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 24, 2026
…and say why this must never be required

Three corrections to #1912, all found in review of it.

The comment said "252 assertions (249 before #1910)". That was a projection,
not a count: 249 was the branch total and +3 was a PR that had not landed.
Neither number was ever the total on main, which went 249 -> 258 (#1908) ->
265 (#1869) while #1912 sat in review. The suite already pins its own total in
its last assertion, `every assertion in this file ran`, which is the only copy
that cannot drift because it fails the run rather than misinforming a reader.
Same defect this lock exists to catch, one level down: a figure published
where nothing can falsify it.

The second is load-bearing. This job is non-required by choice, but with
`pull_request: paths:` a PR that does not touch these files gets NO job rather
than a skipped one, so promoting it to a required check would leave every
unrelated PR sitting on "Expected -- waiting for status" forever. Recorded
next to the trigger so the next person to reach for the branch-protection
settings reads it first.

The third is that my own explanation of the escape hatch was wrong, in the
same commit that complains about unchecked claims. I wrote that `ci.yml`
"documents the mirror image of that trap on its `changes` job, which uses a
job-level `if:`". `changes` has no `if:` at all, deliberately -- its comment
records that gating it once skipped the entire workflow, because a job whose
`needs` dependency is skipped is skipped whatever its own `if:` says. The
job-level `if: needs.changes.outputs.docs_only != 'true'` is on the two
REQUIRED jobs, and it works because a job skipped by a conditional still
reports a conclusion. Workflow-level `paths:` cannot be rescued that way:
there is no job to skip, so there is no conclusion to report.

Also records the first real run of this job on a GitHub-hosted runner, 3m17s,
against the 210s two-core emulation that sized the timeout. The emulation
predicted the real number, which is the reason to keep both rather than
replace one with the other.

Comment-only. No behaviour change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 24, 2026
…at produces it

Follow-up to #1903, fixing a defect its review found and I merged anyway
because auto-merge fired on green CI while I was still writing the fix.

#1903 replaced a fixed `--min-efficiency 0.8` with a threshold derived from a
probe run on the same host, because 0.8 is an exclusive-host assumption and
this suite has to pass co-tenanted (#1802). The correct fix, with a defect:
a threshold derived from the measurement it judges is invariant to any
CONSTANT scaling of that measurement. Review demonstrated it rather than
suspecting it:

    eff = c / (w * n) * 0.5

halves every efficiency, every derived threshold and every ratio together,
and all 249 assertions passed. The fixed 0.8 that #1903 removed is precisely
what would have caught it. The two mutations I had run -- `eff` = constant
1.000, and dropping cstime -- both move the numbers RELATIVE to each other,
which is the one class a self-referential threshold can still see, so my
"coverage is preserved, verified by mutation" was a reassuring conclusion
drawn from the wrong experiment.

So pin the number to two anchors that do not come from that code path:

  * the child's own accounting of its own CPU -- os.times() inside the
    command, written to a file, compared against hostlock's cpu= field;
  * the arithmetic relating the three fields on the row it prints,
    efficiency ~= cpu / (wall * cores).

Both hold under any load, because both compare the run against itself rather
than against an expectation of the host, so neither reintroduces the
quiet-host assumption #1903 removed.

Mutation-verified:

  eff = c/(w*n) * 0.5   (constant scaling)     267/1 RED  <- was 249/0 GREEN
  CPU_TICKS=$((cu))     (kernel time dropped)  265/3 RED  (unchanged)

Both re-measured against the current base rather than carried over: #1908
and #1869 added sixteen assertions to this same file while this PR was open,
and a mutation count taken against a superseded total is a number nobody
measured.

Two robustness fixes to #1903 in the same area, both observed rather than
theorised. The derived-threshold slack goes 0.9 -> 0.5, and the kernel-time
ratio 0.7 -> 0.5: the probe and the run it judges are separate 1.5s arms
taken seconds apart, so a co-tenant arriving between them starves only the
second, and at 0.9 that reddens the pair for a reason that has nothing to do
with the gate under test. That is the same "goes red when somebody starts a
build, so people re-run until green" trap #1903 exists to remove, and it was
observed twice on this box: two assertions failing, then the identical file
passing minutes later with nothing changed but the neighbours. 0.5 still leaves 2x headroom over the ~0.23
that dropping kernel time produces.

And one cell that cannot be made vacuous by load at all: the verdict must
follow the number printed on the same row. That holds at any starvation level
and catches a gate that always says ok, always says contended, or has its
comparison backwards.

Suite 265 -> 268. shellcheck clean. No crate code.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 24, 2026
…and say why this must never be required (#1917)

Two corrections to #1912, both raised in review of it.

## 1. The assertion count was already wrong twice

The comment said **"252 assertions"**. #1908 added nine to the same file
before #1912 merged, and #1910 adds three more — so the number was wrong
*in review* and wrong again *on merge*. A count in a comment is a number
nobody re-measures.

The suite already pins its own total in its last assertion — `every
assertion in this file ran` — which is the only copy of that number that
cannot drift, because it **fails the run** rather than misinforming a
reader. This is the same defect the lock exists to catch, one level
down: a figure published somewhere nothing can falsify it.

The timing evidence keeps its number but now says which version produced
it, since that one *was* measured.

## 2. This job must never be promoted to a required check

Non-required was a cost decision in #1912; there is a harder reason, and
it belongs next to the trigger.

With `pull_request: paths:`, a PR that does not touch these files does
**not** get a `skipped` job — it gets **no job at all**. A required
check by this name would therefore sit `Expected — waiting for status`
on every unrelated PR, forever.

`ci.yml` documents the mirror image of that trap on its `changes` job,
which uses a job-level `if:` precisely so it still reports a conclusion
when there is nothing to do. Recorded here so the next person to reach
for the branch-protection settings reads it before flipping the switch.

## Validation

Comment-only; the `on:`, `jobs:` and step definitions are
byte-identical. YAML parses. No behaviour change, so this PR's own run
of the job is the regression test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 24, 2026
…at produces it (#1910)

## What this fixes, and how it got merged unfixed

#1903's review found a real defect and I merged the PR anyway —
auto-merge fired the moment required CI went green, while I was still
writing the fix. This is that fix, as its own PR.

#1903 replaced a fixed `--min-efficiency 0.8` with a threshold **derived
from a probe run on the same host**, because 0.8 is an exclusive-host
assumption and this suite has to pass co-tenanted (#1802). That was the
right correction, with a defect inside it:

> **A threshold derived from the measurement it judges is invariant to
any constant scaling of that measurement.**

Review demonstrated that rather than suspecting it, by applying the
mutation:

```
eff = c / (w * n) * 0.5
```

Every efficiency, every derived threshold and every ratio halves
**together**, and all 249 assertions passed. The fixed `0.8` that #1903
removed is exactly what would have caught it. My two mutations — `eff` =
constant `1.000`, and dropping `cstime` — both move the numbers
*relative to each other*, which is the one class a self-referential
threshold can still see. So "coverage is preserved, verified by
mutation" was a reassuring conclusion drawn from the wrong experiment.
That is the seventh round in a row where the fix for the previous
finding carried the next one, and the direction is always the reassuring
one.

## The fix: anchor the number outside the code path that produces it

Two anchors, neither of which can be reached by scaling `eff`:

1. **The child's own accounting of its own CPU.** `os.times()` *inside*
the command, written to a file, compared against hostlock's `cpu=`
field.
2. **The internal arithmetic of the row it prints.** `efficiency ≈ cpu /
(wall × cores)`.

Both hold under any load, because both compare the run against
**itself** rather than against an expectation of the host — so neither
reintroduces the quiet-host assumption #1903 removed.

| mutation | before | now |
|---|---|---|
| `eff = c/(w*n) * 0.5` — constant scaling | 249 / **0 GREEN** ❌ | 267 /
**1 RED** ✅ |
| `CPU_TICKS=$((cu))` — kernel time dropped | 246 / 3 RED | 265 / **3
RED** |

Both re-measured against the current base rather than carried over:
#1908 and #1869 added sixteen assertions to this same file while this PR
was open, and a mutation count taken against a superseded total is a
number nobody measured.

## Two robustness fixes in the same area, both observed rather than
theorised

**Derived-threshold slack 0.9 → 0.5, kernel-time ratio 0.7 → 0.5.** The
probe and the run it judges are separate 1.5 s arms taken seconds apart,
so a co-tenant arriving in between starves only the second one. At 0.9
that reddens the pair for a reason that has nothing to do with the gate
under test — the same *"goes red when somebody starts a build, so people
re-run until green"* trap #1903 exists to remove. Observed twice on this
box: two assertions failing, then the identical file passing minutes
later with nothing changed but the neighbours. 0.5 still leaves **2×
headroom** over the ~0.23 that dropping kernel time produces.

**One cell that load cannot make vacuous:** the verdict must follow the
number printed on the same row. That holds at *any* starvation level,
and catches a gate that always says `ok`, always says `contended`, or
has its comparison backwards.

## Validation

- Suite **265 → 268**, green on current `main`, and green under `taskset
-c 30,31` — two cores, the shape of a GitHub runner.
- The new `Host lock conformance` CI job (#1912) runs the suite on this
PR: **pass in 3m17s** on a GitHub-hosted runner, the first time the
suite has ever run outside the box it locks.
- Both decisive mutations red, restored after each.
- `shellcheck` clean. No crate code, no runtime behaviour change —
`scripts/hostlock_test.sh` only.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant