Skip to content

Commit b87e91e

Browse files
unnawutclaude
andcommitted
remote-bench: detach bench in tmux so SSH disconnects can't kill it
Long aggregations exceed the IAP tunnel idle timeout (~30 min in practice). When the tunnel resets, gcloud's `compute ssh --command` session dies, the bench process gets SIGHUP'd, and we lose the run. Hit this today running c4-standard-{8,16,32}: VMs went idle ~30 min in, no result files written. Fix: split the remote work across two SSH calls. 1. Synchronous setup (existing ssh_exec): apt + rustup + uv + clone + prereqs. Short, well-bounded; live output streams to local stdout. 2. The actual bench is launched into a detached `tmux` session (`tmux new-session -d -s leanbench ./run-bench.sh`) so it's owned by tmux, not by the SSH session. The setup ssh_exec returns immediately after launching it. The orchestrator then enters _poll_bench_completion, which every --poll-interval-s seconds (default 30): - tail -c +N leanBench/bench.log (stream new output to local stdout) - tmux has-session -t leanbench (check if bench has finished) - on completion, read leanBench/bench.exit for the exit code Tolerates transient SSH failures (up to 6 in a row) so a brief IAP hiccup doesn't trigger the outer try/except → finally → destroy cascade and abort a healthy bench. On final-give-up, prints the gcloud command to manually `tmux attach -t leanbench` and recover. remote_setup.sh apt-installs `tmux`, writes a small run-bench.sh that captures the bench exit code to bench.exit, and launches it via tmux. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8f546fd commit b87e91e

2 files changed

Lines changed: 92 additions & 13 deletions

File tree

scripts/cloud/remote_bench.py

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import signal
3232
import subprocess
3333
import sys
34+
import time
3435
from pathlib import Path
3536

