Skip to content

fix(dialog): a BYE ends the dialog even when its transaction fails - #180

Merged
shenjinti merged 1 commit into
restsend:mainfrom
tgeorge06:fix/bye-ends-the-dialog-whatever-the-outcome
Oct 7, 2026
Merged

shenjinti merged 1 commit into
restsend:mainfrom
tgeorge06:fix/bye-ends-the-dialog-whatever-the-outcome

Conversation

@tgeorge06

Copy link
Copy Markdown
Contributor

Fixes #179.

Problem

bye_with_headers returns early on an error from the BYE's transaction (invite_dialog.rs:448, client_dialog.rs:181, server_dialog.rs:375), before it transitions to Terminated. When the target locator fails (transaction.rs:274, returned at dialog.rs:1160) or a 401/407 to the BYE cannot be answered (dialog.rs:1279-1280), bye() returns Err, the dialog stays Confirmed and no Terminated is notified. Applications that remove the dialog on Terminated, as the do_invite docs ask (invitation.rs:655-668), keep it forever.

Spec

  • RFC 3261 §15.1.1: the UAC "MUST consider the session terminated ... as soon as the BYE request is passed to the client transaction", and on a 481, a 408 or "no response at all ... (that is, a timeout is returned by the client transaction), the UAC MUST consider the session and the dialog terminated."
  • RFC 3261 §12.2.1.2: on a 481 or 408 to an in-dialog request "the UAC SHOULD terminate the dialog."

Fix

A new DialogInner::send_bye(request, reason) (src/dialog/dialog.rs) runs the BYE's transaction, then transitions to Terminated(reason) whatever it returned, then returns the transaction's result. InviteDialog::bye_with_headers and the deprecated ClientInviteDialog / ServerInviteDialog::bye_with_headers call it instead of do_request(..)? followed by the transition. The reasons are the existing ones, unchanged: UacBye for a client dialog, UasBye for a server dialog (InviteDialog picks by role as before; each wrapper keeps its own).

Unchanged on purpose:

  • A BYE in a state where it does not apply still returns an error and changes nothing, and a terminated dialog is still a no-op Ok.
  • A failure to build the BYE (make_request) still returns before any transaction exists; the dialog stays Confirmed and the caller may retry.
  • A 401/407 to the BYE with a credential is still answered once with an authenticated BYE before anything is decided: send_bye transitions only after do_request returns.
  • The other BYE senders already end the dialog before or without this path and are not changed: end_session_without_ack (fix: end the session when a UAS 2xx to an INVITE is never ACKed #149) transitions to Terminated(Timeout) before its BYE, and bye_2xx_after_cancel / bye_if_2xx run on an INVITE that was already abandoned.

Contract / coverage

What bye() (and bye_with_headers / bye_with_reason, all three types) does once the BYE is handed to its transaction:

BYE outcome bye() returns dialog state before
any final response (2xx, 3xx-6xx, incl. 408 and 481) Ok(()) Terminated(UacBye / UasBye) same
no response (Timer F, local 408) Ok(()) Terminated same
TCP/TLS/WS write failure (local 503) Ok(()) Terminated same
UDP lookup failure (Timer E retries, then Timer F) Ok(()) Terminated same
target locator error Err(..) Terminated Err, stays Confirmed
401/407 that cannot be answered, or the authenticated resend fails Err(..) Terminated Err, stays Confirmed

The 481, 408 and no-response rows and the two error rows are in the test below. The write-failure and lookup-failure rows follow from the transaction, which reports them as a local 503 (transaction.rs:352-361) or enters Calling with its timers anyway (transaction.rs:336-339); this PR does not touch that path.

Behaviour change for callers: an Err from bye() no longer means the dialog is still usable. It reports that the BYE could not be completed; the dialog is Terminated either way, and Terminated has been notified. Code that retried bye() after an Err now gets the Ok no-op of a terminated dialog. Code that already treated the return of bye() as the end of the call is not affected.

A 401/407 to the BYE without a credential already notifies Terminated(ProxyAuthRequired) in send_dialog_request, and bye() then notifies Terminated(UacBye) too. That double notification is on main already and is not changed here; #148 drops the second one (checked below).

Tests

src/dialog/tests/test_client_dialog.rs: test_bye_terminates_the_dialog_whatever_the_outcome. A UAC dialog is set up with DialogLayer::do_invite against a raw UDP peer (short timers: T1 10 ms, 64*T1 640 ms; a FlakyLocator that starts failing on demand). Then one bye() per row, and for each the test asserts the Terminated notifications (exactly one, with the expected reason), that the dialog is terminated, and whether bye() returned an error:

  • 481, 408, no response: Ok, [UacBye] (pins the current behaviour).
  • 401 without a challenge, to a dialog with a credential: Err, [UacBye].
  • no route, through InviteDialog, ClientInviteDialog and ServerInviteDialog (the deprecated wrappers are driven over the same dialog): Err, [UacBye] / [UasBye].

On main (5ef8ea6) it fails at the first new row:

---- dialog::tests::test_client_dialog::test_bye_terminates_the_dialog_whatever_the_outcome stdout ----
panicked at src/dialog/tests/test_client_dialog.rs:1847:9:
assertion `left == right` failed: 401 without a challenge via invite
  left: "[]"
 right: "[UacBye]"

Row by row on main (assertions turned into prints):

481 via invite: terminated=[UacBye] bye()=Ok(())
408 via invite: terminated=[UacBye] bye()=Ok(())
no response via invite: terminated=[UacBye] bye()=Ok(())
401 without a challenge via invite: terminated=[] bye()=Err(DialogError("missing proxy/www authenticate", ..))
no route via invite: terminated=[] bye()=Err(Error("no route"))
no route via client: terminated=[] bye()=Err(Error("no route"))
no route via server: terminated=[] bye()=Err(Error("no route"))

Checks

RFC 3261 §15.1.1: the session ends once the BYE is passed to the client
transaction, and the dialog ends on a 481, a 408 or no response.
`bye_with_headers` notified `Terminated` only after the BYE transaction
returned Ok. When it returned an error (the target locator fails, or a
401/407 to the BYE cannot be answered) the dialog stayed `Confirmed`, and
callers that remove the dialog on `Terminated` kept it forever.

`DialogInner::send_bye`, used by `InviteDialog` and both deprecated
wrappers, notifies `Terminated` (`UacBye` / `UasBye`, as before) whatever
the transaction returns and still returns its error.
@shenjinti
shenjinti merged commit 3bdd74c 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.

bye() leaves the dialog Confirmed, with no Terminated, when the BYE's transaction returns an error

2 participants