Skip to content

fix(dialog): end a dropped INVITE whose dialog was already removed - #183

Merged
shenjinti merged 1 commit into
restsend:mainfrom
tgeorge06:fix/dropped-invite-guard-after-remove-dialog
Oct 7, 2026
Merged

shenjinti merged 1 commit into
restsend:mainfrom
tgeorge06:fix/dropped-invite-guard-after-remove-dialog

Conversation

@tgeorge06

Copy link
Copy Markdown
Contributor

Fixes #182.

Problem

DialogGuardForUnconfirmed::drop (invitation.rs:192) finds the dialog by removing it from the DialogLayer and returns when it is not there (line 194). An application that called remove_dialog for the pending call before dropping the do_invite future therefore gets no CANCEL, no Terminated, and no ACK / BYE for a 2xx that answers the abandoned INVITE (#162, #171 do all of that only for a dialog still in the layer). remove_dialog (dialog_layer.rs:509-513) only drops the registry entry and cancels the dialog's cancel_token; it does not end the INVITE.

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 / §15, RFC 5407 §3.1.2: a 2xx to an abandoned INVITE is ACKed and the session it established is ended with a BYE.

Fix

The guard keeps a clone of the INVITE's dialog (dialog: Option<InviteDialog>). do_invite clears it as soon as process_invite returns. In drop:

  • registry entry present: used exactly as before (Some(Dialog::Invite(..)) is ended, Some(_) of another kind returns);
  • entry missing and the INVITE still pending: the stored dialog is ended through the same code (Calling: Terminated(UacCancel) now, CANCEL after the first provisional, ACK + BYE for a 2xx; Trying / Early: CANCEL, ACK + BYE for a crossing 2xx, Terminated(UacCancel));
  • entry missing because process_invite returned: nothing to do, as before (do_invite handles the outcome).

The rest of drop is unchanged. The diff is +14 / -6 in src/dialog/invitation.rs.

Unchanged on purpose:

  • A dialog that is still in the layer is handled byte for byte as before; the stored clone shares the same Arc.
  • do_invite_async has no drop guard and is not touched.
  • If the application removes the dialog but keeps polling do_invite, a 2xx still completes do_invite, which registers the confirmed dialog again under its new id (invitation.rs:713) and returns it to the caller. That is the caller's own call and is not changed here.
  • The guard's existing limits apply to this path as they do to a registered dialog: it BYEs the first 2xx it sees, not every forked 2xx.

Contract / coverage

What dropping the do_invite future does, by dialog state, when the application already removed the dialog with remove_dialog:

state at drop peer then sends before after
Calling 180, then 487 to the CANCEL no CANCEL, no Terminated Terminated(UacCancel) at once, CANCEL after the 180, 487 ACKed
Calling 200 not ACKed, no BYE, no Terminated Terminated(UacCancel), ACK, BYE
Calling 486 not ACKed, no Terminated Terminated(UacCancel), ACK, no BYE
Trying (100) 200 crossing the CANCEL no CANCEL, no BYE, no Terminated CANCEL, ACK, BYE, one Terminated(UacCancel)
Early (180) 200 crossing the CANCEL no CANCEL, placeholder ACK, no BYE, no Terminated CANCEL, ACK, BYE, one Terminated(UacCancel)

In every row the call never reports Confirmed and reports Terminated once (DialogInner::transition drops anything after the first Terminated, #148). Each row is a test below.

Tests

src/dialog/tests/test_cancel_2xx_race.rs. run_crossing_2xx and run_dropped_before_provisional get a remove flag: when set, the test removes the dialog with DialogLayer::remove_dialog under the id the Calling state reported, asserts the layer is empty, then drops the do_invite future. The existing tests run with remove = false and are otherwise unchanged; run_crossing_2xx also takes the provisional (180 for the existing tests).

  • test_removed_dialog_2xx_crossing_the_cancel_is_acked_and_byed: after a 180 and after a 100, a 200 crossing the CANCEL is ACKed and BYE'd in the dialog it established, a retransmitted 200 is re-ACKed with no second BYE, one Terminated(UacCancel), no Confirmed.
  • test_removed_before_provisional_is_cancelled_after_the_180, test_removed_before_provisional_2xx_is_acked_and_byed, test_removed_before_provisional_final_failure_is_acked_only: dropped in Calling; Terminated(UacCancel) at once, nothing sent before a provisional, then CANCEL (180), ACK + BYE (200) or ACK only (486).

On main (3bdd74c) all four fail, the others pass:

---- dialog::tests::test_cancel_2xx_race::test_removed_before_provisional_2xx_is_acked_and_byed stdout ----
panicked at src/dialog/tests/test_cancel_2xx_race.rs:141:41:
state channel closed
---- dialog::tests::test_cancel_2xx_race::test_removed_before_provisional_final_failure_is_acked_only stdout ----
panicked at src/dialog/tests/test_cancel_2xx_race.rs:141:41:
state channel closed
---- dialog::tests::test_cancel_2xx_race::test_removed_before_provisional_is_cancelled_after_the_180 stdout ----
panicked at src/dialog/tests/test_cancel_2xx_race.rs:141:41:
state channel closed
---- dialog::tests::test_cancel_2xx_race::test_removed_dialog_2xx_crossing_the_cancel_is_acked_and_byed stdout ----
panicked at src/dialog/tests/test_cancel_2xx_race.rs:35:33:
timeout waiting for CANCEL

test result: FAILED. 7 passed; 4 failed; 0 ignored; 0 measured; 391 filtered out

("state channel closed": no Terminated was reported before the dialog went away. The 100 variant alone fails the same way, at the CANCEL.)

Checks

  • cargo test --features bench: 402 lib tests passed, 0 failed (398 on main plus the four new ones), and 65 doc tests passed. Plain cargo test passes too. The test_cancel_2xx_race tests passed 20 runs in a row.
  • cargo check --no-default-features --features platform-embassy: the same output as on main.
  • cargo clippy --features bench --all-targets: the same warnings 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; with -A clippy::never_loop the warnings are the same as on main.)
  • rustfmt --check on the changed files: clean.

When the `do_invite` future is dropped mid-INVITE, `DialogGuardForUnconfirmed`
finds the dialog by removing it from the `DialogLayer` and did nothing when it
was not there. An application that called `remove_dialog` before dropping the
future (for example on its own hangup) therefore got no `Terminated`, no
CANCEL after a provisional, and no BYE for a 2xx that answered the abandoned
INVITE, leaving the callee in a session.

The guard now keeps the INVITE's dialog and falls back to it when the layer
entry is gone, until `process_invite` returns. A dialog that is still
registered is handled exactly as before.
@shenjinti
shenjinti merged commit c051af9 into restsend:main Oct 7, 2026
2 of 3 checks passed
shenjinti added a commit that referenced this pull request Oct 7, 2026
Follow-up to #183. The removed-dialog variant of the drop guard is only
exercised with a crossing 2xx in the InviteOkFirst ordering. Two gaps:

- A CANCEL that wins the race (487 to the INVITE) with the dialog already
  removed: run_cancel_answered_487 now takes a provisional code and a
  remove flag, so test_removed_cancel_answered_487_sends_no_bye covers
  Early (180) and Trying (100) — CANCEL, ACK of the 487, no BYE, exactly
  one Terminated(UacCancel), never Confirmed. The pre-existing test gains
  the same Terminated/Confirmed assertions.
- The CancelOkFirst wire ordering (200 to the CANCEL before the 2xx) with
  the dialog removed:
  test_removed_dialog_2xx_after_the_cancel_response_is_acked_and_byed.
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.

A dropped do_invite does nothing when the application already removed the dialog: no CANCEL, no Terminated, and a 2xx is never BYE'd

2 participants