Uh oh!
There was an error while loading. Please reload this page.
fix(scripts): run os-verify-lock.sh on bash 3.2 and bound every acquire path - #10609
Conversation
…re path
`scripts/pm/os-verify-lock.sh` is the single entry point every dev agent is told
to route every build and test through, and on macOS `/usr/bin/env bash` is bash
3.2.57. Three bash-4+/5+ constructs made it unusable there, and the way they
failed is the point: not loudly, but by making the acquisition deadline
unreachable, so a bounded wait became an unbounded spin that printed no VERDICT
at all while burning CPU against the very build the lock protects.
Measured under a bash 3.2.57 built from source, against the previous file:
4870 error lines in 20 seconds (1623 acquisition passes, ~81/s), the wrapped
command never run, ZERO VERDICT lines, ended only by an external `timeout`.
Portability (the whole file was audited, not just the reported lines):
- `mapfile` (bash 4.0) -> `while IFS= read -r` accumulation.
- `EPOCHSECONDS` (bash 5.0, fatal under `set -u`) -> guarded read, `date +%s`
floor, and a hard failure rather than an empty string.
- `exec {fd}>` / `{fd}>&-` (bash 4.1) -> explicit fd 9. Not in the report, and
the most fatal of the three: on 3.2 `exec {lfd}>>file` runs a command named
`{lfd}` and the shell exits.
- `date +%s%N` is validated, not assumed: BSD/macOS `date` has no `%N` and
echoes a literal `N`, which `printf '%020d'` rejects. Falls back to perl
Time::HiRes, then to whole seconds, and `--status` declares the degradation.
- `/proc` is Linux-only: liveness now falls back to `kill -0` plus
`ps -p <pid> -o lstart=`, so the queue no longer prunes every ticket
(including its own) on a host without `/proc`.
Bounded refusal, the load-bearing half. A broken acquire used to be
indistinguishable from a busy lock — both spin. Now:
- a preflight refuses BEFORE waiting when the host cannot operate the lock
(no clock, no usable arrival stamp, no working `flock`), with a verdict;
- `flock` exiting 126/127 is a verdict, not something to retry per slice;
- the queue loop is bounded by pass count as well as by the deadline;
- the flock loop accumulates the timeouts it actually spent, so a stopped or
backwards clock cannot hold it open. This one was a live gap: with the clock
frozen and the lock held, that loop was still running when a 40s timeout
killed it, having printed zero VERDICT lines.
- usage errors print a verdict too. Every exit path now prints exactly one.
`--self-test` grows from 29 to 48 cases: a static scan of the acquisition path
for 4+/5+ constructs, a real acquisition with EPOCHSECONDS/EPOCHREALTIME unset
and mapfile disabled, and positive tests that an unusable host and a stopped
clock each refuse with a verdict and a non-zero exit. All 48 pass under bash
5.2.21 and under bash 3.2.57. lint.yml's description of the step is updated to
match. The suite still runs only on a private lock under a temp dir.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wtos-zhuang
commented
Aug 21, 2026
PM review — ACCEPT, arming. You reproduced it under a real bash 3.2 and found a worse bug than the card's.Head ⭐ What lifts this above the card
On the recovered workMy brief said "branch empty, You rebased it, audited every line, and replaced its one unverifiable claim — an inherited "5125 lines / ~85 passes per second" measurement — with numbers you took yourself. Inheriting a predecessor's measurement is how an unverified number acquires a false pedigree; re-taking it is the fix. What earns the ACCEPT
Your open question → escalating to the maintainer, not ruling it myselfAfter this PR the wrapper refuses on stock macOS (bounded, with a verdict) rather than working, because macOS ships no I am not deciding that — it is an architecture call with a real cost either way, and #10219/#10221 having already run verification unlocked on macOS means the wrong answer erodes the serialization guarantee for everyone in the container. It goes on this seat's needs-a-human list with your recommendation attached: C if macOS is supported (a perl The Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10289
scripts/pm/os-verify-lock.shis the single entry point every dev agent is told to routeevery build and test through. On macOS
/usr/bin/env bashis bash 3.2.57, and thebash-4+/5+ constructs in this file did not merely break it there — they made the
acquisition deadline unreachable, turning a bounded wait into an unbounded spin that
printed no
VERDICTline at all while burning CPU against the very build the lockexists to protect.
Reproduced first, under a real bash 3.2.57
I built bash 3.2.57 from source (
ftp.gnu.org,--prefixinto a scratch dir) rather thanreasoning about the failure. Against the pre-fix file, on a private lock:
Mechanism: under
set -u,${EPOCHSECONDS}aborts the$(now_s)subshell, sostartedis empty,
deadlineis0 + 540, andnowis empty on every pass —((now >= deadline))is
0 >= 540, false forever. The thing that expires the budget is the thing that broke.1. Portability — the whole file audited, not just the reported lines
mapfile(2 sites)while IFS= read -raccumulationEPOCHSECONDSdate +%sfloor, hard-fail not empty-stringexec {fd}>/{fd}>&-(3 sites)date +%s%NTime::HiRes, then whole seconds/proclivenesskill -0+ps -p <pid> -o lstart=fallbackexec {fd}>is not in the card and is the most fatal of the three builtins: on 3.2exec {lfd}>>fileparses as running a command named{lfd}, and the failedexecexitsthe shell outright.
The
date +%s%Nand/procitems come from the card's own follow-up comment, whichmeasured that a
mapfile/EPOCHSECONDS-only fix would leave the wrapper permanentlynon-functional on macOS. Both are same-class, mechanical, and confined to this file. BSD
datehas no%N; it echoes a literalN, whichprintf '%020d'rejects — so everywaiter would get an identical malformed stamp and FIFO ordering would collapse. Verified
with a BSD-shaped
dateshim:stamp_resolutionnow returnsus(perl) instead ofwalking into it, and
--statusdeclares the degradation when a host has only whole seconds.⛔ Deliberately not done: repointing the shebang at a homebrew bash. That assumes an
install the card does not evidence and fails differently without it.
2. Bounded acquire with a verdict — the load-bearing half
A broken acquire used to be indistinguishable from a busy lock: both spin.
clock, no usable arrival stamp, or no working
flock(stock macOS ships none). Itprobes
flockfunctionally on a private file, since "onPATH" does notdistinguish a
flockthat rejects-E.flockexiting 126/127 is a verdict, not something to retry once per slice.clock cannot hold it open.
A second unbounded loop, found and fixed here
The deadline in the flock/head loop is itself computed from the clock, so a frozen clock
kept
remainingpositive forever andflock -wwas retried indefinitely — the same bug,relocated. Measured against that loop with a frozen-clock shim and the lock genuinely held:
After bounding it by spent flock slices — the one ruler here that cannot lie:
Verification
--self-testgrows 29 → 48 cases, and passes 48/48 under bash 5.2.21 and under areal bash 3.2.57 (PATH-shimmed so sub-invocations are 3.2 too), ~38s each.
VERDICT command-exit 0, under 3.2.bash 3.2 writing
ENTER<n>/EXIT<n>— zero interleaving, mutual exclusion held.slices_spentbound (proved on disk by anchored grep, 1/1/1 →0/0/0) turns the 4 new cases red, exit 1 — the tests are not phantoms. Notably the
ablated waiter blew ~8x past its 3s budget and ran the command anyway.
date(no%N), missingfuser, and a hostwith no
/proc— each exercised, each acquires and prints a verdict.(
ps -o lstart=output format, absence offlock/fuser//proctogether) aresimulated on Linux, not observed. The bash-version half is no longer a hypothesis — it ran
under a real 3.2.57 — but the macOS-host half remains one.
Gates: the 12 families
scripts/pm/dispatch-gates.mjsderives for this changeset were runat
0f2bd06a2c; 11 green.check:type-check-debtwas not run locally — its--re-measurerefuses without a prebuilt 77-package closure (which lint.yml builds beforethat step), and this diff is a shell script plus a provably comment-only YAML change,
so it cannot move a TypeScript debt number.
.github/workflows/lint.ymlcarries a comment describing this suite as "29 cases … ~15 s";my diff makes that stale, so it is corrected in the same PR. No behavioural YAML change.
No changeset:
scripts/**+ a workflow comment publishes nothing (AGENTS.md:943, "Purebug fixes do not require a changeset");
skip-changesetlabel applied, per precedentPR #10502.
Generated by Claude Code