Repository navigation
fix(transaction): add the 2xx ACK route before sending the 2xx - #193
Merged
yeoleobun merged 1 commit intoOct 10, 2026
Merged
Conversation
A server INVITE added the route a 2xx ACK takes (dialog and CSeq) only when it entered Accepted, after the 2xx was sent. An ACK that arrived in between matched no transaction and was dropped, so the dialog was confirmed only by a later re-ACK, or ended at Timer L. respond() now adds the route before the send, and removes it again when the send fails or the respond() future is dropped. Fixes restsend#192.
|
Thanks a lot @tgeorge06 for picking this up so quickly, and for the clear write-up and the deterministic test. I confirm the fix matches what we observed: on our side (Linux, 4 vCPU, UDP on the LAN) the ACK of the 200 OK was occasionally dropped in exactly that window, and the dialog only got confirmed after a 2xx retransmission. We currently work around it in our driver; once this lands in a release we'll drop the workaround and keep our regression test against it. |
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.
Fixes #192.
Thanks to @beija-wq for the report. The analysis in the issue is exact, and the reproduction program made it easy to measure before and after.
Problem
A server INVITE transaction adds the route that a 2xx ACK takes (a new branch, matched by dialog and CSeq in
waiting_ack_cseq) only when it enters Accepted, andrespond()enters Accepted only after the 2xx has been sent. A peer on the same host or LAN can ACK the 2xx in between. That ACK matches no transaction and is dropped without a log line.Expected
RFC 6026 §7.1 (updating RFC 3261 §17.2.1): a server INVITE transaction enters "Accepted" when the TU passes it a 2xx, and
RFC 3261 §13.3.1.4:
An ACK that arrives right after the 2xx left the socket is "received while in Accepted" from the peer's point of view and must reach the TU.
What happens
Line numbers at current
main(b47f434):Transaction::respondsends first and transitions afterwards (src/transaction/transaction.rs:499-500).waiting_ack_cseqroute (and the dialog-onlywaiting_ackentry) is inserted insidetransition(Accepted)(transaction.rs:1366-1373).EndpointInner::on_received_messagerewrites a 2xx ACK's key only when that route exists (src/transaction/endpoint.rs:384-396). Otherwise the ACK's own key (new branch) matches no transaction (endpoint.rs:491) andMethod::Ack => return Ok(())drops it (endpoint.rs:528).What a lost ACK costs:
transaction.rs:1190-1211), the UAC ACKs it again, and the dialog isConfirmedabout T1 (500 ms) late. In the measurement below, every one of the 10 lost ACKs was recovered this way, 500 ms after the 2xx.TerminatedReason::Timeout(src/dialog/dialog.rs:1415, pinned bytest_unacked_2xx_is_retransmitted_then_the_session_is_ended). The same applies to a re-INVITE: an answered re-INVITE whose ACK was dropped ends the call.The window is transport independent: the route is missing for UDP, TCP, TLS and WS alike.
A non-2xx final has no such window: its ACK carries the INVITE's branch (§17.1.1.3), so it matches the transaction key itself, which is attached from the start, and waits in the transaction's queue until
respond()returns and the transaction is in Completed.Fix
respond()adds thewaiting_ack_cseqroute before the send, when the 2xx moves the transaction into Accepted (not for a 2xx retransmitted by the TU while already in Accepted, which has the route). An ACK that arrives during the send is now routed to the transaction's queue and handled byreceive()after the transition, as any ACK in Accepted is today.A small drop guard removes the route again if the send fails or the
respond()future is dropped (for example by thet1x64timeout aroundtx.respond()inprocess_transaction_handle,dialog.rs:1687, which then sends a 501 on the same transaction). It removes the entry only while it still maps to this transaction, and is disarmed once the send has completed.transition(Accepted)still adds the route (now through the sharedadd_ack_cseq_routehelper, a no-op the second time) and the dialog-onlywaiting_ackentry, as before.+49 / -9 lines in
src/transaction/transaction.rs, most of it the guard and comments.Unchanged on purpose:
(dialog, CSeq)(fix: route the ACK of a 2xx to the INVITE with the same CSeq (rebase of #167) #170) and the 2xx kept infinished_transactions(fix(transaction): keep a server INVITE's final response after the ACK ends it #174) behave exactly as before; the ACK takes the same path, only the route exists earlier.waiting_ackmap is still filled only by the transition, soEndpointStats::waiting_ackcounts what it counted before.Contract / coverage
What a server INVITE transaction does with the ACK of its final response:
respond()returnedrespond()with a 2xxwaiting_ack_cseqafterwardsTests
src/transaction/tests/test_server_invite_ack.rs:test_server_invite_ack_during_final_response_send. The server transaction sends over a channel transport whose bounded queue is full, so the send waits; the test pollsreply()once (pending inside the send), delivers the ACK throughEndpointInner::on_received_message, then frees the queue and letsreply()finish. No timing is involved. Rows:receive(), transaction Terminated, no route left;reply()errors, still Trying, no route;reply()future is dropped during the send: still Trying, no route.On
main(b47f434) it fails:The non-2xx, send-failure and cancellation rows pass on
main. With the fix, removing the guard's cleanup makes the send-failure and cancellation rows fail (waiting_ack_cseq.len()is 1).Load, with the reproduction program from the issue (UDP over 127.0.0.1, default
EndpointOption, a raw peer that ACKs each 200 once; "lost" = notConfirmedwithin 400 ms). macOS, 16 CPUs, so the window is hit less often than on the reporter's 4-CPU Linux box:mainChecks
cargo test --features bench: 413 lib tests passed, 0 failed (412 onmainplus the new one), and 65 doc tests passed. Plaincargo testpasses too. The new test passed 20 runs in a row.cargo check --no-default-features --features platform-embassy: the same output as onmain.cargo clippy --features bench --all-targets: the same output as onmain, none in the changed code. (Onmainit stops at aclippy::never_looperror insrc/dialog/tests/test_refer_notify.rs:98, unrelated to this PR; with-A clippy::never_loopthe warnings are the same as onmain.)rustfmt --checkon the changed files: clean.