From 3bf17f99bf666c106e9ce218725ed7a2cffa79e1 Mon Sep 17 00:00:00 2001 From: Tyson George Date: Wed, 7 Oct 2026 14:36:16 -0400 Subject: [PATCH] fix(transaction): Timer F ends a non-INVITE client transaction in Proceeding A non-INVITE client transaction runs Timer F as Timer B, and on_timer handled Timer B only in Calling and Trying. After a provisional other than 100 (e.g. 180 to a BYE) the transaction is in Proceeding, Timer F was ignored there, and a request answered with one 1xx and then nothing never ended. RFC 3261 section 17.1.2.2: if Timer F fires in Proceeding, the TU must be informed of a timeout and the transaction terminated. Handle Timer B for a non-INVITE client in Proceeding like Timer C: a local 408 to the TU, which terminates the transaction. --- src/transaction/tests/test_client.rs | 81 ++++++++++++++++++++++++++++ src/transaction/transaction.rs | 7 ++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/transaction/tests/test_client.rs b/src/transaction/tests/test_client.rs index 70b9cd52..64ac6c9f 100644 --- a/src/transaction/tests/test_client.rs +++ b/src/transaction/tests/test_client.rs @@ -473,3 +473,84 @@ async fn test_invite_2xx_upstream_via_delivery_and_ack() -> Result<()> { } Ok(()) } + +/// RFC 3261 §17.1.2.2: Timer F still runs after a provisional response. A +/// BYE answered with one 1xx and then nothing must end with a 408 at 64*T1, +/// on unreliable and reliable transports alike. +#[tokio::test] +async fn test_non_invite_timer_f_after_provisional() -> Result<()> { + use crate::transaction::{endpoint::EndpointOption, EndpointBuilder}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::{TcpListener, UdpSocket}; + + // The request with its start line swapped for a provisional status line. + fn provisional(code: u16, request: &[u8]) -> Vec { + let text = String::from_utf8_lossy(request); + let (_, headers) = text.split_once("\r\n").unwrap(); + format!("SIP/2.0 {code} Provisional\r\n{headers}").into_bytes() + } + + for (code, tcp) in [(100, false), (180, false), (183, true)] { + let tl = crate::transport::TransportLayer::new(Default::default()); + let udp = UdpConnection::create_connection("127.0.0.1:0".parse()?, None, None).await?; + tl.add_transport(udp.into()); + let t1 = Duration::from_millis(20); + let option = EndpointOption { + t1, + t1x64: t1 * 64, + ..Default::default() + }; + let endpoint = EndpointBuilder::new() + .with_transport_layer(tl) + .with_option(option) + .build(); + + // The peer answers the BYE with one provisional, then stays silent. + let socket = UdpSocket::bind("127.0.0.1:0").await?; + let listener = TcpListener::bind("127.0.0.1:0").await?; + let uri = match tcp { + true => format!("sip:bob@{};transport=tcp", listener.local_addr()?), + false => format!("sip:bob@{}", socket.local_addr()?), + }; + let peer = tokio::spawn(async move { + let mut buf = vec![0u8; 4096]; + if tcp { + let (mut stream, _) = listener.accept().await?; + let mut len = 0; + while !buf[..len].windows(4).any(|w| w == b"\r\n\r\n") { + len += stream.read(&mut buf[len..]).await?; + } + stream.write_all(&provisional(code, &buf[..len])).await?; + std::future::pending::<()>().await; // keep the connection open + } else { + let (len, src) = socket.recv_from(&mut buf).await?; + socket.send_to(&provisional(code, &buf[..len]), src).await?; + } + std::future::pending::>().await + }); + + let mut bye = make_invite_request(&uri)?; + bye.method = crate::sip::Method::Bye; + bye.headers.unique_push(CSeq::new("2 BYE").into()); + let key = TransactionKey::from_request(&bye, TransactionRole::Client)?; + let mut tx = Transaction::new_client(key, bye, endpoint.inner.clone(), None); + let mut codes = vec![]; + let run = async { + tx.send().await?; + while let Some(SipMessage::Response(resp)) = tx.receive().await { + codes.push(resp.status_code.code()); + } + Ok::<_, crate::Error>(()) + }; + // `receive` returning None means the transaction terminated. + let terminated = select! { + r = run => r.map(|_| true)?, + _ = endpoint.serve() => panic!("endpoint stopped"), + _ = sleep(t1 * 64 * 3) => false, + }; + peer.abort(); + assert_eq!(codes, [code, 408], "{code} over tcp={tcp}"); + assert!(terminated, "{code} over tcp={tcp}: not terminated"); + } + Ok(()) +} diff --git a/src/transaction/transaction.rs b/src/transaction/transaction.rs index 96092137..4c70e766 100644 --- a/src/transaction/transaction.rs +++ b/src/transaction/transaction.rs @@ -1161,7 +1161,12 @@ impl Transaction { } } TransactionState::Proceeding => { - if let TransactionTimer::TimerC(_) = timer { + // Timer C (client INVITE), or Timer F, run as Timer B, for a + // non-INVITE client (RFC 3261 §17.1.2.2). + if matches!(timer, TransactionTimer::TimerC(_)) + || (matches!(timer, TransactionTimer::TimerB(_)) + && self.transaction_type == TransactionType::ClientNonInvite) + { // Inform TU about timeout let timeout_response = self.endpoint_inner.make_response( &self.original,