Skip to content

fix: udp:// slots read the socket on a thread of their own; no slot checks during a push (#251) - #252

Merged
compot-dev merged 5 commits into
v2from
fix/udp-push-drops-251
Oct 21, 2026
Merged

compot-dev merged 5 commits into
v2from
fix/udp-push-drops-251

Conversation

@compot-dev

Copy link
Copy Markdown
Collaborator

Fixes #251.

What changes

  • RtpListener (udp:// slots and the U cell): a thread of its own at MAX_PRIORITY only reads the socket into a ring of 1024 datagrams. Reordering, NAL assembly and the decoder stay on the session thread and no longer hold up receive(). When the socket opens, the log shows the buffer the kernel actually gave (getReceiveBufferSize()). An app can't go past rmem_max, so the 2 MiB request becomes ~512 KiB on the Aceline box.
  • U view: while the U cell receives a push (connected), the other slots get no stream checks. A running check is cancelled on the next debug-page update, and the page says Not checked while the U cell receives a push.
  • /stats.json, cells[]: new udp object for a listening slot with rcvbuf, received, ring_dropped, rtp_lost (sequence gaps of the video payload type) and socket_drops (from /proc/net/udp{,6} for the port, only when the app may read it; newer Android versions hide it from apps).
  • Docs: docs/debug.md (cells table, slots note), docs/transports.md.
  • Version 2.18.3 (versionCode 46), CHANGELOG.md.

Tests

Local build in compot/android:2026.10, as in CI: testDebugUnitTest lintDebug assembleDebug. 369 tests pass, and lint finds nothing new in the changed files. New tests:

  • udpStatsCountWhatArrivedAndWhatIsMissing: received and lost counters.
  • socketDropsAreReadForThePort: the /proc/net/udp parser.

Still to do: a check on the Aceline box (.145) with a CI build. We'll push H.265 1080p from Majestic outgoing to the U cell without pacing and compare the udp counters and nal_dropped with 2.18.2 (≈47 drops/s).

…hecks during a push (#251)

An app cannot get a socket buffer above rmem_max (256 KiB on the
Aceline box), about one 1080p key frame, so any stall of the receive
loop lost datagrams of the burst. A high-priority thread now only reads
the socket into a ring of 1024 datagrams; reordering, assembly and the
decoder run as before on the session thread. Stream checks of other
slots in the U view pause while the U cell receives a push.

/stats.json gets a udp object per cell: rcvbuf, received,
ring_dropped, rtp_lost and socket_drops where /proc/net/udp is readable.
@compot-dev compot-dev added the in progress Being worked on right now (open PR) label Oct 21, 2026
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Prevent UDP push drops with a dedicated receive thread

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 20-40 Minutes

Grey Divider

AI Description

• Move UDP socket reads to a dedicated thread so decoding cannot stall reception.
• Pause other-slot stream checks during U-cell pushes and expose UDP loss counters for diagnosis.
• Add counter tests and documentation; release as version 2.18.3.
Diagram

graph TD
  Socket["UDP Socket"] --> Reader["Receive Thread"] --> Ring["Packet Ring"] --> Session["Session Reorder"] --> Decoder["Video Decoder"]
  Session --> Push{"U Push Active"} -->|no| Probes["Slot Checks"]
  Session --> Stats["Stats JSON"]
  Reader --> Stats
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Pace sender key frames
  • ➕ Reduces bursts before they reach the socket without adding receiver concurrency.
  • ➖ Requires control of every sender and does not protect against receiver stalls.
2. Increase the kernel receive-buffer limit
  • ➕ Could absorb larger bursts without an application-level ring.
  • ➖ Requires device-level configuration and cannot be achieved by an app above rmem_max.

Recommendation: Keep the dedicated receive thread and bounded ring: they address decoder-induced stalls within the app's control. Sender pacing can complement this change, but neither pacing nor kernel tuning is a reliable app-wide substitute.

Files changed (9) +240 / -23

Enhancement (2) +17 / -0
QuadCell.javaExpose cell connection state and UDP statistics +4/-0

Expose cell connection state and UDP statistics

• Provides the cell-level accessors used to detect an active U-cell push and retrieve its listener statistics.

android/app/src/main/java/com/openipc/decoder/QuadCell.java

RtspSession.javaPublish listening-session UDP loss statistics +13/-0

Publish listening-session UDP loss statistics

• Exposes statistics for an active UDP listener and tracks the video payload type so RTP loss reflects the selected video stream.

android/app/src/main/java/com/openipc/decoder/RtspSession.java

Bug fix (2) +170 / -20
Decoder.javaExpose UDP stats and pause checks during U-cell pushes +17/-2

Expose UDP stats and pause checks during U-cell pushes

• Adds listening-cell UDP counters to debug state. Stops requesting other-slot stream checks while the U cell is connected and explains the pause on the debug page.

android/app/src/main/java/com/openipc/decoder/Decoder.java

RtpListener.javaDecouple UDP reads from RTP processing +153/-18

Decouple UDP reads from RTP processing

• Moves socket reads to a high-priority thread feeding a bounded 1024-datagram ring; reordering remains on the session thread. Tracks receive and ring-drop counts, reports the actual socket buffer size, and reads per-port kernel drops when available.

android/app/src/main/java/com/openipc/decoder/RtpListener.java

Tests (1) +39 / -0
RtpListenerTest.javaTest UDP counters and socket-drop parsing +39/-0

Test UDP counters and socket-drop parsing

• Verifies received-packet and RTP-gap counts from a UDP stream. Tests per-port drop parsing and unavailable-file behavior.

android/app/src/test/java/com/openipc/decoder/RtpListenerTest.java

Documentation (3) +12 / -1
CHANGELOG.mdDocument the 2.18.3 UDP push fix +9/-0

Document the 2.18.3 UDP push fix

• Records the dedicated receive thread, paused stream checks, and new UDP statistics in the release notes.

CHANGELOG.md

debug.mdDocument UDP debug fields and paused checks +2/-1

Document UDP debug fields and paused checks

• Describes the cell-level UDP statistics object and notes that other slots are not checked during a U-cell push.

docs/debug.md

transports.mdExplain UDP buffering and loss diagnosis +1/-0

Explain UDP buffering and loss diagnosis

• Documents the receive ring, socket-buffer constraint, UDP loss counters, and sender pacing as a way to reduce bursts.

docs/transports.md

Other (1) +2 / -2
build.gradleBump Android release to 2.18.3 +2/-2

Bump Android release to 2.18.3

• Sets versionCode to 46 and versionName to 2.18.3.

android/app/build.gradle

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

qodo-free-for-open-source-projects Bot commented Oct 21, 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


Action required

1. UDP sessions resist stopping under load ✓ Resolved
Description
RtpListener.run() drains filled until it is empty before checking keepGoing, readError, or
reorder deadlines again. If incoming packets keep the queue nonempty while decoding is slow, a slot
change cannot promptly stop the old listener and held packets can outlive their reorder deadline.
Code

android/app/src/main/java/com/openipc/decoder/RtpListener.java[R299-301]

+                    r.push(slot, 4, len, System.nanoTime(), sink);
+                    free.offer(slot);
+                    slot = filled.poll();
Evidence
The new inner loop only exits when filled.poll() returns null; the stop and error checks precede
it, and the reorder poll follows it. The UDP session supplies a condition that observes active and
generation and reports silence, so delaying that check affects session transitions too.

android/app/src/main/java/com/openipc/decoder/RtpListener.java[276-306]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[958-974]
android/app/src/main/java/com/openipc/decoder/RtpReorder.java[93-106]

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

## Issue description
A continuously refilled receive queue can prevent the session thread from checking whether it should stop, propagating a reader error, or polling reorder deadlines.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtpListener.java[276-306]
## Recommended Fix
Drain at most a bounded number of slots or for a bounded time per outer iteration, then recheck the session condition and reader error and poll the reorder deadlines.

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



Remediation recommended

2. Large packet gaps disappear from loss stats ✓ Resolved
Description
publishStats() exposes RtpReorder.skipped(), but the reorderer does not add missing packets to
that counter when a forward sequence jump reaches its 128-packet restart threshold. A burst that
loses at least 128 consecutive video packets can therefore produce little or no rtp_lost increase,
even though the new statistic is meant to identify loss during such bursts.
Code

android/app/src/main/java/com/openipc/decoder/RtpListener.java[R358-359]

+        RtpReorder r = pt >= 0 ? reorders[pt] : null;
+        rtpLost = r != null ? r.skipped() : 0;
Evidence
The new published value comes directly from the reorderer's skipped counter. Its large forward-jump
branch flushes held packets and restarts at the arriving sequence number; restart resets the
expected sequence without accounting for the intervening gap.

android/app/src/main/java/com/openipc/decoder/RtpListener.java[355-359]
android/app/src/main/java/com/openipc/decoder/RtpReorder.java[73-81]
android/app/src/main/java/com/openipc/decoder/RtpReorder.java[117-121]

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 new `rtp_lost` statistic misses large forward RTP sequence gaps because the reorderer restarts without adding the skipped sequence numbers.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtpListener.java[355-359]
- android/app/src/main/java/com/openipc/decoder/RtpReorder.java[73-81]
## Recommended Fix
Account for missing sequence numbers on the large forward-jump path while preserving the existing restart behavior. Add a statistic test with a gap of at least 128 packets.

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


3. Codec changes erase earlier loss totals ✓ Resolved
Description
setVideoPt() switches publishStats() to the reorder counter for the new payload type rather than
retaining losses from the previous video type. When a listening camera changes codec after packets
have been lost, the same session's rtp_lost value falls and no longer includes those earlier
losses.
Code

android/app/src/main/java/com/openipc/decoder/RtpListener.java[R357-359]

+        int pt = videoPt;
+        RtpReorder r = pt >= 0 ? reorders[pt] : null;
+        rtpLost = r != null ? r.skipped() : 0;
Evidence
A video payload-type change calls the new setter in an existing listening session. The published
statistic then reads only the reorderer indexed by the current type, whose skipped count is
independent of the previous type; the transport documentation confirms that codec changes are
followed while playing.

android/app/src/main/java/com/openipc/decoder/RtspSession.java[973-984]
android/app/src/main/java/com/openipc/decoder/RtpListener.java[352-359]
android/app/src/main/java/com/openipc/decoder/RtpReorder.java[44-53]
docs/transports.md[77-80]

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 cumulative `rtp_lost` value resets to the new payload type's reorder counter when an established video stream changes codec.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtpListener.java[352-359]
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[978-984]
## Recommended Fix
Accumulate losses from payload types identified as video for this listener, including previous video types, without including unrelated payload types. Test a 96-to-97 change after a counted loss.

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


4. Other-slot checks still run when a push starts ✓ Resolved
Description
updateProbes() consults pushReceived() only during debug requests, while StreamProbes.tick()
relies on cached wanted state and does not check whether the U cell is connected. If a push
connects between polls or after polling stops, checks of other slots, including RTSP sessions, can
start or remain active until another update or the 60-second idle timeout.
Code

android/app/src/main/java/com/openipc/decoder/Decoder.java[R1729-1730]

 streamProbes.update(urls, mTransports, udpViewShown ? null : mHost,
-                mResumed && (udpViewShown || !quadEnabled));
+                mResumed && (udpViewShown ? !pushReceived() : !quadEnabled));
Evidence
Both callers of updateProbes() are debug snapshot paths, so its pushReceived() guard runs only
when a debug request arrives. QuadCell.onStatus() changes the U cell’s connection state without
updating probes, and neither StreamProbes.tick() nor skipReason() checks that state; the
scheduler therefore continues using cached wanted until a later update or the idle timeout.

Suspend other-slot stream checks during U-cell reception
android/app/src/main/java/com/openipc/decoder/Decoder.java[1723-1742]
android/app/src/main/java/com/openipc/decoder/StreamProbes.java[243-262]
android/app/src/main/java/com/openipc/decoder/Decoder.java[1472-1483]
android/app/src/main/java/com/openipc/decoder/Decoder.java[1711-1741]
android/app/src/main/java/com/openipc/decoder/QuadCell.java[284-296]
android/app/src/main/java/com/openipc/decoder/StreamProbes.java[29-40]
android/app/src/main/java/com/openipc/decoder/StreamProbes.java[192-214]
android/app/src/main/java/com/openipc/decoder/StreamProbes.java[258-277]

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

## Issue description
Stream checks of other slots are suppressed only when a debug request updates the probes. A push can connect between requests while previously authorized checks continue or new checks start.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/Decoder.java[1723-1742]
- android/app/src/main/java/com/openipc/decoder/StreamProbes.java[243-262]
- android/app/src/main/java/com/openipc/decoder/QuadCell.java[284-291]
## Recommended Fix
Make push suppression independent of debug-page polling. Notify the owner of stream probes when the U cell enters `connected` and immediately cancel or disable running checks; ensure `StreamProbes.tick()` does not start new checks while the push is active, for example by consulting a push-active supplier. Retain debug-request updates for normal scheduling.

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


View review recommended (3)
5. Loss counts can lag behind the stream ✓ Resolved
Description
RtpListener.stats() reads the plain reorders array and each reorder object's non-volatile
skipped field from the UI thread while the session thread writes them. During a push,
/stats.json can therefore report a stale or torn rtp_lost value even when its volatile receive
and ring-drop counters have advanced.
Code

android/app/src/main/java/com/openipc/decoder/RtpListener.java[R333-335]

+        for (int pt = 0; pt < reorders.length; pt++) {
+            RtpReorder r = reorders[pt];
+            if (r != null && (videoPt < 0 || pt == videoPt)) lost += r.skipped();
Evidence
The session thread installs and advances reorder instances, whose skipped field is a plain long.
The new stats path reads those same instances from the debug snapshot, which is dispatched to the
main thread.

android/app/src/main/java/com/openipc/decoder/RtpListener.java[293-305]
android/app/src/main/java/com/openipc/decoder/RtpListener.java[327-337]
android/app/src/main/java/com/openipc/decoder/RtpReorder.java[33-54]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[737-744]
android/app/src/main/java/com/openipc/decoder/Decoder.java[1539-1553]

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 new stats reader accesses reorder state without synchronization with the thread updating that state.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtpListener.java[293-306]
- android/app/src/main/java/com/openipc/decoder/RtpListener.java[327-337]
## Recommended Fix
Publish per-payload-type loss totals from the session thread through thread-safe counters or an immutable snapshot, and have stats read only those published values.

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


6. Loss totals can fall when video starts ✓ Resolved
Description
RtpListener.stats() sums gaps from every payload type while listenVideoPt is -1, then switches
to video-only gaps once a video packet is identified. If non-video RTP arrives with sequence gaps
before video, consecutive stats requests can show rtp_lost decreasing and initially attribute
those gaps to video.
Code

android/app/src/main/java/com/openipc/decoder/RtpListener.java[R333-335]

+        for (int pt = 0; pt < reorders.length; pt++) {
+            RtpReorder r = reorders[pt];
+            if (r != null && (videoPt < 0 || pt == videoPt)) lost += r.skipped();
Evidence
The listener creates reorder state for any RTP payload type, but the session ignores non-video
packets. The new metric aggregates all reorder instances while the video type is unknown, and the
session changes that selector when it recognizes video.

android/app/src/main/java/com/openipc/decoder/RtpListener.java[293-300]
android/app/src/main/java/com/openipc/decoder/RtpListener.java[327-337]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[947-951]
android/app/src/main/java/com/openipc/decoder/RtspSession.java[975-990]

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

## Issue description
Before the video payload type is known, the video loss metric includes gaps from unrelated RTP payload types.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtpListener.java[327-337]
- android/app/src/main/java/com/openipc/decoder/RtspSession.java[975-987]
## Recommended Fix
Report zero or omit video loss until the video payload type is known, then read only that type's counter. Where the URL forces a payload type, use it from the outset.

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


7. Every stats poll reads /proc files on the UI thread ✓ Resolved
Description
debugState() calls cell.udpListenStats() for each listening cell, reaching RtpListener.stats()
and socketDrops(port), which synchronously open and scan /proc/net/udp and /proc/net/udp6 on
the main looper. When monitoring tools poll /stats.json during a push, this adds blocking file I/O
per cell per request, delaying other UI work and potentially causing the one-second snapshot wait to
return ui_thread: not responding.
Code

android/app/src/main/java/com/openipc/decoder/Decoder.java[R1700-1702]

+                    // A udp:// push: a full socket buffer or ring, or loss on the way (#251)
+                    Map<String, Object> udp = cell.udpListenStats();
+                    if (udp != null) c.put("udp", udp);
Evidence
debugSnapshot() posts debugState() to the main looper and waits at most one second for its
result. The added cell-stats call reaches RtpListener.stats() and socketDrops(port), which use
FileReader to scan both proc files synchronously for each listening cell.

android/app/src/main/java/com/openipc/decoder/Decoder.java[1540-1550]
android/app/src/main/java/com/openipc/decoder/RtpListener.java[327-374]
android/app/src/main/java/com/openipc/decoder/Decoder.java[1539-1563]
android/app/src/main/java/com/openipc/decoder/Decoder.java[1697-1703]

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

## Issue description
Each `/stats.json` debug snapshot can read `/proc/net/udp` and `/proc/net/udp6` synchronously on the UI thread for every listening cell, delaying UI work and risking a snapshot timeout.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtpListener.java[327-374]
- android/app/src/main/java/com/openipc/decoder/Decoder.java[1697-1703]
## Recommended Fix
Collect socket-drop counts away from the UI thread and publish a cached value for snapshots. For example, refresh a volatile cached count from a session thread at most once a second, without adding file reads to the dedicated receive loop; alternatively, read `/proc` once per snapshot on the server thread before posting to the UI thread. Make `stats()` return the cached value without reading the proc files.

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



Informational

8. Multicast slots on one port share drop counts ✓ Resolved
Description
RtpListener.socketDrops matches /proc/net/udp{,6} entries by port alone and sums their drops
values for an individual listener. When multicast slots bind to different groups on the same port,
each slot's socket_drops includes drops from the other slots, obscuring which socket is
overflowing.
Code

android/app/src/main/java/com/openipc/decoder/RtpListener.java[R396-399]

+                // sl local_address rem_address st ... drops (the last column)
+                if (f.length < 13 || !f[1].endsWith(hexPort)) continue;
+                try {
+                    total = Math.max(0, total) + Long.parseLong(f[f.length - 1]);
Evidence
Multicast listeners enable address reuse and bind to their group and port, allowing multiple
listeners to share a port. The /proc parser checks only whether local_address ends with the port
and sums every matching row; the stats publishing path assigns that sum to an individual listener's
socket_drops.

android/app/src/main/java/com/openipc/decoder/RtpListener.java[199-207]
android/app/src/main/java/com/openipc/decoder/RtpListener.java[388-406]
android/app/src/main/java/com/openipc/decoder/RtpListener.java[200-205]
android/app/src/main/java/com/openipc/decoder/RtpListener.java[366-371]
android/app/src/main/java/com/openipc/decoder/RtpListener.java[379-405]

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

## Issue description
`RtpListener.socketDrops` attributes the combined drops of all UDP sockets on a port to each listener using that port. Multicast slots bound to different groups can therefore report one another's drops.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtpListener.java[379-406]
- android/app/src/main/java/com/openipc/decoder/RtpListener.java[366-372]
- android/app/src/main/java/com/openipc/decoder/RtpListener.java[200-205]
## Recommended Fix
Pass the socket's bound local address to the parser and match the full `/proc` `local_address` field, including the hex IP in `/proc` byte order and the port, rather than matching only the port suffix. Retain port-only matching for wildcard-bound unicast sockets. If the listener's socket cannot be identified reliably and multiple entries match, omit the per-cell `socket_drops` value or explicitly mark it as covering the whole port instead of attributing the combined drops to that listener.

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


9. Pushes leave probe state marked waiting ✓ Resolved
Description
probeText() now says a slot is not checked during a U-cell push, but probeSlots() still assigns
that slot's probe.state to waiting when it has no previous result. During a push, consumers of
/stats.json receive contradictory text and state for the same slot, despite probing being held.
Code

android/app/src/main/java/com/openipc/decoder/Decoder.java[R1775-1776]

+        return pushReceived() ? "Not checked while the U cell receives a push"
+                : "Waiting for a stream check";
Evidence
The added push branch changes the slot text, while the state expression in the same slot-building
loop has no push branch and reaches waiting for an otherwise eligible slot with no result.

android/app/src/main/java/com/openipc/decoder/Decoder.java[1769-1776]
android/app/src/main/java/com/openipc/decoder/Decoder.java[1839-1852]

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

## Issue description
During a U-cell push, a slot without a prior result reports that checks are paused in its text but remains `waiting` in its JSON probe state.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/Decoder.java[1771-1776]
- android/app/src/main/java/com/openipc/decoder/Decoder.java[1839-1847]
## Recommended Fix
Use the active-push condition when assigning the JSON probe state so an unprobed slot is `not checked` while checks are held. Keep the text and state consistent.

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


10. A cell's ring can keep tens of MB after large datagrams ✓ Resolved
Description
In readLoop, a slot shorter than the datagram is replaced with new byte[len + 4], and that
larger array goes back to free and stays for the life of the listener. Slots never shrink, so a
sender of large UDP datagrams (for example IP-fragmented ones up to 64 KiB) can grow the 1024 slots
to about 64 MiB per listening cell, on top of the 1.6 MiB each listener always allocates up front.
Code

android/app/src/main/java/com/openipc/decoder/RtpListener.java[254]

+                if (slot.length < len + 4) slot = new byte[len + 4];
Evidence
The receive buffer is 65536 bytes, so len can be as large as 65535. A grown slot goes back into
free through free.offer(slot) in run(), and nothing shrinks it.

android/app/src/main/java/com/openipc/decoder/RtpListener.java[226-260]
android/app/src/main/java/com/openipc/decoder/RtpListener.java[293-301]

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

## Issue description
Ring slots that grow for a large datagram are reused forever. Up to 1024 slots of 64 KiB can stay allocated per listener.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/RtpListener.java[254-254]
- android/app/src/main/java/com/openipc/decoder/RtpListener.java[299-300]
## Recommended Fix
In run(), after `r.push(...)`, put a slot longer than `SLOT + 4` back into `free` as a fresh `new byte[SLOT + 4]` instead of the oversized array. That keeps the ring's memory bounded.

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


View optional (1)
11. A doc comment now sits on the wrong method ✓ Resolved
Description
The new isConnected() and its javadoc were inserted between the existing javadoc of
showsVideo(long ms) and that method. The showsVideo description (the #98 / KEY_FRAMES_LIVE_MS
rule) is now orphaned and sits right above isConnected, and showsVideo loses its documentation.
Code

android/app/src/main/java/com/openipc/decoder/QuadCell.java[R524-525]

+    /** A stream is coming in: "connected", paused or not. */
+    boolean isConnected() { return "connected".equals(state); }
Evidence
Lines 520-523 hold the showsVideo javadoc. Lines 524-525 insert another javadoc and method before
boolean showsVideo(long ms) at line 527.

android/app/src/main/java/com/openipc/decoder/QuadCell.java[520-530]

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

## Issue description
`isConnected()` was inserted between the javadoc of `showsVideo` and the method, so that comment no longer belongs to `showsVideo`.
## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/QuadCell.java[520-527]
## Recommended Fix
Move the `isConnected()` javadoc and method above the `/** Connected, not paused ... */` comment, so that comment sits directly before `boolean showsVideo(long ms)` again.

ⓘ 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 start a comment with 'qodo' or '@qodo' to chat about any finding

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread android/app/src/main/java/com/openipc/decoder/Decoder.java Outdated
Comment thread android/app/src/main/java/com/openipc/decoder/RtpListener.java Outdated
Comment thread android/app/src/main/java/com/openipc/decoder/RtpListener.java Outdated
Comment thread android/app/src/main/java/com/openipc/decoder/RtpListener.java Outdated
Comment thread android/app/src/main/java/com/openipc/decoder/Decoder.java
Comment thread android/app/src/main/java/com/openipc/decoder/RtpListener.java
Comment thread android/app/src/main/java/com/openipc/decoder/QuadCell.java Outdated
…e hold, bounded ring (#251)

Qodo on #252: the session thread handles at most 64 datagrams before it
checks the stop condition and the reorder deadlines; rtp_lost and
socket_drops are published by the session thread (/proc read at most
once a second, never on the UI thread) and count only the known video
payload type; a slot grown for a long datagram is not kept in the ring;
stream checks are held from the one-second cell tick, not only on debug
requests; isConnected() no longer splits showsVideo() from its javadoc.
@compot-dev

Copy link
Copy Markdown
Collaborator Author

Qodo review addressed in ebf5785. Checked with a local build in compot/android:2026.10 (testDebugUnitTest lintDebug assembleDebug): 369 tests pass, no new lint findings.

  1. Stopping under load: the session thread now handles at most 64 datagrams (BATCH), then re-checks the stop condition, the reader error and the reorder deadlines.
  2. Checks still running when a push starts: StreamProbes.hold() is now called from the one-second cell tick as well as on every debug update. Holding cancels running probes, and tick() starts nothing while held.
  3. and 4. rtp_lost stale or falling: the session thread publishes rtpLost as a volatile value after each batch. It counts only the video payload type: the forced pt=, or the type set by the session when it recognises video. Until then it is 0.
  4. /proc read on the UI thread: the session thread now reads socket_drops at most once a second, and stops trying after the first failure. stats() only returns the cached values.
  5. Ring growth: a slot grown for a long datagram goes back to the ring as a fresh 1600-byte slot, so the ring stays bounded.
  6. Javadoc on the wrong method: isConnected() now sits above the showsVideo() javadoc.

@compot-dev

Copy link
Copy Markdown
Collaborator Author

/review

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

Copy link
Copy Markdown

Qodo review addressed in ebf5785. Checked with a local build in compot/android:2026.10 (testDebugUnitTest lintDebug assembleDebug): 369 tests pass, no new lint findings.

  1. Stopping under load: the session thread now handles at most 64 datagrams (BATCH), then re-checks the stop condition, the reader error and the reorder deadlines.
  2. Checks still running when a push starts: StreamProbes.hold() is now called from the one-second cell tick as well as on every debug update. Holding cancels running probes, and tick() starts nothing while held.
  3. and 4. rtp_lost stale or falling: the session thread publishes rtpLost as a volatile value after each batch. It counts only the video payload type: the forced pt=, or the type set by the session when it recognises video. Until then it is 0.
  4. /proc read on the UI thread: the session thread now reads socket_drops at most once a second, and stops trying after the first failure. stats() only returns the cached values.
  5. Ring growth: a slot grown for a long datagram goes back to the ring as a fresh 1600-byte slot, so the ring stays bounded.
  6. Javadoc on the wrong method: isConnected() now sits above the showsVideo() javadoc.

Thanks for the update. Based on the details provided, commit ebf5785 addresses findings 1–7, including bounded draining, push probe suppression, published loss counters, cached socket-drop stats, bounded ring memory, and the Javadoc placement. The reported local build and test results also look clean.

Comment thread android/app/src/main/java/com/openipc/decoder/RtpListener.java Outdated
Comment thread android/app/src/main/java/com/openipc/decoder/RtpListener.java Outdated
Comment on lines +1775 to +1776
return pushReceived() ? "Not checked while the U cell receives a push"
: "Waiting for a stream check";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Informational

8. Pushes leave probe state marked waiting 🐞 Bug ≡ Correctness

probeText() now says a slot is not checked during a U-cell push, but probeSlots() still assigns
that slot's probe.state to waiting when it has no previous result. During a push, consumers of
/stats.json receive contradictory text and state for the same slot, despite probing being held.
Agent Prompt
## Issue description
During a U-cell push, a slot without a prior result reports that checks are paused in its text but remains `waiting` in its JSON probe state.

## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/Decoder.java[1771-1776]
- android/app/src/main/java/com/openipc/decoder/Decoder.java[1839-1847]

## Recommended Fix
Use the active-push condition when assigning the JSON probe state so an unprobed slot is `not checked` while checks are held. Keep the text and state consistent.

ⓘ 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 ebf5785

…251)

Qodo on #252: a forward jump of 128 or more packets now adds the missing
ones to RtpReorder.skipped (not a jump of 1000 or more: a restarted
stream), and rtp_lost sums every payload type that was video in the
listener, so a codec change does not lower it.
@compot-dev

Copy link
Copy Markdown
Collaborator Author

New Qodo findings addressed in 8882ce6. Checked with a local build in compot/android:2026.10 (testDebugUnitTest lintDebug assembleDebug): 371 tests pass.

  1. Large gaps missing from loss stats: on a forward jump of 128 packets or more, RtpReorder now adds the missing packets to skipped after flushing what it holds. A jump of 1000 or more is still treated as a restarted stream and not counted. New test: RtpReorderTest.aLongJumpCountsAsLostUnlessTheStreamRestarted.
  2. Codec change erasing earlier losses: rtp_lost now sums every payload type that has been video in this listener, so earlier losses are kept after a 96 → 97 switch. New test: RtpListenerTest.lossBeforeACodecChangeIsKept.

@compot-dev

Copy link
Copy Markdown
Collaborator Author

/review

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

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 8882ce6

… not checked (#251)

Qodo on #252: /proc/net/udp{,6} rows are matched by the bound address
and port (the group of a multicast slot, the wildcard otherwise), so
another group on the same port is not counted; a slot not checked
because the U cell receives a push has probe state "not checked",
as its text says.
@compot-dev

Copy link
Copy Markdown
Collaborator Author

Findings 8 and 9 addressed in 926ecd9. Checked with a local build in compot/android:2026.10 (testDebugUnitTest lintDebug assembleDebug): 371 tests pass, no new lint findings.

  1. Multicast slots on one port sharing drop counts: /proc/net/udp{,6} rows are now matched by the full local address, not just the port. That is the bound group for a multicast slot (or the bound local address) and the wildcard otherwise, in the /proc word order; an IPv4 address in udp6 is matched in its IPv4-mapped form. Covered in socketDropsAreReadForThePort.
  2. Probe state left at waiting during a push: while the U cell receives a push, the JSON probe.state is now not checked, matching the text.

@compot-dev

Copy link
Copy Markdown
Collaborator Author

/review

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

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 926ecd9

@compot-dev

Copy link
Copy Markdown
Collaborator Author

Test on hardware

Test box: Aceline TV box (Android 10, rmem_max 256 KiB, so rcvbuf is 524288). Source: a lab camera with H.265 1080p30 at 8 Mbit/s, sent either by Majestic outgoing or by pusher 0.18.1 reading its RTSP stream. pusher ran on a dev host behind a VPN, and the tunnel re-bunches packets, so sender pacing barely survives to the box. Losses are the drops counter of the :5600 socket in /proc/net/udp{,6}. Each run lasted 5 minutes, with /stats.json polled every 1.5 s. Builds: 2.18.2 (release) and 2.18.3 (CI build of 926ecd9), two runs each.

3×3 grid (eight decoders, the box overloaded at load average ~26 on 4 cores), drops per second:

Sender 2.18.2, run 1 / 2 2.18.3, run 1 / 2
Majestic outgoing 158 / 33.9 38 / 32.5
pusher pace=0 56.5 / 205 44 / 55
pusher pace=32000 25.5 / 218 28 / 80

2.18.3 is much steadier: its worst run is 80/s, against 218/s for 2.18.2, while the best runs are about the same. The drops don't go away under this load, though:

  • About 0.5% of packets are dropped in the ring (ring_dropped).
  • The rest are lost in the kernel buffer, which holds only about a hundred datagrams.

U view:

  • 2.18.2 with Majestic: 0 drops in 5 minutes, even though stream checks of the other slots were running (103 Probe- log lines).
  • 2.18.3: no checks start while the push is received (0 lines), and the slots show not checked.
  • pusher on 2.18.3, both pace=0 and the default: 0 socket drops.
  • rtp_lost equals socket_drops, so nothing is lost on the network.

The remaining kernel-buffer drops under heavy load are tracked separately.

@compot-dev
compot-dev merged commit a4c8cc9 into v2 Oct 21, 2026
7 checks passed
@compot-dev
compot-dev deleted the fix/udp-push-drops-251 branch October 21, 2026 18:24
@compot-dev compot-dev removed the in progress Being worked on right now (open PR) label Oct 21, 2026
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.

UDP push to the U cell loses packets: the socket buffer overflows on key-frame bursts

1 participant