Skip to content

fix(ci): reap leaked Activity gate servers and wait for a quiet host - #2119

Merged
inureyes merged 3 commits into
mainfrom
fix/issue-1949-activity-gate-leak-and-quiet
Oct 5, 2026
Merged

inureyes merged 3 commits into
mainfrom
fix/issue-1949-activity-gate-leak-and-quiet

Conversation

@inureyes

@inureyes inureyes commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Summary

The WebUI installed artifact job's Activity performance gate had two environmental faults on the GB10 runner (#1949). It leaked its server when the verifier was killed or its cleanup gave up, and the leaked server then failed every later run at the GPU precondition, which printed only a count (on 2026-10-05, five consecutive runs of PR #2117 after main 680f64e failed in scoring). It also scored while other work ran on the host and reported degradations inside its own noise. This PR closes both in the job itself.

Changes

  • scripts/webui/activity_gate_host.py precheck replaces the bare nvidia-smi count as the first command of the gate body (after the CI flock and gpu-lock):
    • A process carries the job's identity when its executable is in $RUNNER_TEMP/mlxcel-webui-installed/, or its working directory or HOME is a verifier run directory ($RUNNER_TEMP/mlxcel-activity-performance-*). The verifier gives the server, node and the browser HOME=<run dir>/home; Playwright launches the browser detached, in a process group of its own, so HOME is what reaches it.
    • Stops a process of that identity an earlier run leaked: orphaned (PPID 1), or with its directory or executable reading (deleted) or its run directory gone. A live process of that identity, or a leak owned by another uid, fails closed instead.
    • Fails closed if any GPU compute process remains, before the quiet wait (so a conflict is reported at once) and again after it, naming each with pid, process name, memory, parent and uid. Executable and directory paths are printed only for the job's own processes, since logs and artifacts are public.
    • Waits up to 600 s until the mean runnable-task count from /proc/loadavg over 10 one-second samples is at most a quarter of the CPUs (5 on GB10), and fails closed with a host snapshot otherwise. Load averages and ps %cpu lag a fresh compile, so neither is used, and the window must fill before the gate can refuse.
    • Writes the observed host state (snapshots at start and end with load averages and compiler pids, quiet-wait samples, anything reaped, any refusal) to webui-activity-host-state.json, uploaded with the diagnostics.
  • activity_gate_host.py reap, a new if: always() step after the gate: stops the process groups the verifier's pid file names and anything else of the job's identity, prints what it found, and fails the job if anything survives or the pid file is unreadable (the identity sweep still runs). A missing pid file is a clean no-op.
  • Signalling safety: processes with a controlling terminal (a developer's shell on the runner) are never touched; a process matched only by directory or HOME is signalled alone, never with its group; signals go through a pidfd checked against the recorded start time, so a reused pid is never signalled; pid-file records carry the boot id.
  • verify_activity_performance.py:
    • --pid-file records every owned process (Xvfb, openbox, the server, the node activity script) outside the run directory as it starts; Xvfb and openbox now run in the run directory.
    • --host-state embeds the precheck's state into the full evidence as host_state.gate_start, plus a host_state.after_activity load sample taken before the output is validated, so a noisy verdict keeps it. The summary does not carry it and summarize_activity_evidence.py is unchanged.
    • terminate_owned no longer raises out of its SIGKILL wait or on a PermissionError, and it stops group members a quickly exiting leader left behind instead of only recording process_group_empty: false.
  • Step timeout 65 to 75 minutes for the quiet wait, job timeout 90 to 120 for two cold builds plus that step; docs in docs/webui-integration-matrix.md; the new tests join make verify-webui-helper-tests and the job's helper test step.

The server spawns no child processes (model workers are in-process threads), so its process group is the whole server. The verdict rule and thresholds are untouched (#1925).

Changes during review

The security review found that a directory-only match could SIGKILL a developer's shell and its whole group, that node and the browser had no reap handle, and that paths of unrelated GPU processes would reach public logs. All three are fixed above, together with pidfd signalling, tolerant pid-file parsing, the GPU check before the quiet wait, and the after-activity sample on the failure path from the implementation review.

Verification

  • make verify-webui-helper-tests: all suites pass (18 host-helper tests, 21 verifier tests, 10 summary tests). The two terminate_owned tests fail on the previous code: one raises subprocess.TimeoutExpired from the SIGKILL wait, the other ends with the group still populated. actionlint 1.7.7 is clean.
  • Runner, lablup-dgxspark21 (machine spark-101):
    • Run 37387059748 (first head): the precheck found the real leaked server from the earlier failed run, pid 1453937 with executable and cwd both (deleted) and PPID 1, stopped it with SIGTERM, and the gate then passed. This is the leak that failed PR fix(generate): sample each penalized step against the token just read #2117's five runs.
    • Run 37388347876 (final head 634444a): green. Host quiet at mean 1.4 runnable tasks (threshold 5), no GPU process, server_shutdown exit_code 0, forced false, process_group_empty true, full evidence carries host_state.gate_start and after_activity, and reap found nothing to do.
    • Run 37388422039, job 112027483381 (throwaway probe PR fix(ci): runner probe for #1949 evidence, do not merge #2120 on top of the final head, closed): under one busy loop per CPU the precheck refused with host did not become quiet within 60s: mean runnable tasks 23.8 over the last 10 samples, threshold 5; with the load removed it passed at 1.1. The verifier was then SIGKILLed mid-activity while the server held 4604 MiB, and the always() reap stopped Xvfb, openbox, the server, node and the browser (the last by HOME); the following check found nvidia-smi --query-compute-apps and pgrep -af mlxcel-server-webui both empty.
  • GB10 simulation on spark-102 (real mlxcel-server serving qwen3-0.6b-4bit, under gpu-lock): a leaked server (PPID 1, deleted run directory, 4116 MiB) was named and stopped; a foreign GPU process was refused at once, named without its path, and left running; a busy host (another session's compile) was refused after the wait; a server left by a SIGKILLed verifier was reaped by pid file.

Closes #1949

The WebUI Activity gate leaked its server when the verifier died or its cleanup gave up, and the leak failed every later run of the job at the GPU precondition, which printed only a count. The gate also scored while another compile ran on the runner and reported degradations inside its own noise.

- scripts/webui/activity_gate_host.py precheck runs first in the gate body: it stops a process an earlier run leaked (executable in the installed-artifact directory or working in a verifier run directory, and orphaned or with its directory deleted), waits up to 600 s for the mean runnable-task count from /proc/loadavg to fall to a quarter of the CPUs, then fails closed naming each remaining GPU compute process. It writes the host state it saw for the evidence.
- activity_gate_host.py reap runs as an always() step after the gate: it stops what the verifier's pid file names, checking start times so a reused pid is never signalled, plus anything else of the job's identity, and fails the job if anything survives.
- verify_activity_performance.py records every owned process in --pid-file, embeds --host-state into the full evidence with an after-activity load sample, and terminate_owned no longer raises from its SIGKILL wait and now stops group members a quickly exiting leader left behind.
- Tests for both failure paths in terminate_owned (each fails on the previous code), the host helper with real processes, and summary compatibility of the new evidence field.

Closes #1949
@inureyes inureyes added priority:medium Medium priority status:review Under review type:bug Bug fixes, error corrections, or issue resolutions labels Oct 5, 2026
Review follow-ups for the Activity gate host guards.

- Processes with a controlling terminal are never signalled, and a process matched only by its working directory or HOME is signalled alone, never with its group, so a developer's shell sitting in a run directory cannot take its editor or pager down with it.
- The browser Playwright launches leads a process group of its own; it, node and the server now match by HOME=<run directory>/home. The verifier records the node activity script in the pid file and starts Xvfb and openbox in the run directory.
- Signals go through a pidfd checked against the recorded start time; a reused pid ends the recorded group instead of being signalled. Pid-file records carry the boot id, and an unreadable file or record no longer stops the identity sweep (it still fails the step).
- GPU processes that are not the job's are named without executable or directory paths, and host snapshots list only compiler names and pids, since the logs and artifacts are public.
- The precheck checks the GPU before and after the quiet wait, records host snapshots at start and end, and writes the host state atomically. nvidia-smi timeouts are refusals.
- The after-activity load sample is taken before the output is validated, so a noisy verdict keeps it; terminate_owned records a PermissionError instead of raising it.
- Job timeout 90 to 120 minutes to fit two cold builds plus the 75 minute gate step; the reap step skips when the checkout never happened.
@inureyes
inureyes merged commit d253154 into main Oct 5, 2026
26 checks passed
@inureyes
inureyes deleted the fix/issue-1949-activity-gate-leak-and-quiet branch October 5, 2026 23:41
inureyes added a commit that referenced this pull request Oct 23, 2026
The Activity performance gate judged five alternating pairs by a bare median against the 2% budget and also failed any mode whose baseline CV exceeded 5%. On `lablup-dgxspark21` the per-pair noise (SD 3.6 points, kurtosis 12 to 25) makes both rules fire on unchanged code. Each mode now samples until an interval decides, and a mode that cannot be decided fails as noise, not as a slowdown.

## The rule

- Per pair: `(off - on) / off * 100`, alternating order as before. Point estimate: the median. Interval: the distribution-free sign-test interval `[x(k), x(n+1-k)]` at 1% per tail (98% two-sided). It needs no variance estimate and no random bootstrap, so JavaScript and Python agree bit for bit.
- `investigate` (slowdown): the interval lies wholly above the 2% budget.
- `within-target`: it does not, and its upper bound is at most 4%.
- Otherwise add 2 pairs, from 12 up to 40; still undecided at 40 is `inconclusive`, which fails with a message naming measurement noise.
- Baseline CV and the paired range stay in the summary as diagnostics and decide nothing. The 2% budget is unchanged.
- The 4% bound is the precision a pass must reach, not a looser budget: a 4% bound gives 0.15% null inconclusive per mode, while 3% gives 2.4%, which breaks the 1% false-failure target (table below).
- All constants live in `webui/scripts/activity-performance-policy.json`. The verifier re-derives every mode from the raw samples with a Python port (`scripts/webui/activity_performance_verdict.py`) and derives the sample and preflight counts from `paired_runs`. The summarizer has no samples, so it checks each summary's internal consistency under the same policy. Both languages are tested against `webui/scripts/activity-performance-fixtures.json`.

## Resampling estimate

Noise model: all 636 complete diagnostics artifacts on this runner (2026-09-15 to 2026-10-22, 6,360 pairs), centred on a true 0% change, iid pairs drawn from that pool plus a per-run shift N(0, 0.83^2). 0.83 is the between-run SD estimated from the post-#2119 runs; one-visible and two-visible means in the same run are uncorrelated (r = -0.07). 20,000 simulated runs per row, sequential looks included:

| True degradation | investigate | inconclusive | within-target | mean pairs |
|---|---|---|---|---|
| 0% | 0.06% | 0.15% | 99.79% | 13.5 |
| 2% | 11.4% | 7.0% | 81.6% | 20.4 |
| 4% | 78.6% | 8.5% | 12.9% | 21.6 |
| 5% | 96.0% | 2.3% | 1.6% | 17.1 |
| 6% | 99.67% | 0.27% | 0.07% | 14.1 |
| 8% | 100% | 0% | 0% | 12.4 |

- False failure on a true 0% change: 0.21% per mode, about 0.4% per CI job (two modes), about 0.6% in `full` mode. With iid pairs and no run shift it is below 0.01%.
- Detection: at least 95% for true effects of 5% and above, 99.7% at 6%. At 4% it is only 79%, so the injected slowdown for acceptance is sized at about 6%.
- Rejected alternatives: a 3% upper bound (2.4% null inconclusive); passing only when the interval lies wholly below 2% (far more pairs); mean with a t-interval and Hodges-Lehmann (similar power, but the mean is hurt by the heavy tails and HL assumes a symmetry the data lacks, skew -0.7).
- Assumption: the guarantee holds while run-level shifts stay near the measured 0.8 points. A shift SD of 1.5 gives 2.4% false `investigate`; keeping such shifts out is the job of the #1949 host-quiet precheck.
- Warmup: there is no first-pair bias (pair 0 mean -0.50 vs -0.48 overall, and no order effect), so no leading pairs are discarded. The existing warmup request runs 1.6% slower than the first baseline, and that request is already excluded.

## Wall time

Measured per pair on 153 recent runs: one-visible 7.2 s median (7.7 s max), two-visible 8.4 s (9.2 s max), plus about 34 s before the first pair. In CI (visible-only-headed), the worst case at 40 pairs per mode is about 12 minutes, and the expected null run is about 4 minutes (about 2 today). `full` mode adds a hidden block, so its worst case is about 20 minutes, inside the 1800 s activity timeout. `ci.yml` timeouts are unchanged.

## Fixture provenance

The fixture copies the per-sample logs of the `webui-activity-performance-diagnostics-Linux` artifacts, none of which had expired: 35423171315 a1, 35425297407 a1/a2/a3, 35425050365 a1, 35421166203 a1, 35567777700 a1 (#1944), 35588958676 a1 (#1946) and 38265665905 a1/a2 (#2279). At their recorded five pairs, every one of them returns `continue` (no verdict), never `investigate`. Their concatenated pairs decide `within-target` at 12 pairs in both modes. The same pairs with the Activity arm 6% slower decide `investigate`, and spread-amplified or drifting (CV 12%) variants exercise `inconclusive` and CV-only cases.

## Test plan

- [x] `pnpm --dir webui run unit` and `pnpm --dir webui run lint`
- [x] `make verify-webui-helper-tests`, including the new `activity_performance_verdict_tests.py`
- [x] `python3 scripts/webui/verify_activity_performance_tests.py` and `summarize_activity_evidence_tests.py`
- [x] `make verify-versions verify-env-var-docs verify-python-tooling`
- [x] The new gate inside the real `WebUI installed artifact` job on this PR, run [39853154267](https://github.com/lablup/mlxcel/actions/runs/39853154267): one-visible within-target (median -0.15%, 98% interval [-1.83%, 1.25%]) and two-visible within-target (median -0.76%, [-2.64%, 0.47%]), both at 12 pairs, with the verifier and summarizer accepting the evidence; the verifier took 3 min 42 s.

## Acceptance runs on `lablup-dgxspark21`

- Five consecutive green `WebUI installed artifact` runs of this tree (head a7cc03f): run [39853154267](https://github.com/lablup/mlxcel/actions/runs/39853154267) attempts 1 to 5 (jobs 120084449655, 120097378392, 120110916929, 120116442394, 120121248222).
- Injected slowdown, red with `investigate` in both modes: run [39870659579](https://github.com/lablup/mlxcel/actions/runs/39870659579) on throwaway draft PR #2554 (closed, never merged). one-visible median 4.58%, 98% interval [3.89%, 5.68%]; two-visible median 8.05%, interval [7.11%, 9.62%]; 12 pairs each. The injection sleeps 400 us per decode tick for each open `/ui-api/v1/events` stream, which the Activity page holds open exactly while visible.
- The true effect was not measured beforehand on a separate harness (a local build on the shared CI host was not permitted); it is characterised only by the gate's own median and interval above, both inside the 4 to 8% target for one-visible.
- The issue's suggested injection does not work on GB10. A per-poll CPU burst of 80 spinning threads (run 39862888632) raised the runnable tasks to 46 to 87 in the visible arms, but decode stayed at 152 tok/s in both arms. A delay keyed on Activity polls (runs 39866915190 and 39868894929) also measured 0%: the instrumented run showed that no operations poll ever landed inside a decode window, because each poll was answered the moment a request finished.

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

Labels

priority:medium Medium priority status:done Completed type:bug Bug fixes, error corrections, or issue resolutions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(ci): clean up the Activity gate server and score on a quiet host

1 participant