You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
# What this entry point needs from the host, checked ONCE, before any waiting.
@@ -544,26 +579,39 @@ mode_run() {
544
579
# where `now` was the empty string on every pass and `now >= deadline` was
545
580
# `0 >= 540` forever. One pass costs at least POLL_S of sleep, so a run that
546
581
# 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.
548
592
local -a q
549
593
q=()
550
-
local last_report=0 now pos i qline
594
+
local last_report=0 now pos i qline elapsed
551
595
local passes=0 remints=0
552
596
local max_passes=$((BUDGET / POLL_S +60))
553
597
local max_remints=5
554
598
while((ordered ==1));do
555
599
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
560
600
now="$(now_s)"|| {
561
601
log "VERDICT lock-unusable (exit 99) · never acquired · the seconds clock stopped answering mid-wait · refusing to spin · nothing was built or tested"
562
602
exit 99
563
603
}
604
+
elapsed=$((now - started))
564
605
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"
567
615
exit 99
568
616
fi
569
617
q=()
@@ -616,16 +664,40 @@ mode_run() {
616
664
# trust the clock. The deadline check below is computed from `now_s`; if that
617
665
# clock is frozen or running backwards, `remaining` stays positive forever and
618
666
# 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.
623
672
#
624
673
# So elapsed time is ALSO accumulated from the one ruler here that cannot lie:
625
674
# the timeout just handed to `flock`, which really did block for that long. It
626
675
# rises by at least 1 each pass, so the loop terminates within BUDGET passes
627
676
# whatever the clock claims. A flock that fails EARLY over-counts, which errs
628
677
# 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.
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
655
721
# 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.
657
729
if((flock_rc ==126|| flock_rc ==127));then
658
730
log "VERDICT lock-unusable (exit 99) · never acquired · \`${FLOCK_BIN}\` exited ${flock_rc} (not found / not executable) · nothing was built or tested"
659
731
exit 99
660
732
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
662
762
if((now - last_report >= PROGRESS_EVERY_S));then
663
763
last_report="$now"
664
764
log "waiting: at the head of the queue, $((deadline - now))s of budget left · $(holder_line)"
@@ -862,6 +962,53 @@ mode_self_test() {
862
962
"$([[ -e"$franfile" ]] &&echo yes ||echo no)" no
863
963
st_case 'and gives up in bounded wall time (<= 20s on a 3s budget)' \
864
964
"$((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)' \
0 commit comments