rpx_benchmark.cleanup
Graceful-shutdown plumbing for long-running CLI scripts.
Problem this solves
Top-level scripts (run_depth.py, visualize_rerun.py,
dataset_hub.cli, ...) spawn subprocesses: HF download workers, the
Rerun viewer, DataLoader workers, ffmpeg encoders. When the user hits
Ctrl+C or the parent receives SIGTERM, Python's default behaviour
raises KeyboardInterrupt only in the main thread — child processes
keep running and accumulate as orphans (visible as background CPU /
memory the user has to hunt down later).
What this module does
install_signal_cleanup() installs three guarantees in one call:
- The current process becomes its own process group leader via
os.setpgrp(). Every child / grandchild spawned afterwards inherits the group id by default. SIGINTandSIGTERMdeliver aSIGTERMto the whole group, wait up totimeoutseconds for graceful exit, then sendSIGKILLto anything still alive. The signalled process exits viasys.exit(128 + signum)so its own exit code reflects the signal.- A double-tap (Ctrl+C twice within the timeout) skips the wait and goes straight to SIGKILL of the group.
Idempotent: calling it twice is a no-op. Linux-only — on macOS / BSD the process-group dance still works, on Windows the call is a no-op (no equivalent group semantics, no harm in importing).
1"""Graceful-shutdown plumbing for long-running CLI scripts. 2 3Problem this solves 4------------------- 5Top-level scripts (``run_depth.py``, ``visualize_rerun.py``, 6``dataset_hub.cli``, ...) spawn subprocesses: HF download workers, the 7Rerun viewer, DataLoader workers, ffmpeg encoders. When the user hits 8Ctrl+C or the parent receives SIGTERM, Python's default behaviour 9raises ``KeyboardInterrupt`` only in the main thread — child processes 10keep running and accumulate as orphans (visible as background CPU / 11memory the user has to hunt down later). 12 13What this module does 14--------------------- 15:func:`install_signal_cleanup` installs three guarantees in one call: 16 171. The current process becomes its **own process group leader** via 18 ``os.setpgrp()``. Every child / grandchild spawned afterwards 19 inherits the group id by default. 202. ``SIGINT`` and ``SIGTERM`` deliver a ``SIGTERM`` to the **whole 21 group**, wait up to ``timeout`` seconds for graceful exit, then send 22 ``SIGKILL`` to anything still alive. The signalled process exits via 23 ``sys.exit(128 + signum)`` so its own exit code reflects the signal. 243. A double-tap (Ctrl+C twice within the timeout) skips the wait and 25 goes straight to SIGKILL of the group. 26 27Idempotent: calling it twice is a no-op. Linux-only — on macOS / BSD 28the process-group dance still works, on Windows the call is a no-op 29(no equivalent group semantics, no harm in importing). 30""" 31 32from __future__ import annotations 33 34import atexit 35import os 36import signal 37import sys 38import time 39from types import FrameType 40from typing import Optional 41 42_INSTALLED = False 43_KILL_TIMEOUT = 5.0 44_DOUBLE_TAP_AT: Optional[float] = None 45 46 47def _signal_to_group(sig: int) -> None: 48 """Send ``sig`` to our process group, ignoring failures (group already 49 empty, no permission, race with a child exiting normally).""" 50 try: 51 os.killpg(os.getpgrp(), sig) 52 except (ProcessLookupError, PermissionError, OSError): 53 pass 54 55 56def _on_signal(signum: int, _frame: Optional[FrameType]) -> None: 57 global _DOUBLE_TAP_AT 58 now = time.monotonic() 59 name = signal.Signals(signum).name if signum in signal.Signals._value2member_map_ else str(signum) 60 61 # Double-tap (e.g. two Ctrl+C in quick succession) → escalate immediately. 62 if _DOUBLE_TAP_AT is not None and now - _DOUBLE_TAP_AT < _KILL_TIMEOUT: 63 sys.stderr.write(f"\n[cleanup] {name} (double-tap) — SIGKILL process group\n") 64 _signal_to_group(signal.SIGKILL) 65 os._exit(128 + signum) 66 _DOUBLE_TAP_AT = now 67 68 sys.stderr.write( 69 f"\n[cleanup] {name} received — SIGTERM process group, " 70 f"waiting up to {_KILL_TIMEOUT:.0f}s for graceful exit " 71 "(press Ctrl+C again to SIGKILL)\n" 72 ) 73 # SIGTERM the children. We don't SIGTERM ourselves — we want to 74 # complete shutdown and exit normally. 75 pgid = os.getpgrp() 76 for pid in _list_children(pgid): 77 if pid == os.getpid(): 78 continue 79 try: 80 os.kill(pid, signal.SIGTERM) 81 except (ProcessLookupError, PermissionError): 82 pass 83 84 # Wait for the group to drain. 85 deadline = now + _KILL_TIMEOUT 86 while time.monotonic() < deadline and _children_still_alive(pgid): 87 time.sleep(0.1) 88 89 if _children_still_alive(pgid): 90 sys.stderr.write("[cleanup] timeout — SIGKILL stragglers\n") 91 for pid in _list_children(pgid): 92 if pid == os.getpid(): 93 continue 94 try: 95 os.kill(pid, signal.SIGKILL) 96 except (ProcessLookupError, PermissionError): 97 pass 98 99 sys.exit(128 + signum) 100 101 102def _list_children(pgid: int) -> list[int]: 103 """Return PIDs in our process group (Linux /proc walk; falls back to [] 104 on platforms without /proc).""" 105 try: 106 entries = os.listdir("/proc") 107 except OSError: 108 return [] 109 out: list[int] = [] 110 for name in entries: 111 if not name.isdigit(): 112 continue 113 try: 114 with open(f"/proc/{name}/stat", "rb") as f: 115 fields = f.read().split() 116 # /proc/<pid>/stat field 5 (1-indexed) is pgid 117 if len(fields) >= 5 and int(fields[4]) == pgid: 118 out.append(int(name)) 119 except (OSError, ValueError): 120 continue 121 return out 122 123 124def _children_still_alive(pgid: int) -> bool: 125 return any(pid != os.getpid() for pid in _list_children(pgid)) 126 127 128def _on_atexit() -> None: 129 """On normal interpreter shutdown, SIGTERM any lingering descendants. 130 131 Don't escalate to SIGKILL here — normal exit usually means children 132 have already cleaned themselves up; this is a safety net. 133 """ 134 pgid = os.getpgrp() 135 for pid in _list_children(pgid): 136 if pid == os.getpid(): 137 continue 138 try: 139 os.kill(pid, signal.SIGTERM) 140 except (ProcessLookupError, PermissionError): 141 pass 142 143 144def install_signal_cleanup(timeout: float = 5.0) -> None: 145 """Install signal handlers + process group setup. 146 147 Safe to call multiple times — only the first call takes effect. 148 No-op on Windows (no process-group semantics). 149 150 Parameters 151 ---------- 152 timeout 153 Seconds to wait between sending SIGTERM and escalating to 154 SIGKILL. The default (5s) gives child PyTorch / HF workers 155 enough time to flush state without making Ctrl+C feel slow. 156 """ 157 global _INSTALLED, _KILL_TIMEOUT 158 if _INSTALLED: 159 return 160 if sys.platform.startswith("win"): 161 _INSTALLED = True 162 return 163 _KILL_TIMEOUT = float(timeout) 164 # New process group → killing the leader kills every descendant. 165 try: 166 os.setpgrp() 167 except OSError: 168 # Already a group leader, or unsupported on this platform — fine. 169 pass 170 signal.signal(signal.SIGINT, _on_signal) 171 signal.signal(signal.SIGTERM, _on_signal) 172 atexit.register(_on_atexit) 173 _INSTALLED = True
145def install_signal_cleanup(timeout: float = 5.0) -> None: 146 """Install signal handlers + process group setup. 147 148 Safe to call multiple times — only the first call takes effect. 149 No-op on Windows (no process-group semantics). 150 151 Parameters 152 ---------- 153 timeout 154 Seconds to wait between sending SIGTERM and escalating to 155 SIGKILL. The default (5s) gives child PyTorch / HF workers 156 enough time to flush state without making Ctrl+C feel slow. 157 """ 158 global _INSTALLED, _KILL_TIMEOUT 159 if _INSTALLED: 160 return 161 if sys.platform.startswith("win"): 162 _INSTALLED = True 163 return 164 _KILL_TIMEOUT = float(timeout) 165 # New process group → killing the leader kills every descendant. 166 try: 167 os.setpgrp() 168 except OSError: 169 # Already a group leader, or unsupported on this platform — fine. 170 pass 171 signal.signal(signal.SIGINT, _on_signal) 172 signal.signal(signal.SIGTERM, _on_signal) 173 atexit.register(_on_atexit) 174 _INSTALLED = True
Install signal handlers + process group setup.
Safe to call multiple times — only the first call takes effect. No-op on Windows (no process-group semantics).
Parameters
timeout Seconds to wait between sending SIGTERM and escalating to SIGKILL. The default (5s) gives child PyTorch / HF workers enough time to flush state without making Ctrl+C feel slow.