Skip to content

fix: end the session when a UAS 2xx to an INVITE is never ACKed - #11

Merged
tgeorge06 merged 1 commit into
mainfrom
fix/uas-2xx-ack-timeout
Sep 25, 2026
Merged

tgeorge06 merged 1 commit into
mainfrom
fix/uas-2xx-ack-timeout

Conversation

@tgeorge06

Copy link
Copy Markdown
Owner

Assessment

  • Verdict: genuine library defect, confidence HIGH

  • Spec: RFC 3261 §13.3.1.4. The UAS retransmits its 2xx starting at T1 and doubling up to T2 until the ACK arrives. If no ACK arrives within 64·T1, the session SHOULD be ended with a BYE. §14.2 applies the same rule to re-INVITEs. §17.2.1 says a non-2xx waits for its ACK for Timer H (64·T1); T4 (Timer I) applies only after the ACK arrives.

  • Codex tiebreak (library defect vs. application usage): LIBRARY-DEFECT, high. The documented UAS flow (incoming_transactions → get_or_create_server_invite → handle, then accept()) stops retransmitting at T4 and leaves the dialog in WaitAck forever.

  • Codex design calls:

    • The library sends the BYE itself. The RFC gives this job to the UAS core, and after Terminated the public bye() does nothing.
    • Reuse TerminatedReason::Timeout, which was defined but never used.
    • Add EndpointOption::t2. Earlier fields were added the same way, and every in-repo literal already uses ..Default::default().
    • Dropping Timer K from Completed is correct for both 2xx and non-2xx responses.
  • Red on main (5958064): cargo test --lib test_uas_ack_timeout gives 2 failures:

    • the 2xx must be retransmitted until 64*T1, got 4 transmissions
    • the re-INVITE 2xx must be retransmitted until 64*T1, got 4 transmissions

    The ACKed control passes. The T2-cap test needs the new t2 field, so it can't compile on main.

  • Green on this branch: 4 passed, 5 repeat runs with no flakes. cargo test passes 334 lib + 65 doc tests. cargo fmt --all -- --check is clean and the clippy warning set is identical to main.

  • Upstream: upstream-ready, queued. It overlaps open feat(transaction): RFC 6026 Accepted state + Timer L/M for INVITE 200 OK retransmission absorption restsend/rsipstack#128 (RFC 6026 Accepted + Timer L), which would supersede the 2xx half of the transaction change. Rebase whichever lands second.

Problem

When a server INVITE transaction enters Completed it arms Timer K = T4 (5 s by default). Timer K terminates the transaction well before 64·T1 (32 s), which causes two problems:

  • A 2xx that is not ACKed is retransmitted for about 5 s instead of 32 s. A non-2xx likewise stops at T4 instead of Timer H.
  • When the ACK never comes, the dialog is stuck. It stays in WaitAck (initial INVITE) or Confirmed (re-INVITE) forever, with no state event and no BYE. The peer may consider the call up while no media flows, and the application cannot see that anything is wrong.

In addition, Timer G doubled up to 64·T1 rather than T2, so later retransmissions were 8 s and 16 s apart.

Reproduction

New src/dialog/tests/test_uas_ack_timeout.rs uses real UDP, a raw UAC and the usual UAS loop. Timers are short but keep the RFC's T4/T1 ratio: T1 = 20 ms, T4 = 200 ms, 64·T1 = 1.28 s.

  • No ACK for the initial INVITE: the 2xx must be retransmitted with growing intervals until the last part of 64·T1. The dialog must then emit Terminated(Timeout), and a BYE must arrive no earlier than 64·T1, with the right Call-ID, From tag (the 2xx's To tag) and To tag.
  • T2 cap: with t2 = 4·T1, every retransmission interval stays at or below T2 until 64·T1.
  • No ACK for a re-INVITE on an established call: the same retransmission, Terminated(Timeout) and BYE.
  • Control: when the ACK arrives, retransmissions stop, no BYE is sent and the dialog stays Confirmed.

Fix

  • transaction.rs: a server INVITE entering Completed no longer arms Timer K. Timer D (64·T1) already ends the transaction; for a non-2xx it acts as Timer H, and for a 2xx it is the retransmission limit. Confirmed still arms T4 (Timer I).
  • transaction.rs: Timer G doubles up to the new EndpointOption::t2, which defaults to 4 s (the RFC value).
  • dialog.rs: new DialogInner::end_session_without_ack. When the INVITE or re-INVITE transaction reaches Terminated after we sent a 2xx and no ACK came, it emits Terminated(TerminatedReason::Timeout) and then sends a BYE (best effort; a failure is logged). It is called from handle_invite and handle_reinvite in both InviteDialog and the deprecated ServerInviteDialog.
  • For a re-INVITE, whether a 2xx was sent is read right after the final response goes out, because Transaction::cleanup takes last_response on termination.

Nothing changes when the ACK arrives, when the INVITE is rejected or CANCELled, or when the endpoint shuts down (in that case the transaction is not Terminated).

Review

Codex adversarial review, round 1:

  • Fixed, Major: Timer G was capped at 64·T1, not T2. Added t2 and a test.
  • Fixed, Major: re-INVITEs were not covered. Added the hook and a test.
  • Fixed, Minor: the test did not prove retransmission continues to 64·T1. It now asserts a late retransmission and the T2 cap.
  • Kept, Minor: Terminated(Timeout) is published before the BYE is sent. Sending the BYE first would delay Terminated by up to 64·T1 when the peer is gone, and the peer has already failed to ACK. A provisional to that BYE could still notify Early after Terminated; that is the separate transition() ordering fix. Codex agreed this is an acceptable tradeoff.

Confirmation pass: all three fixes confirmed. It found one new Minor: the re-INVITE test did not wait for Confirmed before sending the re-INVITE, which could race. Fixed.

Out of scope, left unchanged:

  • Over reliable transports no Timer G is armed, so the 2xx is not retransmitted. That was already the case; the 64·T1 ACK wait and the BYE still apply.
  • The client Timer A/E cap still uses 64·T1.

🤖 Generated with Claude Code

https://claude.ai/code/session_01L1Gu5CifqgBjASbxYmJ6mr

A server INVITE transaction in Completed armed Timer K (T4, 5 s by
default), which terminated it long before 64*T1: the 2xx was
retransmitted for about 5 s instead of 32 s, and when no ACK came the
dialog stayed in WaitAck (or Confirmed, for a re-INVITE) forever, with
no event and no BYE.

RFC 3261 §13.3.1.4: the UAS retransmits the 2xx starting at T1 and
doubling up to T2 until the ACK arrives; if none arrives within 64*T1,
the session SHOULD be ended with a BYE. §17.2.1 likewise keeps a
non-2xx in Completed for Timer H (64*T1); T4 (Timer I) applies only
once the ACK has arrived.

- Do not arm Timer K when a server INVITE enters Completed; Timer D
  (64*T1) already ends it, and Confirmed still uses T4.
- Cap Timer G at T2 instead of 64*T1, with a new EndpointOption::t2
  (default 4 s, the RFC value).
- When the INVITE or re-INVITE transaction ends without an ACK after a
  2xx, terminate the dialog with TerminatedReason::Timeout and send a
  BYE. Applied to InviteDialog and the deprecated ServerInviteDialog.
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.

1 participant