Skip to content

Harden the audio decoder against bad packets and a closed stream - #127

Merged
josephnef merged 4 commits into
OpenIPC:masterfrom
iflyhere:fix/audio-decoder
Oct 22, 2026
Merged

josephnef merged 4 commits into
OpenIPC:masterfrom
iflyhere:fix/audio-decoder

Conversation

@iflyhere

@iflyhere iflyhere commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

I checked audio end to end on a Quest 3 against an OpenIPC camera (SSC338Q, OpenIPC 2.6) with
audio switched on in majestic: the camera's microphone, Opus in the RTP stream over wfb-ng,
decoded here and played in the headset. It works. Reading AudioDecoder on the way turned up
six defects, most of them reachable from radio input.

The decoder

The packet copy overflowed on ordinary packets. AudioUDPPacket copied into a fixed
250-byte array with no length check. That does not even take malformed input: 20 ms of PCM at
8 kHz is already 320 bytes, so majestic's pcm codec can write past the end with perfectly
ordinary packets. Each packet is stored at its own size now, up to the 65507 bytes the UDP
receiver can hand over, and only something longer is refused — refused, not truncated, because a
cut Opus packet decodes to noise rather than failing.

A short packet sized a stack array from a negative number. The payload was read from a fixed
offset of 12 with no check. A shorter packet made the size negative,
opus_packet_get_nb_frames() then returned an error that was not checked, and the product sized
a variable-length array on the stack. Decoding goes into a fixed buffer for the longest packet
Opus allows — 120 ms, 5760 samples at 48 kHz — and the header length is worked out from the
packet itself, CSRCs, extension and padding included.

Runt packets reached the parser. RTPPacket reads its header straight out of the buffer,
and its length assert is commented out, so a packet shorter than a header was an out-of-bounds
read with an underflowed payload size handed to both the video parser and the audio decoder.
Every RTP packet enters through onNewRTPData(), which now drops anything shorter than a header;
the payload size is clamped in RTPPacket as well.

The stream could be written to after it was closed. VideoActivity.onPause() calls
VideoPlayer.stop(), which closes the stream, before stopAudio() has stopped the decode
thread — so that thread could still write to it. stopAudio() also left the pointer set, so the
destructor closed the same stream a second time. The stream is behind its own mutex now and
cleared when closed.

The Opus decoder was freed with delete. It comes from opus_decoder_create(), which
allocates with malloc; it is released with opus_decoder_destroy() now. A start after a stop
also created a second decoder without freeing the first — and onResume() is such a start,
after every pause.

The receive thread waited for every decode. Packets were decoded with the queue lock held, so
enqueueAudio() — called on the receive thread, which carries the video too — waited out each
decode. The lock is released before decoding. That lock was also, by accident, the only thing
holding the receive thread back, so the queue is bounded now: at most 50 packets, about a second,
oldest dropped first. Whatever is still queued when audio stops is cleared, so a restart does not
begin with stale audio.

initAudio() checked none of the calls it made and reported success regardless; failures are
logged and reported now.

Testing

On a Quest 3 with the camera above: audio and video play on this branch, and the same process
went through several pause and resume cycles — each one closes the stream in onPause() and
opens a new one in onResume(), which is where the leaked decoder and the write to a closed
stream used to happen — with nothing in the crash buffer. That build also carried #126, merged
since; the two touch different files.

Clearing the queue on stop came later, from review, and has not been on the headset yet. It was
checked on a desktop build of AudioDecoder against stubbed AAudio and Opus: with a decode held
up and packets queued behind it, every waiting packet used to be decoded after a stop and start;
now none is.

The packet-handling fixes were found by reading the code and are bounds checks; I have not
driven the decoder with crafted packets.

The branch is on current master and clean under clang-format 18.1.8 (scripts/format_sources.sh).

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

PR Summary by Qodo

Harden RTP audio decoding and stream shutdown

🐞 Bug fix 🕐 20-40 Minutes

Grey Divider

AI Description

• Reject malformed RTP and oversized audio packets before bounded Opus decoding.
• Bound the audio queue and decode without blocking the shared receive thread.
• Synchronize stream closure with writes, fix decoder ownership, and report initialization failures.
Diagram

