gemini_adk_fluent_rs/telephony/
sip.rs

1//! In-process SIP — answer raw SIP calls with no carrier service in the path.
2//!
3//! *(feature `sip`)* Where [`super::twilio`] relies on a carrier
4//! service to terminate the phone network and hand audio over a WebSocket,
5//! this module terminates the call itself: SIP signalling via
6//! [`rsipstack`](https://docs.rs/rsipstack) (the stack underneath the
7//! `rustpbx` PBX), and G.711-over-RTP media built from this crate's own pure
8//! layers ([`super::rtp`], [`super::sdp`], [`super::g711`]).
9//! Any SIP endpoint — a softphone, an Asterisk/FreeSWITCH PBX, a provider's
10//! SIP trunk — dials the agent directly, and each call attaches to a Live
11//! session through the same [`voice::pump`](crate::voice::pump) as every
12//! other audio surface.
13//!
14//! ```ignore
15//! // `ignore`: `SipAgent` needs the `sip` feature and a bound UDP socket.
16//! let mut agent = SipAgent::bind("0.0.0.0:5060".parse()?).await?;
17//! while let Some(incoming) = agent.next_call().await {
18//!     let session = Live::builder()
19//!         .instruction("Answer the phone politely.")
20//!         .greeting("Greet the caller.")
21//!         .connect_from_env().await?;
22//!     let call = incoming.answer(&session).await?;
23//!     tokio::spawn(async move { call.ended().await; });
24//! }
25//! ```
26//!
27//! RFC 4733 telephone events (DTMF) are negotiated in the SDP answer when
28//! the offer proposes them; keypresses land in session state under the
29//! shared [`super::bridge`] keys, where flow guards read them —
30//! identical to the Twilio path. What is deliberately *not* here yet: SIP
31//! registration (the agent is a directly-dialed UAS) and SRTP. Media is
32//! symmetric RTP: the agent sends to the offer's address but re-latches onto
33//! the source of the first arriving packet, which keeps NATted softphones
34//! working.
35
36use std::net::{IpAddr, SocketAddr};
37use std::sync::Arc;
38use std::time::Duration;
39
40use tokio::net::UdpSocket;
41use tokio::sync::{mpsc, watch};
42use tokio::task::JoinHandle;
43use tokio_util::sync::CancellationToken;
44
45use rsipstack::EndpointBuilder;
46use rsipstack::dialog::dialog::DialogState;
47use rsipstack::dialog::dialog_layer::DialogLayer;
48use rsipstack::dialog::invite_dialog::InviteDialog;
49use rsipstack::transport::TransportLayer;
50use rsipstack::transport::udp::UdpConnection;
51
52use gemini_adk_rs::State;
53use gemini_adk_rs::live::LiveHandle;
54
55use super::bridge::{self, DtmfDeduper, FillerConfig};
56use super::g711;
57use super::rtp::{self, PT_PCMA, RtpSender, SAMPLES_PER_PACKET};
58use super::sdp::{self, AudioOffer};
59use crate::voice::{Playback, VoicePump, pump};
60
61/// Errors from the SIP agent.
62#[derive(Debug)]
63pub enum SipError {
64    /// Binding or socket I/O failed.
65    Io(std::io::Error),
66    /// The SIP stack reported an error.
67    Sip(rsipstack::Error),
68    /// The INVITE carried no answerable audio offer (no `m=audio`, port 0).
69    NoAudioOffer,
70    /// The offer had audio but no G.711 codec this agent can speak.
71    NoCommonCodec,
72}
73
74impl std::fmt::Display for SipError {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            Self::Io(e) => write!(f, "sip io error: {e}"),
78            Self::Sip(e) => write!(f, "sip stack error: {e:?}"),
79            Self::NoAudioOffer => write!(f, "INVITE carried no answerable audio offer"),
80            Self::NoCommonCodec => write!(f, "no common G.711 codec with the caller"),
81        }
82    }
83}
84
85impl std::error::Error for SipError {}
86
87impl From<std::io::Error> for SipError {
88    fn from(e: std::io::Error) -> Self {
89        Self::Io(e)
90    }
91}
92
93impl From<rsipstack::Error> for SipError {
94    fn from(e: rsipstack::Error) -> Self {
95        Self::Sip(e)
96    }
97}
98
99// ── Agent ────────────────────────────────────────────────────────────────────
100
101/// A SIP user agent server: binds a UDP SIP port and yields incoming calls.
102pub struct SipAgent {
103    dialog_layer: Arc<DialogLayer>,
104    incoming: rsipstack::transaction::TransactionReceiver,
105    cancel: CancellationToken,
106    local_ip: IpAddr,
107    sip_port: u16,
108}
109
110impl SipAgent {
111    /// Bind the SIP signalling port (conventionally 5060/udp) and start the
112    /// endpoint's serve loop in the background.
113    pub async fn bind(addr: SocketAddr) -> Result<SipAgent, SipError> {
114        let cancel = CancellationToken::new();
115        let transport_layer = TransportLayer::new(cancel.child_token());
116        let udp = UdpConnection::create_connection(addr, None, Some(cancel.child_token()))
117            .await
118            .map_err(SipError::Sip)?;
119        let sip_port = udp
120            .get_addr()
121            .addr
122            .port
123            .as_ref()
124            .map(|p| u16::from(*p))
125            .unwrap_or(addr.port());
126        transport_layer.add_transport(udp.into());
127
128        let endpoint = EndpointBuilder::new()
129            .with_user_agent("gemini-rs")
130            .with_cancel_token(cancel.child_token())
131            .with_transport_layer(transport_layer)
132            .build();
133        endpoint
134            .inner
135            .transport_layer
136            .serve_listens()
137            .await
138            .map_err(SipError::Sip)?;
139        let inner = endpoint.inner.clone();
140        tokio::spawn(async move {
141            let _ = inner.serve().await;
142        });
143
144        let incoming = endpoint.incoming_transactions().map_err(SipError::Sip)?;
145        let dialog_layer = Arc::new(DialogLayer::new(endpoint.inner.clone()));
146
147        Ok(SipAgent {
148            dialog_layer,
149            incoming,
150            cancel,
151            local_ip: addr.ip(),
152            sip_port,
153        })
154    }
155
156    /// Wait for the next incoming call. In-dialog requests (BYE, re-INVITE,
157    /// ACK) and non-call methods are handled internally; only new INVITEs
158    /// surface. Returns `None` once the agent is shut down.
159    pub async fn next_call(&mut self) -> Option<IncomingCall> {
160        while let Some(mut tx) = self.incoming.recv().await {
161            use rsipstack::rsip::{Method, StatusCode};
162            match tx.original.method {
163                Method::Invite => {
164                    let offer = match sdp::parse_audio_offer(
165                        String::from_utf8_lossy(&tx.original.body).as_ref(),
166                    ) {
167                        Some(offer) => offer,
168                        None => {
169                            let _ = tx.reply(StatusCode::NotAcceptableHere).await;
170                            continue;
171                        }
172                    };
173                    let (state_tx, state_rx) = mpsc::unbounded_channel();
174                    let contact = format!(
175                        "sip:gemini@{}:{};transport=udp",
176                        advertised_ip(self.local_ip, &offer),
177                        self.sip_port
178                    );
179                    let contact = match rsipstack::rsip::Uri::try_from(contact.as_str()) {
180                        Ok(uri) => uri,
181                        Err(_) => {
182                            let _ = tx.reply(StatusCode::ServerInternalError).await;
183                            continue;
184                        }
185                    };
186                    let dialog = match self.dialog_layer.get_or_create_server_invite(
187                        &tx,
188                        state_tx,
189                        None,
190                        Some(contact),
191                    ) {
192                        Ok(dialog) => dialog,
193                        Err(err) => {
194                            tracing::warn!("rejecting INVITE: {err:?}");
195                            let _ = tx.reply(StatusCode::ServerInternalError).await;
196                            continue;
197                        }
198                    };
199                    use rsipstack::rsip::HeadersExt as _;
200                    let from = tx
201                        .original
202                        .from_header()
203                        .map(std::string::ToString::to_string)
204                        .unwrap_or_default();
205                    let _ = dialog.ringing(None, None);
206                    // Responses (180/200/603) are queued events on the INVITE
207                    // transaction; pumping receive() is what puts them on the
208                    // wire and later delivers the ACK.
209                    tokio::spawn(async move { while tx.receive().await.is_some() {} });
210                    return Some(IncomingCall {
211                        dialog,
212                        state_rx,
213                        offer,
214                        from,
215                        local_ip: self.local_ip,
216                        dialog_layer: self.dialog_layer.clone(),
217                        filler: None,
218                    });
219                }
220                Method::Ack | Method::Bye | Method::Cancel | Method::Info | Method::Update => {
221                    // In-dialog requests: route to the owning dialog.
222                    match self.dialog_layer.match_dialog(&tx) {
223                        Some(mut dialog) => {
224                            tokio::spawn(async move {
225                                let _ = dialog.handle(&mut tx).await;
226                            });
227                        }
228                        None => {
229                            let _ = tx.reply(StatusCode::CallTransactionDoesNotExist).await;
230                        }
231                    }
232                }
233                Method::Options => {
234                    let _ = tx.reply(StatusCode::OK).await;
235                }
236                _ => {
237                    let _ = tx.reply(StatusCode::MethodNotAllowed).await;
238                }
239            }
240        }
241        None
242    }
243
244    /// The SIP port actually bound (useful with port 0).
245    pub fn sip_port(&self) -> u16 {
246        self.sip_port
247    }
248
249    /// Stop the endpoint and every call it produced.
250    pub fn shutdown(&self) {
251        self.cancel.cancel();
252    }
253}
254
255// ── Incoming call ────────────────────────────────────────────────────────────
256
257/// A ringing inbound call: answer it onto a session, or reject it.
258pub struct IncomingCall {
259    dialog: InviteDialog,
260    state_rx: mpsc::UnboundedReceiver<DialogState>,
261    /// The caller's parsed audio offer.
262    pub offer: AudioOffer,
263    /// The caller's `From` header, for screening/logging.
264    pub from: String,
265    local_ip: IpAddr,
266    dialog_layer: Arc<DialogLayer>,
267    filler: Option<FillerConfig>,
268}
269
270impl IncomingCall {
271    /// Play a latency-masking filler clip when the model stays silent too
272    /// long after the caller stops speaking — see
273    /// [`bridge::spawn_latency_filler`]. The clip must be mono PCM16 at
274    /// 8 kHz (the call's playback rate).
275    pub fn filler(mut self, config: FillerConfig) -> Self {
276        self.filler = Some(config);
277        self
278    }
279
280    /// Answer the call onto a connected session: bind an RTP socket, send the
281    /// SDP answer in the 200 OK, and start the media loop.
282    ///
283    /// When the offer proposes RFC 4733 telephone events, the answer accepts
284    /// them and keypresses are written to session state via
285    /// [`bridge::record_dtmf`]. The caller's `From` identity lands under
286    /// [`bridge::KEY_CALLER`].
287    pub async fn answer(self, handle: &LiveHandle) -> Result<SipCall, SipError> {
288        let payload_type = self.offer.g711_payload_type().ok_or_else(|| {
289            let _ = self.dialog.reject(None, None);
290            SipError::NoCommonCodec
291        })?;
292
293        let media_ip = advertised_ip(self.local_ip, &self.offer);
294        let rtp_socket = UdpSocket::bind((self.local_ip, 0)).await?;
295        let rtp_port = rtp_socket.local_addr()?.port();
296        let remote: SocketAddr = format!("{}:{}", self.offer.host, self.offer.port)
297            .parse()
298            .map_err(|_| SipError::NoAudioOffer)?;
299
300        let telephone_event_pt = self.offer.telephone_event_pt;
301        let answer = sdp::audio_answer(
302            seed() as u64,
303            &media_ip.to_string(),
304            rtp_port,
305            payload_type,
306            telephone_event_pt,
307        );
308        self.dialog
309            .accept(None, Some(answer.into_bytes()))
310            .map_err(SipError::Sip)?;
311        let _ = handle.state().set(bridge::KEY_CALLER, self.from.clone());
312
313        let cancel = CancellationToken::new();
314        let media = rtp_media(
315            handle,
316            Arc::new(rtp_socket),
317            remote,
318            payload_type,
319            telephone_event_pt,
320            self.filler,
321            cancel.clone(),
322        );
323
324        // Tear the media down when the dialog terminates (BYE, error).
325        let mut state_rx = self.state_rx;
326        let media_cancel = cancel.clone();
327        let dialog_id = self.dialog.id();
328        let dialog_layer = self.dialog_layer;
329        let ended = tokio::spawn(async move {
330            while let Some(state) = state_rx.recv().await {
331                if let DialogState::Terminated(_, _) = state {
332                    break;
333                }
334            }
335            media_cancel.cancel();
336            dialog_layer.remove_dialog(&dialog_id);
337        });
338
339        Ok(SipCall {
340            dialog: self.dialog,
341            media,
342            cancel,
343            ended,
344        })
345    }
346
347    /// Decline the call (486 Busy Here by default).
348    pub fn reject(self) {
349        let _ = self.dialog.reject(None, None);
350    }
351}
352
353// ── Live call ────────────────────────────────────────────────────────────────
354
355/// An answered SIP call with media flowing.
356pub struct SipCall {
357    dialog: InviteDialog,
358    media: MediaTasks,
359    cancel: CancellationToken,
360    ended: JoinHandle<()>,
361}
362
363impl SipCall {
364    /// Wait until the call ends (caller hung up, or [`hangup`](Self::hangup)).
365    pub async fn ended(self) {
366        let _ = self.ended.await;
367        self.media.stop().await;
368    }
369
370    /// Hang up: send BYE and stop the media loop.
371    pub async fn hangup(self) {
372        let _ = self.dialog.bye().await;
373        self.cancel.cancel();
374        let _ = self.ended.await;
375        self.media.stop().await;
376    }
377}
378
379// ── Media loop ───────────────────────────────────────────────────────────────
380
381struct MediaTasks {
382    pump: VoicePump,
383    inbound: JoinHandle<()>,
384    outbound: JoinHandle<()>,
385    filler: Option<JoinHandle<()>>,
386}
387
388impl MediaTasks {
389    async fn stop(self) {
390        self.inbound.abort();
391        self.outbound.abort();
392        if let Some(filler) = self.filler {
393            filler.abort();
394        }
395        self.pump.abort();
396        self.pump.join().await;
397    }
398}
399
400/// Wire a session's voice pump to G.711-over-RTP on a UDP socket.
401///
402/// Symmetric RTP: packets go to `remote` until the first packet arrives,
403/// whose source address then becomes the send target (NAT re-latch).
404fn rtp_media(
405    handle: &LiveHandle,
406    socket: Arc<UdpSocket>,
407    remote: SocketAddr,
408    payload_type: u8,
409    telephone_event_pt: Option<u8>,
410    filler: Option<FillerConfig>,
411    cancel: CancellationToken,
412) -> MediaTasks {
413    let (mic_tx, mic_rx) = mpsc::channel::<Vec<i16>>(64);
414    let (speaker_tx, speaker_rx) = mpsc::channel::<Playback>(64);
415    let voice_pump = pump(
416        handle,
417        mic_rx,
418        super::TWILIO_HZ,
419        speaker_tx.clone(),
420        super::TWILIO_HZ,
421    );
422    let (peer_tx, peer_rx) = watch::channel(remote);
423
424    let filler = filler.map(|config| bridge::spawn_latency_filler(handle, speaker_tx, config));
425
426    let inbound = tokio::spawn(inbound_loop(
427        socket.clone(),
428        payload_type,
429        telephone_event_pt,
430        handle.state().clone(),
431        mic_tx,
432        peer_tx,
433        cancel.clone(),
434    ));
435    let outbound = tokio::spawn(outbound_loop(
436        socket,
437        payload_type,
438        speaker_rx,
439        peer_rx,
440        cancel,
441    ));
442
443    MediaTasks {
444        pump: voice_pump,
445        inbound,
446        outbound,
447        filler,
448    }
449}
450
451async fn inbound_loop(
452    socket: Arc<UdpSocket>,
453    payload_type: u8,
454    telephone_event_pt: Option<u8>,
455    state: State,
456    mic_tx: mpsc::Sender<Vec<i16>>,
457    peer_tx: watch::Sender<SocketAddr>,
458    cancel: CancellationToken,
459) {
460    let mut buf = [0u8; 2048];
461    let mut latched = false;
462    let mut dtmf = DtmfDeduper::default();
463    loop {
464        let (len, source) = tokio::select! {
465            _ = cancel.cancelled() => break,
466            received = socket.recv_from(&mut buf) => match received {
467                Ok(pair) => pair,
468                Err(_) => break,
469            },
470        };
471        let Some(packet) = rtp::parse(&buf[..len]) else {
472            continue; // stray non-RTP traffic on the media port
473        };
474        if telephone_event_pt == Some(packet.payload_type) {
475            // RFC 4733 keypress: emit once per end-marked event.
476            if let Some(event) = rtp::parse_telephone_event(&packet.payload)
477                && dtmf.accept(event.end, packet.timestamp)
478                && let Some(digit) = event.digit()
479            {
480                bridge::record_dtmf(&state, digit);
481            }
482            continue;
483        }
484        if packet.payload_type != payload_type {
485            continue; // a payload type we did not negotiate
486        }
487        if !latched {
488            let _ = peer_tx.send(source);
489            latched = true;
490        }
491        let samples = if payload_type == PT_PCMA {
492            g711::decode_alaw(&packet.payload)
493        } else {
494            g711::decode_ulaw(&packet.payload)
495        };
496        if mic_tx.send(samples).await.is_err() {
497            break;
498        }
499    }
500}
501
502async fn outbound_loop(
503    socket: Arc<UdpSocket>,
504    payload_type: u8,
505    mut speaker_rx: mpsc::Receiver<Playback>,
506    peer_rx: watch::Receiver<SocketAddr>,
507    cancel: CancellationToken,
508) {
509    let silence_byte: u8 = if payload_type == PT_PCMA { 0xD5 } else { 0xFF };
510    let seed = seed();
511    let mut sender = RtpSender::new(payload_type, seed, (seed >> 16) as u16, seed.rotate_left(8));
512    let mut pending: std::collections::VecDeque<i16> = std::collections::VecDeque::new();
513    let mut ticker = tokio::time::interval(Duration::from_millis(20));
514    ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
515
516    loop {
517        tokio::select! {
518            _ = cancel.cancelled() => break,
519            playback = speaker_rx.recv() => match playback {
520                Some(Playback::Chunk(samples)) => pending.extend(samples),
521                // Barge-in on raw RTP: we ARE the buffer — drop it.
522                Some(Playback::Flush) => pending.clear(),
523                None => break,
524            },
525            _ = ticker.tick() => {
526                if pending.is_empty() {
527                    sender.skip_silence(SAMPLES_PER_PACKET as u32);
528                    continue;
529                }
530                let take = pending.len().min(SAMPLES_PER_PACKET);
531                let mut payload = Vec::with_capacity(SAMPLES_PER_PACKET);
532                for sample in pending.drain(..take) {
533                    payload.push(if payload_type == PT_PCMA {
534                        g711::linear_to_alaw(sample)
535                    } else {
536                        g711::linear_to_ulaw(sample)
537                    });
538                }
539                // Constant 20 ms ptime: pad a short tail with silence.
540                payload.resize(SAMPLES_PER_PACKET, silence_byte);
541                let datagram = sender.packetize(&payload, SAMPLES_PER_PACKET as u32);
542                let target = *peer_rx.borrow();
543                if socket.send_to(&datagram, target).await.is_err() {
544                    break;
545                }
546            }
547        }
548    }
549}
550
551/// The IP to advertise for media. A wildcard bind cannot go into SDP, so
552/// discover the interface that routes toward the caller's offer address.
553fn advertised_ip(local: IpAddr, offer: &AudioOffer) -> IpAddr {
554    if !local.is_unspecified() {
555        return local;
556    }
557    let probe = std::net::UdpSocket::bind("0.0.0.0:0")
558        .and_then(|s| {
559            s.connect((offer.host.as_str(), offer.port))?;
560            s.local_addr()
561        })
562        .map(|a| a.ip());
563    probe.unwrap_or(local)
564}
565
566/// A cheap non-cryptographic seed for SSRC/sequence/timestamp offsets.
567fn seed() -> u32 {
568    let nanos = std::time::SystemTime::now()
569        .duration_since(std::time::UNIX_EPOCH)
570        .map(|d| d.subsec_nanos())
571        .unwrap_or(0);
572    nanos ^ (std::process::id().rotate_left(16))
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    async fn recv_status(socket: &UdpSocket, buf: &mut [u8]) -> Option<String> {
580        let deadline = Duration::from_secs(3);
581        let (len, _) = tokio::time::timeout(deadline, socket.recv_from(buf))
582            .await
583            .ok()?
584            .ok()?;
585        String::from_utf8_lossy(&buf[..len])
586            .lines()
587            .next()
588            .map(str::to_string)
589    }
590
591    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
592    async fn answers_options_and_rings_then_rejects_an_invite() {
593        let mut agent = SipAgent::bind("127.0.0.1:0".parse().unwrap())
594            .await
595            .expect("bind agent");
596        let agent_port = agent.sip_port();
597        // Drive the agent loop concurrently: OPTIONS is answered inside it,
598        // and the INVITE surfaces through the channel.
599        let (call_tx, call_rx) = tokio::sync::oneshot::channel();
600        tokio::spawn(async move {
601            if let Some(incoming) = agent.next_call().await {
602                let _ = call_tx.send(incoming);
603            }
604        });
605
606        let uac = UdpSocket::bind("127.0.0.1:0").await.unwrap();
607        let uac_port = uac.local_addr().unwrap().port();
608        let target = format!("127.0.0.1:{agent_port}");
609        let mut buf = [0u8; 2048];
610
611        // OPTIONS gets a 200 without surfacing a call.
612        let options = format!(
613            "OPTIONS sip:gemini@{target} SIP/2.0\r\n\
614             Via: SIP/2.0/UDP 127.0.0.1:{uac_port};branch=z9hG4bKopt1\r\n\
615             Max-Forwards: 70\r\n\
616             From: <sip:probe@127.0.0.1>;tag=opt\r\n\
617             To: <sip:gemini@{target}>\r\n\
618             Call-ID: options-1@127.0.0.1\r\n\
619             CSeq: 1 OPTIONS\r\n\
620             Content-Length: 0\r\n\r\n"
621        );
622        uac.send_to(options.as_bytes(), &target).await.unwrap();
623        let status = recv_status(&uac, &mut buf).await.expect("OPTIONS response");
624        assert!(
625            status.contains("200"),
626            "expected 200 to OPTIONS, got {status}"
627        );
628
629        // An INVITE with a G.711 offer surfaces as an IncomingCall (after
630        // 100/180 provisional responses); rejecting it sends a final failure.
631        let sdp_body = "v=0\r\n\
632             o=probe 1 1 IN IP4 127.0.0.1\r\n\
633             s=call\r\nc=IN IP4 127.0.0.1\r\nt=0 0\r\n\
634             m=audio 40000 RTP/AVP 0\r\n\
635             a=rtpmap:0 PCMU/8000\r\n";
636        let invite = format!(
637            "INVITE sip:gemini@{target} SIP/2.0\r\n\
638             Via: SIP/2.0/UDP 127.0.0.1:{uac_port};branch=z9hG4bKinv1\r\n\
639             Max-Forwards: 70\r\n\
640             From: <sip:probe@127.0.0.1>;tag=inv\r\n\
641             To: <sip:gemini@{target}>\r\n\
642             Call-ID: invite-1@127.0.0.1\r\n\
643             CSeq: 1 INVITE\r\n\
644             Contact: <sip:probe@127.0.0.1:{uac_port}>\r\n\
645             Content-Type: application/sdp\r\n\
646             Content-Length: {}\r\n\r\n{sdp_body}",
647            sdp_body.len()
648        );
649        uac.send_to(invite.as_bytes(), &target).await.unwrap();
650
651        let incoming = tokio::time::timeout(Duration::from_secs(3), call_rx)
652            .await
653            .expect("call surfaces")
654            .expect("agent still running");
655        assert_eq!(incoming.offer.port, 40_000);
656        assert_eq!(
657            incoming.offer.g711_payload_type(),
658            Some(super::super::rtp::PT_PCMU)
659        );
660        assert!(incoming.from.contains("probe"), "from: {}", incoming.from);
661        incoming.reject();
662
663        // Drain provisional responses until the final failure arrives.
664        let mut saw_final = false;
665        for _ in 0..6 {
666            match recv_status(&uac, &mut buf).await {
667                Some(status) => {
668                    let code: u32 = status
669                        .split_whitespace()
670                        .nth(1)
671                        .and_then(|c| c.parse().ok())
672                        .unwrap_or(0);
673                    if code >= 400 {
674                        saw_final = true;
675                        break;
676                    }
677                }
678                None => break,
679            }
680        }
681        assert!(
682            saw_final,
683            "expected a final failure response to the rejected INVITE"
684        );
685    }
686}