Skip to content

fix(transaction): keep a server INVITE's final response after the ACK ends it - #174

Merged
shenjinti merged 1 commit into
restsend:mainfrom
tgeorge06:fix/confirmed-carries-the-final-response
Oct 7, 2026
Merged

shenjinti merged 1 commit into
restsend:mainfrom
tgeorge06:fix/confirmed-carries-the-final-response

Conversation

@tgeorge06

Copy link
Copy Markdown
Contributor

Fixes #173.

Problem

Since #169 the matching ACK terminates an Accepted server INVITE transaction (transaction.rs:918) before the ACK is returned to the dialog. cleanup() takes last_response (transaction.rs:1615-1617), so the dialog's Confirmed(id, tx.last_response.clone().unwrap_or_default()) (server_dialog.rs:865, :905; invite_dialog.rs:863, :903) carries Response::default(): no CSeq or any other header, for the initial INVITE and every re-INVITE.

Spec

Fix

In Transaction::cleanup() a server INVITE transaction now gives finished_transactions a clone of last_response instead of taking it. Server non-INVITE transactions still move theirs. Nothing in the dialog layer changes.

Side effects, checked:

  • No new retransmission. cleanup() still cancels Timer G and Timer L first (cleanup_timer()), and the transaction is detached from the endpoint. on_timer does nothing in Terminated, and respond() cannot leave Terminated (can_transition). A retransmitted INVITE or ACK is still absorbed by finished_transactions, which gets the same response as before.
  • No code reads last_response of a terminated server INVITE to decide anything: the only readers after the ACK are the Confirmed builders above. The Timer L path (no ACK) never builds Confirmed; it goes to end_session_without_ack, whose answered_2xx is computed before the receive loop.
  • Memory: the transaction keeps one Response (with its body) until its owner drops it, which for the dialog layer is the end of handle_invite / handle_reinvite, right after Confirmed. Before fix(transaction): end the server Accepted state on the matching ACK (review follow-up to #164) #169 it was kept the same way (the transaction stayed alive in Completed/Accepted with its response). No cycle, no growth with the number of calls.
  • UAC side: not affected. InviteDialog and ClientInviteDialog build Confirmed from the 2xx returned by receive(), not from the terminated transaction.

Contract / coverage

  • On the UAS, every DialogState::Confirmed carries the 2xx its ACK confirms: status 200 and CSeq: N INVITE of that INVITE. This holds for the initial INVITE and for each re-INVITE, on every transport (the transaction path is transport independent; tests cover UDP and TCP).
  • Not changed: the Confirmed re-published after an in-dialog non-INVITE (return_to_confirmed after INFO, UPDATE, MESSAGE, ...) still carries an empty response, because a server non-INVITE transaction terminates on its final response and still moves it. That is not related to fix(transaction): end the server Accepted state on the matching ACK (review follow-up to #164) #169, and the INFO's 200 would not be the response that confirmed the dialog anyway, so it is left alone here.

Tests

src/dialog/tests/test_uas_ack_timeout.rs (the raw-peer UAS harness):

  • wait_confirmed now returns the CSeq number of the response in Confirmed, after asserting it is a 200 with a CSeq whose method is INVITE.
  • test_2xx_over_tcp_is_retransmitted_until_the_ack: initial INVITE over TCP, Confirmed carries CSeq 1.
  • test_late_ack_of_an_earlier_reinvite_reaches_its_own_transaction: initial INVITE over UDP carries CSeq 1; then ACK 2 (late, after re-INVITE 3 was answered) and ACK 3 produce Confirmed with CSeq 2 and then CSeq 3, in that order.

On main (5ef8ea6) both fail:

---- dialog::tests::test_uas_ack_timeout::test_2xx_over_tcp_is_retransmitted_until_the_ack stdout ----
panicked at src/dialog/tests/test_uas_ack_timeout.rs:525:43:
Confirmed carries the 2xx: MissingHeader("CSeq")

---- dialog::tests::test_uas_ack_timeout::test_late_ack_of_an_earlier_reinvite_reaches_its_own_transaction stdout ----
panicked at src/dialog/tests/test_uas_ack_timeout.rs:525:43:
Confirmed carries the 2xx: MissingHeader("CSeq")

With the initial-INVITE assertion of the second test disabled, the re-INVITE Confirmed fails the same way on main. The TCP assertion passes on f27b7c8 (#164) and fails on 52ac300 (#169).

Checks

  • cargo test --features bench: 385 lib tests passed, 0 failed (as on main; existing tests were extended, none added), and 65 doc tests passed. Plain cargo test passes too.
  • cargo check --no-default-features --features platform-embassy: no warnings, as on main.
  • cargo clippy --features bench --all-targets: the same output as on main, none in the changed code. (On main it stops at a clippy::never_loop error in src/dialog/tests/test_refer_notify.rs:98, unrelated to this PR.)
  • rustfmt --check on the changed files: clean.
  • Merges cleanly with fix: keep confirmed dialogs confirmed on 1xx to in-dialog requests #147 and fix: notify dialog state only for transitions that are applied #148; with both merged all tests pass (392 lib tests).

… ends it

Since restsend#169 the matching ACK terminates an Accepted server INVITE
transaction, and cleanup() took last_response before the dialog built
DialogState::Confirmed from it. Confirmed carried Response::default()
(no CSeq) for the initial INVITE and every re-INVITE, so a TU could not
tell which INVITE it confirms.

The server INVITE transaction now keeps last_response and hands a copy
to finished_transactions.
@shenjinti
shenjinti merged commit f3494e3 into restsend:main Oct 7, 2026
3 checks passed
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.

Since #169, DialogState::Confirmed of a server dialog carries an empty response (no CSeq) for the initial INVITE and every re-INVITE

2 participants