Skip to content

[finding] os-verify-lock.sh's clock-stopped backstop misfires on an ORDINARY full-budget timeout, accusing the clock of a fault the same run's own progress lines disprove #10717

Description

@os-warren

Filed unassigned from the domain:services lane while implementing #10424. Observation only — ⛔ I did not touch the script: three sibling agents were using it during the measurement.

The verdict line, verbatim — this line IS the finding

os-verify-lock: VERDICT queue-timeout (exit 99) · never acquired · spent 540s of a 540s
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 pid 18222, held 851s — bash .../issue-10556/gates.sh

That sentence asserts a specific fault: the polled seconds clock stopped advancing. In the run that printed it, the clock was working perfectly, and the same run's own output proves it.

Why the claim is false, from the same run

The 18 progress lines immediately above the verdict decrement monotonically and correctly, one per 30s:

os-verify-lock: waiting: at the head of the queue, 510s of budget left · holder pid 18222, held 341s
os-verify-lock: waiting: at the head of the queue, 480s of budget left · holder pid 18222, held 371s
…
os-verify-lock: waiting: at the head of the queue, 30s of budget left · holder pid 18222, held 821s

Both numbers are computed from now_s(). 510 → 30 in exact 30s steps, and the holder's held counter advancing 341 → 821 in lockstep, is direct evidence that the clock answered every single poll. The backstop then declared that same clock had "reported none of it".

Mechanism — it is an ordering race, not a clock fault

Constants (scripts/pm/os-verify-lock.sh): HARD_CAP_S=540, DEFAULT_WAIT_S=540, SLICE_S=30, PROGRESS_EVERY_S=30.

In the head-of-queue loop the deadline test sits at the top of the iteration and the slice-accounting backstop at the bottom:

while:;do
now="$(now_s)"|| { …clock-stopped verdict… }
remaining=$((deadline - now))if((remaining <=0));then …ordinary queue-timeout… fi# TOP((remaining > SLICE_S))&& remaining="$SLICE_S""$FLOCK_BIN" -w "$remaining" 9 || flock_rc=$?((flock_rc ==0))&&break
slices_spent=$((slices_spent + remaining))if((slices_spent >= BUDGET));then"clock reported none of it"fi# BOTTOM

A waiter that reaches the head of the queue immediately spends its whole budget in slices: 18 × 30s = 540s = BUDGET. slices_spent >= BUDGET becomes true at the bottom of iteration 18 — one check earlier than remaining <= 0 would fire at the top of iteration 19. The two conditions become true at essentially the same instant, and the backstop wins the tie by position.

So for a head-of-queue waiter that consumes its full budget, the clock-fault message is not an edge case — it is the default message for an ordinary timeout.

This also explains why the other two timeouts I hit in the same session printed the ordinary verdict instead:

os-verify-lock: VERDICT queue-timeout (exit 99) · never acquired · waited 540s (9m00s) ·
holder pid 1071, held 425s — bash .../issue-10556/gates2.sh

Those runs spent part of the budget in the pre-queue sleep POLL_S phase before reaching the head, so slices_spent was still below 540 when the wall-clock deadline expired. Which branch you get depends only on how much of your wait happened before vs. after reaching the head of the queue — not on the health of any clock.

Measurements

Three queue-timeout exits in one session, one card, all on the shared container:

#Verdict branchHolder observed
1"clock reported none of it" (the misfire)pid 18222, issue-10556/gates.sh, held up to 851s
2ordinary waited 540s (9m00s)pid 1071, issue-10556/gates2.sh, held 425s
3ordinary waited 540s (9m00s)pid 1071, issue-10556/gates2.sh, held 1111s

Total dead queueing ≈ 27 minutes. The heavy steps did eventually acquire (VERDICT command-exit 0 · held the lock 327s · waited 138s, and · held the lock 36s · waited 407s), so the lock itself functions.

Why this is worth a card even though nothing is "broken"

The refusal behaviour is correct — it refuses to spin and says nothing was built or tested, which is the right answer and appears to be exactly what the "bound every acquire path" work (PR #10609) set out to guarantee. That part is working.

What is wrong is only the explanation, and it is wrong in the direction that costs the most: it names a cause the evidence contradicts. An agent reading it reasonably concludes the container's clock is unreliable and that waiting is futile in a way no retry can fix. The honest reading is far more mundane and far more actionable: a sibling held the lock for longer than one full budget. Those two readings lead to opposite next moves.

That matters because this lock has already cost a seat a whole agent: the #10009 reconciliation agent queued behind a sibling here, armed a monitor and exited — and a monitor armed by an agent that then exits is not a plan. Its PR sat dirty with a live merge conflict until the PM re-dispatched it. A waiter that can consume its entire budget and then report an unfalsifiable-sounding infrastructure fault is precisely the shape that invites an agent to give up and hand back nothing.

Neighbours checked before filing

I believe this is a residual of the backstop added for the bash-3.2 hardening rather than a duplicate of any of them, but I did not verify that attribution against the PR diff and triage should not take it from me.

Possible directions (not a decision — for triage)

  1. Check the wall-clock deadline before the slice backstop, so a budget that genuinely elapsed reports the ordinary verdict and the backstop keeps only the case it was built for.
  2. Fire the clock-fault wording only when slice accounting and wall-clock elapsed actually disagree (e.g. slices_spent >= BUDGET while now - started is materially below BUDGET) — that comparison is the thing the message already claims to have made.
  3. Leave the branch and re-word it so it stops asserting a clock fault it has not established.

Option 2 makes the message falsifiable, which is what it is missing. I have no opinion strong enough to override triage.

Generated by Claude Code

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions