Skip to content

Commit 4384b34

Browse files
os-zhuangclaude
andauthored
fix(pm): stop the verify lock's slice backstop from explaining an ordinary timeout (#10863)
The slice backstop asserted a specific fault — "the clock this script polls reported none of it" — without ever comparing slice accounting against the clock. A waiter that reached the head of the queue spent its whole budget in slices (18 x 30s = 540s = BUDGET), so `slices_spent >= BUDGET` came true at the bottom of pass 18, one check before `remaining <= 0` at the top of pass 19. The backstop won that tie by position, and the clock-fault sentence became the DEFAULT message for an ordinary full-budget timeout — contradicted by the same run's own 18 progress lines decrementing 510 -> 30 in exact 30s steps. Both acquisition loops now read the clock, test the wall-clock DEADLINE, and only then consult the count-based backstop. The backstop still terminates the loop; it only blames the clock when `clock_disagrees` catches it out, printing both accounts so the claim can be checked instead of believed. `verdict_queue_timeout` gives every ordinary-timeout path one identical sentence, and the 126/127 "flock is gone" branch moves above the bounds so an established cause outranks an inferred one. Self-test grows the falsifying pair: an ordinary full-budget wait must NOT mention a clock, and a genuinely stalled clock must still produce the clock-fault wording. Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt Co-authored-by: Claude <noreply@anthropic.com>
1 parent 22f6629 commit 4384b34

1 file changed

Lines changed: 168 additions & 21 deletions

File tree

scripts/pm/os-verify-lock.sh

Lines changed: 168 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,41 @@ human_s() {
411411
if ((s >= 60)); then printf '%ss (%dm%02ds)' "$s" $((s / 60)) $((s % 60)); else printf '%ss' "$s"; fi
412412
}
413413

414+
# The ORDINARY refusal: the budget elapsed and we never got the lock. One
415+
# function because more than one check can be the one that notices, and a
416+
# timeout that describes itself differently depending on WHICH check noticed is
417+
# exactly how an ordinary full-budget wait came to be reported as a clock fault.
418+
# The optional note is the only thing that varies, and it says where the wait
419+
# was spent — never why the deadline passed, which is not in question here.
420+
verdict_queue_timeout() {
421+
local waited="$1" note="${2:-}"
422+
if [[ -n "$note" ]]; then
423+
log "VERDICT queue-timeout (exit 99) · never acquired · waited $(human_s "$waited") · ${note} · $(holder_line)"
424+
else
425+
log "VERDICT queue-timeout (exit 99) · never acquired · waited $(human_s "$waited") · $(holder_line)"
426+
fi
427+
}
428+
429+
# Does a count-based backstop have grounds to blame the CLOCK?
430+
#
431+
# Both backstops below bound this script by counting something that cannot lie
432+
# about the passage of time (seconds handed to `flock`, or polling passes that
433+
# each sleep). Reaching such a bound proves the loop ran; it does NOT by itself
434+
# prove anything about the clock. So the accusation gets its own predicate, and
435+
# it is the comparison the accusation has always claimed to have made: the
436+
# counted real seconds against what the polled clock says elapsed over the SAME
437+
# stretch. `2 * elapsed < counted` — the clock accounted for less than half the
438+
# time the count proves went by.
439+
#
440+
# Scale-free on purpose (no tolerance constant to tune): a stalled clock reports
441+
# ~0 whatever the budget, while the benign disagreements — whole-second
442+
# truncation at each read, a `flock -w N` returning a hair early — are a few
443+
# seconds against a budget of hundreds and can never reach half of it.
444+
clock_disagrees() {
445+
local counted="$1" elapsed="$2"
446+
((elapsed * 2 < counted))
447+
}
448+
414449
# --- host preflight ---------------------------------------------------------
415450

