Skip to content

fix(video): retry an rtsp source over tcp after a udp address-family failure - #485

Merged
michelhonold merged 4 commits into
mainfrom
fix/rtsp-ipv6-fallback
Oct 25, 2026
Merged

michelhonold merged 4 commits into
mainfrom
fix/rtsp-ipv6-fallback

Conversation

@michelhonold

@michelhonold michelhonold commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Plan

  • Regression tests first: the RTSP plugin keeps a host on TCP interleaved after its attempt failed on Invalid address family; the receiver hands a failed attempt's error to the plugin before the next build; the error is classified, not shown raw
  • VideoInputBase.on_bus_error hook, called by the receiver for every failed attempt that carries an error
  • RTSP plugin: latch the dialled host to TCP on an address-family mismatch
  • classify_failure: an address-family mismatch reads by how far the feed got
  • Correct the video guide's account of how rtspsrc picks its UDP family

Summary

Fixes #373.

When a dual-stack camera name's IPv4 address is refused or unreachable, the RTSP connection falls back to IPv6 after the transport was already chosen. rtspsrc then fails with Invalid address family, and every reconnect fails the same way. The operator saw the element's raw "Could not get/set settings from/on resource" text.

Now:

  • The receiver passes every failed attempt's error to the input through a new optional hook, VideoInputBase.on_bus_error, before the next attempt is built. A hook that raises is logged and the retry still runs.
  • The RTSP input switches that host to TCP interleaved when the debug string carries Invalid address family. Every later attempt and heal of the input then uses TCP. A different URL host, or an input rebuilt by a saved change to the source's settings or type, tries every transport again.
  • classify_failure names the failure. An address-family mismatch is classified by how far the feed got, like an open failure: NO_DATA once the stream was described. It no longer comes out as UNKNOWN with the element's raw text, and no new failure type or wording is added.

How rtspsrc picks its UDP family (corrects the video guide)

The guide said rtspsrc opens its UDP ports for the address family of the literal in the URL. That is not what it does. It uses the address type in the server's session description (c=IN IP4 / IP6, or IPv4 when there is no c= line), whatever family the control connection reached the server on.

Checked locally against GStreamer 1.28.7, with a real rtspsrc and a stub RTSP server on ::1 (loopback only):

URL SDP c= Result
name IN IP4 udpsink0 posts gst-resource-error-quark:13, Invalid address family (got N) after on-sdp
IPv6 literal IN IP4 same error
IPv6 literal none same error
IPv6 literal IN IP6 works
name, protocols=TCP IN IP4 works

The GStreamer 1.26 source picks the family the same way (gst_rtspsrc_do_stream_connection). As a result, this fix also recovers an IPv6-literal URL whose server describes IPv4: it now moves to TCP after one failed attempt. Before, it failed on every attempt.

