Skip to content

InviteDialog::cancel() returns Ok(()) and does nothing: UAC before the first provisional, and any UAS dialog #197

Closed

Description

@beija-wq

Found while integrating rsipstack in a SIP application; same reporter as #192.

Versions affected. 0.7.3 (latest) (5/5 runs), 0.6.12 and 0.6.10 (5/5
runs each).

Environment. rsipstack 0.7.3, the latest version on crates.io, default features. Rust 1.98.1 stable, tokio 1.53 (multi-thread runtime). Linux 6.12 x86_64, 4 CPUs. UDP over loopback only: everything on 127.0.0.1, every port assigned by the OS. Default EndpointOption (T1 = 500 ms, T2 = 4 s, T4 = 5 s).

Where. src/dialog/invite_dialog.rs:203-216 (0.7.3).
ClientInviteDialog::cancel delegates to it (client_dialog.rs:267-271):

/// Send a CANCEL request to abort an unanswered INVITE.
/// No-op for UAS dialogs.
pub async fn cancel(&self) -> Result<()> {
    if self.role() != TransactionRole::Client
        || !matches!(self.state(), DialogState::Trying(_) | DialogState::Early(_, _))
    {
        return Ok(());
    }
    self.send_cancel().await
}
  • UAC in Calling (INVITE sent, no 1xx yet): the function returns
    Ok(()). No CANCEL is sent and the request is not remembered. Since fix: CANCEL a dropped INVITE after its first provisional response (rebase of #168) #171
    (0.7.1), dropping the do_invite future in this window is handled
    correctly: the transaction is kept, a CANCEL goes out on the first 1xx, and
    a 2xx gets ACK + BYE (src/dialog/invitation.rs:194-245). An explicit
    cancel(), which is the only way to abandon a do_invite_async call
    without aborting its task, does not take that path.
  • UAS: the function returns Ok(()) and sends nothing. The doc comment
    says so, but the caller cannot tell "CANCEL sent" from "nothing happened".

How to run.

  • Part A: the program calls do_invite_async. The peer receives the INVITE
    and does not answer. The application calls dialog.cancel(). One second
    later the peer sends 180, then after one more second 200.
  • Part B: a server dialog is created and handle() spawned, with no answer.
    The application calls dialog.cancel().
Reproduction program (one self-contained file, 391 lines) and how to run it

It uses only rsipstack's public API plus a raw tokio::net::UdpSocket peer
on 127.0.0.1. It prints what it observes and exits with 1 when the problem
reproduces, 0 when it does not.

Cargo.toml (replace the [dependencies] section):

[dependencies]
rsipstack = "=0.7.3"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync"] }
tokio-util = "0.7"

src/main.rs:

//! Issue C: `InviteDialog::cancel()` returns `Ok(())` and sends nothing.
//!
//! Part A (UAC, no provisional yet): `cancel()` after the INVITE went out and
//! before any response. The peer then sends 180 and, 1 s later, 200.
//! Expected: CANCEL after the 180 (RFC 3261 §9.1), or ACK + BYE on a 2xx.
//! Part B (UAS): `cancel()` on an unanswered server dialog.
//!
//! Usage: cargo run --release
#![allow(dead_code)] // the harness at the bottom is shared by several issues
use rsipstack::dialog::dialog::DialogState;
use rsipstack::dialog::invitation::InviteOption;
use std::net::SocketAddr;
use std::time::{Duration, Instant};
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver};

/// First lines received within `wait`; a BYE is answered 200.
async fn watch(peer: &Peer, wait: Duration) -> Result<Vec<String>, BoxError> {
    let deadline = Instant::now() + wait;
    let mut out = Vec::new();
    while let Some(rx) = peer
        .recv(deadline.saturating_duration_since(Instant::now()))
        .await
    {
        if rx.is_request("BYE") {
            peer.send(rx.from, &peer.response_to(&rx, 200, "OK", ""))
                .await?;
        }
        out.push(rx.first_line().to_string());
    }
    Ok(out)
}