416451
# What this entry point needs from the host, checked ONCE, before any waiting.
@@ -544,26 +579,39 @@ mode_run() {
544579
# where `now` was the empty string on every pass and `now >= deadline` was
545580
# `0 >= 540` forever. One pass costs at least POLL_S of sleep, so a run that
546581
# burns through the whole budget's worth of passes and more has learned
547-
# something about the clock, not about the lock.
582+
# something the deadline check could not.
583+
#
584+
# ORDER MATTERS, and it is the same order as in the flock-slice loop below:
585+
# read the clock, test the DEADLINE, and only then the count. A backstop that
586+
# gets to answer first answers for the ordinary case too, and then an ordinary
587+
# timeout is reported as an infrastructure fault. With the deadline first, a
588+
# working clock always ends this loop by the ordinary route (it reaches the
589+
# budget in BUDGET seconds, long before BUDGET+60 passes), and reaching the
590+
# count at all means the clock did not get there — which the wording then
591+
# states as the measured disagreement rather than as an assertion.
548592
local -a q
549593
q=()
550-
local last_report=0 now pos i qline
594+
local last_report=0 now pos i qline elapsed
551595
local passes=0 remints=0
552596
local max_passes=$((BUDGET / POLL_S + 60))
553597
local max_remints=5
554598
while ((ordered == 1)); do
555599
passes=$((passes + 1))
556-
if ((passes > max_passes)); then
557-
log "VERDICT lock-unusable (exit 99) · never acquired · the queue loop ran ${passes} passes inside a ${BUDGET}s budget, so the clock this script polls is not advancing · refusing to spin · nothing was built or tested"
558-
exit 99
559-
fi
560600
now="$(now_s)" || {
561601
log "VERDICT lock-unusable (exit 99) · never acquired · the seconds clock stopped answering mid-wait · refusing to spin · nothing was built or tested"
562602
exit 99
563603
}
604+
elapsed=$((now - started))
564605
if ((now >= deadline)); then
565-
waited=$((now - started))
566-
log "VERDICT queue-timeout (exit 99) · never acquired · waited $(human_s "$waited") · never reached the head of the queue · $(holder_line)"
606+
verdict_queue_timeout "$elapsed" 'never reached the head of the queue'
607+
exit 99
608+
fi
609+
if ((passes > max_passes)); then
610+
if clock_disagrees $((passes * POLL_S)) "$elapsed"; then
611+
log "VERDICT lock-unusable (exit 99) · never acquired · the queue loop ran ${passes} passes, each sleeping ${POLL_S}s, while the clock this script polls advanced only ${elapsed}s over the same stretch — the two accounts disagree, so the ${BUDGET}s deadline could never expire · refusing to spin · nothing was built or tested"
612+
exit 99
613+
fi
614+
verdict_queue_timeout "$elapsed" "gave up after ${passes} queue passes inside a ${BUDGET}s budget"
567615
exit 99
568616
fi
569617
q=()
@@ -616,16 +664,40 @@ mode_run() {
616664
# trust the clock. The deadline check below is computed from `now_s`; if that
617665
# clock is frozen or running backwards, `remaining` stays positive forever and
618666
# this loop retries `flock -w` for as long as the process is left alive — an
619-
# unbounded wait that prints no verdict, which is the exact failure this card
620-
# is about, merely relocated from the queue loop into this one. Measured while
621-
# fixing it: with the clock frozen and the lock held, this loop was still
622-
# running when a 40s timeout killed it, having printed zero VERDICT lines.
667+
# unbounded wait that prints no verdict, which is the exact failure that
668+
# bound was added for, merely relocated from the queue loop into this one.
669+
# Measured while fixing it: with the clock frozen and the lock held, this loop
670+
# was still running when a 40s timeout killed it, having printed zero VERDICT
671+
# lines.
623672
#
624673
# So elapsed time is ALSO accumulated from the one ruler here that cannot lie:
625674
# the timeout just handed to `flock`, which really did block for that long. It
626675
# rises by at least 1 each pass, so the loop terminates within BUDGET passes
627676
# whatever the clock claims. A flock that fails EARLY over-counts, which errs
628677
# toward terminating — the safe direction.
678+
#
679+
# WHAT THAT BOUND MUST NOT DO IS EXPLAIN THE ORDINARY CASE.
680+
#
681+
# It used to. A waiter that reaches the head of the queue and then spends its
682+
# whole budget here spends it ENTIRELY in slices — 18 × 30s = 540s = BUDGET —
683+
# so `slices_spent >= BUDGET` came true at the bottom of pass 18, one check
684+
# before `remaining <= 0` would have come true at the top of pass 19. The
685+
# backstop won that tie by position and printed "the clock this script polls
686+
# reported none of it" for a completely healthy clock: the same run's own 18
687+
# progress lines, decrementing 510 → 30 in exact 30s steps with the holder's
688+
# `held` counter rising in lockstep, are proof the clock answered every poll.
689+
# That reads as an infrastructure fault no retry can fix, when the truth was
690+
# the mundane and actionable one — a sibling held the lock longer than one
691+
# full budget. The two readings lead to opposite next moves, and the misread
692+
# has already cost a seat an agent.
693+
#
694+
# Hence the order below, which is the whole fix: check the WALL-CLOCK DEADLINE
695+
# after each slice, BEFORE the slice backstop, so a budget that genuinely
696+
# elapsed always reports the ordinary timeout. The backstop keeps its job of
697+
# terminating the loop, but it only gets to blame the clock when the clock is
698+
# actually caught out — `clock_disagrees`, the comparison the old sentence
699+
# asserted it had made and never made. When the two accounts agree, hitting
700+
# the backstop means nothing more than "the budget is spent", and it says so.
629701
local remaining flock_rc slices_spent=0
630702
exec 9>> "$LOCK_FILE" || {
631703
log "✗ cannot open ${LOCK_FILE} for locking."
@@ -639,26 +711,54 @@ mode_run() {
639711
}
640712
remaining=$((deadline - now))
641713
if ((remaining <= 0)); then
642-
waited=$((now - started))
643-
log "VERDICT queue-timeout (exit 99) · never acquired · waited $(human_s "$waited") · $(holder_line)"
714+
verdict_queue_timeout $((now - started))
644715
exit 99
645716
fi
646717
((remaining > SLICE_S)) && remaining="$SLICE_S"
647718
flock_rc=0
648719
"$FLOCK_BIN" -w "$remaining" 9 || flock_rc=$?
649720
((flock_rc == 0)) && break
650-
slices_spent=$((slices_spent + remaining))
651-
if ((slices_spent >= BUDGET)); then
652-
log "VERDICT queue-timeout (exit 99) · never acquired · spent ${slices_spent}s of a ${BUDGET}s budget in flock slices while the clock this script polls reported none of it, so the deadline could never expire · refusing to spin · nothing was built or tested · $(holder_line)"
653-
exit 99
654-
fi
655721
# 126/127 is "the primitive is gone", not "someone else holds it". Retrying
656-
# that costs a fork per slice and can never succeed, so it is a verdict.
722+
# that costs a fork per slice and can never succeed, so it is a verdict —
723+
# and it is tested BEFORE the two bounds below, because it is a cause this
724+
# script has actually established. A cause that is KNOWN outranks one that
725+
# is inferred: left underneath, a flock that returns instantly every pass
726+
# ran the accounting up to BUDGET in no time at all and was reported as a
727+
# clock that had stopped. Same misattribution as the one above, one branch
728+
# over.
657729
if ((flock_rc == 126 || flock_rc == 127)); then
658730
log "VERDICT lock-unusable (exit 99) · never acquired · \`${FLOCK_BIN}\` exited ${flock_rc} (not found / not executable) · nothing was built or tested"
659731
exit 99
660732
fi
661-
now="$(now_s)" || now="$deadline"
733+
slices_spent=$((slices_spent + remaining))
734+
# Re-read the clock now that the slice has really gone by, and let the
735+
# DEADLINE answer first. This is the check whose absence made the backstop
736+
# below the default explanation for an ordinary full-budget wait.
737+
now="$(now_s)" || {
738+
log "VERDICT lock-unusable (exit 99) · never acquired · the seconds clock stopped answering mid-wait · refusing to spin · nothing was built or tested"
739+
exit 99
740+
}
741+
elapsed=$((now - started))
742+
if ((elapsed >= BUDGET)); then
743+
verdict_queue_timeout "$elapsed"
744+
exit 99
745+
fi
746+
if ((slices_spent >= BUDGET)); then
747+
if clock_disagrees "$slices_spent" "$elapsed"; then
748+
log "VERDICT lock-unusable (exit 99) · never acquired · spent ${slices_spent}s of a ${BUDGET}s budget in flock slices while the clock this script polls advanced only ${elapsed}s over the same stretch — the two accounts disagree, so the deadline could never expire · refusing to spin · nothing was built or tested · $(holder_line)"
749+
exit 99
750+
fi
751+
# The count is spent and the clock agrees it is: an ordinary timeout that
752+
# whole-second rounding kept a hair short of the deadline test above.
753+
# Report the larger of the two accounts — both are lower bounds on the
754+
# real wait, and understating it is what invites a pointless retry.
755+
if ((elapsed > slices_spent)); then
756+
verdict_queue_timeout "$elapsed"
757+
else
758+
verdict_queue_timeout "$slices_spent"
759+
fi
760+
exit 99
761+
fi
662762
if ((now - last_report >= PROGRESS_EVERY_S)); then
663763
last_report="$now"
664764
log "waiting: at the head of the queue, $((deadline - now))s of budget left · $(holder_line)"
@@ -862,6 +962,53 @@ mode_self_test() {
862962
"$([[ -e "$franfile" ]] && echo yes || echo no)" no
863963
st_case 'and gives up in bounded wall time (<= 20s on a 3s budget)' \
864964
"$((fz1 - fz0 <= 20))" 1
965+
# CASE 2 OF THE PAIR BELOW: this is the ONE situation entitled to the
966+
# clock-fault wording, and it must still fire — a message that can never fire
967+
# is its own defect, and the fix that stopped it firing for ordinary timeouts
968+
# would be worthless if it also silenced the case it guards. The frozen shim
969+
# pins both numbers: `started` and `now` are the same constant, so the clock
970+
# reports exactly 0s against a slice account of 3s.
971+
st_case 'and the clock-fault wording DOES still fire when the clock really stalls' \
972+
"$([[ "$froze" == *'the clock this script polls advanced only 0s over the same stretch'* ]] && echo yes || echo no)" yes
973+
st_case 'and prints both accounts, so the accusation can be checked rather than believed' \
974+
"$([[ "$froze" == *'spent 3s of a 3s budget in flock slices'*'the two accounts disagree'* ]] && echo yes || echo no)" yes
975+
st_case 'and calls a stalled clock lock-unusable, not a timeout (nothing timed out)' \
976+
"$([[ "$froze" == *'VERDICT lock-unusable'* ]] && echo yes || echo no)" yes
977+
978+
# CASE 1 OF THE PAIR: an ORDINARY full-budget wait, healthy clock, real
979+
# holder — the situation that used to print the clock-fault sentence above.
980+
#
981+
# The shape is the whole point: a waiter that reaches the HEAD of the queue
982+
# spends its entire budget inside the flock-slice loop, so slice accounting
983+
# reaches BUDGET at the bottom of a pass one check before the deadline test at
984+
# the top of the next one would have. The backstop won that tie by position
985+
# and accused a clock that the same run's own progress lines proved was
986+
# answering every poll. An agent reading that concludes the container is
987+
# broken and no retry can help; the truth was that a sibling held the lock for
988+
# longer than one full budget, which is ordinary and actionable. Both readings
989+
# cannot be right, and this case is what keeps them apart.
990+
#
991+
# Budget 3s reproduces it exactly at 1/180th of the size: one 3s slice fills
992+
# the budget in slices with nothing left over, which is what 18 × 30s did.
993+
local ordhold ordout ordrc ordranfile
994+
ordranfile="${tmp}/ordinary-command-ran"
995+
rm -f "$ordranfile"
996+
bash "$SELF" -c 'sleep 25' > /dev/null 2>&1 &
997+
ordhold=$!
998+
sleep 1.5
999+
ordout="$(OS_VERIFY_LOCK_WAIT=3 bash "$SELF" -c ": > '${ordranfile}'" 2>&1)"
1000+
ordrc=$?
1001+
kill "$ordhold" 2> /dev/null
1002+
wait "$ordhold" 2> /dev/null
1003+
st_case 'an ordinary full-budget wait at the head of the queue exits 99' "$ordrc" 99
1004+
st_case 'and reports the ORDINARY timeout' \
1005+
"$([[ "$ordout" == *'VERDICT queue-timeout'*'never acquired · waited'* ]] && echo yes || echo no)" yes
1006+
st_case 'and accuses no clock — the fault this branch used to assert by default' \
1007+
"$([[ "$ordout" == *clock* ]] && echo ACCUSED-THE-CLOCK || echo no)" no
1008+
st_case 'and names the holder instead, which is the half an agent can act on' \
1009+
"$([[ "$ordout" == *'holder pid '* ]] && echo yes || echo no)" yes
1010+
st_case 'and does not run the command' \
1011+
"$([[ -e "$ordranfile" ]] && echo yes || echo no)" no
8651012

8661013
# every exit path prints a verdict, argument errors included.
8671014
st_case 'a usage error prints a verdict too' \

0 commit comments

Comments
 (0)