Skip to content

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

Open
iflyhere wants to merge 2 commits into
OpenIPC:masterfrom
iflyhere:fix/audio-decoder
Open

iflyhere wants to merge 2 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.

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;
the two touch different files.

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

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.
@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 (1) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Old audio plays after playback resumes 🐞 Bug ≡ Correctness ⭐ New
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

Context sources
Review mode: Auto: ⚖️ Balanced: Downgraded extended -> standard: change is below the extended eligibility bar (hunks 8/18, lines 241/200; both must reach the floor). Router rationale: Native packet parsing, threading, lifecycle, and decoder logic create dense independent risks.

Grey Divider

Tip of the day
💡 Did you know, you can move or hide resolved findings to focus on what still needs action

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Previous reviews

Review updated until commit 9011e99 ⚖️ Balanced

Results up to commit N/A


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


Remediation recommended
1. 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


2. 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

Qodo Logo

Comment thread app/videonative/src/main/cpp/AudioDecoder.cpp Outdated
Comment thread app/videonative/src/main/cpp/AudioDecoder.cpp
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.
@josephnef josephnef closed this Oct 18, 2026
@josephnef josephnef reopened this Oct 18, 2026
Comment on lines +68 to +69
lock.unlock();
onNewAudioData(audioPkt.data.data(), audioPkt.data.size());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

1. Old audio plays after playback resumes 🐞 Bug ≡ Correctness

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.
Agent Prompt
## 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

@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.

This branch has not been deployed

No deployments
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