Skip to content

fix(dialog): BYE a forked 2xx's dialog (RFC 3261 §13.2.2.4) - #181

Merged
shenjinti merged 1 commit into
mainfrom
fix/forked-2xx-bye
Oct 7, 2026
Merged

shenjinti merged 1 commit into
mainfrom
fix/forked-2xx-bye

Conversation

@shenjinti

Copy link
Copy Markdown
Contributor

Fixes #150.

Problem

PR #172 fixed the ACK half of #150: a forked 2xx (To tag different from the confirmed dialog's) is now re-ACKed with its tag, remote target and route set. But the issue's second half remained: the Accepted-window drainer (invitation.rs, do_invite and do_invite_async) drained tx.receive() into the void — no dialog was created for the forked branch and no BYE was ever sent, so the forked callee kept retransmitting its 2xx until it gave up, believing the call was up.

Spec

  • RFC 3261 §13.2.2.4: each 2xx with a new To tag establishes its own dialog; the UAC core MUST ACK each 2xx and, if it does not want to continue with that dialog, MUST terminate it by sending a BYE.
  • RFC 3261 §12.2.1.1: the forked dialog's remote target is that 2xx's Contact and its route set that 2xx's Record-Route.

Fix

The drainer inspects every message the client INVITE transaction delivers after confirmation:

  • InviteDialog::end_forked_branch (src/dialog/invite_dialog.rs) ignores non-2xx and 2xx retransmissions with the confirmed tag, and hands a forked 2xx (different tag) to
  • DialogInner::bye_forked_branch (src/dialog/dialog.rs), which builds the BYE from that response: R-URI = its Contact, route set = its Record-Route (reversed), To = its To (the forked tag), From/Call-ID unchanged, CSeq = increment_local_seq() (INVITE + 1 right after confirmation, unique on the shared counter), and sends it through do_request.

Unchanged on purpose:

  • The ACK itself stays in the transaction layer (PR fix(transaction): ACK a forked 2xx with its own tag and target (§13.2.2.4) + ACK test hardening #172); the drainer runs after it.
  • The confirmed dialog's state is not touched: no transition, no notification, no mutation of its remote tag/target/route set (bye_forked_branch builds the request standalone instead of going through make_request, unlike bye_2xx_after_cancel which operates on an abandoned dialog).
  • No dialog is registered for the forked branch — the application keeps knowing exactly one dialog, the first 2xx's. The branch is terminated transparently, per the issue's 'minimal, no API change' suggestion. Surfacing extra dialogs to forking-aware UAs can come later if wanted.
  • A BYE send failure is logged (WARN, no message dump per fix: keep whole SIP messages out of WARN logs #178) and otherwise ignored: the branch is unwanted anyway.

Behaviour change

A forked callee now receives ACK + BYE and stops retransmitting. An application that previously observed the dangling branch only as 2xx retransmission noise now sees a clean BYE on the wire. No public API change.

Tests

src/dialog/tests/test_forked_2xx_bye.rs: test_forked_2xx_is_byed_without_touching_the_confirmed_dialog — a UAC dialog via do_invite against a raw UDP peer (T1 10 ms, 64*T1 640 ms):

  1. INVITE answered 200 with tag-a → dialog Confirmed, notification stream drained.
  2. The peer forks in a second 200 with tag-b and its own Contact (bob-b).
  3. Asserts on the wire: the ACK (tag-b, R-URI = bob-b) and then a BYE with To tag-b, R-URI = the forked Contact, CSeq: 2 BYE, same Call-ID.
  4. Asserts the dialog layer: no state notification after Confirmed, no dialog registered for tag-b, the confirmed dialog still Confirmed, and a normal bye() still ends it with tag-a.

On main (3bdd74c) it fails at the forked-branch BYE:

panicked at src/dialog/tests/test_forked_2xx_bye.rs:27:33:
timeout waiting for the forked-branch BYE

Checks

  • cargo test --features bench: 399 lib tests passed, 0 failed (398 on main plus the new one), 65 doc tests passed. Plain cargo test passes too. The new test passed 5 runs in a row.
  • cargo check --no-default-features --features platform-embassy: same as main.
  • cargo fmt --all -- --check: clean.
  • cargo clippy --features bench --all-targets: no diagnostics in the changed files; the only error is the pre-existing clippy::never_loop in test_refer_notify.rs:98 (present on main).

Every 2xx to the INVITE with a new To tag establishes its own dialog.
The transaction already re-ACKs a forked 2xx with its own tag and
remote target (#172), but the Accepted-window drainer dropped it: no
dialog was created for the branch and no BYE was ever sent, so the
forked callee kept retransmitting its 2xx until it gave up.

The drainer now inspects every message the client INVITE transaction
delivers after confirmation: a 2xx whose To tag differs from the
confirmed dialog's is ended with a BYE built from that response's own
Contact (remote target) and Record-Route (route set), CSeq continuing
the forked dialog's. The confirmed dialog's state is untouched, no
dialog is registered for the branch, and no state is notified.
@shenjinti
shenjinti merged commit a506185 into 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.

Forked 2xx with a different To-tag is ACKed toward the first dialog's target; no dialog or BYE for it (RFC 3261 §13.2.2.4)

1 participant