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
+4 -1
View File
@@ -30,8 +30,11 @@ class EnvV3Tests(unittest.TestCase):
self.assertEqual("b", bird_x._credentials["CT0"])
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(
"lib.bird_x.subprocess.run",
"lib.bird_x.subproc.run_with_timeout",
side_effect=AssertionError("browser-cookie whoami should not run"),
):
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):
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):
proc = _DummyProc()
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")
cmd = popen_mock.call_args.args[0]
cmd = run_mock.call_args.args[0]
self.assertIn("--ignore-config", cmd)
self.assertIn("--no-cookies-from-browser", cmd)
def test_transcript_fetch_ignores_global_config_and_browser_cookies(self):
proc = _DummyProc()
with tempfile.TemporaryDirectory() as temp_dir, \
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)
cmd = popen_mock.call_args.args[0]
cmd = run_mock.call_args.args[0]
self.assertIn("--ignore-config", cmd)
self.assertIn("--no-cookies-from-browser", cmd)