59a0a3844c
PR Test AMD / cancel-on-close (push) Has been skipped
PR Test NVIDIA ARM / scan (push) Has been skipped
PR Test NVIDIA / cancel-on-close (push) Has been skipped
PR Test AMD / scan (push) Has been skipped
PR Test NVIDIA ARM / cancel-on-close (push) Has been skipped
PR Test NVIDIA / scan (push) Has been skipped
Release Docker Images / build (cu129-torch-2.11.0) (push) Has been skipped
Release Docker Images / build (cu130-torch-2.11.0) (push) Has been skipped
Release PyPI / publish (push) Has been skipped
Scheduler Python Test / test (push) Successful in 27m19s
Docs / build (push) Successful in 28m8s
Scheduler C++ Test / test (push) Successful in 28m19s
Scheduler C++ Test / test-flat (push) Successful in 28m18s
Docs / deploy (push) Has been cancelled
PR Test AMD / finish (push) Has been cancelled
PR Test NVIDIA / finish (push) Has been cancelled
PR Test NVIDIA ARM / finish (push) Has been cancelled
PR Test NVIDIA ARM / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test AMD / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test NVIDIA / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
409 lines
13 KiB
Python
409 lines
13 KiB
Python
"""
|
||
Process-group-based process lifecycle manager for CI runners.
|
||
|
||
Usage
|
||
-----
|
||
# At job start (cleans up stale processes from previous run):
|
||
mgr = ProcessGroupManager(runner_id="gb200-node-1")
|
||
mgr.cleanup_stale()
|
||
|
||
# Launch a long-running server:
|
||
proc = mgr.start(command, shell=True, cwd=cwd, env=env)
|
||
|
||
# At job end, terminate all process groups started by this manager:
|
||
mgr.terminate_all()
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
import signal
|
||
import subprocess
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Iterable, Optional
|
||
|
||
# Directory that survives across CI runs on the same host.
|
||
# Must be writable by the runner user.
|
||
_PGID_DIR = Path("/tmp/ci-pgid")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Internal helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _safe_runner_id(runner_id: str) -> str:
|
||
"""Sanitise runner_id so it is safe to use as a filename."""
|
||
return re.sub(r"[^A-Za-z0-9_.-]", "_", runner_id)
|
||
|
||
|
||
def _pgid_path(runner_id: str) -> Path:
|
||
return _PGID_DIR / f"{_safe_runner_id(runner_id)}.pgid"
|
||
|
||
|
||
def _save_pgids(runner_id: str, pgids: Iterable[int]) -> None:
|
||
_PGID_DIR.mkdir(parents=True, exist_ok=True)
|
||
_pgid_path(runner_id).write_text(
|
||
"".join(f"{pgid}\n" for pgid in sorted(set(pgids)))
|
||
)
|
||
|
||
|
||
def _add_pgid(runner_id: str, pgid: int) -> None:
|
||
pgids = _load_pgids(runner_id)
|
||
pgids.add(pgid)
|
||
_save_pgids(runner_id, pgids)
|
||
|
||
|
||
def _load_pgids(runner_id: str) -> set[int]:
|
||
path = _pgid_path(runner_id)
|
||
if not path.exists():
|
||
return set()
|
||
pgids: set[int] = set()
|
||
try:
|
||
for token in path.read_text().split():
|
||
pgids.add(int(token))
|
||
except (ValueError, OSError):
|
||
return set()
|
||
return pgids
|
||
|
||
|
||
def _load_pgid(runner_id: str) -> Optional[int]:
|
||
"""Backward-compatible single-pgid accessor for older callers/tests."""
|
||
pgids = _load_pgids(runner_id)
|
||
if not pgids:
|
||
return None
|
||
return next(iter(sorted(pgids)))
|
||
|
||
|
||
def _remove_pgid(runner_id: str) -> None:
|
||
_pgid_path(runner_id).unlink(missing_ok=True)
|
||
|
||
|
||
def _kill_pgid(pgid: int, sig: signal.Signals) -> None:
|
||
try:
|
||
os.killpg(pgid, sig)
|
||
except (ProcessLookupError, PermissionError):
|
||
pass
|
||
|
||
|
||
class ProcessGroupManager:
|
||
def __init__(self, runner_id: str, term_timeout: float = 10.0) -> None:
|
||
if not runner_id:
|
||
raise ValueError("runner_id must be a non-empty string")
|
||
self.runner_id = runner_id
|
||
self.term_timeout = term_timeout
|
||
self._procs: list[subprocess.Popen] = []
|
||
|
||
def cleanup_stale(self, dry_run: bool = False) -> None:
|
||
"""
|
||
Kill any process group left over from a previous run of this runner.
|
||
Safe to call even if no stale pgid file exists.
|
||
"""
|
||
pgids = _load_pgids(self.runner_id)
|
||
if not pgids:
|
||
print(
|
||
f"[pgm] cleanup_stale: no stale pgid file for runner={self.runner_id}",
|
||
flush=True,
|
||
)
|
||
return
|
||
|
||
print(
|
||
f"[pgm] cleanup_stale: killing stale process groups pgids={sorted(pgids)} "
|
||
f"for runner={self.runner_id}",
|
||
flush=True,
|
||
)
|
||
if dry_run:
|
||
print(
|
||
f"[pgm] cleanup_stale: [dry-run] skip kill pgids={sorted(pgids)}",
|
||
flush=True,
|
||
)
|
||
return
|
||
|
||
print(
|
||
f"[pgm] cleanup_stale: sending SIGTERM to pgids={sorted(pgids)}",
|
||
flush=True,
|
||
)
|
||
for pgid in sorted(pgids):
|
||
_kill_pgid(pgid, signal.SIGTERM)
|
||
print(
|
||
f"[pgm] cleanup_stale: waiting {self.term_timeout}s for graceful shutdown",
|
||
flush=True,
|
||
)
|
||
time.sleep(self.term_timeout)
|
||
print(
|
||
f"[pgm] cleanup_stale: sending SIGKILL to pgids={sorted(pgids)}",
|
||
flush=True,
|
||
)
|
||
for pgid in sorted(pgids):
|
||
_kill_pgid(pgid, signal.SIGKILL)
|
||
_remove_pgid(self.runner_id)
|
||
print(
|
||
f"[pgm] cleanup_stale: removed pgid file for runner={self.runner_id}",
|
||
flush=True,
|
||
)
|
||
|
||
def start(
|
||
self,
|
||
command: str,
|
||
*,
|
||
shell: bool = True,
|
||
cwd: Optional[Path] = None,
|
||
env: Optional[dict] = None,
|
||
dry_run: bool = False,
|
||
) -> Optional[subprocess.Popen]:
|
||
"""Launch command in a new process group (long-running server)."""
|
||
print(f"[pgm] start: launching command for runner={self.runner_id}", flush=True)
|
||
print(f"$ {command}", flush=True)
|
||
if dry_run:
|
||
print(f"[pgm] start: [dry-run] skip", flush=True)
|
||
return None
|
||
|
||
proc = subprocess.Popen(
|
||
command,
|
||
shell=shell,
|
||
cwd=cwd,
|
||
env=env,
|
||
start_new_session=True,
|
||
)
|
||
self._procs.append(proc)
|
||
print(
|
||
f"[pgm] start: spawned pid={proc.pid}, tracked_procs={len(self._procs)}",
|
||
flush=True,
|
||
)
|
||
|
||
try:
|
||
pgid = os.getpgid(proc.pid)
|
||
_add_pgid(self.runner_id, pgid)
|
||
print(
|
||
f"[pgm] start: pid={proc.pid} pgid={pgid} "
|
||
f"runner={self.runner_id} pgid_file={_pgid_path(self.runner_id)}",
|
||
flush=True,
|
||
)
|
||
except ProcessLookupError:
|
||
print(
|
||
f"[pgm] start: pid={proc.pid} already exited before getpgid", flush=True
|
||
)
|
||
|
||
return proc
|
||
|
||
def run(
|
||
self,
|
||
command: str,
|
||
*,
|
||
shell: bool = True,
|
||
cwd: Optional[Path] = None,
|
||
env: Optional[dict] = None,
|
||
dry_run: bool = False,
|
||
check: bool = True,
|
||
) -> dict:
|
||
"""
|
||
Run a short-lived command and track it.
|
||
"""
|
||
print(f"[pgm] run: executing command for runner={self.runner_id}", flush=True)
|
||
print(f"$ {command}", flush=True)
|
||
if dry_run:
|
||
print(f"[pgm] run: [dry-run] skip", flush=True)
|
||
return {"command": command, "returncode": 0, "output": "", "dry_run": True}
|
||
|
||
proc = subprocess.Popen(
|
||
command,
|
||
shell=shell,
|
||
cwd=cwd,
|
||
env=env,
|
||
start_new_session=True,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.STDOUT,
|
||
text=True,
|
||
errors="ignore",
|
||
)
|
||
self._procs.append(proc)
|
||
|
||
try:
|
||
pgid = os.getpgid(proc.pid)
|
||
_add_pgid(self.runner_id, pgid)
|
||
print(
|
||
f"[pgm] run: spawned pid={proc.pid} pgid={pgid} "
|
||
f"tracked_procs={len(self._procs)}",
|
||
flush=True,
|
||
)
|
||
except ProcessLookupError:
|
||
print(
|
||
f"[pgm] run: pid={proc.pid} already exited before getpgid", flush=True
|
||
)
|
||
|
||
output_lines: list[str] = []
|
||
assert proc.stdout is not None
|
||
for line in proc.stdout:
|
||
print(line, end="")
|
||
output_lines.append(line)
|
||
proc.wait()
|
||
self._procs.remove(proc)
|
||
|
||
returncode = proc.returncode
|
||
print(
|
||
f"[pgm] run: finished pid={proc.pid} returncode={returncode} "
|
||
f"tracked_procs={len(self._procs)}",
|
||
flush=True,
|
||
)
|
||
|
||
# Python multiprocessing daemon children receive SIGTERM when the
|
||
# parent exits, which can race with normal shutdown and cause the
|
||
# parent to report -15. If the test output shows ALL tests passed,
|
||
# downgrade the signal exit to a warning.
|
||
if returncode < 0:
|
||
output_text = "".join(output_lines)
|
||
# Match "Test Summary: N/N passed" only when N == N (all passed)
|
||
summary_match = re.search(
|
||
r"Test Summary:\s+(\d+)/(\d+)\s+passed", output_text
|
||
)
|
||
all_passed = (
|
||
summary_match is not None
|
||
and summary_match.group(1) == summary_match.group(2)
|
||
) or re.search(r"Ran \d+ tests?.*\bOK\b", output_text)
|
||
if all_passed:
|
||
print(
|
||
f"[pgm] run: process exited with signal {-returncode}, "
|
||
f"but test output indicates success — treating as passed",
|
||
flush=True,
|
||
)
|
||
returncode = 0
|
||
|
||
if check and returncode != 0:
|
||
raise RuntimeError(f"command failed with exit code {returncode}: {command}")
|
||
|
||
return {
|
||
"command": command,
|
||
"returncode": returncode,
|
||
"output": "".join(output_lines),
|
||
}
|
||
|
||
def terminate_all(self, dry_run: bool = False) -> None:
|
||
"""Kill all processes started via this manager."""
|
||
print(
|
||
f"[pgm] terminate_all: runner={self.runner_id} tracked_procs={len(self._procs)}",
|
||
flush=True,
|
||
)
|
||
if dry_run:
|
||
print(
|
||
f"[pgm] terminate_all: [dry-run] skip killing {len(self._procs)} procs",
|
||
flush=True,
|
||
)
|
||
return
|
||
|
||
procs = list(self._procs)
|
||
if not procs:
|
||
# run() removes completed parents from _procs, but their process
|
||
# groups may still have surviving children. Kill the persisted
|
||
# pgids before discarding the record.
|
||
saved_pgids = _load_pgids(self.runner_id)
|
||
if saved_pgids:
|
||
print(
|
||
f"[pgm] terminate_all: no tracked procs but pgid file exists, "
|
||
f"killing pgids={sorted(saved_pgids)}",
|
||
flush=True,
|
||
)
|
||
for pgid in sorted(saved_pgids):
|
||
_kill_pgid(pgid, signal.SIGTERM)
|
||
time.sleep(1)
|
||
for pgid in sorted(saved_pgids):
|
||
_kill_pgid(pgid, signal.SIGKILL)
|
||
else:
|
||
print(
|
||
f"[pgm] terminate_all: no tracked processes, nothing to do",
|
||
flush=True,
|
||
)
|
||
_remove_pgid(self.runner_id)
|
||
return
|
||
|
||
pids = [p.pid for p in procs]
|
||
print(
|
||
f"[pgm] terminate_all: killing {len(procs)} process(es) pids={pids}",
|
||
flush=True,
|
||
)
|
||
|
||
pgids: set[int] = _load_pgids(self.runner_id)
|
||
for proc in procs:
|
||
try:
|
||
pgid = os.getpgid(proc.pid)
|
||
pgids.add(pgid)
|
||
_kill_pgid(pgid, signal.SIGTERM)
|
||
print(
|
||
f"[pgm] terminate_all: sent SIGTERM to pgid={pgid} (pid={proc.pid})",
|
||
flush=True,
|
||
)
|
||
except ProcessLookupError:
|
||
print(f"[pgm] terminate_all: pid={proc.pid} already exited", flush=True)
|
||
|
||
print(
|
||
f"[pgm] terminate_all: waiting up to {self.term_timeout}s for graceful shutdown",
|
||
flush=True,
|
||
)
|
||
deadline = time.time() + self.term_timeout
|
||
for proc in procs:
|
||
remaining = max(0.0, deadline - time.time())
|
||
try:
|
||
proc.wait(timeout=remaining)
|
||
print(
|
||
f"[pgm] terminate_all: pid={proc.pid} exited gracefully", flush=True
|
||
)
|
||
except subprocess.TimeoutExpired:
|
||
print(
|
||
f"[pgm] terminate_all: pid={proc.pid} did not exit in time",
|
||
flush=True,
|
||
)
|
||
|
||
print(
|
||
f"[pgm] terminate_all: sending SIGKILL to pgids={sorted(pgids)}", flush=True
|
||
)
|
||
for pgid in pgids:
|
||
_kill_pgid(pgid, signal.SIGKILL)
|
||
for proc in procs:
|
||
try:
|
||
proc.wait(timeout=5)
|
||
except subprocess.TimeoutExpired:
|
||
print(
|
||
f"[pgm] terminate_all: pid={proc.pid} still alive after SIGKILL",
|
||
flush=True,
|
||
)
|
||
|
||
self._procs.clear()
|
||
_remove_pgid(self.runner_id)
|
||
print(f"[pgm] terminate_all: cleanup done, all processes cleared", flush=True)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Factory helper
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def make_manager(term_timeout: float = 10.0) -> ProcessGroupManager:
|
||
"""
|
||
Build a ProcessGroupManager from the environment.
|
||
|
||
Looks for a stable runner identifier in order:
|
||
1. RUNNER_NAME – set by GitHub Actions per registered runner
|
||
2. CI_RUNNER_NAME – set by GitLab CI
|
||
3. HOSTNAME – machine hostname (stable on dedicated hosts)
|
||
|
||
Raises RuntimeError if none of the above is available.
|
||
"""
|
||
runner_id = (
|
||
os.environ.get("RUNNER_NAME")
|
||
or os.environ.get("CI_RUNNER_NAME")
|
||
or os.environ.get("HOSTNAME")
|
||
)
|
||
if not runner_id:
|
||
raise RuntimeError(
|
||
"Cannot determine a stable runner identifier. "
|
||
"Set RUNNER_NAME, CI_RUNNER_NAME, or HOSTNAME."
|
||
)
|
||
print(
|
||
f"[pgm] make_manager: runner_id={runner_id} "
|
||
f"(RUNNER_NAME={os.environ.get('RUNNER_NAME', '<unset>')}, "
|
||
f"HOSTNAME={os.environ.get('HOSTNAME', '<unset>')})",
|
||
flush=True,
|
||
)
|
||
return ProcessGroupManager(runner_id=runner_id, term_timeout=term_timeout)
|