3637
from .provisioners import Instance, InstanceSpec
@@ -92,6 +93,11 @@ def main():
9293
help="Don't destroy the VM if the bench fails — useful for debugging")
9394
ap.add_argument("--out-dir", type=Path, default=Path("results"))
9495
ap.add_argument("--ssh-timeout-s", type=int, default=300)
96+
ap.add_argument("--poll-interval-s", type=int, default=30,
97+
help="Seconds between bench-completion polls. The bench "
98+
"runs in a detached tmux session on the VM; the "
99+
"orchestrator tails its log and checks for exit on "
100+
"this cadence.")
95101
ap.add_argument("--signers-cache", type=Path, default=None,
96102
help="Path to a local benchmark_signers_cache_<hash>.bin file to "
97103
"pre-upload to each VM. Skips the ~few-minute lazy regen on "
@@ -272,9 +278,7 @@ def run_one_machine(
272278
prov.scp_to(inst, args.signers_cache,
273279
f"leanBench-signers/{args.signers_cache.name}")
274280

275-
print(f"{prefix}==> running setup + bench")
276-
if not prefix:
277-
print("─" * 64)
281+
print(f"{prefix}==> running setup + launching bench in detached tmux")
278282
bench_args = f"--label {machine_type} {args.bench_args}".strip()
279283
# shlex.quote wraps each value in shell-safe single quotes so a
280284
# malicious branch / args string can't break out into bash.
@@ -285,10 +289,19 @@ def run_one_machine(
285289
)
286290
cmd = env_exports + REMOTE_SETUP_SH.read_text()
287291
rc = prov.ssh_exec(inst, cmd, prefix=prefix)
292+
if rc != 0:
293+
raise RuntimeError(f"setup + launch failed with code {rc}")
294+
295+
# Bench is now running in a detached tmux session on the VM, so a
296+
# dropped SSH/IAP tunnel can't kill it. Poll separately for
297+
# progress + completion.
298+
print(f"{prefix}==> polling bench (interval {args.poll_interval_s}s)")
299+
if not prefix:
300+
print("─" * 64)
301+
_poll_bench_completion(prov, inst, prefix=prefix,
302+
poll_interval_s=args.poll_interval_s)
288303
if not prefix:
289304
print("─" * 64)
290-
if rc != 0:
291-
raise RuntimeError(f"benchmark exited with code {rc}")
292305

293306
marker = prov.ssh_capture(
294307
inst,
@@ -331,6 +344,66 @@ def run_one_machine(
331344
return summary
332345

333346

347+
def _poll_bench_completion(prov, inst, prefix: str, poll_interval_s: int) -> None:
348+
"""Poll the VM until the detached bench tmux session exits.
349+
350+
Streams new bench.log bytes to stdout (with optional `prefix`) so
351+
progress is visible. Raises RuntimeError if bench exited non-zero
352+
or never wrote its exit-code file.
353+
354+
Tolerates transient SSH failures: a brief IAP tunnel outage shouldn't
355+
tear down a healthy VM mid-bench. We only give up after MAX_FAILURES
356+
consecutive bad polls.
357+
"""
358+
MAX_FAILURES = 6
359+
last_byte = 0
360+
consecutive_failures = 0
361+
while True:
362+
try:
363+
chunk = prov.ssh_capture(
364+
inst,
365+
f"tail -c +{last_byte + 1} leanBench/bench.log 2>/dev/null || true",
366+
)
367+
if chunk:
368+
for line in chunk.splitlines():
369+
print(f"{prefix}{line}")
370+
last_byte += len(chunk.encode("utf-8"))
371+
372+
status = prov.ssh_capture(
373+
inst,
374+
"tmux has-session -t leanbench 2>/dev/null && echo running || echo done",
375+
)
376+
consecutive_failures = 0
377+
if status == "done":
378+
break
379+
except subprocess.CalledProcessError as e:
380+
consecutive_failures += 1
381+
print(
382+
f"{prefix} poll failed ({consecutive_failures}/{MAX_FAILURES}): "
383+
f"{e}",
384+
file=sys.stderr,
385+
)
386+
if consecutive_failures >= MAX_FAILURES:
387+
raise RuntimeError(
388+
f"bench poll failed {MAX_FAILURES} times in a row; "
389+
f"VM may be unreachable. Bench may still be running on "
390+
f"the VM — re-attach with: "
391+
f"gcloud compute ssh {inst.name} --zone {inst.data.get('zone', '?')} "
392+
f"--tunnel-through-iap -- tmux attach -t leanbench"
393+
) from e
394+
time.sleep(poll_interval_s)
395+
396+
exit_str = prov.ssh_capture(
397+
inst, "cat leanBench/bench.exit 2>/dev/null || true",
398+
).strip()
399+
if exit_str.startswith("EXIT="):
400+
code = int(exit_str.split("=", 1)[1])
401+
if code != 0:
402+
raise RuntimeError(f"bench exited with code {code}")
403+
else:
404+
raise RuntimeError("bench did not write bench.exit; outcome unknown")
405+
406+
334407
def _confirm(prompt: str) -> bool:
335408
try:
336409
ans = input(f"{prompt} [y/N] ").strip().lower()

scripts/cloud/remote_setup.sh

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ sudo cloud-init status --wait >/dev/null 2>&1 || true
1919
echo '==> [remote] installing build prerequisites...'
2020
sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq
2121
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
22-
build-essential git curl ca-certificates pkg-config
22+
build-essential git curl ca-certificates pkg-config tmux
2323

2424
if ! command -v cargo >/dev/null 2>&1; then
2525
echo '==> [remote] installing rustup...'
@@ -50,11 +50,17 @@ git reset --hard --quiet "origin/$BRANCH"
5050
mkdir -p "$HOME/leanBench-signers"
5151
export SIGNERS_CACHE_DIR="$HOME/leanBench-signers"
5252

53-
echo '==> [remote] running benchmark...'
54-
# Intentionally unquoted: BENCH_ARGS is multi-arg (e.g. "--label foo --samples 10").
53+
echo '==> [remote] launching bench in detached tmux session...'
54+
# Detach the long bench (cargo build + 17 workloads, can run > 30 min)
55+
# so a dropped SSH/IAP tunnel can't kill it. The orchestrator polls
56+
# bench.log + tmux session state separately.
57+
cat > run-bench.sh <<RUN
58+
#!/usr/bin/env bash
5559
# shellcheck disable=SC2086
56-
uv run bench $BENCH_ARGS
57-
58-
# Echo a parseable marker so the orchestrator knows where the result
59-
# landed (independent of bench.py's free-form output).
60-
echo "RESULT_FILE=$(ls -t results/*.json 2>/dev/null | grep -v 'results/index.json' | head -1)"
60+
uv run bench $BENCH_ARGS > bench.log 2>&1
61+
echo "EXIT=\$?" > bench.exit
62+
RUN
63+
chmod +x run-bench.sh
64+
tmux kill-session -t leanbench 2>/dev/null || true
65+
tmux new-session -d -s leanbench ./run-bench.sh
66+
echo '==> [remote] tmux session leanbench started; orchestrator will poll'

0 commit comments

Comments
 (0)