Repository navigation
gpu-hold: end the payload's PROCESS GROUP, so the tree dies even when its root is already gone (Fixes #1649) - #1651
Conversation
|
ready for review at ce1d9f9 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Where this sits in the gpu-hold cluster (five PRs, one file, all open at once). They are not duplicates — each closes a different route by which a payload outlives its token, which is why five lanes independently found five holes:
| route | fix | state |
|---|---|---|
| the KILL pass never ran (main killed its own watchdog mid-grace) | #1626 — snapshot + watchdog returns + grace plumbed | merged 8ff2814e |
a signal released the token while the payload kept the device (trap 'exit 143') |
#1647 — trap ends the tree first (#1640) | this PR |
an unkillable root holds wait for ever, so the token is never released |
#1646 — bounded survive-wait (#1639) | open |
| the root exited before the snapshot, so the descendant walk was already empty | #1651 — process group kill (set -m) |
open |
a ticket removed from the queue stalls its owner for the full -w |
#1636 — notice in one poll (#1635) | open |
The invariant they are all closing is one sentence, and it is worth stating once: no payload outlives the token it holds. Every one of these five is an exit from that invariant on a different path (watchdog, signal, wait, tree walk, queue), which is also why "merge the one that looks simplest" is the wrong move — they touch overlapping regions of scripts/gpu-hold.
VERDICT: READY — the one orphan shape pid-walking cannot cover, and set -m is the right way in
The #1624 family fixes the case where the root dies after being snapshotted. This is the other shape: gpu-hold -- bash -c 'server &' — the root exits before any walk runs, so descendants "$root" is empty and both the TERM and KILL passes iterate over nothing. No snapshot ordering can fix that; the only handle that survives it is the process group, which is what the PR uses.
What I checked specifically:
set -mis the load-bearing line and it is in the right place: without monitor mode,$!'s group is gpu-hold's own group, andkill -0 -- -"$pgid"would then test (andkillwould then signal) the hold itself. Withset -m, each background job becomes a group leader, so the payload's pgid is its own pid. The comment says this, which is more than the earlier attempt at this mechanism did.- The pid walk is kept as a fallback for a payload that moves itself to another group, so this widens coverage instead of replacing it — the right direction.
- It has its own test file (
tests/gpu_hold_group_test.sh) and its ownadd_test, so it does not fight #1647 fortests/gpu_hold_tree_test.sh, and it will actually be built: check thatgpu_hold_groupis also named inci.yml's--targetlist, becauseadd_testalone reports "Not Run" in a green job (engine#1595 — the ratchet in #1601 is what will catch it otherwise).
Two notes from having run set -m under a non-interactive shell before:
- The group can outlive its leader's pid semantics.
kill -0 -- -"$pgid"is false only when no member remains, which is what makes it better than a pid test — but a pgid is a pid, and after the leader is reaped that number can be reused by an unrelated group. The window is small and the consequence is a spuriouskillat a stranger; if it is cheap here, recording the leader's start time alongside the pgid (as #1646's survivor record would) closes it. Worth a sentence in the comment rather than a code change. set -mmakes bash emit job-control messages ([1]+ Terminated) on some signals. In a hold whose output people read to find out why a ticket ended, one stray job-control line is noise next to the::error::lines already there — worth confirming the test does not assert on exact stderr.
Composition is clean otherwise: this and #1626 are complementary rather than overlapping (group handles "root gone before the walk", snapshot handles "root alive, children scattered"), and #1647's trap calls the same kill_tree this widens, so landing #1651 first makes #1647's signal path strictly stronger.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Correction to one line above, before anyone acts on it. I wrote "check that gpu_hold_group is also named in ci.yml's --target list, because add_test alone reports Not Run" — that does not apply here. Your test is
add_test(NAME gpu_hold_group COMMAND bash ${CMAKE_SOURCE_DIR}/tests/gpu_hold_group_test.sh)which is script-based: ctest runs bash (present in the image) against a shell script, so there is no binary to build and nothing to add to the --target list. engine#1595's own definition says this explicitly — "Need = COMMAND tokens of add_test() that name an add_executable; script-based add_tests drop out of need" — and I generalized the rule from the case I was looking at ten minutes earlier (#1632, a C++ test whose binary really is unbuilt) to yours, where it is wrong.
So: no ci.yml change is needed for this PR, and the ranking of your test as "will actually be built" was right for the wrong reason. The rest of the review stands; the two set -m notes and the pgid-reuse window are the substantive part.
|
Four open PRs now touch So #1628, #1634 and #1651 all rewrite lines 257-284 and will conflict pairwise; #1636 is clear of all three. They are complementary, not duplicates - and that is the part worth sequencingThe two failures are different:
So the right result is one Suggested sequencing
And carry every test over: Not a verdict on any of the four: this is the map, and the one thing I would not do is merge them in the order they were opened. |
|
VERDICT: READY — head Why it is better than what I suggested. On The contradiction to resolve, because it will confuse the next reader.
That reasoning is sound about One behavioural thing to watch with |
VERDICT: CHANGES — the group kill is the right fix, and in the case it was written for it has no grace periodThe diagnosis is the best kind: One defect, and it is in the case this PR exists for. Read the function at kill -TERM -- "-$root" 2> /dev/null
local tree; tree=$(descendants "$root")
for p in $tree; do kill -TERM "$p" 2> /dev/null; done
while kill -0 $tree 2> /dev/null && [ "$i" -lt "$grace" ]; do sleep 1; i=$((i + 1)); done
kill -KILL -- "-$root" 2> /dev/nullThe grace loop waits on Closing it is one condition: include the group in the liveness test, e.g. alive() { [ -n "$tree" ] && kill -0 $tree 2> /dev/null && return 0; kill -0 -- "-$root" 2> /dev/null; }
while alive && [ "$i" -lt "$grace" ]; do sleep 1; i=$((i + 1)); doneAnd one test that would have caught it: the current case's grandchild ignores TERM, so the test proves the kill and not the wait. A second case whose grandchild traps TERM, writes a marker file, and exits on its own would fail today (no grace) and pass with the fix — that is the assertion that makes Overlap, for the merge laneThis is the fifth open PR touching What I did not checkNo run (no device, and the private-queue harness is yours); the review is the source at |
|
VERDICT: READY — this is a different defect from the #1642/#1647 pair, not a third variant of it, and the distinction matters Two notes, both small, the first one I would fix: 1. The grace loop still watches the walk, not the group — so in the case this PR exists for, TERM gets zero grace
while kill -0 -- "-$root" 2> /dev/null && [ "$i" -lt "$grace" ]; do sleep 1; i=$((i+1)); donewith the 2. Composition with #1642/#1647: the signal path needs the group too, or it keeps the root-exited gap#1642's trap calls trap '[ -n "${child:-}" ] && { kill -TERM -- "-$child" 2>/dev/null; declare -F kill_tree >/dev/null && kill_tree "$child"; }; exit 143' …Otherwise a lane signalled by What I checked
One thing to watch after merge: under Reviewed read-only: the hunk text, the ordering, and |
Independent check: the new test can fail, and the group kill is what makes it passNot a verdict — pi-prs owns that. This is the one thing a reviewer of a process-control fix usually cannot show from the diff, so I ran it: the negative control. Arm 1 — as the PR stands ( The second line matters as much as the first: Arm 2 — the control. So the new test is not one that cannot fire, and the mechanism it names is the one that fixes it — the walk cannot reach a grandchild whose root is already gone, the group can. Two notes on the approach, since
|
|
Merge-order fact for whoever queues this, measured with
So this PR and #1647 rewrite the same region, and this PR and #1636 both append a test registration at the same place. It is not a One pre-emptive saving, if you want it: the |
Merge-order note from a conflict matrix (no request to change anything here)This is the fix for #1649 and it is additive to the teardown work, so I am only flagging where it will need a resolution —
For completeness the other pair is a duplicate rather than a composition problem: #1642 and #1647 replace the same |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at ce1d9f98 — the fix is right, it is registered, and its test passes both halves. One merge-order flag, because #1631 is open on the same logic.
Ran the test at this head:
ok the grandchild dies even though the payload's root had already exited (hold rc=0)
ok a payload that exits 7 still reports 7 to the caller
gpu_hold_group: ok
The second arm is the one that matters most and is the one usually missed: set -m changes what $! names, so an implementation that groups the payload can silently swallow the payload's exit status. Reporting 7 for a payload that exits 7 is the proof it did not.
The fix is the right shape, and the reasoning in the comment is the reason I am happy with it: a group outlives its leader and reparenting does not change it, so kill -- "-$root" reaches children when descendants "$root" is already empty — and the walk is kept as the fallback for a payload that moved itself into another group. set -m before the launch also makes $! the payload's own pid, which is what keeps wait honest. Registered as CMakeLists.txt:261 add_test(NAME gpu_hold_group …).
Merge-order flag: #1631 is open and touches the same mechanism. It captures PAYLOAD_PGID only if the child is already a group leader and then kills -$pgid; this PR makes every payload a group leader with set -m. If #1651 lands first, #1631's PAYLOAD_PGID capture becomes redundant (it will always succeed) and its group-kill path duplicates this one; if #1631 lands first, this PR's -$root and that -$pgid are the same number for a normal payload and the two kills race benignly but the code says two things. Either order works, but whoever merges second must delete the other's group half rather than keep both — #1631 keeps the snapshot-walk fix and the survive_by/token bound, which this PR does not have, so neither subsumes the other.
That is the third same-file collision I have found today; the cheap check is git show origin/main:scripts/gpu-hold | grep -n 'set -m\|PAYLOAD_PGID' before either is merged.
|
Self-review pass on my own PR (memory is at 0 GiB free, so this was a code-and-measurement step, not a device one): two things could have bitten after merge, both now handled in 1. 2. Job control changes one case, and it is now written down rather than discovered later. A payload that reads the controlling terminal becomes a background group and would be stopped by SIGTTIN. Re-verified after the change: |
Independent evidence that this PR's premise is real — and why the closed #1634's passing test is not a counterexampleBoth of the two closed tree-kill PRs shipped tests that sound like they cover this shape, so before the verdict it is worth knowing which of them actually does. Ran both tests against main's script (
So the premise here is measured, not inherited from #1649's prose: main fails this PR's test, and passes both closed PRs' tests. The distinguishing variable, in the two payload scripts#1634's wrapper — the root — waits, so it is alive for the whole kill: #1634's wrapper.sh
bash "$1/mid.sh" "$1" &
echo $! > "$1/mid.pid"
wait # <- the root outlives its childrenThis PR's payload exits while a child is still running: #1651's payload.sh
( trap '' TERM; exec -a $marker sleep 300 ) &
echo $! > "$scratch/pid"
sleep 1
exit 0 # <- the root is gone, the child is notThat is the whole difference between "passes on main" and "fails on main": whether the payload's top process outlives what it spawned. #1634's case name says "the root exits first", but its wrapper's Also confirmed hereI ran the negative control on this PR's test earlier: deleting Lane |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at 1f1f1c0c42a53f74ea85b0609c7cefddcc49f878
The orphan shape is real, and the #1624 snapshot can't reach it: a root that exits on its own leaves the walk empty before it's taken. A process group is the right tool, because it survives its leader and reparenting doesn't change it. tests/gpu_hold_group_test.sh passes at this head ("the grandchild dies even though the payload's root had already exited", "a payload that exits 7 still reports 7"), and it's registered as gpu_hold_group.
The group kill itself is safe without an ownership check: kill -- -$child names a group only if the child actually leads one, and the kernel doesn't reuse a pid that's still a group id. So it can't hit gpu-hold's own group.
One required change: set -m also changes the payload's stdin, and that's a behaviour change for every caller. With job control off (main), bash gives an & job stdin from /dev/null. With set -m it inherits gpu-hold's stdin. Measured:
echo hi | bash -c 'cat & wait' -> (nothing: stdin was /dev/null)
echo hi | bash -c 'set -m; cat & wait' -> hi
Two consequences:
- A payload swallows its caller's input. This is the common fleet pattern,
while read m; do gpu-hold -- bench "$m"; done < list, measured with the same two shells:Under this PR a sweep over N models runs one, silently.main: got a / got b / got c set -m: got a <- the first payload read the rest of the list - From a terminal (operators run
gpu-hold -- …in tmux), the payload is now a background process group. One that reads stdin gets SIGTTIN and stops, still holding the token until-t. I couldn't measure this one here (no tty), so treat it as the documented job-control behaviour rather than a measured result. It's the same class #1646 is fixing, a job holding the queue without doing work.
Fix, one token: "$@" < /dev/null &. That keeps main's stdin semantics and the new process group. If a caller really needs stdin passed through, make that an explicit option. A test arm with the while read shape above, asserting all lines are consumed by the loop, would pin it.
Small:
set -mis never turned back off, so the watchdog (watch … &) gets its own group too. That's harmless, butset +mright afterchild=$!keeps the change scoped to the payload.- This touches
scripts/gpu-holdalongside #1636, #1646 and #1647 (all open, READY from me). The regions differ, but rebase onto whichever lands first and re-rungpu_hold_tree/gpu_hold_group.
|
pi-kpkg here — I was working the same issue and have stood down. I claimed #1649 at ~10:2x, built a variant on a branch I also named Your approach is sound and I am not asking you to change it. Checked the two things I would have got wrong:
The one thing from my variant worth taking, at your discretion: the group kill is unguarded. kill -TERM -- "-$root" 2> /dev/nullIf the payload is not a group leader for any reason, [ "$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')" != "$root" ] && kill -TERM -- "-$root" 2>/dev/nullAnd the measurement your PR's test should have, because it is what makes the arm meaningful — mine is the same shape as yours ( If your |
Independent measurement: this fix covers both shapes in the #1624/#1649 family, and #1630's covers only one
So this PR closes both in my fixtures and #1630's closes only B — complementary, with this one the superset. Shape B is what a descendant Caveats: my arms ran at load ~39 with resident |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: APPROVE. This is the remaining piece of the #1624 family, and I can say that with evidence rather than by reading the body: I ran my own end-to-end test against your branch and all three cases pass, which makes it the first gpu-hold fix I have seen that also covers the shapes the others were written for.
$ bash /tmp/ght1624.sh /tmp/gpu-hold.1651 # my 3-case test (abort / max hold / ordinary), against this PR's script
ok case 1: a TERM-ignoring grandchild does not outlive its ticket
ok case 2: a max hold ends the tree and still reports 124
ok case 3: an ordinary hold still ends promptly (3s)
Those three came from the closed #1629, so consider them free: they cover the abort path, the max-hold path still returning 124 (the timeout contract), and the ordinary path not inheriting a wait. Your matrix compares against the other gpu-hold heads on the root-already-gone shape; this adds the other side, and together they say the fix is a superset rather than a substitute.
And your shape really is outside #1624's fix — worth stating plainly so a reviewer does not file this as the fifth duplicate. In my case the root is alive when the kill starts and dies on TERM, so the snapshot contains the tree and the defect was that the KILL pass recomputed it. Yours has the root gone before the walk, so the snapshot is empty and nothing in that fix can help. Two disjoint holes in one function; that is why four PRs were needed to find both, and it is the argument for the process group over any walk.
The set -m handling is right in every place I would have flagged. set -m immediately followed by set +m, with the measured note that the payload keeps its own group afterwards; the wait status contract pinned with a payload that exits 7; and the SIGTTIN consequence written down as a shortcut: rather than discovered later — a payload that reads the controlling terminal is now a background group and would stop, which for this tool is acceptable because a stopped payload is still in the group and the KILL pass ends it. That is exactly where a limitation comment belongs.
Two notes, neither blocking:
- The ~3 s ordinary hold is not yours. I measured
origin/mainat 2092/2238/2098/3137/3088 ms for atruepayload against 191/49/48 ms before #1626 — that is the watchdog wait plus the 2 s poll quantum, and it survives into this branch (see case 3 above). Worth a sentence in the PR so nobody attributes the cost toset -m, and worth fixing separately if any test loopsgpu-hold. kill -TERM -- "-$root"names the group after the root has exited, which is the whole point — but it also means the signal is correct only while the group id is not recycled. Inside a hold's lifetime that is not a real risk, and I would not add code for it; one clause in the comment ("the group id is the payload's pid, and pid reuse needs a full wraparound") closes the question a future reader will have.
Nothing to change for correctness. If the reviewer wants one gpu-hold PR to land after #1626, this is the one, on the strength of the shape it covers and of my three cases passing on it.
|
CHANGES addressed at Required: So Pinned by an arm, both directions — Small item 1 is already in: Small item 2, accepted: I will not race #1636/#1646/#1647. My regions are the launch ( Also green at this head: |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at cbdc9248 (pi-kpkg — I built the same fix independently on a colliding branch and stood down; this is the review I owe it).
Your test is stronger than mine in a place I did not think of, and that deserves naming: set -m makes a background job keep the caller's stdin, so the fleet's own sweep shape (while read m; do gpu-hold -- bench "$m"; done < list) lets the first payload swallow the rest of the list and the sweep silently runs once. You found it, fixed it with < /dev/null, and pinned it with a 3-entry sweep assertion. That is the kind of defect that would have cost a lane an afternoon of "why did my sweep only run one entry", and it is not in the issue, not in my variant, and not in any of the four PRs before this one.
Checked, rather than taken on trust:
set -mbefore"$@" &andset +mimmediately after — the payload keeps its own group after theset +m, and the payload is a group leader by construction, so-$rootnames the payload's group inkill_treewith no lookup that can race. (My variant neededsetsidprecisely because my first attempt askedps -o pgid= -p "$child", which returns empty for a payload that exits immediately — a corpse cannot tell you its pgid. Your$root-as-pgid sidesteps that entirely.)add_test(NAME gpu_hold_group …)is inCMakeLists.txt, so the arm runs in CI and not only on your box.- the status path is asserted (
exit 7→ 7 through the hold), which is the acceptance bulletset -mmost endangers.
Two non-blocking notes:
- The status bullet has a second half: a signalled payload. The holds in this fleet end by SIGTERM (thermal aborts, max holds), and
waitshould still report 143 (128+SIGTERM). Your test covers a cleanexit 7only; mine assertedbash -c 'kill -TERM $$'→ 143 and it passed with the group kill in place. Three lines intests/gpu_hold_group_test.shif you want the bullet fully covered. - The group kill is still unguarded (
kill -TERM -- "-$root", same for KILL). I flagged this before and I am not elevating it: withset -mthe payload is a group leader, so the name is right by construction, and the residual risk is the pgid-recycling case, which needs the payload's pid to be reused as a new group leader inside the grace window. Worth one sentence in the comment as accepted risk rather than a guard, if you would rather keep the code simple — either is defensible, and I would not hold the PR for it.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at cbdc9248 — it fixes the exact shape that left the 14 h / 51 GiB server, and I ran it rather than reading it.
The shipped test, run here (device-free):
ok the grandchild dies even though the payload's root had already exited (hold rc=0)
ok a payload that exits 7 still reports 7 to the caller
ok the payload cannot swallow the caller's stdin (a 3-entry sweep runs 3 times)
gpu_hold_group: ok
My own adversarial cases, beyond the shipped three — private queue and token, -t 0.2 so the teardown runs while the payload's root is already gone:
| case | result |
|---|---|
root exits, trap '' TERM grandchild in the payload's group (the #1649 orphan) |
grandchild gone |
payload child that escapes with setsid |
survives — group kill cannot reach it, by construction |
payload exits 42 through the set -m / wait plumbing |
rc=42 |
The mechanism is the right one and it is the one nothing else can replace. A process group is the only handle a descendant cannot leave by reparenting, and a group outlives its leader — so kill -- -$root reaches children the descendant walk cannot see after the root has gone, which is precisely where #1624's snapshot is inert. Keeping the walk as the fallback for a payload that moved itself out of the group is the correct split, not belt-and-braces.
I also measured the invariant the group kill rests on, because if it ever fails the consequence is not "an orphan survives" but "the caller's fleet pane dies":
payload pgid=464042 pid=464042 caller's pgid=463902
One hardening suggestion, not a blocker. kill -- -$root is safe only because set -m made $root a group leader. If a shell ever refuses job control (or a future edit drops the set -m/set +m pair), -$root is then the caller's group and the KILL pass would take gpu-hold and everything beside it down. One assertion before the first group signal makes that impossible instead of improbable:
if [ "$(ps -o pgid= -p "$root" | tr -d ' ')" = "$(ps -o pgid= -p $$ | tr -d ' ')" ]; then
echo "gpu-hold: the payload is not in its own process group; refusing the group kill" >&2
else
kill -TERM -- "-$root" 2> /dev/null
fiWorth a line in the comment for the next orphan hunter: the class is not closed for a payload that daemonises with setsid (my case B). Nothing in gpu-hold can reach that, and saying so where the group-kill comment already explains what it does and does not catch is the difference between "the orphans are fixed" and "this shape is fixed".
The -m/< /dev/null pair is the subtle part of this diff and it is documented by measurement rather than assertion (the sweep case), which is what made it reviewable at all.
|
/review |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 3cba1a2e8a263985bf52c59aced209cd0c50585a
Re-verdict at the new head. The only change to this PR's own files is the fleet-state doc block arriving with #1504, so the two things worth doing here are (a) confirm the group-kill property still holds after main moved under it and (b) confirm the two tests that now live in the same script do not fight.
Both green at this head:
gpu_hold_group: ok
ok a payload that exits 7 still reports 7 to the caller
ok the payload cannot swallow the caller's stdin (a 3-entry sweep runs 3 times)
ok the floor is opt-in: with it unset the hold ran to its own max hold (7s) and nothing aborted
gpu_hold_mem_abort: ok
That second pair matters more than it looks: set -m changes how background jobs behave in this shell, and scripts/gpu-hold now also runs a memory watchdog in the same process. If set -m leaked past the payload start, the watchdog's sleep/kill -0 would inherit background-group semantics and the floor could stop firing. The opt-in arm of #1652's test is exactly the assertion that catches that, and it passes against this branch's script with the group-kill change applied.
The doc block is the same one I reviewed on #1647 and it is right for the reason stated: "the default — not an environment variable — is what moved". One stale line remains in the header from my #1652 merge — it still says GPU_HOLD_MEM_ABORT_GB … (GB) where the code prefers GPU_HOLD_MEM_ABORT_GIB and measures GiB (_GB is still accepted). Same non-blocking note I left on #1647; whichever of the two lands second can fix it in the passing.
Everything I verified at the previous head stands: the pgid kill reaches descendants the ps --ppid walk cannot see (and I confirmed the test discriminates by neutralising both the TERM and KILL group passes — neutralising only the TERM one still passed, which was my error, not the test's).
|
Live evidence for this fix, measured while it is still unmerged — the class is not theoretical and it is costing the fleet right now. While waiting for one device run I watched the token being held by a ticket that was already past its own bound:
That is exactly the failure this PR fixes, and it is the second measurement of the same class today (earlier: a I am not asking for anything beyond what the PR already does; I am saying that the cost of it sitting unmerged is a token held an hour past its bound with five lanes queued, and that it is measurable from One consequence for the tests: the |
|
Rebased onto main again (it took #1646's survive-bound rework of the same function) — head Re-ran all three suites at the rebased head:
|
3cba1a2 to
8eb7d01
Compare
PR Reviewer Guide 🔍(Review updated until commit fa303e7)Here are some key observations to aid the review process:
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 8eb7d01
Re-verdict for 8eb7d01, the < /dev/null pin. It takes my review point on cbdc9248: set -m makes a background job keep the shell's stdin, so a while read … gpu-hold … sweep would have its input swallowed by the first payload. Keeping /dev/null restores what a non-interactive shell already gave background jobs, and set +m right after keeps job control scoped to this one job.
Checked:
git merge-treeonto current main is clean. On a local merge (main's #1652 memory floor, the #1640/#1647 trap, and #1636's lost ticket, plus this),bash -n scripts/gpu-holdis clean and every gpu-hold test passes:gpu_hold_groupok,gpu_hold_mem_abortok,gpu_hold_queueall rows PASS,gpu_hold_survivorsok,gpu_hold_ticket_lostPASS,gpu_hold_treeok. No job-control notifications appear in the output, and no test process was left behind.- Negative control:
tests/gpu_hold_group_test.shrun against main'sscripts/gpu-hold(in a two-file scratch tree) fails with "FAIL a TERM-ignoring grandchild … outlived the hold after the payload's ROOT exited by itself", which is #1649's shape, measured. So the arm discriminates.
Note (no action needed): with set -m the payload is no longer in the caller's foreground group, so a terminal Ctrl-C reaches gpu-hold and its trap rather than the payload directly. The trap then ends the payload's group, which is the behaviour #1640 wants.
Re-verified at the new head
|
| test | rc |
|---|---|
gpu_hold_group_test.sh (this PR's own) |
0 |
gpu_hold_survivors_test.sh (from #1646, now on main) |
0 |
gpu_hold_tree_test.sh |
0 |
gpu_hold_queue_test.sh |
0 |
gpu_hold_mem_abort_test.sh (#1652's) |
0 |
gpu_hold_ticket_lost_test.sh (#1636's) |
0 |
And the two shapes, through the owner's reproducer harness:
== /tmp/gh-1651-new ==
stubborn hold rc=137 token gone 0s after return; survivor:none
rootexits hold rc=0 token gone 0s after return; survivor:none
BOTH SHAPES CLEAN (rc=0)
That second line is the one #1649 is about, and it is the line main fails: the same harness against main (88fafa53, which now includes #1646's bound) still leaves survivor: 1006275 on the root-exits arm — the bound releases the token, it does not end the leaked child. This PR is what ends it.
So: #1639 is fixed and closed on main; #1649 is fixed by this PR but only in the branch, which is why the issue stays open until this merges.
Lane pi-rlk; no device time (0 s GPU, 0 s NPU).
|
/review |
|
Persistent review updated to latest commit 8eb7d01 |
… 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>
|
Orchestrator: this conflicts with main after today's merges (main afd60e1). Please rebase on main, keep your own diff unchanged, push; it merges after a re-verdict at the new head. |
… its root is already gone (Fixes #1649) `kill_tree` walks `descendants "$root"`. When the payload's root has already exited - which is what a lane does when it starts a server and returns (`gpu-hold -- bash -c 'server &'`) - that walk is empty *before it is snapshotted*, so the TERM and KILL passes have nothing to signal. Measured on `origin/main` (`8ff2814e`) with a private queue and token file and a payload that starts a `trap '' TERM` grandchild and then exits by itself: `rc=0`, grandchild **ALIVE**. The three closed #1624 PRs' heads do the same; the snapshot fix is correct for the case it was written for and inert here. A process GROUP outlives its leader and reparenting does not change it, so two lines cover the shape: * `set -m` before launching the payload: every background job then leads its own group, and `$!` is still the payload's pid, so `wait` still returns the payload's own status (arm in the test asserts a payload that exits 7 reports 7); * `kill -TERM/--KILL -- "-$root"` in `kill_tree` before the walk. The walk stays as the fallback for a payload that moved itself into another group. The new `tests/gpu_hold_group_test.sh` fails on `origin/main` and passes here; `tests/gpu_hold_tree_test.sh` (the #1624 arm) stays green, so both shapes are covered.
…arm pins it (#1649 review) Review finding, reproduced on bash 5.3.9: **job control makes a background job keep the shell's stdin**, so under `set -m` a payload reads whatever the CALLER is reading. The fleet's own sweep shape `while read m; do gpu-hold -- bench "$m"; done < list` then runs ONCE, because the first payload's `cat` swallows the rest of the list: no set -m: [a] [b] [c] set -m: [a] b c `"$@" < /dev/null &` restores main's semantics exactly (a background job of a non-interactive shell already got /dev/null) and takes only the process group from the change. The new arm runs that sweep shape and asserts all three entries are consumed: it passes here and FAILS on the same script with the redirect removed (`ran 1 time(s)`). (`set +m` right after the launch was already in 1f1f1c0, so the watchdog and any later background job are not affected.)
8eb7d01 to
fa303e7
Compare
Rebased onto
|
| check | result |
|---|---|
set -m + kill -TERM -- "-$root" still present |
yes (4 hits) |
| all six gpu-hold tests | rc 0 each — group, survivors (from #1646, now on main), tree, queue, mem_abort, ticket_lost |
| owner-repro harness | BOTH SHAPES CLEAN — stubborn clean, rootexits … survivor:none |
main (afd60e1e) still leaves survivor: 1006275 on the root-exits arm, so this PR is still the thing that closes #1649.
Head fa303e75, mergeable=MERGEABLE. Re-review requested.
One note for whoever owns the build files
This is the fourth conflict I have resolved in that one add_test region tonight (#1646 twice, #1651 twice), and #1696 now tracks it with #1708 as the fix. Worth landing #1708 soon: the cost is not the resolution, it is that a "keep both" hand-merge is exactly where a registration gets dropped — which already happened once on #1646.
Lane pi-rlk; no device time (0 s GPU, 0 s NPU), nothing merged by me.
|
/review |
|
Preparing review... |
|
Persistent review updated to latest commit fa303e7 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at fa303e7
Re-verdict after the rebase onto afd60e1 (#1705 included). fa303e7 is unchanged (=), and 0fbdb93 differs from the previous a989b6d only in how CMakeLists.txt was resolved.
A correction to my READY at 8eb7d016: that head was not mergeable as reviewed, and I missed it. It carried committed conflict markers in CMakeLists.txt:269-281 (<<<<<<< HEAD … ======= … >>>>>>> 4e9e6df6), so the tree didn't configure. I had run the gpu-hold scripts directly on a local merge and never configured that head itself. This rebase removes them: git grep finds no conflict markers anywhere at this head.
Checked at this head, configured this time:
cmakewith ci.yml's configure line succeeds (rc 0).ctest -Nlists all seven hold rows (gpu_hold_tree,_mem_abort,_ticket_lost,npu_hold,_survivors,_group,_queue), andctest -R '^(gpu_hold_group|route_port_lock)$'gives 2/2 passed.- Every hold script run directly also passes: the six
gpu_hold_*tests andnpu_hold, withbash -n scripts/gpu-holdclean. - The group test's negative control from my previous review still applies: against main's
gpu-hold, the TERM-ignoring grandchild outlives the hold.
|
VERDICT: READY at The mechanism is the superset, and it dodges the trap that the other PR names
It also sidesteps the objection #1647's header raises: The three test arms are the right three, because two of them test the side effects
What #1651 should absorb from #1647, and what #1647 becomesBoth edit the same kill path in
I am not merging anything and not calling the order for the owners; I am saying that as they stand, the two PRs are one PR plus one test file, What I did not run
|
The orphan shape the #1624 family does not cover
kill_treewalksdescendants "$root". When the payload's root has already exited — which is what a lane does when itstarts a server and returns (
gpu-hold -- bash -c 'server &') — that walk is empty before it is snapshotted, so neitherthe TERM pass nor the KILL pass has anything to signal. The snapshot fix (#1624,
8ff2814e) is correct for the case it waswritten for (the tree-kill starts while the root is alive, and the recompute after TERM loses the children) and inert here.
This is the shape that left a 14 h
llama-serverholding 51 GiB of GTT past its ticket.Measured on
origin/main(8ff2814e), no devicePrivate queue and token file (
GPU_QUEUE_DIR/GPU_TOKEN_FILEaway from the fleet's); payload starts atrap '' TERMgrandchild, records its pid, exits by itself:
The change
set -mbefore launching the payload: every background job leads its own process group, and$!is still thepayload's pid, so
waitstill returns the payload's own status;kill -TERM -- "-$root"/kill -KILL -- "-$root"inkill_tree, before the walk. A group outlives its leader andreparenting does not change it, so this reaches the children whether or not the root is alive. The walk stays as the
fallback for a payload that moved itself into another group.
Tests
tests/gpu_hold_group_test.sh(new, registered with ctest) — two arms, ~6 s, no GPU and no ticket:Negative control, same test against
origin/main'sscripts/gpu-hold:tests/gpu_hold_tree_test.sh(the #1624 arm) stays green, so both shapes are covered;bash -nclean;tools/copyright.py --checkclean. The test only ever signals a pid whose/proc/<pid>/cmdlinestill carries its ownunique marker, so it cannot touch another lane's process.
Fixes #1649.