#[tokio::main(flavor = "multi_thread")]
async fn main() -> Result<(), BoxError> {
    println!(
        "== issue C, cancel() no-op | {} | 127.0.0.1 only",
        rsipstack::VERSION
    );

    // ---- Part A: UAC ----
    let stack = Stack::start().await?;
    let peer = Peer::bind().await?;
    let me = stack.addr;
    let (state_tx, mut state_rx) = unbounded_channel();
    let opt = InviteOption {
        caller: format!("sip:alice@{me}").as_str().try_into()?,
        callee: format!("sip:bob@{}", peer.addr).as_str().try_into()?,
        contact: format!("sip:alice@{me}").as_str().try_into()?,
        ..Default::default()
    };
    let (dialog, join) = stack.dialogs.do_invite_async(opt, state_tx)?;
    let invite = peer
        .recv_until(Duration::from_secs(2), |r| r.is_request("INVITE"))
        .await
        .ok_or("no INVITE")?;
    let cancel = dialog.cancel().await;
    let after_cancel = peer.collect(Duration::from_secs(1)).await;
    peer.send(invite.from, &peer.response_to(&invite, 180, "Ringing", ""))
        .await?;
    let after_180 = watch(&peer, Duration::from_secs(1)).await?;
    let cancel_seen = after_180.iter().any(|l| l.starts_with("CANCEL "));
    let mut after_200 = Vec::new();
    if !cancel_seen {
        peer.send(invite.from, &peer.response_to(&invite, 200, "OK", ""))
            .await?;
        after_200 = watch(&peer, Duration::from_millis(1500)).await?;
    }
    let states_a = drain_states(&mut state_rx, Duration::from_millis(200)).await;
    join.abort();
    println!(
        "A: cancel() before any response -> {:?}",
        cancel.map_err(|e| e.to_string())
    );
    println!("A: peer received in the 1 s after cancel(): {after_cancel:?}");
    println!("A: after the peer's 180: {after_180:?}");
    if !cancel_seen {
        println!("A: after the peer's 200: {after_200:?}");
    }
    println!(
        "A: dialog states: {states_a:?}; final state(): {}",
        state_name(&dialog.state())
    );
    let ack_and_bye = ["ACK ", "BYE "]
        .iter()
        .all(|m| after_200.iter().any(|l| l.starts_with(m)));
    let a_repro = !cancel_seen && !ack_and_bye;
    drop(stack);

    // ---- Part B: UAS ----
    let stack = Stack::start().await?;
    let mut calls = stack.uas_loop()?;
    let uas = stack.addr;
    let peer = Peer::bind().await?;
    peer.send(uas, &peer.invite(uas, "c-uas")).await?;
    let mut call = calls.recv().await.ok_or("no call")?;
    call.spawn_handle();
    tokio::time::sleep(Duration::from_millis(50)).await;
    let cancel_b = call.dialog.cancel().await;
    let got = peer.collect(Duration::from_secs(2)).await;
    let states_b = drain_states(&mut call.states, Duration::from_millis(100)).await;
    println!(
        "B: UAS cancel() on an unanswered INVITE -> {:?}",
        cancel_b.as_ref().map_err(|e| e.to_string())
    );
    println!("B: peer received in 2 s: {got:?}; dialog states: {states_b:?}");
    let final_sent = got
        .iter()
        .any(|l| l.starts_with("SIP/2.0 4") || l.starts_with("SIP/2.0 6"));
    let b_repro = cancel_b.is_ok() && !final_sent;

    verdict(
        a_repro || b_repro,
        &format!("UAC cancel() lost: {a_repro}; UAS cancel() silent Ok: {b_repro}"),
    )
}

// ---- harness: the stack under test and a raw UDP peer, 127.0.0.1 only ----

type BoxError = Box<dyn std::error::Error + Send + Sync>;

