bbf892aecc
bird_x.py and youtube_yt.py had four near-identical copies of the same subprocess cleanup dance (Popen + os.setsid + communicate(timeout) + SIGTERM via killpg + proc.kill() fallback + wait(5)). Extract to lib.subproc.run_with_timeout(), which: - runs the child in its own process group via os.setsid where available - raises SubprocTimeout on timeout - on timeout: SIGTERM the group, fall back to proc.kill(), wait up to 5s - accepts an on_pid callback so bird_x can still register child PIDs with last30days.register_child_pid for whole-process cleanup - captures stdout/stderr as strings in a SubprocResult dataclass Migrated call sites: _run_bird_search, search_handles inner worker, search_youtube, fetch_transcript. With the helper in place, the signal and subprocess imports became dead in both files (plus os in youtube_yt) and went with them. Tests: 9 new subproc tests cover success, non-zero exit, stderr capture, timeout-raises, timeout-kills-group, missing-command, env passthrough, PID callback, and callback-exception suppression. test_env_v3 and test_youtube_yt patch subproc.run_with_timeout instead of the removed bird_x.subprocess and yt-dlp subprocess.
95 lines
2.7 KiB
Python
95 lines
2.7 KiB
Python
"""Subprocess helpers: safe timeout + process-group cleanup.
|
|
|
|
Used by bird_x.py (Node.js Bird search) and youtube_yt.py (yt-dlp search
|
|
and transcript download). Both need the same os.setsid/killpg cleanup
|
|
dance on timeout to avoid orphaning child processes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
from typing import Optional, Sequence
|
|
|
|
|
|
class SubprocTimeout(Exception):
|
|
"""Raised when a subprocess exceeds its timeout and is killed."""
|
|
|
|
|
|
@dataclass
|
|
class SubprocResult:
|
|
"""Result of a subprocess run that captured stdout and stderr."""
|
|
|
|
returncode: int
|
|
stdout: str
|
|
stderr: str
|
|
|
|
|
|
def run_with_timeout(
|
|
cmd: Sequence[str],
|
|
*,
|
|
timeout: int,
|
|
env: Optional[dict] = None,
|
|
on_pid: Optional[callable] = None,
|
|
) -> SubprocResult:
|
|
"""Run a subprocess with process-group cleanup on timeout.
|
|
|
|
Spawns ``cmd`` inside its own process group via ``os.setsid`` where
|
|
available. If ``communicate(timeout=...)`` raises ``TimeoutExpired``,
|
|
signals ``SIGTERM`` to the entire group, falls back to ``proc.kill()``
|
|
if the signal fails, then waits up to 5 seconds for cleanup, and
|
|
raises ``SubprocTimeout``.
|
|
|
|
Args:
|
|
cmd: Command and arguments to spawn.
|
|
timeout: Timeout in seconds passed to ``communicate()``.
|
|
env: Optional environment dict. If None, inherits parent env.
|
|
on_pid: Optional callable invoked with the child PID right after
|
|
spawn. Used by bird_x.py to register child PIDs for cleanup
|
|
tracking. Exceptions raised by the callback are suppressed.
|
|
|
|
Returns:
|
|
SubprocResult with returncode, stdout, and stderr as strings.
|
|
|
|
Raises:
|
|
SubprocTimeout: If the process exceeded ``timeout``.
|
|
FileNotFoundError: If the executable is not found.
|
|
OSError: For other spawn failures.
|
|
"""
|
|
preexec = os.setsid if hasattr(os, "setsid") else None
|
|
|
|
proc = subprocess.Popen(
|
|
list(cmd),
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
preexec_fn=preexec,
|
|
env=env,
|
|
)
|
|
|
|
if on_pid is not None:
|
|
try:
|
|
on_pid(proc.pid)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
stdout, stderr = proc.communicate(timeout=timeout)
|
|
except subprocess.TimeoutExpired:
|
|
try:
|
|
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
|
except (ProcessLookupError, PermissionError, OSError):
|
|
proc.kill()
|
|
proc.wait(timeout=5)
|
|
raise SubprocTimeout(f"Command {cmd[0]} timed out after {timeout}s")
|
|
|
|
return SubprocResult(
|
|
returncode=proc.returncode,
|
|
stdout=stdout or "",
|
|
stderr=stderr or "",
|
|
)
|