Test plan

  • tests/test_video_input_rtsp.py: after an address-family failure the next builds of that host request TCP only. Covered for a dual-stack name and an IPv6 literal. Other failures (refused, empty SDP, rejected login, no error identity), another host, a URL that names no host and a newly built input all keep every transport
  • tests/test_video_receiver.py: the receiver passes the error to the input, a timeout passes nothing, and a hook that raises still gets its retry. With the real RtspInput behind the receiver (fake GStreamer): the retry after the failure runs over TCP, the failure reads NO_DATA, and a TCP retry that also fails stays on TCP
  • tests/test_video_failure_classification.py: the mismatch reads by phase, case-insensitively, and only under RESOURCE_SETTINGS
  • Mutation check: 12 mutants of the new lines (latch, host guard, receiver call, its None guard and exception isolation, classification rule, code check, case folding), all killed
  • 100 % line coverage of the changed lines in rtsp.py, failure.py and receiver.py; ruff, ruff format, mypy clean
  • make ci-remote
  • Station check: an RTSP camera reachable over IPv6 only, dialled by a name that also resolves to an IPv4 address nothing answers on. Set-up: give the name an unused IPv4 address first and the camera's IPv6 address second, through a record on the LAN's resolver or (agreed first, since it touches /etc on the station) /etc/hosts, for example 192.0.2.99 cam6.example and 2001:db8::10 cam6.example, and put rtsp://cam6.example:554/<path> in Video Source. Make sure the station's address selection puts the IPv4 entry first, so the up-front rule still offers UDP. The log should show one attempt failing with Invalid address family, then tcp interleaved: an earlier attempt's UDP ports were of the other IP family, then video. Live Statistics should show the "answered, but … did not receive video" sentence during the retry, not the raw settings text
    • Checked with a simulated camera: MediaMTX serving a test stream on another Pi's IPv6 ULA address only, the name in the station's hosts file (an unused IPv4 address first, then the ULA) and IPv4 preferred in address selection. On the code before this change every attempt failed the same way with the raw settings error. With it: one failed attempt, RTSP UDP ports did not match the server's IP family; using TCP interleaved from now on, the retry logged as tcp interleaved: an earlier attempt's UDP ports were of the other IP family, video 12 s later; Live Statistics showed the "answered, but this station did not receive video from it" sentence during the retry.
  • Station check, same set-up with an IPv6-literal URL to a server whose SDP says c=IN IP4 (for example MediaMTX, if its SDP says so on the station's version): one failed attempt, then video over TCP
    • Checked against the same MediaMTX, whose SDP says c=IN IP4 0.0.0.0 over the IPv6 connection: one failed attempt, then TCP interleaved and video within 2 s.

🤖 Generated with Claude Code

michelhonold and others added 4 commits October 21, 2026 00:27
…failure

rtspsrc opens a stream's UDP ports for the address type of the server's
session description (IPv4 when it names none), not for the family the
control connection reached the server on. When a dual-stack name's IPv4
address is refused or unreachable, the connection falls back to IPv6 after
the transport was chosen, the RTCP udpsink refuses the server's IPv6
address with "Invalid address family", and every reconnect repeats it. The
up-front rule (a name whose first answer is IPv6 gets TCP) cannot see this,
and the error reached the operator as the element's raw settings text.

The receiver now hands every failed attempt's bus error to the input
(VideoInputBase.on_bus_error) before the next attempt is built. The RTSP
input reads "Invalid address family" in the debug string and keeps that
host on TCP interleaved for the input's lifetime; a changed host or a
rebuilt input tries every transport again. classify_failure reads the
mismatch by the phase like an open failure, so it is NO_DATA after the
stream was described rather than UNKNOWN.

Verified against GStreamer 1.28.7 on loopback with a stub RTSP server on
::1: an SDP with c=IN IP4 or no c= line fails this way for a name and an
IPv6 literal alike, c=IN IP6 does not, and TCP interleaved avoids it. The
video guide said the UDP family followed the URL literal; it now states
the session description.

Fixes #373

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@michelhonold michelhonold added the bug Something isn't working label Oct 21, 2026
@michelhonold

Copy link
Copy Markdown
Contributor Author

Review rounds

Each round re-read the whole diff against origin/main, with fresh eyes.

Round 1 (source, docs, tests)

  • Medium, fixed in 184c143. The video guide and one test docstring said that any saved video setting rebuilds the input and resets the transport. Only a saved change to the source's own settings or to the source type does; swap_video runs only when the input's config_changed is true or the type changes. Both now say that.
  • Low, fixed in 184c143. test_a_failed_tcp_retry_is_classified_too also checks that the third attempt stays on TCP. It is renamed to say so.
  • Mutation follow-up, fixed in ff28156 and cbd9d55. The self._dialled_host guard in on_bus_error could never change an outcome, because create_pipeline never matches an empty host. The guard was removed, along with the test that could not fail without it. The create_pipeline host guard first survived a mutant; it is now pinned by test_a_url_naming_no_host_is_never_moved. The code check on the classification rule and the case-folding test were also tightened so that a mutant can kill them.
  • Checked and fine:
    • The error is handed over after classification and before _reset_video_flow_state, so the CLAUDE.md classify-first rule holds.
    • Both bus-error paths (_handle_bus_error and _handle_refused_start) go through _schedule_reconnect.
    • No URL or credential reaches the new log lines.
    • The bus watch is torn down per pipeline, so the error comes from the pipeline whose host was recorded.

Round 2 (whole diff again)

Nothing beyond cosmetic:

  • The address-family BusError fixture is written out three times, once in each test module. Left as is: each module reads standalone, and a shared fixture would couple three suites for one constant.
  • The no-identity case in test_any_other_failure_keeps_every_transport is a BusError with no domain. A real timeout passes no error at all. It is kept as a stand-in for "an error that names nothing".

Round 3 (whole diff again)

Nothing beyond cosmetic or rare:

  • Rare, ignored. On a GStreamer build without the on-sdp signal, the phase stays STARTING, so the mismatch would read UNREACHABLE. Every supported version has the signal.
  • Rare, ignored. The host is latched until the input is rebuilt. A UDP-only server, whose IPv4 address comes back later, stays on TCP until then.
  • Checked. Importing BusError into _base.py adds no cycle, because receiver_bus imports only the standard library.

Results

  • Targeted suites pass: tests/test_video_*.py, tests/test_receiver_*.py, tests/test_diagnostics*.py (1435 passed, 32 skipped for the platform).
  • ruff, ruff format --check and mypy openfollow are clean.
  • 12 of 12 mutants killed.
  • Changed lines in rtsp.py, failure.py and receiver.py are at 100 % coverage.

@michelhonold
michelhonold marked this pull request as ready for review October 25, 2026 07:42
@michelhonold
michelhonold merged commit 93cb4cd into main Oct 25, 2026
4 checks passed
@michelhonold
michelhonold deleted the fix/rtsp-ipv6-fallback branch October 25, 2026 07:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(video): an RTSP connection that falls back to IPv6 keeps failing on UDP

1 participant