Skip to content

tools(GATE-GPU-LOCK-WRAPPER): one sanctioned way to take the GPU, and it records which lock it took (#587) - #596

Open
localai-bot wants to merge 1 commit into
mainfrom
row/GATE-GPU-LOCK-WRAPPER
Open

tools(GATE-GPU-LOCK-WRAPPER): one sanctioned way to take the GPU, and it records which lock it took (#587)#596
localai-bot wants to merge 1 commit into
mainfrom
row/GATE-GPU-LOCK-WRAPPER

Conversation

@localai-bot

@localai-bot localai-bot commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Refs #587.

Closes corrected to Refs (review of #596). The wrapper does not
close #587. The issue's Done-when is one canonical path, every caller
uses it
, and the discipline document names it unambiguously
, and the
middle clause fails: a THIRD spelling /tmp/gpu is still live in four
dgx-* scripts, in .github/workflows/triton-aot-sync.yml, in this box's
untracked .env (GPU_LOCK=/tmp/gpu, which this wrapper honours and
which .env.example documents loading) and in 99 places across 53 files
under .agents/specs/. So an agent that loads .env and one that does
not still resolve different files, through the wrapper. Full enumeration
and the review repair: .agents/specs/gpu-lock-wrapper.md on
row/GATE-GPU-LOCK-WRAPPER-FIX.

GPU serialisation on dgx.casa was split across two lock files, so it did
not serialise: $HOME/gpu.lock (what the discipline documents, 432 references
in this tree) and /tmp/gpu.lock (what one job took). Two jobs holding
different files run concurrently while each believes it owns the GPU, and
fuser $HOME/gpu.lock shows an empty waiter list throughout. On GB10 that is an
OOM-reboot mechanism; it also voids contention-sensitive numbers silently.

What lands

scripts/gpu-lock.sh — one sanctioned way to take the GPU, so the path stops
being something each script re-decides.

scripts/gpu-lock.sh --label 'ours-c1' --record evidence/lock.txt -- ./bench ...

Canonical path: $HOME/gpu.lock. It has the callers and the documentation,
and it is per-user. A lock under /tmp is world-writable — any process on the
box can hold or clobber the mutex guarding someone else's measurement — and
/tmp is cleared by the very reboot the lock exists to prevent, taking the
evidence of who held it. $GPU_LOCK still overrides, and the wrapper stamps
whichever path it resolved, so a divergence shows up in the record instead
of surfacing when two jobs collide.

It refuses, never falls back. Missing, unwritable, unopenable lock path, or
an unwritable --record file → exit 78, command not run. Bounded wait
expiry → exit 75, its own status, so a dead holder is not something a job
sits behind forever.

It records, exit code first. Disk and load say the box was unhealthy; the
exit code says which thing killed the run — 137 (SIGKILL, another agent's
pkill) versus a compiler diagnostic versus an ENOSPC line are three diagnoses
and three repairs, and only the first says it was not your code at all.

Stamped (stderr, and --record FILE if given):

block fields
ACQUIRE mode, lock-path (resolved, absolute), requested-lock, holder-pid, waited-seconds, timeout-seconds, acquired-utc, host, label, loadavg, df-root, command
RELEASE exit-code, exit-reason, outcome, lock-path, holder-pid, waited-seconds, elapsed-seconds, released-utc, loadavg-at-exit, df-root-at-exit
REFUSED / TIMEOUT outcome, reason/holder, lock-path, loadavg, df-root, the command did NOT run

No self-deadlock. A nested invocation on the same resolved path passes
through (VLLM_CPP_GPU_LOCK_HELD); a different path is still taken for real.

The near-miss worth naming: a peer's same-binary A/B on this box survived #587
only because its conclusion rested on diff -r -q over two output directories —
byte-identity is contention-immune — while the wall times printed beside it were
not. Had the conclusion rested on the times, #587 would have voided it silently
and nothing in the record would have said so. Stamping does not prevent
contention; it prevents contention being undetectable afterwards.

Evidence

RED first: 48 tests written against an absent script, 48/48 errored, then
48/48 OK (27.8 s, serial). shellcheck -S warning clean, bash -n clean.
scripts/agent-preflight.sh --staged: All gates green.

Eight mutations, each caught:

# mutation caught by
M1 refusal return 0s instead of exiting test_unwritable_lock_directory_refuses, test_missing_lock_parent_refuses
M2 drop every lock-path= stamp test_acquire_block_carries_every_required_field, test_symlinked_lock_path_is_stamped_resolved
M3 drop every exit-code= stamp test_exit_code_is_the_first_field_of_the_release_block, test_nonzero_propagates_unchanged
M4 exit "$RC"exit 0 test_nonzero_propagates_unchanged, test_sigkill_137_propagates_unchanged
M5 EXIT_TIMEOUT=750 test_contended_lock_times_out_with_distinct_status
M6 acquisition replaced by true (never takes the lock) test_lock_is_actually_taken_so_a_holder_excludes_us
M7 flock -w "$TIMEOUT"flock (unbounded) test_wait_is_bounded_not_indefinite
M8 pass-through guard → false (self-deadlock) test_nested_invocation_does_not_deadlock

Disk logged beside every result per the box discipline: dev box 88% used /
56G free
throughout; dgx 99% used / 61G free, read-only sweep only —
nothing built, transferred or deleted there.

Lock-spelling sweep — NOT closed

Repo: 432 $HOME/gpu.lock, 0 /tmp/gpu.lock. But a third spelling
#587 did not name
: /tmp/gpu, no extension —

  • scripts/dgx-online-serving.sh:1108exec 9>/tmp/gpu
  • scripts/dgx-gdn-packed-component.sh:524,543exec 9>/tmp/gpu
  • scripts/opt-dgx-gate.sh:4, scripts/dgx-sglang-low-concurrency.sh:6 — named
  • .agents/coordination.md:99 — documents this box's ${GPU_LOCK} as flock /tmp/gpu

Those callers are outside this change's authority. Until they are repointed, a
run under this wrapper does not exclude an online-serving or
gdn-packed-component run
#587 with different filenames.

dgx (read-only): ~/*.sh converges on $HOME/gpu.lock across 14 spellings that
all resolve to one file; ~/work/* shows ~1.7k /tmp/gpu occurrences, almost
all inside checked-out copies of the four repo scripts above.
~/work/marlin442's own scripts (bps.sh, paired.sh, sweep_ours.sh, …)
contain no lock reference at all. /tmp/gpu.lock did not survive the 09:00
reboot, so what held it is not recoverable from the filesystem and is not
asserted here.

Owed follow-ups (outside this change's authority)

  1. Repoint the four /tmp/gpu callers and .agents/coordination.md:99 at
    scripts/gpu-lock.sh.
  2. Wire test_gpu_lock into scripts/agent-preflight.sh SUITES and
    .github/workflows/ci.yml (both explicitly excluded from this change) —
    ~30 s, no GPU, no build.
  3. ~/work/marlin442 takes no lock; whoever owns it should wrap it.

🤖 Generated with Claude Code

… it records which lock it took (#587)

GPU serialisation on `dgx.casa` was split across two lock FILES. `$HOME/gpu.lock`
is what `.agents/benchmarking.md` and `.agents/environment.md` document and what
432 references in this tree take; `~/work/marlin442` took `/tmp/gpu.lock`. Two
jobs holding different files run concurrently while each believes it owns the
box, and nothing reveals it: `fuser $HOME/gpu.lock` shows an empty waiter list
while the other job is mid-run. On GB10 that is an OOM-reboot mechanism, because
unified memory makes a `gpu_memory_utilization` reservation HOST RAM, and it
voids any contention-sensitive number silently.

`scripts/gpu-lock.sh` is the one sanctioned way to take it, so the path stops
being something each script re-decides. `$HOME/gpu.lock` is canonical: it has
the callers and the documentation, and it is per-user, where a lock under `/tmp`
is world-writable -- any process on the box can hold or clobber the mutex
guarding someone else's measurement -- and is cleared by the very reboot it
exists to prevent, taking the evidence of who held it with it. `$GPU_LOCK` still
overrides, and the wrapper stamps whichever path it RESOLVED, so a divergence
shows up in the record instead of surfacing when two jobs collide.

Two properties are the design.

It REFUSES, never falls back. An untakeable lock aborts with exit 78 and the
command does not run; a bounded wait expires with its own exit 75 rather than
sitting forever behind a dead holder. A wrapper that proceeds on the assumption
it holds a lock it never took is one more instrument that cannot report its own
failure -- the class that produces a `Status: SUCCESS!` from a binary an ENOSPC
build never recompiled, and a "policy violation" that is really a checker unable
to write a temp file.

It RECORDS, and the exit code comes FIRST. Disk and load say the box was
unhealthy; the exit code says WHICH thing killed the run. `137` (SIGKILL --
another agent's `pkill`) versus a compiler diagnostic versus an ENOSPC line are
three different diagnoses and three different repairs, and only the first says
it was not your code at all. The stamp carries the resolved lock path, the
requested spelling, holder pid, wait, timeout, `df -h /` and load average at
acquire and again at exit, the command, and `exit-code` + `exit-reason` as the
first two fields of the release block.

The near-miss this is built from: a peer's same-binary A/B on this box survived
#587 only because its conclusion rested on `diff -r -q` over two output
directories -- byte-identity is contention-immune -- while the wall times
printed beside it were not. Had the conclusion rested on the times, #587 would
have voided it silently and nothing in the record would have said so. Stamping
does not prevent contention. It prevents contention being undetectable
afterwards.

Nesting passes through on the same resolved path instead of deadlocking on a
lock its own parent holds; a different path is still taken for real.

RED first: 48 tests written against an absent script, all 48 erroring, then
green. Eight mutations prove the checks catch their defect rather than
describing it -- the refusal returning instead of exiting, dropping the
`lock-path` field, dropping the `exit-code` field, swallowing the exit code,
collapsing the timeout status to 0, skipping the acquisition entirely,
unbounding the wait, and removing the pass-through so nesting self-deadlocks.
Each is caught.

THE SWEEP IS NOT CLOSED. The repo has 432 `$HOME/gpu.lock` references and zero
`/tmp/gpu.lock` -- but a THIRD spelling #587 did not name: `/tmp/gpu`, no
extension, taken as `exec 9>/tmp/gpu` by `scripts/dgx-online-serving.sh` and
`scripts/dgx-gdn-packed-component.sh`, named by `scripts/opt-dgx-gate.sh` and
`scripts/dgx-sglang-low-concurrency.sh`, and documented as this box's
`${GPU_LOCK}` in `.agents/coordination.md:99`. Those callers are outside this
change's authority and are owed a follow-up: until they are repointed, a run
under this wrapper does NOT exclude an online-serving or gdn-packed-component
run, which is #587 with different filenames. On dgx, `~/*.sh` converges on
`$HOME/gpu.lock` across 14 spellings that all resolve to one file, and
`~/work/marlin442`'s own scripts take NO lock at all; `/tmp/gpu.lock` did not
survive the 09:00 reboot, so what held it is not recoverable from the filesystem
and is not asserted here.

`.agents/benchmarking.md` and `.agents/environment.md` name the wrapper as the
sanctioned path, so the next agent does not read the old instruction and take a
raw `flock`.

Disk logged beside every result, per the box discipline: dev box 88% used /
56G free throughout; dgx 99% used / 61G free, read-only sweep only, nothing
built or deleted there.

Not wired into `scripts/agent-preflight.sh` SUITES or `.github/workflows/ci.yml`
-- both outside this change's authority, and owed.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5 [Claude Code]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants