Skip to content

Commit 955d68f

Browse files
committed
review: prevent helper backpressure and trim API reads
1 parent 624bf9c commit 955d68f

4 files changed

Lines changed: 139 additions & 38 deletions

File tree

bin/youtube-autoencoder

Lines changed: 47 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import selectors
1717
import signal
1818
import subprocess
1919
import sys
20+
import tempfile
2021
import time
2122
from typing import Any
2223
from urllib.parse import urlsplit
@@ -39,6 +40,7 @@ URL_CREDENTIALS_RE = re.compile(r"(?P<scheme>[A-Za-z][A-Za-z0-9+.-]*://)[^/\s:@]
3940
RTMP_SECRET_PATH_RE = re.compile(r"(?i)\b(?P<prefix>rtmps?://[^\s/]+/)[^\s]+")
4041
YOUTUBE_SECRET_PATH_RE = re.compile(r"(?i)\b(?P<prefix>https?://[^\s/]*(?:youtube|google)[^\s/]*/)[^\s]+")
4142
PROGRESS_LINE_RE = re.compile(r"^[A-Za-z0-9_]+=.*$")
43+
RTSP_OPTION_CACHE: dict[tuple[str, str], bool] = {}
4244
SUPERVISOR_LOCK_FILE = pathlib.Path(
4345
os.environ.get("YTA_SUPERVISOR_LOCK_FILE", HOME / ".config/youtube-autoencoder/supervisor.lock")
4446
).expanduser()
@@ -300,6 +302,9 @@ def stream_config() -> tuple[str, str]:
300302

301303

302304
def binary_supports_rtsp_option(binary: str, option: str) -> bool:
305+
cache_key = (binary, option)
306+
if cache_key in RTSP_OPTION_CACHE:
307+
return RTSP_OPTION_CACHE[cache_key]
303308
try:
304309
result = subprocess.run(
305310
[binary, "-hide_banner", "-h", "demuxer=rtsp"],
@@ -312,7 +317,9 @@ def binary_supports_rtsp_option(binary: str, option: str) -> bool:
312317
log(f"could not inspect {pathlib.Path(binary).name} RTSP options: {exc}")
313318
return False
314319
output = "\n".join((result.stdout or "", result.stderr or ""))
315-
return any(line.lstrip().startswith(f"-{option} ") for line in output.splitlines())
320+
supported = any(line.lstrip().startswith(f"-{option} ") for line in output.splitlines())
321+
RTSP_OPTION_CACHE[cache_key] = supported
322+
return supported
316323

317324

318325
def ffmpeg_supports_rtsp_option(option: str) -> bool:
@@ -575,38 +582,46 @@ def api_command_while_streaming(
575582
timeout: int,
576583
) -> dict[str, Any]:
577584
cmd = [env("YTA_YOUTUBE_API", "youtube-autoencoder-api"), *args]
578-
try:
579-
api_process = subprocess.Popen(
580-
cmd,
581-
text=True,
582-
stdout=subprocess.PIPE,
583-
stderr=subprocess.PIPE,
584-
)
585-
except OSError as exc:
586-
raise ApiCommandError(
587-
returncode=-1,
588-
payload={"message": f"could not start YouTube API helper: {exc}", "retry_class": "fatal"},
589-
) from exc
590-
deadline = time.monotonic() + timeout
591-
try:
592-
while api_process.poll() is None:
593-
remaining = max(0.0, deadline - time.monotonic())
594-
drain_ffmpeg_output(runtime, timeout=min(0.5, remaining))
585+
with (
586+
tempfile.TemporaryFile(mode="w+t", encoding="utf-8") as stdout_file,
587+
tempfile.TemporaryFile(mode="w+t", encoding="utf-8") as stderr_file,
588+
):
589+
try:
590+
api_process = subprocess.Popen(
591+
cmd,
592+
text=True,
593+
stdout=stdout_file,
594+
stderr=stderr_file,
595+
)
596+
except OSError as exc:
597+
raise ApiCommandError(
598+
returncode=-1,
599+
payload={"message": f"could not start YouTube API helper: {exc}", "retry_class": "fatal"},
600+
) from exc
601+
deadline = time.monotonic() + timeout
602+
try:
603+
while api_process.poll() is None:
604+
remaining = max(0.0, deadline - time.monotonic())
605+
drain_ffmpeg_output(runtime, timeout=min(0.5, remaining))
606+
ensure_encoder_running(runtime)
607+
if time.monotonic() >= deadline:
608+
raise ApiCommandError(
609+
returncode=-1,
610+
payload={
611+
"message": f"YouTube API command timed out after {timeout}s",
612+
"retry_class": "api",
613+
},
614+
)
615+
drain_ffmpeg_output(runtime, timeout=0.0)
595616
ensure_encoder_running(runtime)
596-
if time.monotonic() >= deadline:
597-
raise ApiCommandError(
598-
returncode=-1,
599-
payload={
600-
"message": f"YouTube API command timed out after {timeout}s",
601-
"retry_class": "api",
602-
},
603-
)
604-
drain_ffmpeg_output(runtime, timeout=0.0)
605-
ensure_encoder_running(runtime)
606-
stdout, stderr = api_process.communicate()
607-
except BaseException:
608-
stop_process(api_process)
609-
raise
617+
api_process.wait()
618+
stdout_file.seek(0)
619+
stderr_file.seek(0)
620+
stdout = stdout_file.read()
621+
stderr = stderr_file.read()
622+
except BaseException:
623+
stop_process(api_process)
624+
raise
610625
return parse_api_result(api_process.returncode, stdout, stderr)
611626

612627

bin/youtube-autoencoder-api

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -783,10 +783,7 @@ def stream_status(stream_id: str | None = None) -> dict[str, Any]:
783783
}
784784

785785

786-
def broadcast_status(broadcast_id: str) -> dict[str, Any]:
787-
broadcast = broadcast_by_id(broadcast_id)
788-
if broadcast is None:
789-
raise ReconciliationError(f"liveBroadcast not found: {broadcast_id}")
786+
def broadcast_status_payload(broadcast: dict[str, Any]) -> dict[str, Any]:
790787
return {
791788
"broadcast_id": broadcast.get("id"),
792789
"title": (broadcast.get("snippet") or {}).get("title"),
@@ -797,6 +794,13 @@ def broadcast_status(broadcast_id: str) -> dict[str, Any]:
797794
}
798795

799796

797+
def broadcast_status(broadcast_id: str) -> dict[str, Any]:
798+
broadcast = broadcast_by_id(broadcast_id)
799+
if broadcast is None:
800+
raise ReconciliationError(f"liveBroadcast not found: {broadcast_id}")
801+
return broadcast_status_payload(broadcast)
802+
803+
800804
def set_broadcast_privacy(broadcast_id: str, privacy: str) -> dict[str, Any]:
801805
current = broadcast_by_id(broadcast_id)
802806
if current is None:
@@ -956,7 +960,7 @@ def set_privacy_command(args: argparse.Namespace) -> int:
956960
}
957961
)
958962
write_state(state)
959-
print(json.dumps(broadcast_status(broadcast_id), indent=2, sort_keys=True))
963+
print(json.dumps(broadcast_status_payload(result), indent=2, sort_keys=True))
960964
return 0
961965