graph TD
  Rx["UDP and UDS"] --> Guard{"Valid RTP header?"} --> Route{"Audio packet?"} --> Queue["Bounded audio queue"] --> Decode["Opus decoder"] --> Output["AAudio stream"]
  Route --> Video["Video parser"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Centralize full RTP validation in RTPPacket
  • ➕ One parser could enforce header, extension, and padding bounds for both audio and video.
  • ➖ Broadens this fix into video parsing, which currently handles RTP payloads differently.

Recommendation: Keep the focused receive-entry guard and audio-specific parsing for this bug fix; they protect the affected paths without changing video payload handling. Consider shared full-RTP parsing separately if both consumers need the same extension and padding behavior.

Files changed (4) +188 / -53

Bug fix (4) +188 / -53
AudioDecoder.cppValidate audio packets and coordinate decoding with stream shutdown +145/-44

Validate audio packets and coordinate decoding with stream shutdown

• Drops empty or oversized packets, caps the queue, and releases its lock before decoding. Validates RTP audio header, extension, and padding lengths before decoding into a fixed PCM buffer. Reuses and correctly destroys the Opus decoder, checks stream initialization failures, and locks stream writes against closure.

app/videonative/src/main/cpp/AudioDecoder.cpp

AudioDecoder.hDefine bounded packet storage and synchronized audio state +31/-8

Define bounded packet storage and synchronized audio state

• Replaces the fixed 250-byte packet array with packet-sized storage and defines packet, queue, and PCM limits. Adds the stream mutex and helpers used to coordinate output lifecycle operations.

app/videonative/src/main/cpp/AudioDecoder.h

VideoPlayer.cppDrop runt RTP packets before routing +9/-0

Drop runt RTP packets before routing

• Rejects packets shorter than an RTP header before constructing RTPPacket or passing data to the audio and video paths.

app/videonative/src/main/cpp/VideoPlayer.cpp

RTP.hppPrevent RTP payload-size underflow +3/-1

Prevent RTP payload-size underflow

• Clamps the computed payload size to zero when the supplied length is shorter than the fixed header, supplementing the receive-entry check.

app/videonative/src/main/cpp/parser/RTP.hpp

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Old audio plays after playback resumes ✓ Resolved
Description
processAudioQueue() now releases the queue lock while decoding, allowing packets to accumulate,
but stopAudioProcessing() leaves those packets in m_audioQueue. If playback pauses while
decoding falls behind, the restarted worker consumes the retained packets before new audio,
potentially playing up to 50 old packets after resume.
Code

app/videonative/src/main/cpp/AudioDecoder.cpp[R68-69]

+        lock.unlock();
+        onNewAudioData(audioPkt.data.data(), audioPkt.data.size());
Evidence
The new unlock permits enqueueing while a decode is in progress; the bounded queue can hold 50
packets. Stopping joins the worker without clearing that queue, and the next start processes its
existing contents.

app/videonative/src/main/cpp/AudioDecoder.cpp[39-49]
app/videonative/src/main/cpp/AudioDecoder.cpp[55-69]
app/videonative/src/main/cpp/AudioDecoder.h[50-69]
app/videonative/src/main/cpp/VideoPlayer.cpp[460-468]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Audio queued during a decode can survive a pause and play after resume.
## Fix Focus Areas
- app/videonative/src/main/cpp/AudioDecoder.h[56-69]
- app/videonative/src/main/cpp/AudioDecoder.cpp[61-69]
## Recommended Fix
After stopping and joining the decode worker, clear the pending audio queue under its mutex before a new worker can start. Preserve the existing synchronization around enqueue and dequeue.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. A slow decoder can exhaust app memory ✓ Resolved
Description
processAudioQueue() now releases the queue lock before decoding, allowing enqueueAudio() to keep
adding fixed-size packets whenever decoding falls behind. During sustained incoming audio, the queue
has no size or age limit, so pending packets can consume memory without bound.
Code

app/videonative/src/main/cpp/AudioDecoder.cpp[R61-62]

+        lock.unlock();
+        onNewAudioData(audioPkt.data, audioPkt.len);
Evidence
Audio RTP callbacks enqueue packets on the receive path; each enqueue pushes a packet, while the
modified worker releases the lock before its decode. No capacity check limits the queue when
arrivals outpace consumption.

app/videonative/src/main/cpp/VideoPlayer.cpp[125-145]
app/videonative/src/main/cpp/AudioDecoder.cpp[31-43]
app/videonative/src/main/cpp/AudioDecoder.cpp[48-62]
app/videonative/src/main/cpp/AudioDecoder.h[78-83]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The receive thread can enqueue audio faster than the decoder consumes it, growing the queue without limit.
## Fix Focus Areas
- app/videonative/src/main/cpp/AudioDecoder.cpp[31-62]
- app/videonative/src/main/cpp/AudioDecoder.h[78-83]
## Recommended Fix
Set a small queue capacity or maximum packet age appropriate for live audio. When that limit is reached, discard stale packets instead of allowing memory and playback latency to grow.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Larger audio datagrams go silent ✓ Resolved
Description
enqueueAudio() returns without decoding when the complete RTP datagram exceeds the new 1500-byte
limit. The UDP and Unix-socket receivers both accept larger datagrams and pass their lengths
through, so audio from a sender using those packet sizes is silently discarded.
Code

app/videonative/src/main/cpp/AudioDecoder.cpp[R35-37]

+    if (data_length == 0 || data_length > AudioUDPPacket::kMaxLen)
+    {
+        return;
Evidence
The new size check drops packets above kMaxLen, but the RTP callback forwards complete packets
from receivers whose buffers exceed that value.

app/videonative/src/main/cpp/AudioDecoder.cpp[31-41]
app/videonative/src/main/cpp/AudioDecoder.h[18-30]
app/videonative/src/main/cpp/VideoPlayer.cpp[125-145]
app/videonative/src/main/cpp/UdpReceiver.h[75-79]
app/videonative/src/main/cpp/UdsReceiver.cpp[95-103]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The audio queue drops datagrams above 1500 bytes even though the receive paths accept and forward larger packets.
## Fix Focus Areas
- app/videonative/src/main/cpp/AudioDecoder.cpp[31-41]
- app/videonative/src/main/cpp/AudioDecoder.h[18-30]
## Recommended Fix
Use length-checked storage sized to the actual received packet rather than a fixed 1500-byte element, with an explicit supported maximum aligned to the receive path. Keep dropping packets beyond that documented maximum without truncating them.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can ask Qodo to dismiss a finding you disagree with, with your reason on record

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread app/videonative/src/main/cpp/AudioDecoder.cpp Outdated
Comment thread app/videonative/src/main/cpp/AudioDecoder.cpp
@josephnef josephnef closed this Oct 18, 2026
@josephnef josephnef reopened this Oct 18, 2026
Comment thread app/videonative/src/main/cpp/AudioDecoder.cpp
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 9011e99

@josephnef josephnef left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is a good set of fixes and I want it in. The six defects are real: the 250-byte fixed copy, the negative payload size feeding a VLA, runt RTP packets reaching the parser, the write-after-close on the AAudio stream, delete on a malloc'd Opus decoder, and decoding under the queue lock. The replacements are correct on my read: the RTP header walk checks header + 4 before reading the extension length, padding is validated against the payload, m_pcm is sized for the longest legal Opus packet, the queue is bounded with oldest-drop, and m_mtxStream covers both the decode-thread write and the close. The follow-ups to Qodo's two findings in 9011e99 are right as well.

One blocking item, mechanical: the clang-format job fails on this branch. app/videonative has its own .clang-format, and clang-format 18.1.8 wants 19 lines changed in AudioDecoder.cpp, 8 in AudioDecoder.h, 7 in VideoPlayer.cpp. pip install clang-format==18.1.8 && scripts/format_sources.sh and push; the rest of CI (host test, debug APK, emulator smoke) is already green on the reopened run.

Minor, take or leave: kMaxQueued = 50 at 20 ms frames is a second of audio; fine for live playback, just worth a comment that it is deliberate latency bounding, which you have.

Audio was checked end to end on a Quest 3 against an OpenIPC camera with audio switched on in
majestic (audio.enabled true, Opus): the camera's microphone, Opus in the RTP stream over wfb,
decoded here and played in the headset. It works. Reading the decoder on the way turned up six
defects, most of them reachable from radio input.

- The packet copy wrote into a fixed 250-byte array with no length check. That does not even
  take malformed input: 20 ms of PCM at 8 kHz is already 320 bytes, so majestic's pcm codec can
  write past the end with perfectly ordinary packets. The buffer holds 1500 bytes now and
  anything larger is dropped - dropped, not truncated, because a cut Opus packet decodes to noise
  rather than failing.

- The payload was read from a fixed offset of 12 with no check. A shorter packet gave a negative
  size; opus_packet_get_nb_frames() then returned an error that was not checked; and the product
  sized a variable-length array on the stack. Decoding goes into a fixed buffer for the longest
  packet Opus allows - 120 ms, 5760 samples at 48 kHz - and the header length is worked out from
  the packet, CSRCs, extension and padding included.

- RTPPacket reads its header straight out of the buffer and its length assert is commented out,
  so a runt packet was an out-of-bounds read with an underflowed payload size handed to both the
  video parser and the audio decoder. Every RTP packet enters through onNewRTPData(), which now
  drops anything shorter than a header; the payload size is clamped in RTPPacket as well.

- The stream could be written to after it was closed. VideoActivity.onPause() calls
  VideoPlayer.stop(), which closes the stream, before stopAudio() has stopped the decode thread,
  so that thread could still write to it. stopAudio() also left the pointer set, so the
  destructor closed the same stream a second time. The stream is behind its own mutex now and
  cleared when closed.

- The Opus decoder comes from opus_decoder_create(), which allocates with malloc, and was freed
  with delete. It is destroyed with opus_decoder_destroy() now. A start after stop also created
  a second decoder without freeing the first - and onResume() is such a start, after every pause.

- Packets were decoded with the queue lock held, so enqueueAudio() on the receive thread - which
  carries the video too - waited out every decode. The lock is released before decoding.

initAudio() checked none of the calls it made and reported success regardless; failures are
logged and reported now.
Two points from review, both fair.

Decoding now runs with the queue lock released, which was the point - the receive thread, which
carries the video too, no longer waits out every decode. But that lock was also, by accident,
the only thing holding the receive thread back, and the queue itself has never had a limit. If
the decoder ever fell behind, packets would pile up without bound. The queue holds at most 50
packets now, about a second of 20 ms frames, and when it is full the oldest goes: for live audio
a packet played late is only latency.

The 1500-byte limit was arbitrary. The receivers hand over up to 65507 bytes (UdpReceiver's
UDP_PACKET_MAX_SIZE; UdsReceiver stops at 3700), so a packet the link could legitimately deliver
was being dropped. Each packet is stored at its own size now, and only something longer than
either receiver can produce is refused.
What scripts/format_sources.sh produces with the version CI pins, and nothing else. The branch
was written before the format check existed; VideoPlayer.cpp needed nothing once it was rebased
onto the tree OpenIPC#131 formatted.
From review: with the decode running outside the queue lock, packets can be waiting when audio
stops, and the next start played them before anything new. Whatever the decode thread had not
reached when it was stopped is cleared once it has been joined. nativeStartAudio() stops before
it starts, so every start begins with an empty queue.

Checked on a desktop against stubbed AAudio and Opus, with a decoder held up while ten packets
queued behind it: before this change the nine waiting packets were all decoded after the
restart, ahead of the fresh one; now only the fresh one is.
@iflyhere

Copy link
Copy Markdown
Contributor Author

@josephnef Thanks for the careful read.

Rebased onto current master (a68a61f) and formatted with scripts/format_sources.sh under clang-format 18.1.8: 4004f1c is that and nothing else. After the rebase only AudioDecoder.cpp and AudioDecoder.h needed it; VideoPlayer.cpp was already clean against the tree #131 formatted. With the pinned version the script now leaves the branch unchanged, and the host GoogleTest passes.

Qodo's second finding was real: audio still queued when the decoder stopped was played after the next start. Fixed in e920502, details in that thread.

kMaxQueued is left as it is; the comment at the declaration says it bounds latency on purpose.

@josephnef josephnef left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. Checked the new head e920502 against the previously reviewed 9011e99 with whitespace ignored: the only non-format change is the queue clear in stopAudioProcessing(), which sits after the thread join so it cannot contend with the decode thread, and it correctly stops pre-stop audio from being the first thing played after a restart. Rebase is on current master. CI is green on this head: clang-format, host GoogleTest, debug APK and the emulator smoke test.

@josephnef
josephnef merged commit 3518c1b into OpenIPC:master Oct 22, 2026
4 checks passed
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