chromium-setup/stall-watch.py
Philip Peterson fb86bdf57e Add stall-watch.py diagnostic wrapper; fix PTY hang in file upload
stall-watch.py wraps a command and, if it produces no output for
--stall-timeout seconds, dumps the process tree, network connections,
per-process /proc/*/wchan, and a 5s live strace sample of the stalled
process before killing it. Wired around gclient sync in sync.sh: ci run
#56 (2026-08-04) stalled there for ~3h with zero output and no diagnostic
trail (last thing logged: devtools-frontend's sync_rollup_libs.py hook).
Allocates its own PTY for the wrapped child so it doesn't reintroduce the
block-buffering the SSH PTY fix was added to avoid.

Needed a general upload capability to get stall-watch.py onto a box that
otherwise only ever receives whatever's piped to `bash -s` -- added
`exec -upload local=remote` (cat > remote over a plain SSH session).

That surfaced a real bug: sshSession() requested a PTY unconditionally,
which uploadFile inherited for its `cat > file` transfer. PTY canonical
mode needs a line-oriented EOF signal a plain channel-close doesn't
reliably deliver, so the very first real upload test hung indefinitely
(confirmed: had to manually kill the process and delete the orphaned
server). Fixed by making PTY allocation opt-in per session (still on for
sshExec/sshExecScript, off for uploadFile) and rewriting uploadFile to
use Start()+StdinPipe()+explicit Close() instead of Run()'s implicit
Stdin-reader-copying, for precise control over when EOF is signaled.
Re-verified end-to-end on a real box: checksum-clean upload, and a
simulated stall correctly captured a real process tree/strace/wchan
showing the stuck `sleep` and killed it with no orphan left behind.
2026-08-04 19:27:17 -08:00

157 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""Run a command; if it produces no output for --stall-timeout seconds,
dump process/network/strace diagnostics and kill it.
Exists to catch silent hangs without paying for hours of a hung, billing
Hetzner box before anyone notices -- ci run #56 (2026-08-04) stalled for
~3h with zero output after devtools-frontend's sync_rollup_libs.py hook,
and there was no diagnostic trail left to say why.
Allocates a PTY for the child (not a plain pipe) so wrapping a command
here doesn't reintroduce the block-buffering that hetzner-runner's own
SSH PTY was added to avoid -- git/gclient still think they're attached to
a terminal and line-buffer accordingly.
"""
import argparse
import os
import pty
import signal
import subprocess
import sys
import threading
import time
def run_capture(cmd, timeout=15):
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return out.stdout + out.stderr
except Exception as e:
return f"(failed: {e})"
def dump_diagnostics(pid):
print("===== STALL DETECTED: dumping diagnostics =====", flush=True)
print("--- process tree (ps -eo pid,ppid,stat,etimes,cmd, sorted by runtime) ---", flush=True)
print(run_capture(["ps", "-eo", "pid,ppid,stat,etimes,cmd", "--sort=-etimes"]), flush=True)
print("--- network connections (ss -tnp) ---", flush=True)
print(run_capture(["ss", "-tnp"]), flush=True)
print("--- processes with a cmdline, and their /proc/<pid>/wchan (kernel wait reason) ---", flush=True)
try:
proc_entries = sorted(os.listdir("/proc"))
except OSError as e:
proc_entries = []
print(f"(/proc unavailable: {e})", flush=True)
for entry in proc_entries:
if not entry.isdigit():
continue
p = entry
try:
with open(f"/proc/{p}/cmdline", "rb") as f:
cmdline = f.read().replace(b"\0", b" ").decode(errors="replace").strip()
if not cmdline:
continue
wchan = ""
try:
with open(f"/proc/{p}/wchan") as f:
wchan = f.read()
except OSError:
pass
print(f"pid={p} wchan={wchan} cmd={cmdline}", flush=True)
except OSError:
continue
print("--- 5s strace sample of the stalled process (syscall it's actually blocked in) ---", flush=True)
if subprocess.run(["which", "strace"], capture_output=True).returncode == 0:
try:
proc = subprocess.Popen(
["strace", "-f", "-tt", "-T", "-p", str(pid)],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
time.sleep(5)
proc.send_signal(signal.SIGINT)
out, _ = proc.communicate(timeout=10)
print(out, flush=True)
except Exception as e:
print(f"(strace sample failed: {e})", flush=True)
else:
print("(strace not installed -- apt-get install -y strace to get syscall-level diagnostics next time)", flush=True)
print("===== end stall diagnostics =====", flush=True)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--stall-timeout", type=int, default=300,
help="seconds of zero output before treating the command as stalled")
ap.add_argument("cmd", nargs=argparse.REMAINDER)
args = ap.parse_args()
cmd = args.cmd[1:] if args.cmd[:1] == ["--"] else args.cmd
if not cmd:
sys.exit("usage: stall-watch.py [--stall-timeout SECONDS] -- <cmd...>")
master_fd, slave_fd = pty.openpty()
proc = subprocess.Popen(
cmd, stdout=slave_fd, stderr=slave_fd, stdin=subprocess.DEVNULL,
start_new_session=True, close_fds=True,
)
os.close(slave_fd)
last_output = time.time()
lock = threading.Lock()
stalled = threading.Event()
def reader():
nonlocal last_output
while True:
try:
chunk = os.read(master_fd, 4096)
except OSError:
break
if not chunk:
break
sys.stdout.buffer.write(chunk)
sys.stdout.flush()
with lock:
last_output = time.time()
t = threading.Thread(target=reader, daemon=True)
t.start()
while proc.poll() is None:
time.sleep(10)
with lock:
elapsed = time.time() - last_output
if elapsed >= args.stall_timeout and not stalled.is_set():
stalled.set()
# Diagnostics must never block the kill that follows -- a
# missing tool, a permission error, or a process that exits
# mid-scan must not leave a stalled build hanging forever
# because dump_diagnostics() raised.
try:
dump_diagnostics(proc.pid)
except Exception as e:
print(f"(dump_diagnostics itself failed: {e!r} -- killing anyway)", flush=True)
print(f"killing process group (pgid={os.getpgid(proc.pid)}) after {elapsed:.0f}s of silence", flush=True)
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except ProcessLookupError:
pass
time.sleep(5)
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except ProcessLookupError:
pass
break
t.join(timeout=5)
code = proc.wait()
os.close(master_fd)
sys.exit(124 if stalled.is_set() else code)
if __name__ == "__main__":
main()