962966

tests/test_api.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,34 @@ def fake_api(method, path, params, body=None):
500500
]
501501

502502

503+
def test_set_privacy_command_reuses_verified_readback(load_script, monkeypatch, tmp_path, capsys):
504+
api = load_script("youtube-autoencoder-api", "yta_api_privacy_command")
505+
configure_reconciliation(api, monkeypatch, tmp_path)
506+
public = managed_broadcast(api, "broadcast-1", "live")
507+
public["status"]["privacyStatus"] = "public"
508+
api.write_state(
509+
{
510+
"schema_version": 2,
511+
"instance_id": "encoder-1",
512+
"stream_id": "stream-1",
513+
"broadcast_id": "broadcast-1",
514+
}
515+
)
516+
monkeypatch.setattr(api, "set_broadcast_privacy", lambda _broadcast_id, _privacy: public)
517+
monkeypatch.setattr(
518+
api,
519+
"broadcast_status",
520+
lambda _broadcast_id: pytest.fail("third broadcast read called"),
521+
)
522+
523+
args = argparse.Namespace(broadcast_id="broadcast-1", privacy="public")
524+
525+
assert api.set_privacy_command(args) == 0
526+
output = json.loads(capsys.readouterr().out)
527+
assert output["broadcast_id"] == "broadcast-1"
528+
assert output["privacy"] == "public"
529+
530+
503531
def test_retry_state_updates_preserve_broadcast_identity(load_script, monkeypatch, tmp_path):
504532
api = load_script("youtube-autoencoder-api", "yta_api_retry_state")
505533
configure_reconciliation(api, monkeypatch, tmp_path)

