blob: a4774ea7a56b6c4e2f72db886a4a2b8aec6c50ce [file] [edit]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Subprocess lifecycle management and process-group signal routing."""
import enum
import functools
import os
import signal
import subprocess
from collections.abc import Sequence
from typing import Any, Self
class ProcessTarget(enum.Enum):
"""Target scope for signals dispatched to a process."""
LEADER = "leader"
GROUP = "group"
def preexec_setup(separate_pgrp: bool = True) -> None:
"""Prepares a child process for robust signal handling.
This function is intended to be used as a 'preexec_fn' in subprocess.Popen.
It performs two critical tasks:
1. Resets standard signals (SIGINT, SIGHUP, SIGTERM, SIGQUIT) to SIG_DFL.
This is necessary because if a child starts with these signals ignored
(common in background jobs), bash wrappers cannot set their own traps.
2. Optionally creates a new process group for the child tree.
Args:
separate_pgrp: If True, calls os.setpgrp() to isolate the child.
"""
for sig in (signal.SIGINT, signal.SIGHUP, signal.SIGTERM):
signal.signal(sig, signal.SIG_DFL)
if hasattr(signal, "SIGQUIT"):
signal.signal(signal.SIGQUIT, signal.SIG_DFL)
if separate_pgrp:
os.setpgrp()
class ManagedProcess:
"""Encapsulates a managed subprocess with isolated process group and signal routing."""
def __init__(
self, process: subprocess.Popen[Any], pgid: int | None = None
) -> None:
self._process = process
self.pid = process.pid
if pgid is not None:
self._pgid = pgid
else:
try:
self._pgid = os.getpgid(self.pid)
except OSError:
self._pgid = self.pid
@property
def process(self) -> subprocess.Popen[Any]:
"""Underlying subprocess.Popen instance."""
return self._process
@property
def pgid(self) -> int:
"""Process group ID, resolved and cached at instantiation."""
return self._pgid
@property
def is_group_leader(self) -> bool:
"""Whether this process is the group leader of its process group."""
return self.pgid == self.pid
@property
def returncode(self) -> int | None:
"""Exit code if the process has terminated."""
return self._process.returncode
def poll(self) -> int | None:
"""Checks if process has finished without blocking."""
return self._process.poll()
def wait(self, timeout: float | None = None) -> int:
"""Waits up to timeout seconds for process termination."""
return self._process.wait(timeout=timeout)
def send_signal(
self, sig: int, target: ProcessTarget = ProcessTarget.LEADER
) -> None:
"""Sends signal to leader PID or entire process group, safely handling exit races."""
try:
if target == ProcessTarget.LEADER or not self.is_group_leader:
os.kill(self.pid, sig)
else:
os.killpg(self.pgid, sig)
except ProcessLookupError:
pass
@classmethod
def spawn(
cls,
command: Sequence[str],
*,
separate_pgrp: bool = True,
**popen_kwargs: Any,
) -> Self:
"""Spawns child with isolated process group and default signals."""
kwargs = dict(popen_kwargs)
if "preexec_fn" not in kwargs:
kwargs["preexec_fn"] = functools.partial(
preexec_setup, separate_pgrp=separate_pgrp
)
proc = subprocess.Popen(command, **kwargs)
expected_pgid = proc.pid if separate_pgrp else os.getpgid(0)
return cls(proc, pgid=expected_pgid)