#2387·go2rtc

Producer.reconnect() silently drops receivers it cannot re-match, permanently severing their consumers

Author: ajplotkinCreated Jul 30, 2026Updated Sep 15, 2026

Correction, 2026-09-15. The analysis below attributes the sever to the two continues. On the WebRTC/Nest deployment this was observed on, neither can fire: webrtc.Conn.GetTrack returns a nil error on every non-panicking branch, and a fresh Nest session answers with the same H264/Opus medias, so MatchCodec matches like-for-like. The operative line is the break at :222reconnect() matches at most one receiver per media. When p.receivers holds two H264 receivers for the same media, which is the #2389 state (a cold consumer on the PT 96 receiver and a warm one on the PT 98 receiver), the second is never Replace()d and is closed by p.conn.Stop(). The evidence section below proves this rather than the continue path: the new connection had an H264 media, so a MatchCodec miss cannot explain the orphaned sender.

This makes the two issues one chain. #2480 removes the precondition by stopping a second receiver being created for a media, so p.receivers cannot reach the state the break severs. The suggestion below still stands on its own: a receiver dropped for any reason should not be dropped silently, and the discarded GetTrack error at :216 is still reachable for RTSP producers.

Summary

When a producer reconnects, any receiver that cannot be matched to any media/codec on the new connection is silently left behind — each failed attempt ends in a bare continue, and a receiver is severed when every media iteration does. The old connection is then stopped, which closes that receiver and detaches the consumer senders attached to it.

The affected consumers then receive nothing while the stream keeps receiving from the source — and when every consumer of a stream is severed, the result is a stream that receives but forwards nothing at all, with no log line identifying it at any level.

Where

internal/streams/producer.go, reconnect() (line numbers from master c245815):

go
for _, media := range conn.GetMedias() {          // 206
    switch media.Direction {
    case core.DirectionRecvonly:                  // 208
        for i, receiver := range p.receivers {
            codec := media.MatchCodec(receiver.Codec)
            if codec == nil {
                continue                          // 211-213
            }

            track, err := conn.GetTrack(media, codec)
            if err != nil {
                continue                          // 216-218: err discarded
            }

            receiver.Replace(track)
            p.receivers[i] = track
            break
        }

then, after the media loop:

go
// stop previous connection after moving tracks (fix ghost exec/ffmpeg)
_ = p.conn.Stop()                                 // 237-238

The reconnect attempt itself is logged (log.Debug() at :185), but neither skip is logged at any level, so there is nothing to correlate with the resulting failure.

Note that the codec == nil continue is also normal cross-kind flow — an audio media iterating over a video receiver hits it on every healthy reconnect. Only a receiver that is missed by every media is left behind.

Mechanism

  1. A receiver that is never matched by any media — every iteration ending in one of the two continues — is never Replace()d, so it stays attached to the old connection and keeps its consumer senders as children.
  2. p.conn.Stop()Connection.Stop() (pkg/core/connection.go:70-81) calls receiver.Close() on every receiver of the old connection.
  3. Node.Close() (pkg/core/node.go:61-73): a receiver has no parent, so it takes the else branch and closes its children — detaching each consumer sender from the graph. (Node.Close removes each child; the sender objects themselves live on, with frozen counters.)
  4. Node.RemoveChild() (pkg/core/node.go:50-59) removes the child from the parent's list but never clears the child's own parent pointer.

Step 4 makes the damage directly observable: Sender.MarshalJSON (pkg/core/track.go:198-217) reads s.parent.id from that pointer, so a severed sender keeps reporting the id of a receiver that no longer exists.

By contrast, a receiver that is matched carries its consumers over cleanly — Receiver.Replace() is MoveNode (track.go:54-56), which re-points each child's parent to the new track.

Observable evidence

From a live instance in this state, /api/streams (URLs redacted):

json
{
  "producers": [{
    "id": 282,
    "bytes_recv": 206111596,
    "receivers": [
      { "id": 285, "codec": { "codec_name": "h264", "codec_type": "video" } },
      { "id": 286, "codec": { "codec_name": "opus" }, "childs": [314], "bytes": 2773649 },
      { "id": 287, "codec": { "codec_name": "h264" }, "childs": [313], "bytes": 197335469 }
    ]
  }],
  "consumers": [{
    "id": 25,
    "format_name": "preload",
    "senders": [{ "id": 26, "parent": 22, "bytes": 32750652 }],
    "bytes_send": 32750652
  }]
}

The preload consumer's sender reports "parent": 22. There is no receiver 22 — the producer's receivers are 285/286/287. Its byte counter has been frozen ever since, which is consistent with having been severed by an earlier reconnect. Receiver 285 is a video receiver with no childs and no bytes: a receiver with no consumer attached, on a producer where a video consumer exists but is severed.

Impact

On this deployment (Nest cameras via the nest producer, consumed over RTSP by ffmpeg), a doorbell stream spent an entire morning with bytes_recv climbing at ~150 KB/s and nothing reaching its consumers. Sampled over 60s:

producer bytes_recv delivery to consumers
affected stream 45,864,234 → 50,465,693 → 54,613,528 frozen, byte-identical every sample
healthy stream, same process climbing climbing

go2rtc logged nothing for the affected stream. Consumers connected successfully (new consumer stream=... was logged) and then received nothing, so from the client side it looks like a working RTSP session that never delivers a frame. Restarting go2rtc cleared it instantly.

The severity comes from the silence: there is no log and no error, and nothing on the producer side looks unhealthy — bytes_recv climbs normally throughout.

Not the same as #1733 / #716

#1733 reports consumers that remain listed after the client has gone away, with static bytes_send — stale records that should have been reaped. #716 reports consumers/senders accumulating and never being closed while the client is still active, with resulting CPU growth.

The API shape here looks similar, but the consumers are live and still expecting data; they were severed by go2rtc itself while connected. Reaping them would not help, because the consumer is not the thing that went away.

Suggestions

Most valuable regardless of the rest: make it diagnosable. Track which receivers were matched during the media loop and, just before p.conn.Stop(), emit one log.Warn() per unmatched receiver naming the stream and the receiver's codec. A Warn inside the loop itself would false-positive, since the codec == nil continue is also normal cross-kind flow. Separately, the GetTrack error at :216 is worth logging on its own — it currently discards a real error, and it is reachable for RTSP producers.

Beyond that, the design question is what should happen when a receiver cannot be re-matched, since silently continuing means its consumers are severed by the Stop() three lines later. Plausible directions, in rough order of invasiveness — I have not benchmarked these and defer to you on what fits the architecture:

  • Either re-attach the orphaned senders to a compatible receiver on the new connection (the MoveNode plumbing that Replace already uses), or failing that, close those consumer connections outright so clients see a disconnect and can reconnect.
  • Skip p.conn.Stop() when any receiver failed to move — though this leaks the old connection and would reintroduce the ghost exec/ffmpeg problem the existing comment references, so probably worse.
  • Retry the reconnect when the new connection's medias are a subset of what the receivers need, with a retry cap — a source that has permanently lost a track (e.g. camera audio disabled) would otherwise never converge.

Environment

  • Observed on a v1.9.14-based build; the code path is byte-identical on master c245815 (verified against source).
  • Producer: nest (WebRTC); consumers: RTSP (ffmpeg) and the internal preload.
  • Linux/arm64, Docker, Raspberry Pi 4.

Happy to test a patch against this deployment — the failure recurs often enough here to be a useful validation target.