tests/test_supervisor.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,26 @@ def test_ffmpeg_capability_probe_reads_rtsp_demuxer_options(load_script, monkeyp
137137
assert supervisor.ffmpeg_supports_rtsp_option("rw_timeout") is False
138138

139139

140+
def test_ffmpeg_capability_probe_is_cached_per_binary_and_option(load_script, monkeypatch):
141+
supervisor = load_script("youtube-autoencoder", "yta_supervisor_capability_cache")
142+
calls = []
143+
144+
def inspect(*_args, **_kwargs):
145+
calls.append(True)
146+
return subprocess.CompletedProcess(
147+
args=["ffmpeg"],
148+
returncode=0,
149+
stdout=" -timeout <int64> set timeout\n",
150+
stderr="",
151+
)
152+
153+
monkeypatch.setattr(supervisor.subprocess, "run", inspect)
154+
155+
assert supervisor.ffmpeg_supports_rtsp_option("timeout") is True
156+
assert supervisor.ffmpeg_supports_rtsp_option("timeout") is True
157+
assert len(calls) == 1
158+
159+
140160
def test_ffmpeg_capability_probe_fails_closed_on_timeout(load_script, monkeypatch):
141161
supervisor = load_script("youtube-autoencoder", "yta_supervisor_capability_timeout")
142162

@@ -256,7 +276,13 @@ def test_api_wait_parses_structured_error(load_script, monkeypatch):
256276
"retry_class": "quota",
257277
}
258278
api_process = FakeApiProcess(returncode=75, stderr=json.dumps(payload))
259-
monkeypatch.setattr(supervisor.subprocess, "Popen", lambda *_args, **_kwargs: api_process)
279+
280+
def launch(*_args, **kwargs):
281+
kwargs["stderr"].write(api_process.stderr_text)
282+
kwargs["stderr"].flush()
283+
return api_process
284+
285+
monkeypatch.setattr(supervisor.subprocess, "Popen", launch)
260286
runtime = supervisor.StreamRuntime(
261287
process=FakeFfmpegProcess(returncode=None),
262288
selector=FakeSelector(),
@@ -270,6 +296,34 @@ def test_api_wait_parses_structured_error(load_script, monkeypatch):
270296
assert raised.value.retry_after == 120
271297

272298

299+
def test_api_wait_spools_large_helper_output_without_pipe_backpressure(load_script, monkeypatch):
300+
supervisor = load_script("youtube-autoencoder", "yta_supervisor_api_spool")
301+
payload = {"stream_id": "stream-1", "padding": "x" * 100_000}
302+
api_process = FakeApiProcess(returncode=0)
303+
304+
def launch(*_args, **kwargs):
305+
assert kwargs["stdout"] != subprocess.PIPE
306+
assert kwargs["stderr"] != subprocess.PIPE
307+
kwargs["stdout"].write(json.dumps(payload))
308+
kwargs["stdout"].flush()
309+
return api_process
310+
311+
monkeypatch.setattr(supervisor.subprocess, "Popen", launch)
312+
runtime = supervisor.StreamRuntime(
313+
process=FakeFfmpegProcess(returncode=None),
314+
selector=FakeSelector(),
315+
watchdog=supervisor.ProgressWatchdog(
316+
started_at=supervisor.time.monotonic(),
317+
timeout=30.0,
318+
last_progress_at=supervisor.time.monotonic(),
319+
),
320+
)
321+
322+
result = supervisor.api_command_while_streaming(["stream-status"], runtime, timeout=5)
323+
324+
assert result == payload
325+
326+
273327
def test_api_wait_wraps_helper_launch_failure(load_script, monkeypatch):
274328
supervisor = load_script("youtube-autoencoder", "yta_supervisor_api_missing")
275329
monkeypatch.setattr(

0 commit comments

Comments
 (0)