fix(camera): probe ffmpeg for the right RTSP socket-timeout flag (#1504)

A previous attempt swapped `-timeout` → `-stimeout` unconditionally to
  fix EADDRINUSE on the reporter's transitional ffmpeg. That broke every
  install on a modern ffmpeg (5+/6+/7+) — current Debian/Ubuntu/Homebrew
  — where `-stimeout` was removed and `-timeout` is back to meaning
  socket I/O. Verified locally: `ffmpeg -stimeout ...` errors
  "Unrecognized option 'stimeout'" on ffmpeg 7.1.
  install on a modern ffmpeg (5+/6+/7+) — current Debian/Ubuntu/Homebrew
  — where `-stimeout` was removed and `-timeout` is back to meaning
  socket I/O. Verified locally: `ffmpeg -stimeout ...` errors
  "Unrecognized option 'stimeout'" on ffmpeg 7.1.

  ffmpeg has shipped THREE arrangements of this option over time and
  Bambuddy supports the full range:

  - Pre-deprecation (early 4.x and earlier): `-timeout` is socket I/O.
  - Transitional (~late-4.x, Jammy-era): `-timeout` is deprecated and
    repurposed to RTSP listen-mode timeout; any non-zero value implies
    `-listen`, which makes ffmpeg bind the TLS-proxy port and fail with
    EADDRINUSE. `-stimeout` is the replacement socket I/O option.
  - Modern (5.x / 6.x / 7.x): `-stimeout` REMOVED. `-timeout` is back to
    socket I/O — the original meaning.

  So no single literal is correct on all installs.

  Fix: `rtsp_socket_timeout_flag()` in services/camera.py probes
  `ffmpeg -h demuxer=rtsp` once and picks `-stimeout` when ffmpeg
  advertises it (covers transitional + older builds that kept it as an
  alias), else `-timeout` (modern + pre-deprecation). Cached at module
  level for the process lifetime — ffmpeg doesn't swap mid-run.

  The function returns the option name without a leading dash; callers
  prepend it themselves so a formatting bug can't pass an empty flag.

  Wired into both RTSP ffmpeg call sites in lockstep: routes/camera.py
  (printer camera) and services/external_camera.py (external RTSP),
  which use the same TLS-proxy + ffmpeg pattern and would hit the same
  regression on either ffmpeg cohort.

  Tests: 8 in test_ffmpeg_rtsp_timeout_flag.py — 6 probe unit tests
  (prefers stimeout when advertised, falls back to timeout on modern,
  defaults to timeout when ffmpeg missing or probe raises, caches across
  calls, trailing-space substring guard against `-listen_timeout`
  false-positives), 2 parametrised guards against either RTSP ffmpeg
  argv re-hard-coding a literal instead of consuming the probe. 37
  probe + existing external-camera tests green.
This commit is contained in:
maziggy
2026-05-24 08:49:21 +02:00
parent 6591fc011f
commit eae96da56e
5 changed files with 218 additions and 3 deletions
+1
View File
@@ -25,6 +25,7 @@ All notable changes to Bambuddy will be documented in this file.
- **PyJWT CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): permanently ignored in pip-audit** — Advisory is disputed by the PyJWT maintainers, with the advisory description literally noting *"this is disputed by the Supplier because the key length is chosen by the application that uses the library."* `fix_versions=[]` on the advisory confirms no PyJWT patch exists or will exist. Bambuddy is not affected: `backend/app/core/auth.py:184` auto-generates secrets via `secrets.token_urlsafe(64)` (~86 chars of entropy, far above any sane minimum) and the file-loaded path at `:177` rejects secrets shorter than 32 chars. Added a permanent `--ignore-vuln CVE-2025-45768` to `.github/workflows/security.yml` with an inline comment citing the file:line evidence so a future maintainer reviewing the ignore list sees why it's load-bearing. Also dropped the stale `--ignore-vuln CVE-2026-4539` for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified: `pip-audit --ignore-vuln CVE-2025-45768` alone reports clean).
### Fixed
- **X1/H2/P2 live camera no longer fails with `Address already in use` on transitional ffmpeg builds (#1504, reported by @rage03usa, confirmed by @PawseHaxor)** — On a native Ubuntu install with the Jammy-era system ffmpeg, the RTSP live-view path retried indefinitely with `Unable to open RTSP for listening … Address already in use`. Snapshots, the camera diagnostic, and OrcaSlicer all kept working — only live view was broken. Cause: the ffmpeg argv built in `backend/app/api/routes/camera.py` (added in 530a7a46 as part of an RTSP-stability bundle) passed `-timeout 30000000`. That ffmpeg version *deprecated* the original `-timeout` (socket I/O microseconds) and repurposed the name to mean the *RTSP listen-mode incoming-connection timeout* — any non-zero value **implies `-listen`**. ffmpeg then flipped into RTSP server mode and tried to bind the same localhost port Bambuddy's TLS proxy was already listening on, hence EADDRINUSE on every retry (the odd-port pattern @PawseHaxor noticed is coincidence — the ephemeral allocator just picked odd values that run). The reporter's own workaround (drop the option) works but silently loses the socket-level read timeout, so a hung TLS handshake would block past the OS TCP timeout instead of failing fast into the existing reconnect loop. **Why this can't be a one-line literal swap**: ffmpeg has shipped *three* arrangements of this option over time and Bambuddy supports the full range. Pre-deprecation builds: `-timeout` is the socket I/O timeout. Transitional builds (~late-4.x, what the reporter is on): `-timeout` is the broken listen-mode option, `-stimeout` is the replacement. **Modern ffmpeg (5.x / 6.x / 7.x — current Debian 13, Ubuntu 24.04, current Homebrew)**: `-stimeout` was removed entirely and `-timeout` is back to socket I/O. So both literals regress one half of the install base. **Fix**: a new `rtsp_socket_timeout_flag()` helper in `backend/app/services/camera.py` probes `ffmpeg -h demuxer=rtsp` once at first use and picks `-stimeout` when ffmpeg advertises it (transitional window) or `-timeout` otherwise (modern + very old). The result is cached for the process lifetime — ffmpeg won't swap mid-run. The function returns the option name without a leading dash so callers prepend it themselves (no empty-flag formatting bug). Wired into both RTSP ffmpeg call sites — `routes/camera.py` (printer camera) and `services/external_camera.py` (external RTSP) — in lockstep, same TLS-proxy + ffmpeg pattern, same regression. The reporter had tried `-listen_timeout` (doesn't help — we *don't* want listen mode) and `-rw_timeout` (AVIO-level, RTSP demuxer doesn't honour it on its control socket), but no manual swap could be correct for both transitional and modern installs simultaneously. **Tests**: 8 in `test_ffmpeg_rtsp_timeout_flag.py` — 6 unit tests for the probe (picks `-stimeout` when advertised, falls back to `-timeout` on modern, defaults to `-timeout` when ffmpeg missing or probe raises, caches across calls, substring-match guard against false-positives on `-listen_timeout`), 2 parametrised regression guards against either RTSP ffmpeg argv re-hard-coding a literal flag instead of consuming the probe. 37 (probe + existing external-camera) tests green; backend ruff clean.
- **SliceModal: process / filament dropdowns now filter by nozzle diameter too, not just printer model (#1325 follow-up #2, reported by @IndividualGhost1905)** — With the @BBL name fallback in place, the reporter saw that an X2D 0.4 selection still mixed 0.2 / 0.6 / 0.8 nozzle process variants into the main list. The fallback's regex stripped any trailing `<size> nozzle` suffix from both sides before comparing, so `"Bambu Lab X2D 0.4 nozzle"` and `"0.40mm Strength @BBL X2D 0.8 nozzle"` both reduced to `"X2D"` and matched. The bundle path was already nozzle-correct (a `.bbscfg` is scoped to one printer-preset-name including its nozzle, so the bundle-side exact-match was nozzle-aware); only the name fallback needed fixing. **Fix**: `extractPrinterPresetModel` and `extractBblToken` now each return `{ model, nozzle }`. The nozzle is the parsed string ("0.4" / "0.6" / etc.) or `null` when the name has no suffix. `classifyByBambuName` treats a `null` process nozzle as `"0.4"` — Bambu's convention is to omit the suffix on 0.4 (the default) and include it for 0.2 / 0.6 / 0.8, exactly as the reporter described. Both `model` and `nozzle` must compare equal for a `'match'`; differing nozzles fall into the existing "Other printers" group, no new group label needed. If the selected printer preset name has no parseable nozzle (non-Bambu / hand-typed), the matcher degrades to model-only — Bambu printer presets always include nozzle in practice, so this is defensive. **Tests**: 9 new in `slicerPrinterMatch.test.ts` covering the matrix (0.4 printer vs no-suffix / 0.6 / 0.8 process; 0.6 printer vs 0.6 / no-suffix-=-0.4; explicit 0.4-suffix-on-process still matches 0.4 printer; same rule on filament presets; wrong-model dominates over matching-nozzle; no-nozzle printer name degrades to model-only); one existing test reframed (the case that previously asserted a 0.6-nozzle process matched a 0.4 printer — the exact bug — now asserts mismatch). 46 slicerPrinterMatch + 34 SliceModal tests green; frontend build clean.
- **Timelapse now attaches to the archive after a backend restart mid-print (#1485 follow-up, reported by @pwostran)** — With the duplicate-archive fix from #1485 in place, a restart mid-print stopped creating ghosts — but the resulting archive came back without its timelapse video (only the finish snapshot was attached). Cause is a side-effect of the #1304 first-push guard: on the first MQTT push after Bambuddy starts (`_previous_gcode_state = None`), `is_new_print` is deliberately False so `on_print_start` doesn't fire — which prevents duplicate archive creation **but also** prevents the timelapse-baseline capture, since both live behind the same callback. At PRINT COMPLETE, `_scan_for_timelapse_with_retries` finds an empty `_timelapse_baselines` for the printer and falls into the "take baseline now" fallback in `main.py`. By that point the printer has already uploaded the in-flight MP4, so the snapshot includes it. Every retry then reports "N files found / no new files since baseline" and the scan gives up. The reporter's support bundle is the smoking gun — pre-reboot baseline of 7 files, post-reboot fallback baseline of 8 files (including the just-uploaded one), 4 retries all unable to see the diff. **Fix**: `bambu_mqtt.py` now fires a sibling `on_print_running_observed` callback inside the "Now tracking RUNNING state" branch when the first-push guard suppresses `on_print_start`. `main.py` wires it to a thin handler that fetches the printer row from DB and calls the existing `_capture_timelapse_baseline_at_start`. The callback only fires the first time we observe RUNNING per session (gated on the same `not self._was_running` branch the timelapse-flag restore already lives in), so a normal print start path is unaffected. The handler is also idempotent: if a baseline already exists for that printer, it returns without touching it. Safe because the printer doesn't upload the timelapse until *after* PRINT COMPLETE, so a baseline captured any time during the in-flight print is still pre-upload — no narrow window. The plumbing (`set_print_running_observed_callback` setter, in-`connect_printer` wrapper, constructor pass-through) mirrors the existing `on_print_start` / `on_print_complete` callback chain in `printer_manager.py`. **Tests**: 7 new in `TestPrintRunningObservedCallback` in `test_bambu_mqtt.py` (fires on first RUNNING after startup, doesn't double up with `on_print_start`, fires only once per session, skips on non-RUNNING / missing file / no-callback-set, payload shape mirrors `on_print_start`); 3 new in a dedicated `test_timelapse_baseline_restart_recovery.py` (handler captures the printer's existing-videos snapshot into `_timelapse_baselines`, skips when a baseline already exists, skips when the printer row was deleted between push and handler). 336 MQTT + print-start + timelapse tests green; backend ruff clean.
- **SliceModal: process / filament dropdowns now filter for users who haven't uploaded slicer bundles (#1325 follow-up, reported by @IndividualGhost1905)** — The original #1325 fix replaced a stale hardcoded `@BBL <model>` allow-list with bundle-based compatibility: a process / filament preset was classified against the selected printer by consulting the user's uploaded Slicer Bundles (.bbscfg). That works perfectly for users who have uploaded bundles for every printer their cloud catalogue covers — and silently no-ops for everyone else: every cloud preset resolves to `'unknown'`, nothing moves into "Other printers", and the dropdown looks identical to the pre-fix state. **Fix**: restored the `@BBL <token>` name fallback as a third tier *below* the bundle path, but with the token-to-printer mapping driven by **the backend's canonical `PRINTER_MODEL_MAP`** (`backend/app/utils/printer_models.py`) instead of a duplicated frontend table. A new `GET /api/v1/slicer/printer-models` route ships the mapping unmodified; `slicerPrinterMatch.buildCompatibilityIndex` accepts it as a second arg, inverts it into a short-code → display-fragment table (`X1C` → `X1 Carbon`, `P2S` → `P2S`, `A1 Mini` → `A1 mini`, …), and `presetCompatibility` uses it only after `compatible_printers` and the bundle index have already returned `'unknown'`. The match is case- and whitespace-insensitive (`"A1 mini"`, `"A1 Mini"` and `"a1mini"` all compare equal). When the registry doesn't list a token, the matcher falls back to comparing the raw token against the printer-preset model fragment — so a brand-new "Q1" printer with `@BBL Q1`-tagged presets matches without any code change. Adding a new model only requires updating the existing backend `PRINTER_MODEL_MAP` (already the single source of truth for `is_dual_nozzle_model`, the rod-type/ethernet registries, and 3MF metadata normalisation) — no frontend table to keep in sync. **Tests**: 2 new in `test_slicer_presets.py` (`/printer-models` returns the full `PRINTER_MODEL_MAP`; the route hands back a copy, not the live module dict); the existing 25 `slicerPrinterMatch.test.ts` cases were extended to 36 covering: registry-driven X1C vs X1 Carbon match, A1 vs A1 mini disambiguation, H2D vs H2D Pro disambiguation, the previously-missing P2S / H2C / H2S / X2D, raw-token fallback for unregistered models, graceful degradation when the registry fetch hasn't resolved yet, the `compatible_printers`-wins-over-name rule, and the bundle-wins-over-name rule. 38 slicer-presets + 36 slicerPrinterMatch tests green; backend ruff clean; frontend build clean.
+6 -2
View File
@@ -29,6 +29,7 @@ from backend.app.services.camera import (
get_ffmpeg_path,
is_chamber_image_model,
read_next_chamber_frame,
rtsp_socket_timeout_flag,
test_camera_connection,
)
from backend.app.services.camera_fanout import (
@@ -348,8 +349,11 @@ async def generate_rtsp_mjpeg_stream(
"tcp",
"-rtsp_flags",
"prefer_tcp",
"-timeout",
"30000000", # 30 seconds in microseconds
# Socket I/O timeout name varies by ffmpeg version (#1504); see
# rtsp_socket_timeout_flag(). The 30s value is microseconds for
# both names.
f"-{rtsp_socket_timeout_flag()}",
"30000000",
"-buffer_size",
"1024000", # 1MB buffer
"-max_delay",
+67
View File
@@ -11,6 +11,7 @@ import os
import shutil
import ssl
import struct
import subprocess
import uuid
from datetime import datetime
from pathlib import Path
@@ -24,6 +25,9 @@ JPEG_END = b"\xff\xd9"
# Cache the ffmpeg path after first lookup
_ffmpeg_path: str | None = None
# Cached result of rtsp_socket_timeout_flag(); see that function for context.
_rtsp_socket_timeout_flag: str | None = None
# Track PIDs of ffmpeg processes spawned for one-shot frame capture (snapshot).
# The cleanup task in routes/camera.py checks this set to avoid killing active captures.
_active_capture_pids: set[int] = set()
@@ -66,6 +70,69 @@ def get_ffmpeg_path() -> str | None:
return ffmpeg_path
def rtsp_socket_timeout_flag() -> str:
"""Return the ffmpeg argv flag (without the leading dash) that sets the
RTSP demuxer's client-side TCP socket I/O timeout, in microseconds.
ffmpeg has shipped three different option arrangements for this over
time, and Bambuddy supports the full range:
- **Modern ffmpeg (5.x / 6.x / 7.x)** — Debian 13, Ubuntu 24.04, current
Homebrew, etc. ``-timeout`` is the socket I/O timeout (microseconds);
``-stimeout`` was REMOVED.
- **Transitional ffmpeg (~late-4.x, some 5.x builds)** — Ubuntu 22.04's
shipped version is one of these. ``-timeout`` was deprecated and
*repurposed* to mean the RTSP listen-mode incoming-connection
timeout — and any non-zero value implies ``-listen``, which makes
ffmpeg bind the localhost proxy port and fail with EADDRINUSE
(#1504). ``-stimeout`` was the replacement socket I/O timeout in
that window.
- **Old ffmpeg (early 4.x and earlier)** — ``-timeout`` is socket I/O
timeout (the original meaning, before the deprecation churn).
We probe ``-h demuxer=rtsp`` once and cache: if ``-stimeout`` is
advertised, prefer it (covers the transitional window and stays
correct on the older builds that still accept it as an alias); else
fall back to ``-timeout`` (correct on modern and pre-deprecation
ffmpeg). The result is cached for the process lifetime — ffmpeg
isn't going to swap mid-run.
Returns the option name without the leading dash, e.g. ``"timeout"``
or ``"stimeout"``. Callers must prepend ``-`` themselves so a string
formatting bug can't pass an empty flag.
"""
global _rtsp_socket_timeout_flag
if _rtsp_socket_timeout_flag is not None:
return _rtsp_socket_timeout_flag
ffmpeg = get_ffmpeg_path()
chosen = "timeout" # safe default for modern ffmpeg
if ffmpeg:
try:
result = subprocess.run(
[ffmpeg, "-hide_banner", "-h", "demuxer=rtsp"],
capture_output=True,
text=True,
timeout=5,
check=False,
)
help_text = (result.stdout or "") + (result.stderr or "")
# Help lines list each option as `-<name> ` (trailing space) — match
# that exact form so we don't accidentally hit a substring elsewhere.
if "-stimeout " in help_text:
chosen = "stimeout"
except (OSError, subprocess.SubprocessError) as exc:
# If probing fails, keep the modern-ffmpeg default. Worst case
# is the EADDRINUSE regression returns for transitional-ffmpeg
# users — same as before this function existed.
logger.warning("Could not probe ffmpeg RTSP timeout flag, defaulting to -timeout: %s", exc)
_rtsp_socket_timeout_flag = chosen
logger.info("RTSP socket I/O timeout flag: -%s", chosen)
return chosen
def supports_rtsp(model: str | None) -> bool:
"""Check if printer model supports RTSP camera streaming.
+5 -1
View File
@@ -683,6 +683,8 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
logger.error("ffmpeg not found - required for RTSP streaming")
return
from backend.app.services.camera import rtsp_socket_timeout_flag
# If the URL uses rtsps://, set up a TLS proxy so ffmpeg uses plain rtsp://
proxy_server = None
effective_url = url
@@ -715,7 +717,9 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
"tcp",
"-rtsp_flags",
"prefer_tcp",
"-timeout",
# Socket I/O timeout name varies by ffmpeg version (#1504); see
# `rtsp_socket_timeout_flag()` in services.camera.
f"-{rtsp_socket_timeout_flag()}",
"30000000",
"-buffer_size",
"1024000",
@@ -0,0 +1,139 @@
"""Regression for #1504: ffmpeg RTSP socket-I/O timeout flag.
The RTSP demuxer's client-side socket I/O timeout option name varies by
ffmpeg version (full chronology in
`backend/app/services/camera.rtsp_socket_timeout_flag`). Hard-coding
either ``-timeout`` or ``-stimeout`` regresses one half of the install
base. The flag is therefore probed at runtime; this module tests that
probe and guards against either RTSP ffmpeg argv re-hard-coding the
wrong literal.
"""
from pathlib import Path
from unittest.mock import patch
import pytest
import backend.app.services.camera as camera_svc
from backend.app.services.camera import rtsp_socket_timeout_flag
@pytest.fixture(autouse=True)
def _reset_cache():
"""The probe caches its result in a module-level global. Reset it
before every test so each one sees a fresh probe."""
camera_svc._rtsp_socket_timeout_flag = None
yield
camera_svc._rtsp_socket_timeout_flag = None
class TestRtspSocketTimeoutFlagProbe:
def test_prefers_stimeout_when_ffmpeg_advertises_it(self):
"""Transitional ffmpeg (~late-4.x): both options are listed and
``-timeout`` is the broken listen-mode option — pick ``-stimeout``."""
transitional_help = (
" -listen_timeout <int> ... incoming connections ...\n"
" -stimeout <int64> ... socket TCP I/O ...\n"
" -timeout <int> ... DEPRECATED ...\n"
)
with (
patch.object(camera_svc, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
patch("backend.app.services.camera.subprocess.run") as mock_run,
):
mock_run.return_value.stdout = transitional_help
mock_run.return_value.stderr = ""
assert rtsp_socket_timeout_flag() == "stimeout"
def test_falls_back_to_timeout_on_modern_ffmpeg(self):
"""Modern ffmpeg (5+/6+/7+): ``-stimeout`` no longer exists and
``-timeout`` is back to meaning socket I/O — pick ``-timeout``."""
modern_help = (
" -listen_timeout <int> ... incoming connections ...\n"
" -timeout <int64> ... socket I/O ...\n"
" -reorder_queue_size <int> ... reordered packets ...\n"
)
with (
patch.object(camera_svc, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
patch("backend.app.services.camera.subprocess.run") as mock_run,
):
mock_run.return_value.stdout = modern_help
mock_run.return_value.stderr = ""
assert rtsp_socket_timeout_flag() == "timeout"
def test_defaults_to_timeout_when_ffmpeg_missing(self):
"""No ffmpeg available — return the modern default so we don't
wedge ffmpeg-less unit tests trying to import camera.py."""
with patch.object(camera_svc, "get_ffmpeg_path", return_value=None):
assert rtsp_socket_timeout_flag() == "timeout"
def test_defaults_to_timeout_when_probe_raises(self):
"""If subprocess probe blows up, prefer the modern default —
breaking the transitional-ffmpeg case is preferable to crashing
every live-view start."""
with (
patch.object(camera_svc, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
patch("backend.app.services.camera.subprocess.run", side_effect=OSError("boom")),
):
assert rtsp_socket_timeout_flag() == "timeout"
def test_result_is_cached_across_calls(self):
"""Probing ffmpeg is a subprocess spawn; cache it for the
process lifetime (ffmpeg won't swap mid-run)."""
with (
patch.object(camera_svc, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
patch("backend.app.services.camera.subprocess.run") as mock_run,
):
mock_run.return_value.stdout = " -timeout <int64>\n"
mock_run.return_value.stderr = ""
rtsp_socket_timeout_flag()
rtsp_socket_timeout_flag()
rtsp_socket_timeout_flag()
assert mock_run.call_count == 1
def test_substring_match_does_not_false_positive(self):
"""Match the option as ``-stimeout `` (trailing space) so an
unrelated mention like ``-listen_timeout`` or a fragment in
another section doesn't trick us into picking the missing flag."""
only_listen_help = (
" -listen_timeout <int> ... incoming connections ...\n"
" -timeout <int64> ... socket I/O ...\n"
)
with (
patch.object(camera_svc, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
patch("backend.app.services.camera.subprocess.run") as mock_run,
):
mock_run.return_value.stdout = only_listen_help
mock_run.return_value.stderr = ""
assert rtsp_socket_timeout_flag() == "timeout"
class TestRtspArgvUsesProbe:
"""The two RTSP ffmpeg callers must not hard-code either flag literal —
they must consume the probe so version-dependent correctness is
preserved. Guards #1504 from being half-fixed again."""
# Anchor on this file so the assertion is CWD-independent (pytest can
# be invoked from the project root OR from backend/, depending on who
# runs it). __file__ lives at backend/tests/unit/, so the repo root
# is three parents up.
_REPO_ROOT = Path(__file__).resolve().parents[3]
_RTSP_FFMPEG_CALLERS = (
"backend/app/api/routes/camera.py",
"backend/app/services/external_camera.py",
)
@pytest.mark.parametrize("rel", _RTSP_FFMPEG_CALLERS)
def test_no_hard_coded_timeout_literal(self, rel):
"""Neither RTSP ffmpeg argv may pass a hard-coded ``-timeout``
or ``-stimeout`` literal — both must come from the probe."""
src = (self._REPO_ROOT / rel).read_text()
assert '"-timeout"' not in src, (
f"{rel} hard-codes `-timeout` — this is the listen-mode option on "
f"transitional ffmpeg (EADDRINUSE, #1504). Use rtsp_socket_timeout_flag()."
)
assert '"-stimeout"' not in src, (
f"{rel} hard-codes `-stimeout` — this option was removed in ffmpeg 7. Use rtsp_socket_timeout_flag()."
)
assert "rtsp_socket_timeout_flag()" in src, (
f"{rel} should derive its RTSP socket timeout flag from rtsp_socket_timeout_flag() — see #1504."
)