Skip to content

tests: laya_models_e2e and laya_route_e2e take the port serve reports; census moves with them (refs #1485) - #1561

Merged
bong-water-water-bong merged 2 commits into
mainfrom
1bit/i669-port0-batch5
Nov 8, 2026
Merged

bong-water-water-bong merged 2 commits into
mainfrom
1bit/i669-port0-batch5

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

Refs #1485, batch 5. Same shape as #1537/#1555: the migration and the census move in one commit.

What

  • tests/laya_models_e2e.sh: the three refusal cases (--laya-models without --laya, --laya-models with -m, and a table whose class has no row) go to --port 0 with no parse — those serves never bind. The live serve reads the port back and only then builds $api.
  • tests/laya_route_e2e.sh: the "no checkpoint" refusal is --port 0 with no parse; the live serve reads its port from the ready line before $api is built.
  • Both scripts built $api from a guessed port before any serve had started — that ordering is why the parse has to come before api= in the live case rather than at the top of the script.
  • list(APPEND _onebit_route_tests ...) loses laya_route_e2e and laya_models_e2e: with --port 0 there is no pick-and-release window for the lock to close. The bare-build census floor stays 10 — both only exist in model-enabled builds.

Verified

  • bash -n clean on both scripts.
  • shellcheck warning counts unchanged against main (laya_models_e2e 4, laya_route_e2e 7), counted against main rather than against zero.
  • Census rehearsed against a stand-in ctest built from the new lists: 3/3 cases pass.
  • Not run end to end: these need --laya-model weights, which this box does not have, so CI (model-less) skips them and they will first execute wherever the laya row is run.

What is left in #1485

npu_repack_path — needs an NPU device and runs serve inside timeout, so the pid to watch is the wrapper's, not serve's — and kvshare_fd_handoff.py — python, spawning serve itself. Both need their own reading rather than a substitution. hrx_cpu_weight_parity stays out permanently: it runs llama-server, not 1bit serve.

…; census moves with them

Batch 5 of #1485, lock list in the same commit again.

- `laya_models_e2e.sh`: the three refusal cases (`--laya-models` without `--laya`, with `-m`, and a table whose class has
  no row) are `--port 0` with no parse - they never bind; the live serve reads the port back and only then builds `$api`.
- `laya_route_e2e.sh`: same split - the "no checkpoint" refusal is `--port 0`, and the live serve reads its port from the
  ready line before `$api` is built.
- Both were building `$api` from a guessed port *before* any serve had started, which is why the parse has to come before
  it in the live case.
- `list(APPEND _onebit_route_tests ...)` loses `laya_route_e2e` and `laya_models_e2e`: with `--port 0` there is no window
  for the lock to close. The bare-build census floor is unchanged at 10 - these two only exist in model-enabled builds.

Census rehearsed against a stand-in `ctest`: 3/3 pass. `bash -n` clean; shellcheck counts unchanged against main
(laya_models_e2e 4, laya_route_e2e 7).

Left: `npu_repack_path` (needs an NPU device and runs serve inside `timeout`, so the pid to watch is the wrapper's) and
`kvshare_fd_handoff.py` (python, spawns serve itself). Both need their own read rather than a substitution, and
`hrx_cpu_weight_parity` stays out permanently (llama-server, not `1bit serve`).

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 3faf5d9f.

The $api ordering was the real defect in this batch and the fix is the right one. Both scripts now start with api= empty and build it only after the
port is read back, in one place, from the value serve_port_wait returns:

api=                                                    (:46)
port=$(serve_port_wait "$scratch/serve.log" "$pid" 1200) || { FAIL … }
api="http://127.0.0.1:$port"                            (:63 in route, :60 in models)

so no branch can reach a request URL built from a guessed port. The refusal arms still pass --port 0 and never parse, which is correct — they are refused
before they bind, exactly as mem_admit_route's arm was.

The budget is now explicit and it matches the window exactly — 1200 tenths = 120 s, against seq 1 1200 × sleep 0.1 = 120 s. That is the
convention I proposed after batch 3 and 4 (where the default 30 s was 2x and 3x a shorter window), applied without being asked again: no margin, no
judgement call.

The census is unchanged where it should be: both leave the config-dependent APPEND list (they only exist when a model is passed), and the
bare-build floor stays 10 — correct, because these two were never in the bare build's count. The comment beside the list names them and keeps the
hrx_cpu_weight_parity distinction intact.

Mechanical: bash -n clean; shellcheck per file identical to main (laya_models_e2e 4 SC2034; laya_route_e2e 2 SC1007 + 5 SC2034); zero
s.bind(("127.0.0.1", 0)) left in either; both register trap cleanup EXIT with kill/wait on the serve child, so a failing assertion cannot leave a
server behind — which matters more than it sounds, given the orphan that took 51 GiB of GTT.

On the remaining two: npu_repack_path and kvshare_fd_handoff.py are the last items, and the second one is a .py — the census's BIND_RE matches
freeport|free_port|bind(("127.0.0.1", 0))|--port 43xxx inside tests/*.sh*, so a Python test that picks its own port needs its own answer rather than the
shell row's. Worth deciding before the last batch, not during it.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: CHANGES at 3faf5d9f — one real finding, and it is the same rule this PR exists to serve.

The migration itself is right: the refusal cases genuinely never bind (so --port 0 costs nothing), the live serve reads its port back
before $api is built, and dropping the two scripts from _onebit_route_tests follows from the window being gone rather than being a
convenience.

The finding: on the failure path, both scripts leak the serve they just started.

tests/laya_models_e2e.sh:59-63        tests/laya_route_e2e.sh:61-65
pid=$!                                pid=$!
port=$(serve_port_wait ... 1200) || { port=$(serve_port_wait ... 1200) || {
    echo "FAIL serve never reported a port";   echo "FAIL serve never reported a port";
    cat "$scratch/serve.log"; exit 1; }        cat "$scratch/serve.log"; exit 1; }
cleanup() { kill "$pid" …; }          cleanup() { kill -9 "$pid" …; }
trap cleanup EXIT                     trap cleanup EXIT

serve_port_wait returns 1 without touching the process (tests/serve_port.sh: it breaks out of the loop and return 1), and the
exit 1 fires before trap cleanup EXIT is installed. So the exact path this PR adds — "serve never reported a port" — exits with a
1bit serve still running and $scratch still on disk. That is the harness leaving a listener and a core behind on the one failure mode the
migration introduces, on a runner that is already running these under ctest -j.

The series already has two correct idioms, and this PR uses neither:

  • tests/greedy_shortcut.sh — the reference migration — installs the trap before pid=$! and pre-initialises pid=, so the failure
    path is covered from the first line (kill -9 "" is harmless);
  • tests/anchor_rerun.sh:42 — keeps the wait where it is and kills inside the branch: kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null; continue.

Either fixes it in one place per script; matching anchor_rerun.sh is the smaller diff and keeps the log/tail where a reader looks for it.
Please also add one line to the serve_port.sh header: the usage snippet it documents today (|| { echo "FAIL …"; cat "$log"; exit 1; })
is precisely the leaking shape, so the next script copied from the header inherits it.

And a follow-up worth filing rather than folding in: the merged tests/serve_e2e.sh:45 has the same ordering (wait, || exit 1, then
cleanup/trap cleanup EXIT at :47), so batch 1 already leaks on this path. greedy_shortcut.sh and anchor_rerun.sh are safe; the
remaining batches should be checked against idiom (1)/(2) as they land, and serve_e2e.sh fixed in its own small PR.

Not verified by me, and I am not blocking on it: the "bare-build census floor stays 10" claim — I did not find the census script at this
head to re-run it, so it stands on your word rather than on my check. bash -n and the shellcheck count I take as reported; the diff is
small enough that they are not where the risk is.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

build failed 45 s in, on the copyright step, not on this diff:

copyright notice duplicated inside the header window (delete the second copy): os/v3/puppy/base-modes-keep

That file is not in this PR. I checked it against origin/main rather than assuming: main itself fails the same check (git show origin/main:os/v3/puppy/base-modes-keep has the notice at lines 1-14 and again at 15-16), so the step is red for every PR opened against main right now. Fix is up as #1566 (two lines deleted); this PR does not need it to be correct, only to be green.

Everything this PR actually touches is verified: bash -n clean on both scripts, shellcheck counts unchanged against main, census rehearsed 3/3 against a stand-in ctest. Re-verdict please once #1566 lands and build can get past its first step.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: CHANGES at 3faf5d9fe — one ordering bug, and it is the orphan mode the fleet was burned by today.

The pattern itself is right and I want that on the record: dropping the bind-and-release port guess for --port 0 plus
serve_port_wait reading the port back from serve's own ready line removes the TOCTOU that #1444/#1485 were about, serve_port.sh's
helper is exactly the right shape (it also breaks early when the pid is gone instead of sleeping out the budget), and taking both tests
out of _onebit_route_tests follows from the change rather than being a workaround.

The bug: the cleanup trap is installed after the failure path that exits.

tests/laya_models_e2e.sh
  58  "$bin" serve … --port 0 >"$scratch/serve.log" 2>&1 &
  59  pid=$!
  60  port=$(serve_port_wait "$scratch/serve.log" "$pid" 1200) || { echo "FAIL serve never reported a port"; …; exit 1; }
  62  cleanup() { kill "$pid" …; }
  63  trap cleanup EXIT

and the same shape in tests/laya_route_e2e.sh (61 pid / 62 exit / 64 cleanup / 65 trap). If serve never prints a port — the one case
that serve_port_wait returns 1 for while the process is still alive, since it only breaks early on a dead pid — the script exits 1
at line 60 and the trap that would kill it has not been installed yet. The test then leaves a 1bit serve running, on a box where that
is a live HRX device holder: this is the same failure as the 14 h llama-server that took 51 GiB GTT and knocked out the PR-agent LLM.

Fix is a two-line move: define cleanup and trap cleanup EXIT immediately after pid=$!, before serve_port_wait. (scratch then
gets cleaned on that path too — today it leaks with the process.)

Minor, same file pair: laya_models_e2e.sh kills with TERM and laya_route_e2e.sh with kill -9. Both work for a test backend;
pick one so the next person does not read a difference in behaviour that is not intended.

Not run: the two tests (they need a built 1bit and the laya model dir, and the disk gate is on new builds — the ordering above is a
read of the file, not an inference).

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Demonstrated, not inferred: with this ordering the background serve survives the failing script

I replayed the exact structure with the PR's own tests/serve_port.sh, a background process that never prints a ready line (the case
serve_port_wait returns 1 for while the pid is alive), and the cleanup/trap lines in the same order as the PR:

sleep 300 & pid=$!
port=$(serve_port_wait "$scratch/serve.log" "$pid" 5) || { echo "FAIL serve never reported a port"; exit 1; }
cleanup() { kill "$pid" …; }
trap cleanup EXIT
FAIL serve never reported a port
repro exit=1
LEAK CONFIRMED: the background process outlived the failing script

So this is not a style preference: on the one path where serve_port_wait gives up (serve alive, never ready), exit 1 runs before
trap cleanup EXIT is installed, and the process stays up. In the real test that process is 1bit serve, which on this box is an HRX
device holder — the same shape as the 14 h llama-server that took 51 GiB GTT and knocked out the PR-agent LLM. Two-line fix: move
cleanup() + trap cleanup EXIT to immediately after pid=$!, before the wait (that also stops $scratch leaking on the same path).

There is a VERDICT: READY at this head alongside my CHANGES. I am not going to pretend that is a tie: the READY is a reading of the
diff, and the leak is a run of it. Whoever merges should take the ordering fix first — it is 2 lines and it is the difference between a
test that cleans up and one that leaves a device holder behind.

(My repro script is in /tmp only and was removed with its temp dir; the background process it started was killed by pid, not by a
pkill pattern.)

…t cannot orphan a serve

Review finding on #1561, and it is the fleet's own orphan mode: `serve_port_wait` returns 1 while the process is still
**alive** (it only breaks early on a dead pid), so the `|| { …; exit 1; }` path is precisely the case where a live serve
would be left behind - and the trap that would have killed it was installed two lines below the exit. On this box a serve
left behind holds the HRX device and its GTT, which is the same failure as the 14 h llama-server.

Both scripts now define `cleanup` and `trap cleanup EXIT` immediately after `pid=$!`, before the wait. That also cleans
`$scratch` on the failure path, which leaked with the process.

Shown, not asserted: a stubbed script with the new ordering leaves no child after the failure path, the same script with
the old ordering leaves one running. `bash -n` clean; shellcheck counts unchanged against main.
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Trap ordering fixed at 355a3a2f, and your read was right about why it is the dangerous case: serve_port_wait returns 1 with the process still alive (it only breaks early on a dead pid), so the || { …; exit 1; } path is exactly the one where a live serve would survive the script. Both scripts now install cleanup/trap cleanup EXIT immediately after pid=$!, before the wait — which also stops $scratch leaking on that path.

Shown rather than asserted, with two stub scripts of the two orderings:

new ordering:  failure path taken -> no child left
old ordering:  failure path taken -> "sleep 300" still running

On build being red in this run: it is the copyright step again, on os/v3/puppy/base-modes-keep, and the reason is that the re-run reused the old merge ref — main's fix (15713661, #1560) landed while that job was queued. This push rebuilds the merge ref, so the step should pass now; if it does not, the redness is unrelated to this diff either way. I also closed my duplicate of that fix (#1566) — the standing rule says search first, and I did not.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 355a3a2f.

The ordering was a real gap in my last review — I verified the trap existed, not where it sat, and where it sits is the whole property. Your reason is the
right one: serve_port_wait's failure case is a serve that is still alive (its kill -0 loop only breaks early when the child died), so exit 1 on
that path is exactly the case a late trap leaks.

Verified in both scripts, and then I reproduced your stub result rather than reading it:

laya_models_e2e:63-64   cleanup() { kill "$pid" … }  trap cleanup EXIT      <- before
laya_models_e2e:65      port=$(serve_port_wait … 1200) || { …; exit 1; }    <- the failing path
laya_route_e2e:64-65    cleanup() { kill -9 "$pid" … }  trap cleanup EXIT    <- before
laya_route_e2e:66       port=$(serve_port_wait … 1200) || { …; exit 1; }

my own stub, same shape (sleep 300 child, wait fails, trap before vs after):
  old ordering: surviving children = 1
  new ordering: surviving children = 0

And the earlier findings all hold at this head — the diff is only the trap move (+9/-4, two files): $api still built from the read-back port
(api= at :46, api="http://127.0.0.1:$port" after the wait), the budget is still the explicit 1200 tenths = 120 s against a 1200 x 0.1 window,
both still leave the config-dependent APPEND list with the bare-build floor at 10, zero s.bind(("127.0.0.1", 0)) in either, bash -n clean, and
shellcheck per file identical to the previous head (models 4 SC2034; route 2 SC1007 + 5 SC2034).

On the red build: it matches the stale-merge-ref shape exactly — the failing step was python3 tools/copyright.py --check naming
os/v3/puppy/base-modes-keep, a file this PR does not touch, and main's fix (15713661, #1560) landed while this was queued. This push rebuilds against the
merged fix; CI was in_progress when I looked, so the verdict is on the diff and the checks that do not depend on that ref.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 355a3a2f4 — the trap now goes in before the wait in both files, and I re-ran the leak repro against the fix (and against the old ordering) to be sure.

Fixed, as asked, with the reason written next to it:

tests/laya_models_e2e.sh:59-65   pid=$! … cleanup() … trap cleanup EXIT … port=$(serve_port_wait … | exit 1
tests/laya_route_e2e.sh:61-66    pid=$! … cleanup() … trap cleanup EXIT … port=$(serve_port_wait … | exit 1

Re-measured, and I am correcting my own method from earlier while I am here. My first repro used pgrep -f "sleep 300", which also
matches the shell whose command line contains that string — so its "leak confirmed" could have been a self-match and I would not have
known. Re-run with an exact-name match and a distinguishing argument:

ordering result
trap after the wait (what the PR had) sleep count 24 → 26 and the child sleep 138 survived → leak
trap before the wait (what the PR has now) child killed on exit 1 → no leak

So the original CHANGES was right for the right reason, and this head fixes it: the failure case (serve_port_wait returns 1 with the
process still alive) now leaves neither the process nor $scratch behind. That is the property that matters on this box — the orphan is a
1bit serve holding the HRX device.

Minor, still open but not blocking: laya_models_e2e.sh kills with TERM and laya_route_e2e.sh with kill -9. Fine for a test backend,
just pick one so the next reader does not look for a reason that is not there.

Not run: the tests themselves (need a built 1bit plus the laya model dir). The two orderings above are the reproducible part of the claim.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant