项目文件夹

文件
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:32:31 +08:00

409 行
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)