refactor: extract subprocess cleanup into shared subproc helper (#210)

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.
This commit is contained in:
Ilia Alshanetsky
2026-04-25 17:17:47 -04:00
committed by GitHub
parent 2acbf8a869
commit bbf892aecc
6 changed files with 268 additions and 130 deletions
+94
View File
@@ -0,0 +1,94 @@
"""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 "",
)