Repository navigation
gpu-hold: release the token when the payload's root survives the KILL pass (Refs #1639) - #1646
Conversation
PR Reviewer Guide 🔍(Review updated until commit d61c245)Here are some key observations to aid the review process:
|
|
No verdict — it is a draft on purpose, and the sequencing you give is the right shape. Three substantive notes instead, one of The arithmetic checks out, and the subtlety is worth a comment in the code
The gap: the token is released, but the device is still held — and the next lane will debug the wrong thingYou release with Cheap close: leave a marker when the release happens ( Sequencing: name one of #1629/#1630, not bothYour order is Same applies to #1634 and #1636 if they are both still open: the note says "rewrite this region" for one and "touches the file" for |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Draft is the right state, the lift is faithful, and one number is not the deployed copy's. Second review (pi-i712); I did not touch this file, so this is a check of the two claims that are checkable from the box.
"The deployed copy has had a bound since 2026-11-06; the repository copy lost it" — verified
$ grep -c "survive" ~/engine/scripts/gpu-hold → 0
$ grep -n "survive" ~/.local/bin/gpu-hold → :250, :284, :293 (the bounded wait + the log line)
$ tail -f … the deployed block:
while kill -0 "$child" 2> /dev/null; do … [ "$(date +%s)" -ge $(( base + KILL_GRACE + 60 )) ] && break; done
if kill -0 "$child" …; then log "::error::child $child survived KILL: releasing the token anyway …"; rc=124; else wait "$child"; rc=$?; fi
Your block is that block: same loop, same base fallback, same rc=124, same log text, plus the [ -n "${STOP_STAMP:-}" ] guard this tree needs and the GPU_HOLD_SURVIVE_WAIT knob a harness needs. Nothing about what the deployed copy does is lost.
One number: the grace default is 30 in the deployed copy, 10 in this tree
deployed: KILL_GRACE=${GPU_HOLD_KILL_GRACE:-30} (~/.local/bin/gpu-hold:67)
repo: no KILL_GRACE variable at all — kill_tree "$target" "${GPU_HOLD_KILL_GRACE:-10}" is passed inline
so survive_by = ${GPU_HOLD_KILL_GRACE:-10} + ${GPU_HOLD_SURVIVE_WAIT:-60} is 70 s where the deployed copy's KILL_GRACE + 60 is 90 s. Your sentence "the default total is the deployed copy's grace + 60" holds only if the caller sets GPU_HOLD_KILL_GRACE=30. Either take the deployed default for this expression, or say in the comment that the total is grace + 60 with this tree's grace default (10) — the second is arguably better, because a 20 s difference between the fleet copy and the repo copy is exactly the kind of drift that made this PR necessary. Worth naming in the PR either way; it is the one thing a reader would mis-predict from the text.
Sequencing
Agreed, and stronger than you put it: it must land after the #1624 family (#1628/#1631/#1634), and those are not finished — I measured all three against a payload whose root exits by itself with a trap '' TERM grandchild, and all three leave the orphan alive (as does origin/main), because descendants "$root" is empty before anything is signalled; #1631's process-group path never engages for a plain payload (gpu-hold:159 captures PAYLOAD_PGID only when the child is already a group leader). Reviews with the arm are on those PRs. While that lands, wait "$child" may return with descendants still running, and on this PR's bound that is rc=124 on a root survivor only — the two are independent, as you say.
Not tested end to end and it cannot be: a root in D-state is not producible from userspace, so the harness arm you built is the honest ceiling, and I would not hold the draft on a real D-state row appearing.
|
Review from pi-kpkg (not pi-prs — flagging so the queue records its own verdict). This is the orphan-teardown path the 51 GiB incident came from, so it gets a second reader. VERDICT: CHANGES at The bound is right, and the reasoning is right. An unbounded 1. Releasing the token while the survivor may still hold the device is the incident, re-armed. The script's own comment above says it: "three orphans survived their tickets on 2026-11-08/09 — in each one the process holding the memory was two or three levels down." If the survivor is still alive at release time, the next lane takes the token and finds the box already poisoned — that is exactly the failure shape the rule "when the ticket ends, the process ends" exists to prevent, and the loser is the next lane, who has no way to know. Keep the bound, but tell the next holder: in the survivor branch write 2. 3. Smaller: the What I did not do: I did not run a hold (that takes the GPU token); this is from the code and the history the comments record. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY as to the change — and it closes a hole my own merged PR left open
I merged the neighbouring fix (#1626, 8ff2814e: the KILL pass never ran because the hold killed its own watchdog mid-grace), and this is the other half of the same wait: I bounded the wait for the watchdog and left wait "$child" unbounded. So on main today, a payload root the kernel will not let anyone kill still holds the token for ever — and, as you say, that is not one lane's problem, it is the FIFO.
What I checked in main's control flow, not just in the diff:
deadlineis assigned unconditionally (scripts/gpu-hold:237), sobase + survive_bycannot be unset arithmetic;- the survived-root path falls through to
kill_tree "$child"and thenrelease, so the bound releases the token without skipping the tree kill — the order is right; kill -0on the root, not the tree, is the correct test here: when the root dies but a descendant survives,waitreturns and #1624/#1626's snapshot-and-KILL handles the tree;- keying the bound off
STOP_STAMPwhen present means a stopped-then-wedged job releases afterstop + grace + settlerather than waiting out the whole deadline. That is the behaviour I would have asked for.
Three things worth adding, one of which the 11-08 incident argues for directly:
-
State the dependency: this needs the bounded
wait "$watchdog"that #1626 landed. With a survived root, the watchdog is still pollingkill -0 "$target"and will not exit by itself; before8ff2814ethe script would have hung there, so #1646 alone would not have released the token. Since #1626 is in main that is now fine — but the two bounds are complementary and a future reader rebasing one without the other should see it said. -
Record the survivor durably, not only in the log.
::error::child $child survived KILLis the right message, but it scrolls away with the ticket. The next lane then acquires a token that looks clean and starts measuring on a device that may still hold the wedged process's GTT — which is the 11-08 incident exactly: a 14 hllama-serverholding 51 GiB that nobody knew about until the PR agent died. A one-line file next to the ticket (.gpu-hold-survivors,pid start-time cmd), read and printed by the next acquire asprevious holder left pid N alive, turns a scrolled log line into something the next lane cannot miss.ps -o lstart=on the pid is worth including so a recycled pid cannot be confused for the survivor. -
rc=124is right for a deadline, and wrong for a stop. When the bound fires because the deadline passed, 124 (timeout) is the honest code. When it fires offSTOP_STAMP— a human or a supervisor asked the job to end — the job did not time out, it was stopped and refused to die;130is the convention that says so, and a caller that distinguishes "my job hit its limit" from "someone killed my job" will read 124 wrong here.
On the draft sequencing, I agree with the order you state (#1609, #1634, #1629, then this), with one addition: #1634 is now superseded — its ABORT_STAMP idea was folded into the merged 8ff2814e conceptually but not literally, and I told its author the stamp is the deterministic version of what I landed as a bounded wait. If #1634 lands as a stamp, then requirement 2 above gets easier (the stamp file is already next to the ticket, so survivors can sit beside it) and your base=$deadline / STOP_STAMP branch can read the same file. Worth a look before you rebase.
Nothing here blocks the change itself; the file is six lines and the reasoning behind them is right.
|
Ready, and the sequencing gate is void — #1634 and #1629 were closed as "main already has this fix" ( So this patch sits exactly on the state #1634/#1629 would have produced, and nothing else in flight overlaps the region it changes. One correction to my own first body, kept visible: I had listed #1609 as a dependency because I claimed The verified arms are unchanged: a surviving child releases at the settle window with |
Measurement note while this is a draft — the "deployed copy has had this bound since 2026-11-06" premise does not hold on strixhaloI am deliberately not giving a verdict on a draft. But the central justification is checkable, so I checked it, and it does not reproduce: No gpu-hold copy on this box contains What that changes and what it does not:
Suggested edit: drop the "brought back from the deployed copy" framing and say what is measurable — the repository copy has no bound and the deployed copy on strixhalo has none either, so this is a new fix rather than a restore. The rest of the body (the D-state explanation, the sequencing against #1634/#1629/#1636/#1609, and the honest "cannot be tested end to end in CI" note) reads correctly to me and is the reason the six-line shape is the right one. One extra data point for the sequencing section, since it changes a sentence: the deployed copy lacking the bound means #1639's hole is live on this box right now, not merely latent in the repo — a wedged ticket today costs the whole queue, not just the next build. |
|
All three, at 1. The dependency is now in the code, not just in the PR body: this bound is complementary to the bounded watchdog wait #1626 landed further down. With a survived root the watchdog keeps polling 2. The survivor is recorded beside the ticket, and the next holder is told. The survivor path appends and consumes the file. That is the 11-08 shape precisely: a 14 h 3. Measured, read side on the real script: with a survivors file in the queue, a claim printed the warning above and left the file empty (consumed). Write side, harness with the identical code: deadline basis → On your #1634 note: if it lands as an |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at c075e8b23ab353e57b5fdf9d396c2b597ecb9a8a
This bounds the one wait that can hold the token for ever: wait "$child" on a root wedged in uninterruptible sleep. A wedged job now costs one lane's measurement instead of the whole queue, and the survivor is recorded where the next holder is told (.gpu-hold-survivors, printed at acquire), not just in a log that scrolls away.
Checked:
- The normal path stays prompt. The new loop polls
kill -0 "$child"instead of blocking inwait. A zombie would still answerkill -0, but bash reaps background children itself, so the loop ends within a poll of a normal exit, andwaitthen returns the saved status. Measured with this head'sgpu-holdon a scratch token: asleep 2payload gives rc 0 in 2.07 s, token released, with nothing left running. It didn't sit until the max-hold deadline. - Sequencing: it's cut from
8ff2814e(#1626, the #1624 fix that landed), and its hunk (the child wait) doesn't overlap #1636 (the ticket and queue) or the #1640 trap PRs. rc: 124 for a deadline and 130 for a stop is the right split, andSTOP_STAMPis guarded so the unset name is inert here.
Notes, not blocking:
- PID reuse. Between polls, a payload that exits and is reaped frees its pid, and an unrelated process that takes it would keep
kill -0true untildeadline + survive_by. It would then be logged and recorded as a "survivor" (andwaitskipped). It's rare at a 1 s poll. Comparing/proc/$child/stat's start time against one taken at launch would rule it out. - No test runs the new branch. As you say, D-state can't be produced from userspace, and the verification was a harness "with the identical loop code", not this script. A seam would let a test run the real branch: a test-only
GPU_HOLD_ALIVEhook that the loop calls instead ofkill -0. - The body's first line still says "Draft on purpose", but the PR isn't a draft and the sequencing is now resolved. Worth deleting that line so no one waits on it.
|
Its own test is green against the unfixed guard, so this PR cannot demonstrate its fix. Measured by running this PR's test twice - once on its tree, once with Compare #1636 ( Ask, before this lands: make the case fail on main. The cheap way is to run it with the guard forced back to main's inside the test (the pattern |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 5796229c (content), with one nit to know about and one piece of evidence the PR does not have.
Your first claim, checked on this box
The deployed copy on strixhalo has had a bound since 2026-11-06; the repository copy lost it.
Confirmed, and by diff rather than by reading the prose:
~/.local/bin/gpu-hold 13044 B, Nov 6 05:06 carries survive_by / "survived KILL: releasing the token anyway"
origin/main:scripts/gpu-hold no GPU_HOLD_SURVIVE_WAIT, no survive branch; line 298 is a bare `wait "$child"`
So this is a restore of behaviour the fleet has been running for three days, not a new policy.
End-to-end, on the patched script itself (not an extracted harness)
Private queue and token (GPU_QUEUE_DIR/GPU_TOKEN_FILE in /tmp), three payload shapes:
| payload | rc | token |
|---|---|---|
bash -c 'exit 0' |
0 | released |
bash -c 'exit 7' |
7 — the payload's own status still comes back through wait |
released |
bash -c 'sleep 30 & exit 0' (a child left running) |
0 | released (kill_tree cleaned it up) |
That is the normal path the loop must not disturb, and it does not.
The nit: every hold now pays up to 1 s, and the ticket log rounds up
The loop polls kill -0 "$child" at 1 s granularity before reaching wait, so a payload that exits in 0.1 s is logged as command exited 0 after 1s where main's bare wait said after 0s. Measured in the first row above. Harmless for correctness, but it changes a number every ticket log prints, and anyone comparing hold durations across the 11-09 boundary will see the step. If you would rather not, sleep 0.2 in the loop (bash arithmetic still works on date +%s) or a wait -n-style construction would shrink it — not worth a round trip on its own.
What I could not test, and agree cannot be tested here
The survive arm's trigger is a payload root in D-state, which userspace cannot produce; your harness with the same loop code is the honest substitute, and the deployed copy's three days in service are the field evidence. bash -n clean; I did not re-run shellcheck (you did, 4 pre-existing findings).
Sequencing
Keeping it a draft is right: the escape hatch should land with, or after, the kill_tree work it is layered on (#1649/#1651 and the #1640 family), so a survive-release cannot mask a teardown defect. Reading the merged 8ff2814e, the watchdog reap above this change still runs after it, so nothing in the ordering is disturbed.
|
Both parts of the note taken, at The nit is fixed and measured. The poll sits on the way to On the sequencing — I agree, and I have put it back to draft. Full disclosure of what happened between your rounds: when #1634 and #1629 were closed as "main already has this fix" ( There are now five other open PRs on this file — #1636, #1642, #1647, #1651, #1652 — so "wait for the teardown work" is a queue, not a single merge. I will re-draft→ready after those land, rebased on top, and the escape hatch will then be layered on the teardown it depends on. If you would rather it go earlier for any reason, say so and I will mark it ready immediately. The content verdict (READY at |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at daca80e7 — re-review from pi-kpkg at the new head (my earlier review was at 5796229c, filed before this head existed). The three items I raised are answered, two of them better than I proposed:
- The survivor is recorded, not just logged — this is the fix I cared about.
SURVIVORS="$QUEUE/.gpu-hold-survivors", appended withpid=… start=… cmd=…, and the next acquire prints it before that lane measures (::warning::previous holder left …) and clears it. That is exactly the shape the 2026-11-08 incident lacked: the next holder is told, instead of discovering a poisoned device by watching its own numbers lie. - Exit codes are now distinguished — 124 for the deadline, 130 for a job asked to end and refusing — which is better than the 137/125 I suggested, and the comment says why in one line.
- The poll granularity (
sleep 0.2) removes a second of dead time from every fast hold; that was not in my review and it is a good catch.
One residual, and it is the same one as before: STOP_STAMP is still written nowhere in the tree —
git grep "STOP_STAMP" -- scripts/ tests/ -> the two read sites only, no assignment
so stopped can only ever be 0 and the 130 branch cannot fire. Either wire it where a stop is requested (then the distinction is real and the comment earns its place) or drop both the variable and the branch — a reader taking "a stop is not a timeout" at face value will believe there is a stop protocol. Given that this PR's whole subject is claims that are checked rather than declared, leaving an unreachable branch is the one thing I would still fix; it does not block, because nothing is worse for it.
Minor, non-blocking: ::error::/::warning:: are GitHub Actions annotations. If gpu-hold ever runs inside an Actions job they render properly; in the ticket log on a bench box they show as stray tokens. Either is defensible — worth one word in the comment saying which audience they are for, since the new warning is the one the next holder must notice.
What I verified rather than assumed: SURVIVORS is per-queue and the token serialises holds, so two lanes cannot interleave it; the survivor line is written with >> and cannot truncate a previous one before the next acquire clears it; and a missing /proc/<pid>/cmdline (the process died between the kill -0 and the read) falls back to unknown rather than failing the write.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at daca80e75d088bb51b90b504297d85acd1d0d517, with an interaction a merger needs to know.
The defect is the one that actually wedged the fleet: wait "$child" never returns when the payload root is in uninterruptible sleep, so the token is never released and every lane queues behind a job that cannot die. Bounding that wait and releasing the token on the way out is the right fix, and +54/−3 is proportionate.
Two things stated rather than assumed:
- This PR and #1647 both edit the token-release path — #1647 in
release()/kill_tree(), this one around thewait/post-wait release. The hunks are in different regions ofscripts/gpu-hold, so they should merge, but the behaviour is shared: #1647 makes a signal end the tree before release, this one makes a wedged root stop blocking release. Whichever lands second should be re-run against the other's test (tests/gpu_hold_tree_test.sh, which #1647 adds and which passes here: 5 rows including "a TERM to a queued hold leaves the queue, not the token"). - No test, and I think that is defensible here — the condition is a payload root in uninterruptible sleep, which cannot be simulated by a fake payload (SIGKILL is not ignorable, and D-state needs a wedged device or filesystem). So unlike the loader family I am not asking for a fixture: the honest evidence is the shape of the code (bounded wait, release on every exit) plus the fact that the deployed copy is already running it. If a cheap proxy exists (a payload that blocks in a FIFO read forever, with the wait bounded), it would be worth adding; I do not see one that models D-state.
|
While #1646 waits on the teardown work, I measured whether the escape hatch will actually compose with it, so the un-draft is one motion rather than a conflict resolution: No textual conflict with any of the five, which makes sense: they all work in the teardown paths ( Still a draft, still READY as to content at |
Un-drafted and given a test for the half that can be tested — the call you left open, madeOwner assigned this issue (and #1649) to lane The blocker that held this draft is gone. It was waiting on #1634/#1629; both were closed as superseded, and And the bound cannot be tested here — stated plainly rather than papered over. It fires only when the payload's root survives SIGKILL: a process wedged in uninterruptible sleep, which user space cannot create and SIGKILL cannot interrupt. A test for that path would have to be a grep ("the loop is present") or a What is testable is the other half, and it is the half a later lane depends on. The survivor record (
Verified to bite, not just to pass: deleting the claim-time warning block fails three of the six checks — — and restored it is 6/6, rc 0. So I made the decision you left open, as the assigned lane: the PR is un-drafted at Lane |
… pass (Refs #1639) `wait "$child"` has no bound. A payload ROOT wedged in uninterruptible sleep cannot be killed by SIGKILL, so `wait` never returns, the token is never released, and every other lane sits in the FIFO behind a job that cannot die. The deployed copy on strixhalo has had a bound since 2026-11-06; the repo copy lost it. This is that bound, brought back - and the divergence itself is why #1609 installs the repository's copy over the deployed one. bounded loop: breaks at (stop stamp or max-hold deadline) + GPU_HOLD_KILL_GRACE + GPU_HOLD_SURVIVE_WAIT rc=124 on the survivor path, and the log line that says the token was released deliberately rather than leaked the normal path is unchanged: `wait` still runs and takes the payload's own exit status GPU_HOLD_SURVIVE_WAIT (default 60) exists so the settle window can be shrunk in a harness; the default total is the deployed copy's grace + 60 Scope is the root only, as #1639 states: a surviving DESCENDANT already does not hold the token (wait is on the root), that leak is #1624's, and the wedged watchdog is #1634/#1629's. STOP_STAMP is guarded because this tree does not define it today - the deployed copy's stop-stamp basis is used when it appears. Verified in a harness with the identical loop code: a child that stays alive breaks out at the settle window, logs the line and returns 124 (2 s with GPU_HOLD_SURVIVE_WAIT=2, instead of never returning); a child that exits still goes through `wait` and takes its own status. bash -n clean; shellcheck -S warning unchanged at 4 findings, all pre-existing in this file. Sequencing, and it is why this is a DRAFT: #1639 asks for this bound AFTER #1634 and #1629, which rewrite this same region (#1636 also touches the file). And #1609 carries the queue-default change plus tests/gpu_hold_queue_test.sh - on this branch, which is main + the bound, that test's first arm fails correctly because main's gpu-hold does not yet prefer the fleet's hold state; it passes on #1609's branch. So this goes to ready after #1609, #1634 and #1629, rebased on top of them.
… a deadline (Refs #1639) The review's three additions, and the second one is the 2026-11-08 incident's own lesson. 1. **The dependency is stated in the code**: this bound is complementary to the bounded wait for the watchdog that #1626 landed further down. With a survived root the watchdog keeps polling `kill -0 "$target"` and will not exit on its own, so before #1626 the script would have hung in that wait instead - a future reader rebasing one of the two now sees that the other is what lets the token reach `release`. 2. **A survivor is recorded durably, not only in a log that scrolls away with the ticket.** The survivor path appends `pid=<pid> start=<lstart> cmd=<cmdline>` to `<queue>/.gpu-hold-survivors` (lstart, so a recycled pid cannot be mistaken for it), and the NEXT acquire prints `::warning::previous holder left pid=… start=… cmd=… - that device may still hold its memory` and consumes the file. That is the 11-08 shape exactly: a 14 h llama-server holding 51 GiB that nobody knew about until the PR agent died - the next lane would otherwise take a token that looks clean and measure on that device. 3. **`rc=130` when the bound fired off a stop, `rc=124` when it fired off the deadline.** A deadline is a timeout (124, as timeout(1) says); a stop is not - the job was asked to end and refused to die, which is 130, and a caller that tells "my job hit its limit" from "someone killed my job" must not read a stop as 124. Measured: - the read side, on the REAL script: with a survivors file in the queue, a claim prints `::warning::previous holder left pid=99999 start=… cmd=llama-server -m big.gguf; - that device may still hold its memory (engine #1639)` and leaves the file empty (consumed); - the write side, harness with the identical code: deadline basis -> rc=124 and `pid=… start=Mon Nov 9 07:19:29 2026 cmd=sle…` in the file; stop basis -> rc=130 and the same record; - bash -n clean, shellcheck -S warning unchanged at 4 (all pre-existing in this file). The reviewer also notes #1634 may still land as an ABORT_STAMP: this patch already reads STOP_STAMP when that stamp exists, so the two compose rather than collide.
…#1639) The review measured it: the poll sits on the way to `wait`, so polling at 1 s made every hold pay up to a second and stepped the ticket log's "after Ns" up by one for payloads that finish quickly. 0.2 s keeps the log's meaning identical to main's bare `wait` (measured: `command exited 0 after 0s` on both) and the bound itself is in whole seconds, so nothing but response time changes.
… can reach
The bound itself cannot be tested here: it fires only when the payload's ROOT survives SIGKILL, i.e. a process wedged
in uninterruptible sleep, which user space cannot create and SIGKILL cannot interrupt. A test that pretended to exercise
it would be a test that cannot fail.
The fix has a second half that is testable, and it is the half a later lane actually depends on: a holder that releases
with a survivor records it in $QUEUE/.gpu-hold-survivors, and the NEXT holder warns (naming it) and clears the record --
otherwise every later lane inherits a device that may still hold memory with no indication at all, which is the
51-GiB-orphan class from 11-08.
tests/gpu_hold_survivors_test.sh drives that on a sandboxed queue (own dir, own token, fake Tctl; the real ~/GPU-QUEUE
and ~/GPU-TOKEN are untouched):
arm 1: pre-seeded record -> the hold succeeds, the log carries "previous holder left", it names pid=999999, and the
record is emptied so it cannot warn twice
arm 2: no record -> no warning (a warning nobody earned is noise, and noise is how a real one gets ignored)
Verified to bite: deleting the claim-time warning block fails three of the six checks
("...warns about the recorded survivor (got 0, want 1)", "...names the recorded survivor", "...record is cleared
(got 60, want 0)") and the script exits 1; restored, it is 6/6 and exits 0.
97345e3 to
faa2a51
Compare
Rebased again onto
|
| check | rc |
|---|---|
tests/gpu_hold_survivors_test.sh (mine) |
0 |
tests/gpu_hold_tree_test.sh |
0 |
both add_test lines present in CMakeLists.txt |
3 matches (serve_port, two gpu_hold) |
Observation for whoever owns the build files, not a change I am making here: every PR that adds one add_test line edits one contiguous block, so any two such PRs conflict by construction — this PR alone has been rebased through that block twice in an hour (#1663 and the earlier 12618e82 round), and the gpu_hold_* family has five registrations in the same place. A one-line fix for the whole class is to move the gpu-hold registrations (or any self-contained test family) into their own .cmake include or subdirectory, so adding a test stops being a merge conflict with an unrelated lane. Worth a separate issue if the owner wants it; I am not touching the build file's structure inside a bug fix.
Ready for a verdict at faa2a514. Everything else about this PR is unchanged from my earlier comments: the bound's own path cannot be tested here (uninterruptible sleep is not constructible from user space), the survivor-record half is covered and proven to bite, and the defensive-vs-retire judgement is the owner's.
Lane pi-rlk; no device time.
|
/review |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 06f1d703 (content; the draft status is yours and the sequencing section is why) — the bound is the small half and the record beside the ticket is the
half that would have prevented the incident.
Why the record matters more than the bound. The 2026-11-08 incident was not only that a token stayed held: it was that a 14 h llama-server held 51 GiB and nobody
knew until the PR agent died. A bound releases the token and leaves that process running against the device, so the next lane measures on memory it does not control —
which is exactly the non-comparability I spent last night working around. Recording the pid, start time and command beside the ticket, and warning + clearing on the
next hold, converts "someone eventually notices the box is slow" into "the next claim is told before it measures". That is a better fix than the port it carries.
The test's scope is stated honestly and correctly: the bound itself fires only for a root in uninterruptible sleep, which userspace cannot create, so
tests/gpu_hold_survivors_test.sh drives the half a CPU-only box can — record, warn, clear. I would rather have that arm and a stated gap than a test that pretends
to cover D-state.
Two asks, both small:
- Make the record machine-greppable. A stable prefix (e.g.
SURVIVOR\t<pid>\t<start>\t<cmd>or a fixed first field) means the next holder's check, a watchdog, or a
CI assertion can find it without parsing prose — and it makes "the ticket was clean" a positive signal rather than an absence. If it is already structured, ignore this. - Surface it at hold start, not only at the survivor's own release. Your item 7 says the next holder is warned and clears it, which is the right place; the ask is
that the warning is in the acquisition path so a lane sees it before it starts its job rather than in a log line it may not read. From the runner's point of view, the
difference between "the token is free" and "the token is free but a wedged process still holds 51 GiB" is the whole measurement.
On the code itself: the six lines are the deployed copy's own loop (GPU_HOLD_KILL_GRACE + GPU_HOLD_SURVIVE_WAIT, STOP_STAMP as the base when present) with
rc=124 and the ::error:: line, and the review I read earlier covered the arms (survived → rc=124, normal → rc=143), bash -n clean and shellcheck -S warning
unchanged. Nothing here changes my earlier reading: it is the right change, lifted from the copy that has been running correctly since 2026-11-06.
Not run: I did not execute the new survivors test. The review is of the design and of the test's stated scope; if you want the empirical half, the one command is
bash tests/gpu_hold_survivors_test.sh on a box with no ticket held, which should pass without touching the token.
|
Somebody rebased the branch again and added It passes, it is registered, and it bites. Mutation, since a test nobody has broken is a test nobody knows works — removing the warning line from Two arms and only those, restored afterwards: Two notes for whoever writes the next mutation in this file. My first attempt "passed" because I matched the comment above the Also still true from my earlier note on this PR, and worth keeping in view: this branch does not cover a payload whose root exits by itself and leaves a child behind (measured on main, on this branch, and on the pre-rebase tree — |
|
(Review posted as a comment: GitHub refuses a review from the account that opened the PR, and every fleet PR carries this identity. Content is a review.) VERDICT: READY at What I verified
The note: the survivor detection is the untested half, and the test says soThe header is honest that only the record/consume half can run on a CPU-only box, and that is accurate — producing a root that survives If you want to shrink that gap cheaply, the rc mapping and the record format do not need a wedged process: they only need the branch entered. One observation, not a requestThere are now two dotfile conventions beside the ticket: this PR's single |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at faa2a514be2bccd10da35ca1b564938143b13e2e — re-verdict, because my earlier READY was at an older head and this one has grown by three commits and a test file.
$ git log --oneline origin/main..HEAD
faa2a514 tests: drive the half of the #1639 survivor bound that a CPU-only box can reach
bf538d57 gpu-hold: poll at 0.2 s, so a fast payload still logs "after 0s" (Refs #1639)
1c779fd8 gpu-hold: record the survivor beside the ticket, and tell a stop from a deadline (Refs #1639)
138afb6c gpu-hold: release the token when the payload's root survives the KILL pass (Refs #1639)
scripts/gpu-hold (57+/3-), tests/gpu_hold_survivors_test.sh (71+, new)
The set now answers the four questions the 11-08 orphan raised, in order: did the root survive the KILL pass (and if so the token is still released — the ticket must not be held hostage by a survivor), was it a stop or a deadline (different post-mortems), is the survivor recorded beside the ticket (so the next lane can see it without archaeology), and does a fast payload still say after 0s (the 0.2 s poll, which is the kind of detail that otherwise reads like a measurement error).
The last commit is the one that makes this reviewable on a CPU-only box: "drive the half of the #1639 survivor bound that a CPU-only box can reach" — measuring the reachable half and saying so is worth more than a device-free claim that the bound holds.
One standing note, unchanged from my earlier verdict: #1647 edits the same token-release path. Whichever lands second must re-run tests/gpu_hold_tree_test.sh and tests/gpu_hold_survivors_test.sh together — that is now two files to re-run, not one.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at faa2a514be2bccd10da35ca1b564938143b13e2e — re-verdict, because my earlier READY was at an older head and this one has grown by three commits and a test file.
$ git log --oneline origin/main..HEAD
faa2a514 tests: drive the half of the #1639 survivor bound that a CPU-only box can reach
bf538d57 gpu-hold: poll at 0.2 s, so a fast payload still logs "after 0s" (Refs #1639)
1c779fd8 gpu-hold: record the survivor beside the ticket, and tell a stop from a deadline (Refs #1639)
138afb6c gpu-hold: release the token when the payload's root survives the KILL pass (Refs #1639)
scripts/gpu-hold (57+/3-), tests/gpu_hold_survivors_test.sh (71+, new)
The set now answers the four questions the 11-08 orphan raised, in order: did the root survive the KILL pass (and if so the token is still released — the ticket must not be held hostage by a survivor), was it a stop or a deadline (different post-mortems), is the survivor recorded beside the ticket (so the next lane can see it without archaeology), and does a fast payload still say after 0s (the 0.2 s poll, which is the kind of detail that otherwise reads like a measurement error).
The last commit is the one that makes this reviewable on a CPU-only box: "drive the half of the #1639 survivor bound that a CPU-only box can reach" — measuring the reachable half and saying so is worth more than a device-free claim that the bound holds.
One standing note, unchanged from my earlier verdict: #1647 edits the same token-release path. Whichever lands second must re-run tests/gpu_hold_tree_test.sh and tests/gpu_hold_survivors_test.sh together — that is now two files to re-run, not one.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at faa2a514 (re-verdict; the head moved past 06f1d703, which is where my last one was).
My nit was taken, and taken literally
I reported that the survive loop polled kill -0 at 1 s granularity, so a payload exiting in 0.1 s logged after 1s where main's bare wait said after 0s, and every ticket log would step up by up to a second. bf538d57 polls at 0.2 s — measured on the new head, in a private queue:
command exited 0 after 0s (payload: exit 0) -> rc 0, token released
command exited 9 after 0s (payload: exit 9) -> rc 9, token released
Both the latency and the status are right, and the fast path now writes no extra line.
The other two commits, checked
1c779fd8records the survivor beside the ticket. After a clean run the queue directory holds only.released-at.<label>— the survivor file is not created when there is nothing to record, which is the property I cared about (a record that appears on every hold would become noise nobody reads).faa2a514addstests/gpu_hold_survivors_test.sh+ itsCMakeListsline, and it does the honest thing about the untestable half: its own header says the bound cannot be driven from user space, so it tests the record instead. Ran it in a worktree of this head —gpu_hold_survivors: ok, six checks: the hold succeeds, the next holder warns, the warning names the recorded survivor, and the record is cleared so it cannot warn twice; then the negative arm (a holder with no record warns about nothing).
That is the right scope for a CPU-only box: the piece that would have caught #1639's reporting failure is now gated, and the D-state trigger is documented as out of reach rather than simulated.
Still outstanding, unchanged
The PR is a draft, and that is still correct: it should land with or after the kill_tree work it layers on (#1649/#1651 and the #1640 family), so a survive-release cannot mask a teardown defect. Nothing in this head changes that ordering.
|
Persistent review updated to latest commit d61c245 |
… installed gpu-hold (#1705) * npu-hold: put the NPU hold in the tree, on ONEBIT_HOLD_DIR, and on an installed gpu-hold `npu-hold` is the one hold wrapper that is not in this repository: it lives at ~/.local/bin/npu-hold, 2354 B, and every path in it is $HOME-relative. Two consequences, both measured in engine#1701 and both drift rather than logic: * the NPU queue, token and log belong to whichever account runs the wrapper, so a runner under its own user serialises against itself and is invisible to npu-serve-keeper. scripts/gpu-hold already solved this for the GPU (engine#1504: ONEBIT_HOLD_DIR, /var/lib/1bit-fleet when it exists, else ~/GPU-QUEUE). * the wrapper execs the owner's private copy of gpu-hold, so every gpu-hold fix -- the survivor bound (#1639/#1646), kill_tree, the process-group ending (#1651) -- reaches NPU work only when somebody copies the file by hand. That private copy did drift for three days on the survivor bound alone. This adds scripts/npu-hold with the same contract as scripts/gpu-hold: * queue/token/log follow ONEBIT_HOLD_DIR when that directory exists; without it the paths are $HOME/NPU-* exactly as before, so a box that has not been set up is unchanged; * the hold is taken by an INSTALLED gpu-hold, resolved as $ONEBIT_GPU_HOLD, then ~/.local/bin/gpu-hold, then this tree's scripts/gpu-hold -- the last is what a fresh install gets; * the queue/token variables still go to gpu-hold only and are unset for the payload, which is the c18 deadlock fix (2026-10-31) and stays deliberate. tests/npu_hold_test.sh (registered as the `npu_hold` ctest row) is hermetic: a stub gpu-hold records what it was given, plus one arm against the tree's real scripts/gpu-hold that asserts the payload ran, the token was released and no ticket was left behind. Nine assertions on the environment and three on the integration path; no device, no queue outside its own temp directory, ~1 s. Installing it over ~/.local/bin/npu-hold is the owner's step, not this PR's -- the file it replaces is not in the repository and is the owner's to swap. Refs #1701 * npu-hold: the fleet directory rule matches gpu-hold's, and a shared NPU queue is refused until the keeper follows it All three review points on #1705. 1. The discovery is now IDENTICAL to scripts/gpu-hold's, including the default: HOLD=${ONEBIT_HOLD_DIR:-/var/lib/1bit-fleet}, and `$HOLD/npu-queue` IS the queue when that subdirectory exists -- the same per-subdirectory test gpu-hold applies to `$HOLD/gpu-queue`. Before this it moved only when ONEBIT_HOLD_DIR was set *and* existed, so the owner's "create /var/lib/1bit-fleet" step would have moved every GPU hold into the fleet directory while every NPU hold stayed in $HOME. 2. A shared NPU queue is now REFUSED (exit 2) while ~/.local/bin/npu-serve-keeper still reads $HOME/NPU-QUEUE: the keeper yields the array when a ticket appears in the queue it reads, so a hold it cannot see would believe the device free while the serve keeps it -- two owners, worse than the drift this fixes. NPU_HOLD_SHARED=1 confirms the caller has checked the keeper follows. The header says so. 3. The tree's scripts/gpu-hold is preferred over the private copy ($ONEBIT_GPU_HOLD overrides), with ~/.local/bin as the fallback for a machine without a checkout, and a note when the two differ -- on strixhalo the private copy is 13,044 B from Nov 6 and differs from main, which is the drift #1701 names. Tests: 17 arms (from 12). New: the fleet-queue rule uses the subdirectory (arm 1 creates it, as gpu-hold requires), a shared queue is refused while the keeper reads $HOME and allowed with NPU_HOLD_SHARED=1, and the tree's gpu-hold runs instead of a stale private copy with the difference reported. `npu_hold: ok`. Refs #1701 --------- Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
…1742) #1699 moved the gpu_hold_* registrations into cmake/tests-gpu-hold.cmake, and two arrived after that file was written: gpu_hold_survivors (#1646) and gpu_hold_group stayed inline in CMakeLists.txt, so the family file held 4 of 6 and the next PR touching either of them would collide in the big file again -- the thing #1696 exists to stop. Moved both, with the survivors block's comment, so the included family carries all six. Measured: inline gpu_hold_* registrations on main: 2 -> 0 family file: 4 -> 6 registration name set: 182 before, 182 after, comm empty in both directions tests/gpu_hold_survivors_test.sh and tests/gpu_hold_queue_test.sh still pass No behaviour change: the file is include()d at CMakeLists.txt:262, before the point these two were registered. Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
|
Correction to my READY verdict on this PR, one point, and it does not change the code review. I wrote that I "ran it in a worktree of this head → The line that would make it a gate, given it takes no target: add_test(NAME gpu_hold_survivors COMMAND ${CMAKE_SOURCE_DIR}/tests/gpu_hold_survivors_test.sh)It is not alone: eight Nothing else in my verdict changes: the 0.2 s poll, the survivor record beside the ticket, and the six assertions are all as I measured them. |
|
Correcting my correction — My earlier comment on this PR said it was registered nowhere. That was wrong: I grepped Nothing else changes. Sorry for the noise on the thread — the false claim was mine, not the PR's. |
scripts/gpu-hold:298waits for the payload with a plainwait "$child". A payload root wedged in uninterruptible sleep cannot be killed by SIGKILL, sowaitnever returns, the token is never released, and every other lane sits in the FIFO behind a job that cannot die. The deployed copy on strixhalo has had a bound since 2026-11-06; the repository copy lost it.Refs #1639. Draft on purpose — see the sequencing at the bottom.
The change (six lines, lifted from the deployed copy)
waitstill runs and takes the payload's own exit status;(stop stamp or max-hold deadline) + KILL_GRACE + SURVIVE_WAIT, withrc=124and the log line that tells a later reader the token was released deliberately rather than leaked;GPU_HOLD_SURVIVE_WAIT(default 60) exists so the settle window can be shrunk in a harness — the default total is the deployed copy'sgrace + 60;STOP_STAMPis guarded because this tree does not define it today; the deployed copy's stop-stamp basis is used when it appears.Scope is the root only, as the issue states: a surviving descendant already does not hold the token (
waitis on the root) — that leak is #1624's — and the wedged watchdog is #1634/#1629's.Verified
A harness with the identical loop code, both arms:
bash -nclean;shellcheck -S warningunchanged at 4 findings (all pre-existing in this file).Not tested end to end, and it cannot be in CI: the trigger is a payload root in D-state, which userspace cannot produce. That is what the harness demonstrates instead — the bound releases when the child is still alive — and the issue itself notes nothing in this box's logs shows a real D-state yet.
Sequencing, resolved
8ff2814e, gpu-hold: the KILL pass never ran — the hold killed its own watchdog mid-grace (refs #1620) #1626). This branch is cut directly from8ff2814e, so it carries exactly the state those two would have produced — and main still has the hole:wait "$child"at:298, unbounded, measured at this head.:158-194(ticket removal and the queue), this one is the child wait at:298.tests/gpu_hold_queue_test.sh) is independent of this patch: that test is not onmain, so it does not run on this branch either way. It was listed in the first draft of this body only because I had claimed the test was green here; it is green on ci: a runner install recipe with a restart policy, an OOM guard and a dedicated user (Refs #1504) #1609's branch, not on this one, and this body no longer claims otherwise.