[core] RCV: recover undecryptable (AEAD) packets by re-requesting them (completes #2649) - #3348
Open
danryu wants to merge 2 commits into
Open
[core] RCV: recover undecryptable (AEAD) packets by re-requesting them (completes #2649)#3348danryu wants to merge 2 commits into
danryu wants to merge 2 commits into
Conversation
On a decryption failure the packet is dropped from the receiver buffer, which marks the buffer slot as dropped. That prevents a valid packet with the same sequence number, should it arrive later, from being stored. Erase it instead: the slot is left empty and therefore fillable. TODO: Erasing the packet means it has to be added back to the loss list. Originally submitted as Haivision#2649; rebased here onto master and adapted to the current CRcvBuffer API (dropUpTo signature, EntryState entries) and to the statistics/frequent-log block introduced by Haivision#2654.
Completes the TODO left by the previous commit. With AEAD (AES-GCM) the receiver can detect a payload corrupted in transit - the auth tag fails - but the packet was then only erased, never re-requested, so it stayed a permanent gap in the delivered stream. For applications that need bit-exact delivery over paths that produce corruption surviving the UDP checksum, AEAD is currently detect-but-not-recover: a silent corruption becomes a dropout. Treat an undecryptable packet exactly like a lost one. The payload is erased (slot kept fillable), the sequence stays in or goes back into the receiver loss list, a NAK is sent, and the existing ARQ machinery retransmits the good copy into the held-open slot. No sender-side change and no new machinery. Four receiver-side parts, three of which were only found under adversarial testing and are the reason completing the TODO literally is not enough: - Undo insert()'s countBytes() accounting in CRcvBuffer::erase(). Without this the retransmission landing in the freed slot double-counts buffer bytes and packets. - Push the erased sequence into the loss output, AFTER the loss-detection block. CRcvLossList::insert() rejects entries below its largest-ever sequence, so a preceding sequence-jump range must be recorded first. - Do not unlose() an undecrypted packet. The arrival of a corrupt retransmission removed the sequence from the loss list and the re-add was then rejected by the monotonic guard, leaving it in no loss list at all. Since the ACK position is derived from that list, the next full ACK advanced past the erased hole and every later copy - corrupt or clean - was discarded as belated, making the sequence unrecoverable. Measured before the fix: ~5-6% of corrupt-retransmit rounds died this way. - Run sequence-jump loss detection for undecrypted packets too. The check was gated on adding_successful (false on decrypt failure) while m_iRcvCurrSeqNo advanced regardless, so packets genuinely lost immediately before a corrupt arrival were never recorded or NAKed and became permanent gaps. This defect is reachable in the existing GCM path on its own, independently of recovery. Every new branch is conditional on an AEAD decryption failure; on clean and on unencrypted streams the code is inert.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
I hit this while testing SRT for sample-accurate audio delivery. On ordinary
consumer network paths, occasional audible dropouts persisted even with AES-GCM
enabled. They turned out not to be packet loss: the packets were arriving and
failing their auth tag — genuine payload corruption that survived the UDP
checksum, which AEAD correctly detected and then discarded without ever
re-requesting. Application-layer CRC32 stamping confirmed the corruption
independently. On the affected path this ran at a steady 2–5 events per minute,
each one a permanent gap in otherwise lossless audio.
With AEAD (AES-GCM) the receiver can detect a payload that was corrupted in
transit — the auth tag fails. Today the packet is then dropped and never
re-requested, so it becomes a permanent gap in the delivered stream. For
applications that need bit-exact delivery over paths that can produce corruption
surviving the UDP checksum (checksum-recomputing middleboxes / CGNAT), AEAD is
currently detect-but-not-recover: a silent corruption becomes a dropout.
This PR makes the receiver treat an undecryptable packet exactly like a lost
packet: the payload is erased (slot kept fillable), the sequence stays in or
goes back into the receiver loss list, a NAK is sent, and SRT's existing ARQ
retransmits the good copy into the held-open slot. Bit-exact delivery is
restored with no sender-side change and no new machinery.
I should say up front that I'm a user of the library rather than a regular
contributor here, and that this work was done with LLM assistance (guided steps
with Fable 5). The reasoning, the adversarial testing and the evidence below are
all real and reproducible, but please review it as work from an outsider to this
codebase.
Relationship to #2649 and #2626
was closed by [core] Drop undecrypted packet based on sequence number. #2654, which switched to dropping by sequence number. Dropping
marks the buffer slot as dropped, which is what makes the corrupted sequence
unrecoverable — a later valid copy can no longer be stored.
CRcvBuffer::erase(int32_t seqno),which drops the corrupt payload while leaving the slot fillable, and left the
TODO:
This PR is #2649 rebased onto current master, plus the changes needed to make
recovery actually work. The first commit is @maxsharabayko's original work with
authorship preserved, adapted to the current
CRcvBufferAPI (thedropUpTosignature,
EntryStateentries) and to the statistics/frequent-log block thatarrived with #2654. The second commit is mine.
I implemented the naive completion of that TODO first, and broke it under
adversarial testing. The three additional failure modes are the interesting part
and are the reason the one-liner isn't enough.
The change (4 parts, receiver-side only)
insert()'scountBytes()accounting inerase(). Callers thatrelease a unit account for it themselves (
readMessage,dropMessage);releaseUnitInPos()does not. Without this, the retransmission landing in thefreed slot double-counts buffer bytes and packets.
(seq, seq)intow_srt_loss_seqs— after the loss-detection block (see 4).CRcvLossList::insert()rejects entries below its largest-ever sequence, so apreceding jump range has to be recorded first; order matters here.
unlose()an undecrypted packet. The arrival of a corruptretransmission removed the sequence from the receiver loss list, and the
re-add was then rejected by the
m_iLargestSeqmonotonic guard — leaving thesequence in no loss list at all. Since the ACK position
(
getFirstNoncontSequence) is derived from that list, the next full ACKadvances past the erased hole, after which every further copy — corrupt or
clean — is discarded as belated (
seq %< m_iRcvLastAck) and the sequence isunrecoverable. Measured before the fix: ~5–6% chain death per corrupt
retransmit round (≈ round latency / ACK period); clean retransmissions were
log-captured being belated-discarded. An erased packet is still missing — keep
it in the list.
check is gated on
adding_successful(false on decrypt failure) whilem_iRcvCurrSeqNoadvances regardless — so packets genuinely lost immediatelybefore a corrupt arrival were never recorded or NAKed, and became permanent
gaps. Measured before the fix at loss+corruption: gaps ≈ loss rate ×
corruption rate, exactly as predicted (17 predicted ≈ 17 observed at 10% + 2%).
This defect is reachable in the existing GCM path on its own, independently
of anything else in this PR: a sequence jump that arrives together with an
undecryptable packet is silently never reported. It may be worth addressing
even if the rest is rejected.
Why it should be low-risk
and unencrypted streams the code is inert — verified byte-identical behaviour
versus stock: plaintext ± corruption, loss-only, and a 1 h clean soak with
pktRcvRetrans=0.no longer increments
pktRcvDrop, because the packet is no longer dropped.pktRcvUndecryptstill counts it.Testing
Unit tests.
test-srtis green on this branch: 288/288 pass (macOSclang, C++17). I also built
USE_CXX_STD=11andENABLE_ENCRYPTION=OFFconfigurations clean, and
codespellwith your config passes on the changedfiles.
I have not added a new unit test, and I'd like guidance on where you'd want
one. The behaviour needs a packet whose ciphertext is corrupted on the wire so
that the auth tag genuinely fails, plus a full ARQ round trip to observe the
recovery — my harness does that with a userspace UDP relay between caller and
listener, which doesn't obviously fit the existing
test-srtscaffolding. Ifyou'd point me at the right seam (an
srt-test-*case, or something at theCRcvBufferlevel asserting that an erased slot is refillable and the sequenceis re-requested) I'll write it.
Lab evidence (deterministic). Isolated caller → listener, live mode, AES-GCM,
SRTO_LATENCY=500, paced 2 ms. A userspace UDP relay flips one byte inside theciphertext on the wire (data packets only) so the auth tag genuinely fails.
The application payload is sequence + CRC32 stamped, so delivery is verified
bit-exact end to end.
pktRcvRetrans=0(inert)badCrc— corrupt bytes delivered to the application — was 0 in every run,patched or not. AEAD never delivers corrupt data; this change closes the recovery
half of it.
Field evidence (real WAN). A production-shaped path: Windows sender → cloud
relay → macOS receiver, where the sender's uplink demonstrably produces
checksum-surviving corruption (independently verified by application-layer CRC32
instrumentation). With this patch deployed on the relay and receivers, real
corruption events logged at relay ingress at ~2–4/min were all recovered:
pktRcvDrop=0, zero application-level sequence gaps, zero corrupt bytesdelivered, across the acceptance runs. An unpatched same-day control turned every
detection into a dropout. A later post-deploy check recovered 9 events in a single
2-minute session, and 15-run strict batches showed zero delivered corruption.
Open questions for maintainers
needs the receiver's negotiated latency to be ≳2.5×RTT. Should that be
documented as an operational requirement somewhere in the encryption docs?
RCV-LOSS/insert ... REJECTINGwarning now fires benignly (the entry isalready present) on corrupt-retransmit rounds. Worth demoting to debug?
erase()handles the non-TSBPD out-of-orderbookkeeping explicitly, inherited from RCV erase undecrypted packet instead of dropping. #2649, but all of my validation is
live mode with TSBPD — which is also what the existing
SRT_ASSERT(m_bTsbPd)on this path restricts it to. Someone who exercises message mode should look
at that path.
original authorship, on the assumption that's most reviewable. Happy to squash,
reorder, or split part 4 out as an independent fix if you'd prefer — it stands
on its own.