Skip to content

fix: CANCEL a dropped INVITE after its first provisional response - #168

Closed
tgeorge06 wants to merge 1 commit into
restsend:mainfrom
tgeorge06:fix/cancel-dropped-invite-on-first-provisional
Closed

tgeorge06 wants to merge 1 commit into
restsend:mainfrom
tgeorge06:fix/cancel-dropped-invite-on-first-provisional

Conversation

@tgeorge06

Copy link
Copy Markdown
Contributor

Fixes #166.

Problem

When the do_invite future is dropped while the dialog is Calling (no response yet), the guard drops the INVITE transaction and sends nothing. A later 180 gets no CANCEL, a later 2xx is neither ACKed nor BYE'd, and a later non-2xx final is not ACKed. The callee keeps ringing, or answers into dead air.

Spec

  • RFC 3261 §9.1: "If no provisional response has been received, the CANCEL request MUST NOT be sent; rather, the client MUST wait for the arrival of a provisional response before sending the request."
  • RFC 3261 §13.2.2.4: "The UAC core MUST generate an ACK request for each 2xx received from the transaction layer."
  • RFC 3261 §15, RFC 5407 §3.1.2: a 2xx to an abandoned INVITE is ACKed and the session it established is ended with a BYE (as fix: ACK and BYE a 2xx that crosses the CANCEL of a dropped INVITE (rebase of #159) #162 does for the Trying / Early drop).

Fix (src/dialog/invitation.rs, src/dialog/invite_dialog.rs)

  • DialogGuardForUnconfirmed::drop: Calling now shares the Trying / Early arm. For Calling it still reports Terminated(UacCancel) at once, before spawning, as before.
  • The spawned task, for Calling only, first stops the INVITE retransmissions and waits up to 64*T1 for the first response (wait_response, which is wait_final_response with a final_only flag):
    • a provisional: it continues into the existing Trying / Early code unchanged (CANCEL, 2 s settle window, wait for the final response, ACK and BYE a crossing 2xx). Its 64*T1 final-response window starts after the provisional. The second Terminated(UacCancel) that path reports is skipped for Calling, which already reported it.
    • a 2xx: the transaction ACKs it, and the session is ended with a BYE.
    • any other final response, including Timer B's 408: the transaction ACKs it if it came from the peer; nothing else is sent.
    • no response within 64*T1: nothing is sent.
  • That path calls InviteDialog::send_cancel, split out of cancel() without its state check: the dialog is already Terminated when a Calling drop sends its CANCEL. For Trying / Early the guard already matched the state, so the CANCEL sent is the same as before.
  • The 2xx-then-BYE tail moves into bye_if_2xx, shared by both paths.

Contract / coverage

  • Abandoning a call before any response reports Terminated(UacCancel) at once, as before, and once. Nothing is sent before a provisional, and the INVITE is no longer retransmitted.
  • The first response then gets: a CANCEL for any 1xx (100 included, as in Trying), an ACK and a BYE for a 2xx, the transaction's ACK for 3xx-6xx. No Confirmed is reported for an abandoned call.
  • Same code on UDP, TCP, TLS and WS; on reliable transports there were no retransmissions to stop. No tokio-only code: the task uses the crate::platform primitives already used by this arm.
  • Unchanged: the Trying / Early drop (test_2xx_before_cancel_response_is_acked_and_byed, test_2xx_after_cancel_response_is_acked_and_byed, test_2xx_after_cancel_settle_window_is_acked_and_byed, test_cancel_answered_487_sends_no_bye pass unchanged), the Confirmed drop (hangup), InviteDialog::cancel() for applications, and do_invite_async, which does not use the guard.
  • Not changed, the same as in the existing Trying / Early path: only the first 2xx of a forked INVITE is BYE'd; with EndpointOption::auto_ack_2xx = false the 2xx is not ACKed before the BYE; a reliable provisional (100rel) gets no PRACK before the CANCEL; the task does not watch the endpoint's cancellation token. These belong to both paths and are left for separate changes.

Tests

src/dialog/tests/test_cancel_2xx_race.rs, with the file's raw UDP peer (default T1 = 500 ms). run_dropped_before_provisional(first) drops do_invite right after the INVITE arrives, asserts Terminated(UacCancel) within 200 ms and that the peer receives nothing for 1.1 s (over twice T1), then sends first:

  • test_dropped_before_provisional_is_cancelled_after_the_180: a CANCEL with the INVITE's CSeq follows, then 200 to it and 487 to the INVITE, whose ACK follows; no BYE.
  • test_dropped_before_provisional_2xx_is_acked_and_byed: the 200 is ACKed and a BYE follows, both in the 2xx's dialog.
  • test_dropped_before_provisional_final_failure_is_acked_only: the 486 is ACKed; no BYE.

Each also asserts that no second Terminated and no Confirmed is reported. On main:

test_dropped_before_provisional_is_cancelled_after_the_180 ... FAILED
  panicked at src/dialog/tests/test_cancel_2xx_race.rs:34:33: timeout waiting for CANCEL
test_dropped_before_provisional_2xx_is_acked_and_byed ... FAILED
  panicked at src/dialog/tests/test_cancel_2xx_race.rs:34:33: timeout waiting for ACK
test_dropped_before_provisional_final_failure_is_acked_only ... FAILED
  panicked at src/dialog/tests/test_cancel_2xx_race.rs:34:33: timeout waiting for ACK

A 2xx that crosses the deferred CANCEL runs the same code as run_crossing_2xx, so it is not tested again here.

Checks

When the do_invite future is dropped before any provisional response,
the guard drops the INVITE transaction and sends nothing. The INVITE may
still have reached the callee: its 180 gets no CANCEL, and a 2xx is
neither ACKed nor ended, so the callee keeps ringing or answers into
dead air.

A CANCEL must not be sent before a provisional response; the UAC has to
wait for one and CANCEL then (RFC 3261 §9.1). Keep the INVITE
transaction, without retransmissions, until its first response (up to
64*T1): on a provisional, cancel it as a call dropped while ringing; on
a 2xx, ACK it and send a BYE; any other final response is ACKed by the
transaction. Terminated(UacCancel) is still reported at once, and
nothing is sent when no response arrives.
shenjinti added a commit that referenced this pull request Oct 6, 2026
…rst-provisional-rebased

fix: CANCEL a dropped INVITE after its first provisional response (rebase of #168)
@shenjinti

Copy link
Copy Markdown
Contributor

Adopted via #171 (clean rebase, no conflicts). Merged with all tests green — thank you!

@shenjinti shenjinti closed this Oct 6, 2026
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.

An INVITE dropped before any response is never CANCELed, and its 2xx is never ACKed

2 participants