fn verdict(reproduced: bool, what: &str) -> ! {
    let tag = if reproduced {
        "REPRODUCED"
    } else {
        "not reproduced"
    };
    println!("RESULT: {tag} - {what}");
    std::process::exit(i32::from(reproduced))
}

/// The stack under test: one UDP transport on 127.0.0.1:0, default options.
struct Stack {
    endpoint: rsipstack::transaction::Endpoint,
    dialogs: std::sync::Arc<rsipstack::dialog::dialog_layer::DialogLayer>,
    addr: SocketAddr,
    cancel: tokio_util::sync::CancellationToken,
}

impl Stack {
    async fn start() -> Result<Stack, BoxError> {
        let cancel = tokio_util::sync::CancellationToken::new();
        let tl = rsipstack::transport::TransportLayer::new(cancel.child_token());
        let udp = rsipstack::transport::udp::UdpConnection::create_connection(
            "127.0.0.1:0".parse()?,
            None,
            Some(cancel.child_token()),
        )
        .await?;
        let addr = udp.get_addr().get_socketaddr()?;
        tl.add_transport(udp.into());
        let endpoint = rsipstack::EndpointBuilder::new()
            .with_user_agent("repro")
            .with_transport_layer(tl)
            .with_cancel_token(cancel.child_token())
            .build();
        let inner = endpoint.inner.clone();
        tokio::spawn(async move { inner.serve().await });
        let dialogs = std::sync::Arc::new(rsipstack::dialog::dialog_layer::DialogLayer::new(
            endpoint.inner.clone(),
        ));
        Ok(Stack {
            endpoint,
            dialogs,
            addr,
            cancel,
        })
    }
}

impl Drop for Stack {
    fn drop(&mut self) {
        self.cancel.cancel();
    }
}

/// A new server INVITE dialog, its state receiver and its (not yet handled)
/// transaction.
struct UasCall {
    dialog: rsipstack::dialog::invite_dialog::InviteDialog,
    states: UnboundedReceiver<DialogState>,
    tx: Option<rsipstack::transaction::Transaction>,
}

impl UasCall {
    fn spawn_handle(&mut self) {
        if let Some(mut tx) = self.tx.take() {
            let mut d = self.dialog.clone();
            tokio::spawn(async move { d.handle(&mut tx).await });
        }
    }
}

impl Stack {
    /// The usual UAS loop: in-dialog requests go to `match_dialog` + `handle()`;
    /// each new INVITE gets `get_or_create_server_invite` and is handed over.
    fn uas_loop(&self) -> Result<UnboundedReceiver<UasCall>, BoxError> {
        use rsipstack::sip::prelude::HeadersExt;
        let mut incoming = self.endpoint.incoming_transactions()?;
        let dl = self.dialogs.clone();
        let (call_tx, call_rx) = unbounded_channel();
        tokio::spawn(async move {
            while let Some(mut tx) = incoming.recv().await {
                let to_tag = tx
                    .original
                    .to_header()
                    .ok()
                    .and_then(|h| h.tag().ok().flatten());
                if to_tag.is_some() {
                    if let Some(mut d) = dl.match_dialog(&tx) {
                        tokio::spawn(async move { d.handle(&mut tx).await });
                    }
                } else if tx.original.method == rsipstack::sip::Method::Invite {
                    let (state_tx, states) = unbounded_channel();
                    if let Ok(dialog) = dl.get_or_create_server_invite(&tx, state_tx, None, None) {
                        let _ = call_tx.send(UasCall {
                            dialog,
                            states,
                            tx: Some(tx),
                        });
                    }
                }
            }
        });
        Ok(call_rx)
    }
}

fn state_name(s: &DialogState) -> String {
    match s {
        DialogState::Calling(_) => "Calling".into(),
        DialogState::Trying(_) => "Trying".into(),
        DialogState::Early(..) => "Early".into(),
        DialogState::WaitAck(..) => "WaitAck".into(),
        DialogState::Confirmed(..) => "Confirmed".into(),
        DialogState::Terminated(_, r) => format!("Terminated({r:?})"),
        _ => "Other".into(),
    }
}

