Skip to content

fix(packet_pool): don't drop a payload that is flushed by the same packet that completes a single-packet PES - #78

Merged
asticode merged 2 commits into
asticode:masterfrom
jhcho-hanvision:fix/packet-pool-dropped-payload
Sep 18, 2026
Merged

asticode merged 2 commits into
asticode:masterfrom
jhcho-hanvision:fix/packet-pool-dropped-payload

Conversation

@jhcho-hanvision

Copy link
Copy Markdown
Contributor

Problem

When a TS packet both starts a new payload (PUSI) and completes a bounded PES on its own, packetAccumulator.add assigns to ps twice, so the payload accumulated so far is silently dropped — it is neither returned nor kept in the queue, and no error is reported.

if p.Header.PayloadUnitStartIndicator {
    ps = mps                    // previous payload
    mps = make([]*Packet, 0, cap(mps))
}
mps = append(mps, p)
...
} else if isPESPayload(mps[0].Payload) && isPESComplete(mps) {
    ps = mps                    // overwrites the previous payload
    mps = nil
}

The immediate flush of a complete single-packet PES was added in #72 to reduce latency for sparse streams (e.g. DVB teletext).

How we hit it

GStreamer's mpegtsmux writes PES_packet_length = 0 for video only when the PES exceeds 65535 bytes, and the real length otherwise. So a large IDR access unit (length 0, spanning many TS packets) followed by a tiny P frame that fits into a single TS packet (length set) makes the IDR disappear.

Downstream this shows up as video corruption every few seconds with no demuxer error and no transport-level loss — we chased it for a while in an SRT ingest (mediamtx, which uses go-astits). FFmpeg always writes 0 for video PES, which is why streams produced by FFmpeg never trigger it. Both writers are spec compliant: ISO/IEC 13818-1 allows 0 only for video PES, but does not require it.

Fix

If this call is already returning the previous payload, keep the newly started payload queued; it is flushed on the next payload start, as before #72 for this particular sequence. The latency improvement of #72 is therefore unaffected for sparse streams, where a payload start does not coincide with a flush.

A complete fix would let add return several payloads, but that changes the internal API and all of its call sites, so this keeps the change minimal.

Test

TestPacketPoolUnboundedPESFollowedByOnePacketPES reproduces the sequence with synthetic packets. It fails on master (only the one-packet PES is returned, len=1 instead of len=2) and passes with this change. The rest of the test suite is unaffected.

…ete single-packet PES

When a packet both flushes the previous payload and completes a PES on its own
(bounded PES that fits in one packet, added in asticode#72), the second assignment to
ps overwrote the first one, so the previous payload was silently dropped.

This happens in practice with GStreamer's mpegtsmux, which writes PES length 0
only for video PES larger than 65535 bytes: a large IDR access unit followed by
a tiny P frame makes the IDR disappear, with no demuxer error.

Keep the newly started payload queued in that case; it is flushed on the next
payload start, as before asticode#72 for this specific sequence.

@asticode asticode left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR ❤️

A few minor changes needed 👍

Comment thread packet_pool_lost_payload_test.go Outdated

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you move those tests in packet_pool_test.go instead?

Comment thread packet_pool_lost_payload_test.go Outdated
// GStreamer's mpegtsmux) must not be dropped when the PES that starts right
// after it is complete within that single packet.
func TestPacketPoolUnboundedPESFollowedByOnePacketPES(t *testing.T) {
pp := newPacketPool(nil)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since both pesPacketPayload and tsPacket are only used in TestPacketPoolUnboundedPESFollowedByOnePacketPES, could you declare them inside TestPacketPoolUnboundedPESFollowedByOnePacketPES instead ?

func TestPacketPoolUnboundedPESFollowedByOnePacketPES(t *testing.T) {
  // pesPacketPayload builds a PES payload: start code, stream id, packet length
  // (0 = unbounded), minimal optional header, then n data bytes.
  pesPacketPayload := func(packetLength uint16, n int) []byte {
	p := []byte{0, 0, 1, 0xe0, byte(packetLength >> 8), byte(packetLength), 0x80, 0x00, 0x00}
	return append(p, make([]byte, n)...)
  }
...

...
}

Comment thread packet_pool_lost_payload_test.go Outdated
return append(p, make([]byte, n)...)
}

func tsPacket(cc uint8, pusi bool, payload []byte) *Packet {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you rename into packet?

Address review: keep the test next to the other packet pool tests,
declare its helpers inside the test and rename tsPacket to packet.
@jhcho-hanvision
jhcho-hanvision force-pushed the fix/packet-pool-dropped-payload branch from 4bf577d to 3256154 Compare September 18, 2026 02:03
@jhcho-hanvision

Copy link
Copy Markdown
Contributor Author

Thanks for the review! Done in 3256154:

  • moved the test into packet_pool_test.go
  • declared pesPacketPayload and the packet helper inside the test
  • renamed tsPacket to packet

The test still fails without the fix in packet_pool.go and the whole suite passes.

@asticode
asticode merged commit d3a7a51 into asticode:master Sep 18, 2026
1 check passed
@asticode

Copy link
Copy Markdown
Owner

Thanks for the PR ❤️

Let me know whether you need a tag 👍

@jhcho-hanvision

Copy link
Copy Markdown
Contributor Author

No tag needed on our side for now — we only use it as an indirect dependency. Thanks for merging! 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants