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
+35 -66
View File
@@ -7,13 +7,11 @@ See scripts/lib/vendor/bird-search/package.json for authoritative version.
import json import json
import os import os
import signal
import shutil import shutil
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from . import http, log from . import http, log, subproc
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
@@ -168,63 +166,52 @@ def _run_bird_search(query: str, count: int, timeout: int) -> Dict[str, Any]:
"--json", "--json",
] ]
# Use process groups for clean cleanup on timeout/kill pid_holder: list[int] = []
preexec = os.setsid if hasattr(os, 'setsid') else None
def _register(pid: int) -> None:
pid_holder.append(pid)
try: try:
proc = subprocess.Popen( from last30days import register_child_pid
cmd, register_child_pid(pid)
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
preexec_fn=preexec,
env=_subprocess_env(),
)
# Register for cleanup tracking (if available)
try:
from last30days import register_child_pid, unregister_child_pid
register_child_pid(proc.pid)
except ImportError: except ImportError:
pass pass
try: try:
stdout, stderr = proc.communicate(timeout=timeout) result = subproc.run_with_timeout(
except subprocess.TimeoutExpired: cmd,
# Kill the entire process group timeout=timeout,
try: env=_subprocess_env(),
os.killpg(os.getpgid(proc.pid), signal.SIGTERM) on_pid=_register,
except (ProcessLookupError, PermissionError, OSError): )
proc.kill() except subproc.SubprocTimeout:
proc.wait(timeout=5)
return {"error": f"Search timed out after {timeout}s", "items": []} return {"error": f"Search timed out after {timeout}s", "items": []}
except Exception as e:
return {"error": str(e), "items": []}
finally: finally:
if pid_holder:
try: try:
from last30days import unregister_child_pid from last30days import unregister_child_pid
unregister_child_pid(proc.pid) unregister_child_pid(pid_holder[0])
except Exception: except Exception:
pass pass
if proc.returncode != 0: if result.returncode != 0:
error = stderr.strip() if stderr else "Bird search failed" error = result.stderr.strip() or "Bird search failed"
return {"error": error, "items": []} return {"error": error, "items": []}
output = stdout.strip() if stdout else "" output = result.stdout.strip()
if not output: if not output:
return {"items": []} return {"items": []}
try:
parsed = json.loads(output) parsed = json.loads(output)
except json.JSONDecodeError as e:
return {"error": f"Invalid JSON response: {e}", "items": []}
if isinstance(parsed, list): if isinstance(parsed, list):
return {"items": parsed} return {"items": parsed}
return parsed return parsed
except json.JSONDecodeError as e:
return {"error": f"Invalid JSON response: {e}", "items": []}
except Exception as e:
return {"error": str(e), "items": []}
def search_x( def search_x(
topic: str, topic: str,
@@ -330,47 +317,29 @@ def search_handles(
"--json", "--json",
] ]
preexec = os.setsid if hasattr(os, 'setsid') else None
try: try:
proc = subprocess.Popen( result = subproc.run_with_timeout(cmd, timeout=15, env=_subprocess_env())
cmd, except subproc.SubprocTimeout:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
preexec_fn=preexec,
env=_subprocess_env(),
)
try:
stdout, stderr = proc.communicate(timeout=15)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError):
proc.kill()
proc.wait(timeout=5)
_log(f"Handle search timed out for @{handle}") _log(f"Handle search timed out for @{handle}")
return [] return []
except OSError as e:
if proc.returncode != 0: _log(f"Handle search error for @{handle}: {e}")
_log(f"Handle search failed for @{handle}: {(stderr or '').strip()}")
return [] return []
output = (stdout or "").strip() if result.returncode != 0:
_log(f"Handle search failed for @{handle}: {result.stderr.strip()}")
return []
output = result.stdout.strip()
if not output: if not output:
return [] return []
try:
response = json.loads(output) response = json.loads(output)
return parse_bird_response(response, query=core_topic)
except json.JSONDecodeError: except json.JSONDecodeError:
_log(f"Invalid JSON from handle search for @{handle}") _log(f"Invalid JSON from handle search for @{handle}")
except (OSError, subprocess.SubprocessError) as e:
_log(f"Handle search error for @{handle}: {e}")
return [] return []
return parse_bird_response(response, query=core_topic)
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
+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 "",
)
+8 -40
View File
@@ -8,11 +8,8 @@ Inspired by Peter Steinberger's toolchain approach (yt-dlp + summarize CLI).
import json import json
import math import math
import os
import re import re
import signal
import shutil import shutil
import subprocess
import sys import sys
import tempfile import tempfile
import urllib.error import urllib.error
@@ -37,7 +34,7 @@ TRANSCRIPT_LIMITS = {
# Max words to keep from each transcript # Max words to keep from each transcript
TRANSCRIPT_MAX_WORDS = 5000 TRANSCRIPT_MAX_WORDS = 5000
from . import http, log from . import http, log, subproc
from .relevance import token_overlap_relevance as _compute_relevance from .relevance import token_overlap_relevance as _compute_relevance
@@ -227,30 +224,16 @@ def search_youtube(
"--no-download", "--no-download",
] ]
preexec = os.setsid if hasattr(os, 'setsid') else None
try: try:
proc = subprocess.Popen( result = subproc.run_with_timeout(cmd, timeout=120)
cmd, except subproc.SubprocTimeout:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
preexec_fn=preexec,
)
try:
stdout, stderr = proc.communicate(timeout=120)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError):
proc.kill()
proc.wait(timeout=5)
_log("YouTube search timed out (120s)") _log("YouTube search timed out (120s)")
return {"items": [], "error": "Search timed out"} return {"items": [], "error": "Search timed out"}
except FileNotFoundError: except FileNotFoundError:
return {"items": [], "error": "yt-dlp not found"} return {"items": [], "error": "yt-dlp not found"}
if not (stdout or "").strip(): stdout = result.stdout
if not stdout.strip():
_log("YouTube search returned 0 results") _log("YouTube search returned 0 results")
return {"items": []} return {"items": []}
@@ -452,24 +435,9 @@ def _fetch_transcript_ytdlp(video_id: str, temp_dir: str) -> Optional[str]:
f"https://www.youtube.com/watch?v={video_id}", f"https://www.youtube.com/watch?v={video_id}",
] ]
preexec = os.setsid if hasattr(os, 'setsid') else None
try: try:
proc = subprocess.Popen( subproc.run_with_timeout(cmd, timeout=30)
cmd, except subproc.SubprocTimeout:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
preexec_fn=preexec,
)
try:
proc.communicate(timeout=30)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError):
proc.kill()
proc.wait(timeout=5)
return None return None
except FileNotFoundError: except FileNotFoundError:
return None return None
@@ -556,7 +524,7 @@ def fetch_transcripts_parallel(
vid = futures[future] vid = futures[future]
try: try:
results[vid] = future.result() results[vid] = future.result()
except (OSError, subprocess.SubprocessError) as exc: except OSError as exc:
_log(f"Transcript fetch error for {vid}: {exc}") _log(f"Transcript fetch error for {vid}: {exc}")
results[vid] = None results[vid] = None
except Exception as exc: except Exception as exc:
+4 -1
View File
@@ -30,8 +30,11 @@ class EnvV3Tests(unittest.TestCase):
self.assertEqual("b", bird_x._credentials["CT0"]) self.assertEqual("b", bird_x._credentials["CT0"])
def test_bird_auth_never_checks_browser_cookies(self): def test_bird_auth_never_checks_browser_cookies(self):
# The guarantee: is_bird_authenticated() must not spawn any child
# process to probe for cookies. All subprocess paths in bird_x go
# through subproc.run_with_timeout, so patching that covers it.
with mock.patch("lib.bird_x.is_bird_installed", return_value=True), mock.patch( with mock.patch("lib.bird_x.is_bird_installed", return_value=True), mock.patch(
"lib.bird_x.subprocess.run", "lib.bird_x.subproc.run_with_timeout",
side_effect=AssertionError("browser-cookie whoami should not run"), side_effect=AssertionError("browser-cookie whoami should not run"),
): ):
bird_x._credentials.clear() bird_x._credentials.clear()
+102
View File
@@ -0,0 +1,102 @@
"""Tests for scripts/lib/subproc.py.
Covers the process-group cleanup path, timeout behavior, success path,
PID callback wiring, and environment inheritance.
"""
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import subproc
class TestRunWithTimeout(unittest.TestCase):
def test_success_returns_stdout(self):
result = subproc.run_with_timeout(
["sh", "-c", "echo hello"],
timeout=5,
)
self.assertEqual(result.returncode, 0)
self.assertEqual(result.stdout.strip(), "hello")
self.assertEqual(result.stderr, "")
def test_nonzero_exit_returns_returncode_not_exception(self):
result = subproc.run_with_timeout(
["sh", "-c", "exit 3"],
timeout=5,
)
self.assertEqual(result.returncode, 3)
def test_captures_stderr(self):
result = subproc.run_with_timeout(
["sh", "-c", "echo err >&2"],
timeout=5,
)
self.assertEqual(result.stderr.strip(), "err")
def test_timeout_raises_subproctimeout(self):
with self.assertRaises(subproc.SubprocTimeout):
subproc.run_with_timeout(
["sh", "-c", "sleep 10"],
timeout=1,
)
def test_timeout_kills_process_group(self):
"""A slow child inside a shell should be killed when the group is signaled."""
with self.assertRaises(subproc.SubprocTimeout):
# Parent shell spawns a child that sleeps long.
# Without process-group cleanup, the child would orphan.
subproc.run_with_timeout(
["sh", "-c", "sleep 10 & wait"],
timeout=1,
)
def test_missing_command_raises_oserror(self):
"""Missing executables raise FileNotFoundError (or PermissionError on
some filesystems if a same-named junk file exists)."""
with self.assertRaises(OSError):
subproc.run_with_timeout(
["/nonexistent-path/last30days-test-no-such-bin"],
timeout=5,
)
def test_env_is_passed_through(self):
result = subproc.run_with_timeout(
["sh", "-c", "echo $LAST30DAYS_TEST_VAR"],
timeout=5,
env={"LAST30DAYS_TEST_VAR": "custom_value", "PATH": "/usr/bin:/bin"},
)
self.assertEqual(result.stdout.strip(), "custom_value")
def test_on_pid_callback_receives_pid(self):
seen_pids = []
subproc.run_with_timeout(
["sh", "-c", "true"],
timeout=5,
on_pid=lambda pid: seen_pids.append(pid),
)
self.assertEqual(len(seen_pids), 1)
self.assertIsInstance(seen_pids[0], int)
self.assertGreater(seen_pids[0], 0)
def test_on_pid_callback_exceptions_are_suppressed(self):
"""If the PID callback raises, the subprocess should still run to completion."""
def raising_callback(pid):
raise RuntimeError("boom")
# Should not raise, callback exception is swallowed.
result = subproc.run_with_timeout(
["sh", "-c", "echo ok"],
timeout=5,
on_pid=raising_callback,
)
self.assertEqual(result.returncode, 0)
self.assertEqual(result.stdout.strip(), "ok")
if __name__ == "__main__":
unittest.main()
+8 -6
View File
@@ -63,24 +63,26 @@ class TestYouTubeEngagementZero(unittest.TestCase):
class TestYtDlpFlags(unittest.TestCase): class TestYtDlpFlags(unittest.TestCase):
def _fake_result(self, stdout: str = "", returncode: int = 0):
from lib.subproc import SubprocResult
return SubprocResult(returncode=returncode, stdout=stdout, stderr="")
def test_search_ignores_global_config_and_browser_cookies(self): def test_search_ignores_global_config_and_browser_cookies(self):
proc = _DummyProc()
with mock.patch.object(youtube_yt, "is_ytdlp_installed", return_value=True), \ with mock.patch.object(youtube_yt, "is_ytdlp_installed", return_value=True), \
mock.patch.object(youtube_yt.subprocess, "Popen", return_value=proc) as popen_mock: mock.patch.object(youtube_yt.subproc, "run_with_timeout", return_value=self._fake_result()) as run_mock:
youtube_yt.search_youtube("Claude Code", "2026-02-01", "2026-03-01") youtube_yt.search_youtube("Claude Code", "2026-02-01", "2026-03-01")
cmd = popen_mock.call_args.args[0] cmd = run_mock.call_args.args[0]
self.assertIn("--ignore-config", cmd) self.assertIn("--ignore-config", cmd)
self.assertIn("--no-cookies-from-browser", cmd) self.assertIn("--no-cookies-from-browser", cmd)
def test_transcript_fetch_ignores_global_config_and_browser_cookies(self): def test_transcript_fetch_ignores_global_config_and_browser_cookies(self):
proc = _DummyProc()
with tempfile.TemporaryDirectory() as temp_dir, \ with tempfile.TemporaryDirectory() as temp_dir, \
mock.patch.object(youtube_yt, "is_ytdlp_installed", return_value=True), \ mock.patch.object(youtube_yt, "is_ytdlp_installed", return_value=True), \
mock.patch.object(youtube_yt.subprocess, "Popen", return_value=proc) as popen_mock: mock.patch.object(youtube_yt.subproc, "run_with_timeout", return_value=self._fake_result()) as run_mock:
youtube_yt.fetch_transcript("abc123", temp_dir) youtube_yt.fetch_transcript("abc123", temp_dir)
cmd = popen_mock.call_args.args[0] cmd = run_mock.call_args.args[0]
self.assertIn("--ignore-config", cmd) self.assertIn("--ignore-config", cmd)
self.assertIn("--no-cookies-from-browser", cmd) self.assertIn("--no-cookies-from-browser", cmd)