From ea33e2031f5442a69458dd84ef73cd5183cf2466 Mon Sep 17 00:00:00 2001 From: Tyson George Date: Wed, 7 Oct 2026 10:40:55 -0400 Subject: [PATCH] fix(dialog): end the session on a never-ACKed re-INVITE 2xx in ClientInviteDialog The deprecated ClientInviteDialog has its own handle_reinvite, which missed the teardown that InviteDialog and ServerInviteDialog apply (RFC 3261 13.3.1.4): when the callee re-INVITEs a UAC dialog handled through the wrapper and never ACKs the 2xx, the 2xx was retransmitted until 64*T1 and then nothing happened. The dialog stayed Confirmed, with no event and no BYE. Track the 2xx and the ACK as the other two handlers do and call end_session_without_ack, so the dialog ends with TerminatedReason::Timeout and a BYE. --- src/dialog/client_dialog.rs | 9 ++ src/dialog/tests/test_uas_ack_timeout.rs | 115 +++++++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/src/dialog/client_dialog.rs b/src/dialog/client_dialog.rs index 2cb34453..dafa0b7b 100644 --- a/src/dialog/client_dialog.rs +++ b/src/dialog/client_dialog.rs @@ -668,6 +668,11 @@ impl ClientInviteDialog { .transition(DialogState::Updated(self.id(), tx.original.clone(), handle))?; self.inner.process_transaction_handle(tx, rx).await?; + let answered_2xx = tx + .last_response + .as_ref() + .is_some_and(|resp| resp.status_code.kind() == crate::sip::StatusCodeKind::Successful); + let mut acked = false; // wait for ACK while let Some(msg) = tx.receive().await { @@ -675,11 +680,15 @@ impl ClientInviteDialog { SipMessage::Request(req) if req.method == crate::sip::Method::Ack => { debug!(id = %self.id(), "received ACK for re-INVITE"); self.inner.remote_ack.lock().replace(req); + acked = true; break; } _ => {} } } + self.inner + .end_session_without_ack(tx, answered_2xx && !acked) + .await; Ok(()) } diff --git a/src/dialog/tests/test_uas_ack_timeout.rs b/src/dialog/tests/test_uas_ack_timeout.rs index 23b43daf..e6f73224 100644 --- a/src/dialog/tests/test_uas_ack_timeout.rs +++ b/src/dialog/tests/test_uas_ack_timeout.rs @@ -958,3 +958,118 @@ async fn test_acked_reinvite_sends_no_bye() -> crate::Result<()> { token.cancel(); Ok(()) } + +/// The deprecated `ClientInviteDialog` has its own re-INVITE handler: a UAC +/// dialog re-INVITEd by the callee must end the same way as `InviteDialog`. +async fn legacy_client_dialog_reinvite(ack: bool) -> crate::Result<()> { + use crate::dialog::{client_dialog::ClientInviteDialog, invitation::InviteOption}; + let token = CancellationToken::new(); + let transport_layer = TransportLayer::new(token.child_token()); + let udp = UdpConnection::create_connection( + "127.0.0.1:0".parse().unwrap(), + None, + Some(token.child_token()), + ) + .await?; + let uac: SocketAddr = udp.get_addr().get_socketaddr()?; + transport_layer.add_transport(udp.into()); + let endpoint = EndpointBuilder::new() + .with_transport_layer(transport_layer) + .with_cancel_token(token.child_token()) + .with_option(short_timers()) + .build(); + let dialog_layer = Arc::new(DialogLayer::new(endpoint.inner.clone())); + let mut incoming = endpoint.incoming_transactions()?; + let endpoint_inner = endpoint.inner.clone(); + tokio::spawn(async move { endpoint_inner.serve().await }); + let layer = dialog_layer.clone(); + tokio::spawn(async move { + while let Some(mut tx) = incoming.recv().await { + if let Some(Dialog::Invite(dialog)) = layer.match_dialog(&tx) { + let mut legacy = ClientInviteDialog::try_from(dialog).expect("a UAC dialog"); + tokio::spawn(async move { legacy.handle(&mut tx).await }); + } + } + }); + let peer = Peer { + socket: UdpSocket::bind("127.0.0.1:0").await?, + uas: uac, + }; + let callee = peer.socket.local_addr()?; + let option = InviteOption { + caller: format!("sip:alice@{uac}").as_str().try_into()?, + callee: format!("sip:bob@{callee}").as_str().try_into()?, + contact: format!("sip:alice@{uac}").as_str().try_into()?, + call_id: Some(CALL_ID.to_string()), + ..Default::default() + }; + let (state_sender, mut states) = unbounded_channel(); + let invite = tokio::spawn(async move { dialog_layer.do_invite(option, state_sender).await }); + let mut buf = vec![0u8; 4096]; + let (len, _) = tokio::time::timeout(Duration::from_secs(2), peer.socket.recv_from(&mut buf)) + .await + .expect("timeout waiting for the INVITE")?; + let SipMessage::Request(req) = SipMessage::try_from(std::str::from_utf8(&buf[..len]).unwrap())? + else { + panic!("expected the INVITE"); + }; + peer.send(format!( + "SIP/2.0 200 OK\r\nVia: {}\r\nFrom: {}\r\nTo: {};tag={FROM_TAG}\r\nCall-ID: {CALL_ID}\r\n\ + CSeq: 1 INVITE\r\nContact: \r\nContent-Length: 0\r\n\r\n", + req.via_header()?.value(), + req.from_header()?.value(), + req.to_header()?.value(), + )) + .await; + let (dialog, _) = invite.await.unwrap()?; + let local_tag = dialog.id().local_tag; + + // The callee re-INVITEs and the application answers it. + peer.send_request(Method::Invite, 7, Some(&local_tag)).await; + let handle = loop { + match tokio::time::timeout(Duration::from_secs(2), states.recv()).await { + Ok(Some(DialogState::Updated(_, _, handle))) => break handle, + Ok(Some(_)) => {} + _ => panic!("timeout waiting for the re-INVITE"), + } + }; + let answered = Instant::now(); + handle.reply(crate::sip::StatusCode::OK).await.ok(); + let mut messages = Vec::new(); + while !messages.iter().any(|(_, m)| is_2xx_to(m, 7)) { + assert!(answered.elapsed() < T1X64, "timeout waiting for the 2xx"); + messages.extend(peer.collect(Instant::now() + T1 / 2).await); + } + if ack { + peer.send_request(Method::Ack, 7, Some(&local_tag)).await; + } + messages.extend(peer.collect(answered + T1X64 * 2).await); + let bye = messages + .iter() + .any(|(_, m)| matches!(m, SipMessage::Request(r) if r.method == Method::Bye)); + if ack { + assert!(!bye, "an ACKed re-INVITE must not end the session"); + assert!(terminated_reason(&mut states).is_none()); + assert!(dialog.state().is_confirmed()); + } else { + assert!(messages.iter().filter(|(_, m)| is_2xx_to(m, 7)).count() >= 2); + assert!(bye_of_dialog(&messages, &local_tag) - answered >= T1X64 - T1); + assert!(matches!( + terminated_reason(&mut states), + Some(TerminatedReason::Timeout) + )); + } + token.cancel(); + Ok(()) +} + +#[tokio::test] +async fn test_unacked_reinvite_2xx_ends_the_session_on_a_legacy_client_dialog() -> crate::Result<()> +{ + legacy_client_dialog_reinvite(false).await +} + +#[tokio::test] +async fn test_acked_reinvite_keeps_a_legacy_client_dialog() -> crate::Result<()> { + legacy_client_dialog_reinvite(true).await +}