/// Every state queued on `rx` within `wait`.
async fn drain_states(rx: &mut UnboundedReceiver<DialogState>, wait: Duration) -> Vec<String> {
    let mut out = Vec::new();
    let deadline = Instant::now() + wait;
    while let Ok(Some(s)) = tokio::time::timeout_at(deadline.into(), rx.recv()).await {
        out.push(state_name(&s));
    }
    out
}

/// The far end: a raw UDP socket that writes and reads SIP text by hand.
struct Peer {
    sock: tokio::net::UdpSocket,
    addr: SocketAddr,
}

/// One datagram received by the peer.
struct Rx {
    text: String,
    from: SocketAddr,
}

impl Rx {
    fn first_line(&self) -> &str {
        self.text.lines().next().unwrap_or("")
    }
    fn is_request(&self, method: &str) -> bool {
        self.first_line().starts_with(&format!("{method} "))
    }
    fn is_response(&self, code: u16, cseq_method: &str) -> bool {
        self.first_line().starts_with(&format!("SIP/2.0 {code} "))
            && header(&self.text, "CSeq").is_some_and(|c| c.ends_with(cseq_method))
    }
}

/// The value of the first header `name` (full name, case-insensitive).
fn header(text: &str, name: &str) -> Option<String> {
    let prefix = format!("{}:", name.to_ascii_lowercase());
    text.split("\r\n")
        .find(|l| l.to_ascii_lowercase().starts_with(&prefix))
        .map(|l| l[prefix.len()..].trim().to_string())
}

impl Peer {
    async fn bind() -> Result<Peer, BoxError> {
        let sock = tokio::net::UdpSocket::bind("127.0.0.1:0").await?;
        let addr = sock.local_addr()?;
        Ok(Peer { sock, addr })
    }

    async fn send(&self, to: SocketAddr, text: &str) -> Result<(), BoxError> {
        self.sock.send_to(text.as_bytes(), to).await?;
        Ok(())
    }

    async fn recv(&self, wait: Duration) -> Option<Rx> {
        let mut buf = vec![0u8; 65536];
        let (n, from) = tokio::time::timeout(wait, self.sock.recv_from(&mut buf))
            .await
            .ok()?
            .ok()?;
        Some(Rx {
            text: String::from_utf8_lossy(&buf[..n]).into_owned(),
            from,
        })
    }

    /// The first datagram matching `pred` within `wait`; others are dropped.
    async fn recv_until(&self, wait: Duration, pred: impl Fn(&Rx) -> bool) -> Option<Rx> {
        let deadline = Instant::now() + wait;
        loop {
            let rx = self
                .recv(deadline.saturating_duration_since(Instant::now()))
                .await?;
            if pred(&rx) {
                return Some(rx);
            }
        }
    }
}

impl Peer {
    /// The first line of everything received within `wait`.
    async fn collect(&self, wait: Duration) -> Vec<String> {
        let deadline = Instant::now() + wait;
        let mut out = Vec::new();
        while let Some(rx) = self
            .recv(deadline.saturating_duration_since(Instant::now()))
            .await
        {
            out.push(rx.first_line().to_string());
        }
        out
    }
}

impl Peer {
    /// An out-of-dialog INVITE from this peer to `uas`.
    fn invite(&self, uas: SocketAddr, call_id: &str) -> String {
        let me = self.addr;
        format!(
            "INVITE sip:bob@{uas} SIP/2.0\r\n\
             Via: SIP/2.0/UDP {me};branch=z9hG4bK-{call_id}-inv;rport\r\n\
             Max-Forwards: 70\r\n\
             From: <sip:alice@{me}>;tag=a1\r\n\
             To: <sip:bob@{uas}>\r\n\
             Call-ID: {call_id}\r\n\
             CSeq: 1 INVITE\r\n\
             Contact: <sip:alice@{me}>\r\n\
             Content-Length: 0\r\n\r\n"
        )
    }

