From 326c8fb209fc5f28372fc4b7426bac0f3666e320 Mon Sep 17 00:00:00 2001 From: AJ Plotkin Date: Mon, 27 Jul 2026 09:21:29 -0400 Subject: [PATCH] h264: wait for a partition head before depayloading RTPDepay creates a fresh codecs.H264Packet and starts feeding it whatever packet arrives first. When a consumer attaches to a live stream that is mid-way through a fragmented NAL unit, the first packets it sees are FU-A fragments whose start fragment was never received. codecs.H264Packet appends FU-A fragments without checking the start bit, and on the end bit it synthesizes a NAL header from the fragment type. The result is a NAL unit that never existed on the wire: correctly shaped, but missing its head, including the slice header. When the fragmented type is 5 it also passes IsKeyframe, so callers treat it as a valid keyframe and hand it to a decoder, which cannot decode it. The added test demonstrates it: feeding only the tail fragments of a fragmented IDR currently emits [0 0 0 5 101 aa bb cc dd] - a synthesized type-5 NAL built purely from orphan fragments. RFC 6184 section 5.8 requires that fragments following a lost start be discarded, and pion's own SampleBuilder refuses to begin a sample that is not a partition head. Do the same here: skip packets until IsPartitionHead reports one, then depayload as before. Packets dropped by this gate could only ever have produced a malformed NAL. Co-Authored-By: Claude Opus 4.8 --- pkg/h264/rtp.go | 18 +++++++++++++++ pkg/h264/rtp_test.go | 53 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 pkg/h264/rtp_test.go diff --git a/pkg/h264/rtp.go b/pkg/h264/rtp.go index bb5fe4703..6f1adb31a 100644 --- a/pkg/h264/rtp.go +++ b/pkg/h264/rtp.go @@ -21,7 +21,25 @@ func RTPDepay(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc { buf := make([]byte, 0, 512*1024) // 512K + // A depayloader attaching to a live stream can land in the middle of a + // fragmented NAL unit. codecs.H264Packet appends FU-A fragments without + // checking the start bit, so those orphan fragments are assembled and + // emitted under a synthesized NAL header - a NAL that looks valid (and + // passes IsKeyframe when the fragmented type is 5) but is missing its + // head, including the slice header. Consumers then fail to decode it. + // RFC 6184 ยง5.8 requires discarding fragments that follow a lost start, + // and pion's own SampleBuilder gates on IsPartitionHead for this reason. + // Wait for a partition head before feeding the depayloader anything. + synced := false + return func(packet *rtp.Packet) { + if !synced { + if !depack.IsPartitionHead(packet.Payload) { + return + } + synced = true + } + //log.Printf("[RTP] codec: %s, nalu: %2d, size: %6d, ts: %10d, pt: %2d, ssrc: %d, seq: %d, %v", codec.Name, packet.Payload[0]&0x1F, len(packet.Payload), packet.Timestamp, packet.PayloadType, packet.SSRC, packet.SequenceNumber, packet.Marker) payload, err := depack.Unmarshal(packet.Payload) diff --git a/pkg/h264/rtp_test.go b/pkg/h264/rtp_test.go new file mode 100644 index 000000000..eff770931 --- /dev/null +++ b/pkg/h264/rtp_test.go @@ -0,0 +1,53 @@ +package h264 + +import ( + "testing" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/pion/rtp" + "github.com/stretchr/testify/require" +) + +// fuA builds an FU-A packet carrying one fragment of a fragmented NAL unit. +func fuA(naluType byte, start, end bool, seq uint16, payload []byte) *rtp.Packet { + const fuAType = 28 + indicator := byte(0x60) | fuAType // nri=3, type=FU-A + header := naluType + if start { + header |= 0x80 + } + if end { + header |= 0x40 + } + return &rtp.Packet{ + Header: rtp.Header{SequenceNumber: seq, Marker: end}, + Payload: append([]byte{indicator, header}, payload...), + } +} + +// A depayloader that attaches mid-way through a fragmented IDR must not emit a +// NAL assembled from the leftover fragments. Without a partition-head gate, +// codecs.H264Packet appends the tail fragments and synthesizes a NAL header, +// producing a keyframe-shaped NAL whose slice header is missing - undecodable, +// but indistinguishable from a real keyframe to IsKeyframe. +func TestRTPDepay_SkipsNALUStartedBeforeAttach(t *testing.T) { + codec := &core.Codec{Name: core.CodecH264} + + var got [][]byte + depay := RTPDepay(codec, func(packet *core.Packet) { + got = append(got, append([]byte(nil), packet.Payload...)) + }) + + // Attach mid-NAL: the start fragment (and everything before it) was never + // seen. Only the tail of a fragmented IDR arrives. + depay(fuA(NALUTypeIFrame, false, false, 100, []byte{0xaa, 0xbb})) + depay(fuA(NALUTypeIFrame, false, true, 101, []byte{0xcc, 0xdd})) + + require.Empty(t, got, "must not emit a NAL whose start fragment was never received") + + // A complete NAL that begins after we attached must still be delivered. + depay(fuA(NALUTypeIFrame, true, false, 102, []byte{0x01, 0x02})) + depay(fuA(NALUTypeIFrame, false, true, 103, []byte{0x03, 0x04})) + + require.Len(t, got, 1, "a NAL received in full must be emitted") +}