From c678342d9bb3b89e58657d4f95b1333c57b283b7 Mon Sep 17 00:00:00 2001 From: Tyson George Date: Wed, 7 Oct 2026 12:37:43 -0400 Subject: [PATCH] fix(dialog): a server in-dialog request with no route and no dial-back fails at once When the target locator fails, Transaction::send returns before the transaction enters Calling, so no timer is armed and nothing is ever sent. A server dialog then looks for a dial-back address; with none it went on to wait in tx.receive() for a response that cannot come, and BYE, re-INVITE, INFO and every other in-dialog request never returned. Return the send error in that case, as a client dialog already does. The dial-back retry is unchanged. --- src/dialog/dialog.rs | 7 +++ src/dialog/tests/test_connection_affinity.rs | 63 ++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/src/dialog/dialog.rs b/src/dialog/dialog.rs index b0e4aaae..d59152ed 100644 --- a/src/dialog/dialog.rs +++ b/src/dialog/dialog.rs @@ -1134,6 +1134,7 @@ impl DialogInner { } } let need_fallback_retry; + let mut send_error = None; match tx.send().await { Ok(_) => { debug!( @@ -1161,6 +1162,7 @@ impl DialogInner { debug!(id = self.id.lock().to_string(), req = %tx.original, "request that failed to send"); return Err(e); } + send_error = Some(e); } } @@ -1232,6 +1234,11 @@ impl DialogInner { method = %method, "no usable connection and no dial-back target; giving up after first send" ); + // The failed send never started the transaction: no response + // or timer will ever end it, so report the error now. + if let Some(e) = send_error { + return Err(e); + } } } diff --git a/src/dialog/tests/test_connection_affinity.rs b/src/dialog/tests/test_connection_affinity.rs index 489c0883..494276c7 100644 --- a/src/dialog/tests/test_connection_affinity.rs +++ b/src/dialog/tests/test_connection_affinity.rs @@ -527,6 +527,69 @@ async fn test_restored_dialog_falls_back_to_initial_via_dialback() { } } +/// Fails every lookup, as a registrar-backed locator does once the target is gone. +struct NoRoute; + +#[async_trait::async_trait] +impl crate::transaction::endpoint::TargetLocator for NoRoute { + async fn locate(&self, _: &crate::sip::Uri) -> crate::Result { + Err(crate::Error::Error("no route".to_string())) + } +} + +#[tokio::test] +async fn test_server_request_without_route_fails_unless_dialed_back() -> crate::Result<()> { + // The locator error fails the send before the transaction starts. With no + // dial-back address nothing will ever end that transaction, so the request + // must return the error at once; with one, it is dialed back as before. + let tl = TransportLayer::new(CancellationToken::new()); + let udp = UdpConnection::create_connection("127.0.0.1:0".parse()?, None, None).await?; + tl.add_transport(udp.into()); + let endpoint = EndpointBuilder::new() + .with_transport_layer(tl) + .with_target_locator(Box::new(NoRoute)) + .build(); + let confirmed = |call_id: &str, via: &str| { + let invite = plain_udp_invite("alice-tag", call_id, via); + let (ep, role) = (endpoint.inner.clone(), TransactionRole::Server); + let inner = server_dialog(ep, role, invite, "sip:bob@127.0.0.1:5060"); + let id = inner.id.lock().clone(); + inner + .transition(DialogState::Confirmed(id, Response::default())) + .unwrap(); + InviteDialog::from_inner(Arc::new(inner)) + }; + + let dialog = confirmed("no-route", "SIP/2.0/UDP a.invalid;branch=z9hG4bKnoroute"); + for method in ["INFO", "re-INVITE", "BYE"] { + let request = async { + match method { + "INFO" => dialog.info(None, None).await.map(|_| ()), + "re-INVITE" => dialog.reinvite(None, None).await.map(|_| ()), + _ => dialog.bye().await, + } + }; + let result = tokio::time::timeout(Duration::from_secs(5), request).await; + let err = result.unwrap_or_else(|_| panic!("{method} must fail at once, not hang")); + let err = err.expect_err(method).to_string(); + assert!(err.contains("no route"), "{method}: {err}"); + } + + let probe = UdpSocket::bind("127.0.0.1:0").await?; + let via = format!( + "SIP/2.0/UDP alice.invalid:5060;branch=z9hG4bKdialback;received=127.0.0.1;rport={}", + probe.local_addr()?.port() + ); + let dialog = confirmed("dialback", &via); + tokio::spawn(async move { dialog.bye().await }); + let mut buf = [0u8; 2048]; + let (len, _) = tokio::time::timeout(Duration::from_secs(3), probe.recv_from(&mut buf)) + .await + .expect("the BYE must be dialed back to the Via's received/rport")?; + assert!(buf[..len].starts_with(b"BYE ")); + Ok(()) +} + /// A cancelled (dead) WebSocket flow must not cause affinity retransmissions /// into a dead socket, and the dial-back ladder must still deliver the BYE /// through tier 1 — the address captured from the connection at creation —