    /// The ACK for a 2xx (new branch, To copied from the 2xx).
    fn ack_for(&self, uas: SocketAddr, ok: &Rx) -> String {
        let me = self.addr;
        let to = header(&ok.text, "To").unwrap_or_default();
        let call_id = header(&ok.text, "Call-ID").unwrap_or_default();
        format!(
            "ACK sip:bob@{uas} SIP/2.0\r\n\
             Via: SIP/2.0/UDP {me};branch=z9hG4bK-{call_id}-ack;rport\r\n\
             Max-Forwards: 70\r\n\
             From: <sip:alice@{me}>;tag=a1\r\n\
             To: {to}\r\n\
             Call-ID: {call_id}\r\n\
             CSeq: 1 ACK\r\n\
             Content-Length: 0\r\n\r\n"
        )
    }
}

impl Peer {
    /// A response to `req`; a To tag is added when the request has none.
    fn response_to(&self, req: &Rx, code: u16, reason: &str, extra: &str) -> String {
        let mut out = format!("SIP/2.0 {code} {reason}\r\n");
        for line in req.text.split("\r\n") {
            let lower = line.to_ascii_lowercase();
            if ["via:", "from:", "call-id:", "cseq:", "to:"]
                .iter()
                .any(|h| lower.starts_with(h))
            {
                out.push_str(line);
                if lower.starts_with("to:") && !lower.contains(";tag=") {
                    out.push_str(";tag=b1");
                }
                out.push_str("\r\n");
            }
        }
        let me = self.addr;
        out + &format!("Contact: <sip:bob@{me}>\r\n{extra}Content-Length: 0\r\n\r\n")
    }
}

Run:

cargo new repro && cd repro    # then paste the two blocks above
cargo run --release

Observed.

== issue C, cancel() no-op | rsipstack/0.7.3 | 127.0.0.1 only
A: cancel() before any response -> Ok(())
A: peer received in the 1 s after cancel(): ["INVITE sip:bob@127.0.0.1:54817 SIP/2.0"]
A: after the peer's 180: []
A: after the peer's 200: ["ACK sip:bob@127.0.0.1:54817 SIP/2.0"]
A: dialog states: ["Calling", "Early", "Confirmed"]; final state(): Confirmed
B: UAS cancel() on an unanswered INVITE -> Ok(())
B: peer received in 2 s: ["SIP/2.0 100 Trying"]; dialog states: ["Calling"]
RESULT: REPRODUCED - UAC cancel() lost: true; UAS cancel() silent Ok: true

The INVITE in the second line of part A is the normal Timer A
retransmission. In part A, the call the application cancelled gets
established: no CANCEL after the 180, the 200 is ACKed, and there is no BYE.
In part B, the caller keeps ringing until its own timers expire.

Expected.

  • 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." So the request has to be
    deferred, not dropped: CANCEL on the first 1xx, ACK + BYE if a 2xx comes
    first (§13.2.2.4, §15). This is what the drop guard already does since
    fix: CANCEL a dropped INVITE after its first provisional response (rebase of #168) #171.
  • A UAS ends an unanswered INVITE with a final response (§13.3.1.3), which
    reject() does. cancel() should not report success for the wrong role.

Suggested fix.

  • In Calling, have cancel() arm the same deferred path as the drop guard
    (keep the INVITE transaction for one response window: CANCEL on the first
    1xx, ACK + BYE on a 2xx). Return only once that is armed, or return an
    outcome the caller can inspect.
  • For a UAS, return Err(DialogError(..., "cancel is a UAC operation; use reject()")), or log a warning as fix(dialog): warn when ServerInviteDialog::bye is skipped #137 did for bye().

Related. #77 (question about cancelling), #166 and #171 (the same window
for a dropped do_invite future), #136 and #137 (the same silent Ok(())
pitfall for bye()).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions