Skip to main content

wavekat_sip/
session_timer.rs

1//! RFC 4028 session timers — keep long calls from outliving a dead dialog.
2//!
3//! Without session timers, a call whose dialog silently died (peer
4//! crashed, NAT binding dropped, proxy lost state) lives forever:
5//! nothing on the signaling path re-validates the dialog, so an
6//! unattended consumer (voice bot, AI agent) keeps streaming RTP into
7//! the void. RFC 4028 bounds that window: one side periodically
8//! refreshes the session with a re-INVITE, and the other side tears the
9//! call down with BYE if no refresh arrives before the negotiated
10//! session interval lapses.
11//!
12//! This module has three layers:
13//!
14//! 1. **Pure header logic** — [`SessionExpires`] parse/build for the
15//!    `Session-Expires` header (with its `;refresher=uac|uas` param),
16//!    [`min_se_in`] for `Min-SE`, and [`supports_timer`] for the
17//!    `Supported: timer` option tag. rsip 0.4 has no typed variants for
18//!    these, so they are parsed manually from
19//!    [`rsip::Header::Other`] — same style as the crate's SDP and RTP
20//!    parsing.
21//! 2. **Negotiation** — [`negotiate_uac`] (from a 2xx response, caller
22//!    side) and [`negotiate_uas`] (from an INVITE, callee side) decide
23//!    the [`SessionTimer`]: the negotiated interval and whether *we*
24//!    are the refresher. [`SessionTimer::refresh_after`] /
25//!    [`SessionTimer::expiry_after`] give the RFC 4028 §10 schedule.
26//! 3. **Runtime** — [`session_timer_loop`], shaped like
27//!    [`crate::Registrar::keepalive_loop`]: a `select!` over sleeps and
28//!    a [`CancellationToken`] that either sends the periodic refresh
29//!    re-INVITE (when we are the refresher) or watches for the peer's
30//!    refreshes and sends BYE when the session lapses.
31//!
32//! # Wiring it up
33//!
34//! [`crate::Caller`] and [`crate::IncomingCall`] negotiate for you:
35//! [`crate::Call::session_timer`] carries the result. Drive the loop
36//! against the call's shareable dialog handle
37//! ([`crate::Call::session_handle`]) so it runs alongside the audio path:
38//!
39//! ```ignore
40//! if let Some(timer) = call.session_timer() {
41//!     let handle = call.session_handle();
42//!     let refreshed = std::sync::Arc::new(tokio::sync::Notify::new());
43//!     // A refresh re-offer must pin the negotiated codec — never the
44//!     // full menu, which would invite a mid-call codec switch.
45//!     let menu = CodecMenu::Pinned {
46//!         codec: call.remote_media.codec.expect("established call has a codec"),
47//!         dtmf: call.remote_media.dtmf(),
48//!     };
49//!     let sdp = build_sdp_with(local_ip, rtp_port, menu, MediaDirection::SendRecv, version);
50//!     tokio::spawn(async move {
51//!         let outcome =
52//!             session_timer_loop(&handle, timer, Some(sdp), refreshed, cancel).await;
53//!         tracing::info!(?outcome, "session timer finished");
54//!     });
55//! }
56//! ```
57//!
58//! When the **peer** is the refresher, its refresh re-INVITEs arrive as
59//! inbound in-dialog requests. The consumer answers them with an SDP
60//! answer (echoing `Session-Expires`) and pings `refreshed` so the
61//! watchdog deadline resets.
62
63use std::future::Future;
64use std::sync::Arc;
65use std::time::Duration;
66
67use rsip::prelude::UntypedHeader;
68use rsip::Header;
69use tokio::select;
70use tokio::sync::Notify;
71use tokio_util::sync::CancellationToken;
72use tracing::{debug, info, warn};
73
74type BoxError = Box<dyn std::error::Error + Send + Sync>;
75
76/// Smallest session interval we will run a timer at, in seconds.
77/// RFC 4028 §4 fixes 90 s as the absolute minimum (and the default
78/// `Min-SE`); anything shorter would churn re-INVITEs.
79pub const MIN_SESSION_EXPIRES_SECS: u32 = 90;
80
81/// Session interval requested on outbound INVITEs, in seconds — the
82/// RFC 4028 recommended default of 30 minutes.
83pub const DEFAULT_SESSION_EXPIRES_SECS: u32 = 1800;
84
85/// Cap on how far *before* the session expiry the non-refresher fires
86/// its BYE watchdog (RFC 4028 §10: `min(32 s, interval / 3)`).
87const MAX_EXPIRY_HEADROOM_SECS: u32 = 32;
88
89/// Which side of the original INVITE transaction performs refreshes —
90/// the value space of the `Session-Expires` `refresher` parameter.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum Refresher {
93    /// The party that sent the request refreshes.
94    Uac,
95    /// The party that answered the request refreshes.
96    Uas,
97}
98
99impl Refresher {
100    /// Canonical lowercase parameter value (`"uac"` / `"uas"`).
101    pub fn as_str(&self) -> &'static str {
102        match self {
103            Refresher::Uac => "uac",
104            Refresher::Uas => "uas",
105        }
106    }
107
108    fn parse(s: &str) -> Result<Self, String> {
109        if s.eq_ignore_ascii_case("uac") {
110            Ok(Refresher::Uac)
111        } else if s.eq_ignore_ascii_case("uas") {
112            Ok(Refresher::Uas)
113        } else {
114            Err(format!("invalid refresher value {s:?} (want uac|uas)"))
115        }
116    }
117}
118
119/// Parsed `Session-Expires` header value: interval in seconds plus the
120/// optional `refresher` parameter.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub struct SessionExpires {
123    /// Session interval in seconds.
124    pub interval_secs: u32,
125    /// Who refreshes, if pinned. `None` means "answerer's choice".
126    pub refresher: Option<Refresher>,
127}
128
129impl SessionExpires {
130    /// Parse a `Session-Expires` header *value* (not the full header
131    /// line), e.g. `"1800"` or `"1800;refresher=uas"`. Parameter names
132    /// and values are case-insensitive; unknown parameters are ignored
133    /// per RFC 4028 §4.
134    pub fn parse(value: &str) -> Result<Self, String> {
135        let mut parts = value.split(';');
136        let interval = parts.next().unwrap_or("").trim();
137        let interval_secs: u32 = interval
138            .parse()
139            .map_err(|e| format!("invalid Session-Expires interval {interval:?}: {e}"))?;
140        let mut refresher = None;
141        for param in parts {
142            if let Some((name, val)) = param.split_once('=') {
143                if name.trim().eq_ignore_ascii_case("refresher") {
144                    refresher = Some(Refresher::parse(val.trim())?);
145                }
146            }
147        }
148        Ok(Self {
149            interval_secs,
150            refresher,
151        })
152    }
153
154    /// Serialize back to a header value, e.g. `"1800;refresher=uac"`.
155    pub fn header_value(&self) -> String {
156        match self.refresher {
157            Some(r) => format!("{};refresher={}", self.interval_secs, r.as_str()),
158            None => self.interval_secs.to_string(),
159        }
160    }
161
162    /// Build the untyped `Session-Expires` header (rsip 0.4 has no
163    /// typed variant).
164    pub fn header(&self) -> Header {
165        Header::Other("Session-Expires".into(), self.header_value())
166    }
167}
168
169/// `Supported: timer` — advertise RFC 4028 support on a request or
170/// response.
171pub fn supported_timer_header() -> Header {
172    Header::Supported("timer".into())
173}
174
175/// `Require: timer` — placed in a 2xx by the answerer when the offerer
176/// advertised timer support (RFC 4028 §9).
177pub fn require_timer_header() -> Header {
178    Header::Require("timer".into())
179}
180
181/// Find the value of the first untyped header matching any of `names`
182/// (case-insensitive).
183fn other_header_value<'a>(headers: &'a rsip::Headers, names: &[&str]) -> Option<&'a str> {
184    headers.iter().find_map(|h| match h {
185        Header::Other(name, value) if names.iter().any(|n| name.eq_ignore_ascii_case(n)) => {
186            Some(value.as_str())
187        }
188        _ => None,
189    })
190}
191
192/// Extract and parse the `Session-Expires` header (long or compact `x`
193/// form) from a header list. Malformed values are logged and treated as
194/// absent — a broken peer header must not kill the call.
195pub fn session_expires_in(headers: &rsip::Headers) -> Option<SessionExpires> {
196    let raw = other_header_value(headers, &["Session-Expires", "x"])?;
197    match SessionExpires::parse(raw) {
198        Ok(se) => Some(se),
199        Err(e) => {
200            warn!("ignoring malformed Session-Expires header: {e}");
201            None
202        }
203    }
204}
205
206/// Extract the `Min-SE` interval (seconds) from a header list, ignoring
207/// any generic parameters. Malformed values are treated as absent.
208pub fn min_se_in(headers: &rsip::Headers) -> Option<u32> {
209    let raw = other_header_value(headers, &["Min-SE"])?;
210    let interval = raw.split(';').next().unwrap_or("").trim();
211    match interval.parse() {
212        Ok(v) => Some(v),
213        Err(e) => {
214            warn!("ignoring malformed Min-SE header {raw:?}: {e}");
215            None
216        }
217    }
218}
219
220fn has_timer_token(value: &str) -> bool {
221    value
222        .split(',')
223        .any(|tag| tag.trim().eq_ignore_ascii_case("timer"))
224}
225
226/// `true` if any `Supported` header (typed, untyped, or compact `k`
227/// form) carries the `timer` option tag.
228pub fn supports_timer(headers: &rsip::Headers) -> bool {
229    headers.iter().any(|h| match h {
230        Header::Supported(s) => has_timer_token(s.value()),
231        Header::Other(name, value)
232            if name.eq_ignore_ascii_case("Supported") || name.eq_ignore_ascii_case("k") =>
233        {
234            has_timer_token(value)
235        }
236        _ => false,
237    })
238}
239
240/// Negotiated session-timer state for one dialog, from our side's
241/// perspective.
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub struct SessionTimer {
244    /// Negotiated session interval in seconds.
245    pub interval_secs: u32,
246    /// `true` if we send the periodic refresh re-INVITEs; `false` if we
247    /// only watch for the peer's refreshes and BYE on expiry.
248    pub we_are_refresher: bool,
249}
250
251impl SessionTimer {
252    /// When the refresher sends its refresh: half the session interval
253    /// after the last refresh (RFC 4028 §10).
254    pub fn refresh_after(&self) -> Duration {
255        Duration::from_secs(u64::from(self.interval_secs / 2))
256    }
257
258    /// When the non-refresher gives up and sends BYE: the session
259    /// interval minus `min(32 s, interval / 3)` after the last refresh
260    /// (RFC 4028 §10).
261    pub fn expiry_after(&self) -> Duration {
262        let headroom = (self.interval_secs / 3).min(MAX_EXPIRY_HEADROOM_SECS);
263        Duration::from_secs(u64::from(self.interval_secs.saturating_sub(headroom)))
264    }
265}
266
267/// UAC-side negotiation: decide the [`SessionTimer`] from the 2xx
268/// response to our INVITE.
269///
270/// Returns `None` when the response carries no `Session-Expires` — the
271/// answerer declined (or doesn't support) session timers, so no timer
272/// runs. When the (mandatory per RFC 4028 §9) `refresher` parameter is
273/// missing we defensively take the refresher role ourselves: refreshing
274/// when the peer also refreshes is redundant but harmless, while *not*
275/// refreshing when the peer expects us to would drop the call.
276///
277/// The interval is floored at [`MIN_SESSION_EXPIRES_SECS`] so a bogus
278/// tiny grant can't spin the refresh loop.
279pub fn negotiate_uac(response_headers: &rsip::Headers) -> Option<SessionTimer> {
280    let se = session_expires_in(response_headers)?;
281    Some(SessionTimer {
282        interval_secs: se.interval_secs.max(MIN_SESSION_EXPIRES_SECS),
283        we_are_refresher: !matches!(se.refresher, Some(Refresher::Uas)),
284    })
285}
286
287/// UAS-side negotiation result: the timer to run plus what to put in
288/// our 2xx so the peer agrees on it.
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub struct UasSessionTimer {
291    /// Timer state from our (UAS) perspective.
292    pub timer: SessionTimer,
293    /// `Session-Expires` to echo in the 2xx (interval + pinned
294    /// refresher).
295    pub echo: SessionExpires,
296    /// `true` if the peer advertised `Supported: timer`, in which case
297    /// the 2xx must also carry `Require: timer` (RFC 4028 §9).
298    pub require_timer: bool,
299}
300
301/// UAS-side negotiation: decide the session timer from an inbound
302/// INVITE's headers.
303///
304/// Returns `None` when the INVITE carries no `Session-Expires` — we do
305/// not insert timers into sessions the caller didn't ask for (allowed
306/// by RFC 4028, deliberately deferred; see
307/// `docs/07-session-timers.md`).
308///
309/// The interval is floored at `max(90, Min-SE)`. The refresher is the
310/// request's `refresher` parameter when the peer advertised timer
311/// support, defaulting to `uac` (peer refreshes). When the peer did
312/// *not* advertise `Supported: timer` — e.g. a proxy inserted the
313/// `Session-Expires` — the peer cannot refresh, so we take the
314/// refresher role and skip `Require: timer`.
315pub fn negotiate_uas(invite_headers: &rsip::Headers) -> Option<UasSessionTimer> {
316    let se = session_expires_in(invite_headers)?;
317    let floor = min_se_in(invite_headers)
318        .unwrap_or(0)
319        .max(MIN_SESSION_EXPIRES_SECS);
320    let interval_secs = se.interval_secs.max(floor);
321    let peer_supports = supports_timer(invite_headers);
322    let refresher = if peer_supports {
323        se.refresher.unwrap_or(Refresher::Uac)
324    } else {
325        Refresher::Uas
326    };
327    Some(UasSessionTimer {
328        timer: SessionTimer {
329            interval_secs,
330            we_are_refresher: refresher == Refresher::Uas,
331        },
332        echo: SessionExpires {
333            interval_secs,
334            refresher: Some(refresher),
335        },
336        require_timer: peer_supports,
337    })
338}
339
340/// The dialog operations [`session_timer_loop`] needs, abstracted so
341/// the loop's timing logic stays unit-testable without a live dialog.
342///
343/// Implemented for [`crate::Call`]'s shareable session handle over the
344/// engine's in-dialog re-INVITE / BYE.
345pub trait SessionDialogOps {
346    /// Send a session-refresh re-INVITE with the given extra headers
347    /// and (typically SDP) body. Returns the final response, or
348    /// `Ok(None)` if the dialog is no longer confirmed.
349    fn refresh(
350        &self,
351        headers: Vec<Header>,
352        body: Option<Vec<u8>>,
353    ) -> impl Future<Output = Result<Option<rsip::Response>, BoxError>> + Send;
354
355    /// Hang up the dialog with BYE.
356    fn send_bye(&self) -> impl Future<Output = Result<(), BoxError>> + Send;
357}
358
359/// How [`session_timer_loop`] ended.
360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub enum SessionTimerOutcome {
362    /// The [`CancellationToken`] fired — the call ended through the
363    /// normal path (local/remote BYE) and the loop just stood down.
364    Cancelled,
365    /// We were the watchdog and no refresh arrived before the session
366    /// interval lapsed. A BYE was sent (best effort).
367    Expired,
368    /// We were the refresher and a refresh re-INVITE failed (non-2xx or
369    /// transport error). A BYE was sent (best effort).
370    RefreshFailed,
371    /// The dialog was no longer confirmed when we tried to refresh —
372    /// it already terminated through another path. No BYE needed.
373    DialogGone,
374}
375
376/// Drive RFC 4028 session keepalive for one confirmed dialog. Runs
377/// until cancelled or the session dies; same shape as
378/// [`crate::Registrar::keepalive_loop`].
379///
380/// - When `timer.we_are_refresher`, sends a refresh re-INVITE every
381///   `interval / 2` carrying `refresh_body` (repeat the SDP you sent
382///   when the call was set up — an identical offer is a no-op per
383///   RFC 3264). A 2xx resets the clock (adopting any `Session-Expires`
384///   the peer granted); failure tears the call down with BYE.
385/// - Otherwise runs the expiry watchdog: every
386///   `peer_refreshed.notify_one()` resets the deadline, and if the
387///   deadline lapses the call is torn down with BYE. The consumer pings
388///   `peer_refreshed` after answering the peer's refresh re-INVITE.
389///
390/// All failures are folded into the returned [`SessionTimerOutcome`];
391/// the loop never panics and never returns early without standing the
392/// session down.
393pub async fn session_timer_loop<D: SessionDialogOps>(
394    dialog: &D,
395    timer: SessionTimer,
396    refresh_body: Option<Vec<u8>>,
397    peer_refreshed: Arc<Notify>,
398    cancel: CancellationToken,
399) -> SessionTimerOutcome {
400    let mut interval_secs = timer.interval_secs.max(MIN_SESSION_EXPIRES_SECS);
401    if timer.we_are_refresher {
402        loop {
403            let current = SessionTimer {
404                interval_secs,
405                we_are_refresher: true,
406            };
407            select! {
408                _ = tokio::time::sleep(current.refresh_after()) => {}
409                _ = cancel.cancelled() => return SessionTimerOutcome::Cancelled,
410            }
411
412            // In this refresh re-INVITE we are the UAC of the new
413            // transaction, so the refresher param says `uac`.
414            let headers = vec![
415                supported_timer_header(),
416                SessionExpires {
417                    interval_secs,
418                    refresher: Some(Refresher::Uac),
419                }
420                .header(),
421            ];
422            match dialog.refresh(headers, refresh_body.clone()).await {
423                Ok(Some(resp)) if resp.status_code.kind() == rsip::StatusCodeKind::Successful => {
424                    // Adopt a re-granted interval (a peer may shorten or
425                    // lengthen mid-call), floored to avoid a hot loop.
426                    if let Some(granted) = session_expires_in(&resp.headers) {
427                        interval_secs = granted.interval_secs.max(MIN_SESSION_EXPIRES_SECS);
428                    }
429                    debug!(interval_secs, "session refresh accepted");
430                }
431                Ok(Some(resp)) => {
432                    warn!(
433                        status = %resp.status_code,
434                        "session refresh rejected; hanging up"
435                    );
436                    if let Err(e) = dialog.send_bye().await {
437                        warn!("BYE after rejected refresh failed: {e}");
438                    }
439                    return SessionTimerOutcome::RefreshFailed;
440                }
441                Ok(None) => {
442                    debug!("dialog no longer confirmed; session timer standing down");
443                    return SessionTimerOutcome::DialogGone;
444                }
445                Err(e) => {
446                    warn!("session refresh error: {e}; hanging up");
447                    if let Err(e) = dialog.send_bye().await {
448                        warn!("BYE after failed refresh failed: {e}");
449                    }
450                    return SessionTimerOutcome::RefreshFailed;
451                }
452            }
453        }
454    } else {
455        let current = SessionTimer {
456            interval_secs,
457            we_are_refresher: false,
458        };
459        loop {
460            select! {
461                _ = tokio::time::sleep(current.expiry_after()) => {
462                    info!(
463                        interval_secs,
464                        "session lapsed without refresh; sending BYE"
465                    );
466                    if let Err(e) = dialog.send_bye().await {
467                        warn!("BYE after session expiry failed: {e}");
468                    }
469                    return SessionTimerOutcome::Expired;
470                }
471                _ = peer_refreshed.notified() => {
472                    debug!("peer refreshed session; watchdog deadline reset");
473                }
474                _ = cancel.cancelled() => return SessionTimerOutcome::Cancelled,
475            }
476        }
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use std::sync::Mutex;
484
485    // ---- SessionExpires parse / build ----
486
487    #[test]
488    fn parse_bare_interval() {
489        let se = SessionExpires::parse("1800").unwrap();
490        assert_eq!(se.interval_secs, 1800);
491        assert_eq!(se.refresher, None);
492    }
493
494    #[test]
495    fn parse_with_refresher_param() {
496        let se = SessionExpires::parse("1800;refresher=uas").unwrap();
497        assert_eq!(se.interval_secs, 1800);
498        assert_eq!(se.refresher, Some(Refresher::Uas));
499        let se = SessionExpires::parse("90;refresher=uac").unwrap();
500        assert_eq!(se.refresher, Some(Refresher::Uac));
501    }
502
503    #[test]
504    fn parse_is_case_insensitive_and_whitespace_tolerant() {
505        let se = SessionExpires::parse(" 600 ; Refresher = UAS ").unwrap();
506        assert_eq!(se.interval_secs, 600);
507        assert_eq!(se.refresher, Some(Refresher::Uas));
508    }
509
510    #[test]
511    fn parse_ignores_unknown_params() {
512        let se = SessionExpires::parse("1800;foo=bar;refresher=uac;baz").unwrap();
513        assert_eq!(se.refresher, Some(Refresher::Uac));
514    }
515
516    #[test]
517    fn parse_rejects_garbage() {
518        assert!(SessionExpires::parse("").is_err());
519        assert!(SessionExpires::parse("soon").is_err());
520        assert!(SessionExpires::parse("1800;refresher=bogus").is_err());
521        assert!(SessionExpires::parse("-5").is_err());
522    }
523
524    #[test]
525    fn header_value_round_trips() {
526        for se in [
527            SessionExpires {
528                interval_secs: 1800,
529                refresher: None,
530            },
531            SessionExpires {
532                interval_secs: 90,
533                refresher: Some(Refresher::Uac),
534            },
535            SessionExpires {
536                interval_secs: 7200,
537                refresher: Some(Refresher::Uas),
538            },
539        ] {
540            let parsed = SessionExpires::parse(&se.header_value()).unwrap();
541            assert_eq!(parsed, se, "round-trip via {:?}", se.header_value());
542        }
543    }
544
545    #[test]
546    fn header_builds_untyped_session_expires() {
547        let h = SessionExpires {
548            interval_secs: 1800,
549            refresher: Some(Refresher::Uac),
550        }
551        .header();
552        assert_eq!(h.to_string(), "Session-Expires: 1800;refresher=uac");
553    }
554
555    // ---- header extraction ----
556
557    fn headers(items: Vec<Header>) -> rsip::Headers {
558        let mut h = rsip::Headers::default();
559        for item in items {
560            h.push(item);
561        }
562        h
563    }
564
565    #[test]
566    fn session_expires_in_finds_header_case_insensitively() {
567        let h = headers(vec![Header::Other(
568            "session-expires".into(),
569            "600;refresher=uas".into(),
570        )]);
571        let se = session_expires_in(&h).unwrap();
572        assert_eq!(se.interval_secs, 600);
573        assert_eq!(se.refresher, Some(Refresher::Uas));
574    }
575
576    #[test]
577    fn session_expires_in_accepts_compact_form() {
578        // RFC 4028 §4 defines `x` as the compact form of Session-Expires.
579        let h = headers(vec![Header::Other("x".into(), "300".into())]);
580        assert_eq!(
581            session_expires_in(&h),
582            Some(SessionExpires {
583                interval_secs: 300,
584                refresher: None
585            })
586        );
587    }
588
589    #[test]
590    fn session_expires_in_absent_or_malformed_is_none() {
591        assert_eq!(session_expires_in(&headers(vec![])), None);
592        let h = headers(vec![Header::Other("Session-Expires".into(), "soon".into())]);
593        assert_eq!(session_expires_in(&h), None);
594    }
595
596    #[test]
597    fn min_se_in_parses_and_ignores_params() {
598        let h = headers(vec![Header::Other("Min-SE".into(), "120".into())]);
599        assert_eq!(min_se_in(&h), Some(120));
600        let h = headers(vec![Header::Other("min-se".into(), "240;lr".into())]);
601        assert_eq!(min_se_in(&h), Some(240));
602        assert_eq!(min_se_in(&headers(vec![])), None);
603        let h = headers(vec![Header::Other("Min-SE".into(), "never".into())]);
604        assert_eq!(min_se_in(&h), None);
605    }
606
607    #[test]
608    fn supports_timer_scans_typed_untyped_and_compact() {
609        assert!(supports_timer(&headers(vec![supported_timer_header()])));
610        assert!(supports_timer(&headers(vec![Header::Supported(
611            "100rel, timer".into()
612        )])));
613        assert!(supports_timer(&headers(vec![Header::Other(
614            "k".into(),
615            "timer".into()
616        )])));
617        assert!(supports_timer(&headers(vec![Header::Other(
618            "Supported".into(),
619            "TIMER".into()
620        )])));
621        assert!(!supports_timer(&headers(vec![])));
622        assert!(!supports_timer(&headers(vec![Header::Supported(
623            "100rel".into()
624        )])));
625        // `timer` must be a whole option tag, not a substring.
626        assert!(!supports_timer(&headers(vec![Header::Supported(
627            "timers".into()
628        )])));
629    }
630
631    // ---- interval math (RFC 4028 §10) ----
632
633    #[test]
634    fn refresh_fires_at_half_the_interval() {
635        let t = SessionTimer {
636            interval_secs: 1800,
637            we_are_refresher: true,
638        };
639        assert_eq!(t.refresh_after(), Duration::from_secs(900));
640        let t = SessionTimer {
641            interval_secs: 90,
642            we_are_refresher: true,
643        };
644        assert_eq!(t.refresh_after(), Duration::from_secs(45));
645    }
646
647    #[test]
648    fn expiry_keeps_min_of_32s_or_a_third_headroom() {
649        // Long interval: headroom caps at 32 s.
650        let t = SessionTimer {
651            interval_secs: 1800,
652            we_are_refresher: false,
653        };
654        assert_eq!(t.expiry_after(), Duration::from_secs(1768));
655        // Short interval: a third of 90 s = 30 s < 32 s.
656        let t = SessionTimer {
657            interval_secs: 90,
658            we_are_refresher: false,
659        };
660        assert_eq!(t.expiry_after(), Duration::from_secs(60));
661    }
662
663    // ---- negotiation: UAC side ----
664
665    #[test]
666    fn uac_no_session_expires_means_no_timer() {
667        assert_eq!(negotiate_uac(&headers(vec![])), None);
668    }
669
670    #[test]
671    fn uac_refresher_uas_means_peer_refreshes() {
672        let h = headers(vec![Header::Other(
673            "Session-Expires".into(),
674            "1800;refresher=uas".into(),
675        )]);
676        assert_eq!(
677            negotiate_uac(&h),
678            Some(SessionTimer {
679                interval_secs: 1800,
680                we_are_refresher: false
681            })
682        );
683    }
684
685    #[test]
686    fn uac_refresher_uac_or_missing_means_we_refresh() {
687        let h = headers(vec![Header::Other(
688            "Session-Expires".into(),
689            "600;refresher=uac".into(),
690        )]);
691        assert!(negotiate_uac(&h).unwrap().we_are_refresher);
692        // RFC 4028 §9 says the 2xx MUST pin the refresher; a peer that
693        // omits it gets the defensive default: we refresh.
694        let h = headers(vec![Header::Other("Session-Expires".into(), "600".into())]);
695        assert!(negotiate_uac(&h).unwrap().we_are_refresher);
696    }
697
698    #[test]
699    fn uac_floors_tiny_grants_at_90s() {
700        let h = headers(vec![Header::Other(
701            "Session-Expires".into(),
702            "20;refresher=uac".into(),
703        )]);
704        assert_eq!(negotiate_uac(&h).unwrap().interval_secs, 90);
705    }
706
707    // ---- negotiation: UAS side ----
708
709    fn invite_headers(session_expires: &str, min_se: Option<&str>, timer: bool) -> rsip::Headers {
710        let mut items = vec![Header::Other(
711            "Session-Expires".into(),
712            session_expires.into(),
713        )];
714        if let Some(m) = min_se {
715            items.push(Header::Other("Min-SE".into(), m.into()));
716        }
717        if timer {
718            items.push(supported_timer_header());
719        }
720        headers(items)
721    }
722
723    #[test]
724    fn uas_no_session_expires_means_no_timer() {
725        assert_eq!(
726            negotiate_uas(&headers(vec![supported_timer_header()])),
727            None
728        );
729    }
730
731    #[test]
732    fn uas_default_makes_supporting_peer_the_refresher() {
733        let uas = negotiate_uas(&invite_headers("1800", None, true)).unwrap();
734        assert_eq!(uas.timer.interval_secs, 1800);
735        assert!(!uas.timer.we_are_refresher, "peer (UAC) should refresh");
736        assert_eq!(
737            uas.echo,
738            SessionExpires {
739                interval_secs: 1800,
740                refresher: Some(Refresher::Uac)
741            }
742        );
743        assert!(uas.require_timer);
744    }
745
746    #[test]
747    fn uas_honors_requested_refresher_uas() {
748        let uas = negotiate_uas(&invite_headers("1800;refresher=uas", None, true)).unwrap();
749        assert!(uas.timer.we_are_refresher, "we (UAS) were asked to refresh");
750        assert_eq!(uas.echo.refresher, Some(Refresher::Uas));
751    }
752
753    #[test]
754    fn uas_without_peer_support_takes_refresher_role() {
755        // A proxy inserted Session-Expires but the UAC itself never
756        // advertised `Supported: timer` — it can't refresh, so we must,
757        // and we must not Require: timer in the 2xx.
758        let uas = negotiate_uas(&invite_headers("1800;refresher=uac", None, false)).unwrap();
759        assert!(uas.timer.we_are_refresher);
760        assert_eq!(uas.echo.refresher, Some(Refresher::Uas));
761        assert!(!uas.require_timer);
762    }
763
764    #[test]
765    fn uas_floors_interval_at_min_se_and_90s() {
766        // Requested 30 s with Min-SE 120 → 120 s.
767        let uas = negotiate_uas(&invite_headers("30", Some("120"), true)).unwrap();
768        assert_eq!(uas.timer.interval_secs, 120);
769        assert_eq!(uas.echo.interval_secs, 120);
770        // Requested 30 s without Min-SE → RFC floor of 90 s.
771        let uas = negotiate_uas(&invite_headers("30", None, true)).unwrap();
772        assert_eq!(uas.timer.interval_secs, 90);
773    }
774
775    // ---- session_timer_loop against a mock dialog ----
776
777    #[derive(Debug, Clone, PartialEq, Eq)]
778    enum Event {
779        Refresh { session_expires: String },
780        Bye,
781    }
782
783    /// Scripted mock: pops one canned reply per refresh; records every
784    /// call with a timestamp readable under tokio's paused clock.
785    struct MockDialog {
786        events: Mutex<Vec<(Duration, Event)>>,
787        refresh_replies: Mutex<Vec<Result<Option<rsip::Response>, String>>>,
788        started: tokio::time::Instant,
789    }
790
791    impl MockDialog {
792        fn new(refresh_replies: Vec<Result<Option<rsip::Response>, String>>) -> Self {
793            Self {
794                events: Mutex::new(Vec::new()),
795                refresh_replies: Mutex::new(refresh_replies),
796                started: tokio::time::Instant::now(),
797            }
798        }
799
800        fn events(&self) -> Vec<(Duration, Event)> {
801            self.events.lock().unwrap().clone()
802        }
803    }
804
805    fn response(code: u16, extra: Vec<Header>) -> rsip::Response {
806        rsip::Response {
807            status_code: rsip::StatusCode::from(code),
808            version: rsip::Version::V2,
809            headers: headers(extra),
810            body: Vec::new(),
811        }
812    }
813
814    impl SessionDialogOps for MockDialog {
815        async fn refresh(
816            &self,
817            hdrs: Vec<Header>,
818            _body: Option<Vec<u8>>,
819        ) -> Result<Option<rsip::Response>, BoxError> {
820            let se = hdrs
821                .iter()
822                .find_map(|h| match h {
823                    Header::Other(name, value) if name == "Session-Expires" => Some(value.clone()),
824                    _ => None,
825                })
826                .unwrap_or_default();
827            self.events.lock().unwrap().push((
828                self.started.elapsed(),
829                Event::Refresh {
830                    session_expires: se,
831                },
832            ));
833            let reply = self.refresh_replies.lock().unwrap().remove(0);
834            reply.map_err(Into::into)
835        }
836
837        async fn send_bye(&self) -> Result<(), BoxError> {
838            self.events
839                .lock()
840                .unwrap()
841                .push((self.started.elapsed(), Event::Bye));
842            Ok(())
843        }
844    }
845
846    fn timer(interval_secs: u32, we_are_refresher: bool) -> SessionTimer {
847        SessionTimer {
848            interval_secs,
849            we_are_refresher,
850        }
851    }
852
853    #[tokio::test(start_paused = true)]
854    async fn refresher_sends_refresh_every_half_interval() {
855        let dialog = Arc::new(MockDialog::new(vec![
856            Ok(Some(response(200, vec![]))),
857            Ok(Some(response(200, vec![]))),
858            Ok(None), // third tick: dialog gone, loop exits
859        ]));
860        let cancel = CancellationToken::new();
861        let outcome = session_timer_loop(
862            &*dialog,
863            timer(180, true),
864            None,
865            Arc::new(Notify::new()),
866            cancel,
867        )
868        .await;
869        assert_eq!(outcome, SessionTimerOutcome::DialogGone);
870
871        let events = dialog.events();
872        assert_eq!(events.len(), 3);
873        assert_eq!(events[0].0, Duration::from_secs(90));
874        assert_eq!(events[1].0, Duration::from_secs(180));
875        assert_eq!(events[2].0, Duration::from_secs(270));
876        for (_, e) in &events {
877            assert_eq!(
878                e,
879                &Event::Refresh {
880                    session_expires: "180;refresher=uac".into()
881                }
882            );
883        }
884    }
885
886    #[tokio::test(start_paused = true)]
887    async fn refresher_adopts_interval_granted_in_refresh_response() {
888        // First 200 re-grants a longer interval; the second refresh must
889        // fire at the *new* half-interval.
890        let regrant = response(
891            200,
892            vec![Header::Other(
893                "Session-Expires".into(),
894                "360;refresher=uac".into(),
895            )],
896        );
897        let dialog = Arc::new(MockDialog::new(vec![Ok(Some(regrant)), Ok(None)]));
898        let cancel = CancellationToken::new();
899        let outcome = session_timer_loop(
900            &*dialog,
901            timer(180, true),
902            None,
903            Arc::new(Notify::new()),
904            cancel,
905        )
906        .await;
907        assert_eq!(outcome, SessionTimerOutcome::DialogGone);
908
909        let events = dialog.events();
910        assert_eq!(events[0].0, Duration::from_secs(90), "first at 180/2");
911        assert_eq!(
912            events[1].0,
913            Duration::from_secs(90 + 180),
914            "second at 90 + 360/2 after the re-grant"
915        );
916    }
917
918    #[tokio::test(start_paused = true)]
919    async fn refresher_rejected_refresh_sends_bye() {
920        let dialog = Arc::new(MockDialog::new(vec![Ok(Some(response(481, vec![])))]));
921        let cancel = CancellationToken::new();
922        let outcome = session_timer_loop(
923            &*dialog,
924            timer(180, true),
925            None,
926            Arc::new(Notify::new()),
927            cancel,
928        )
929        .await;
930        assert_eq!(outcome, SessionTimerOutcome::RefreshFailed);
931        let events = dialog.events();
932        assert!(matches!(events[0].1, Event::Refresh { .. }));
933        assert_eq!(events[1].1, Event::Bye);
934    }
935
936    #[tokio::test(start_paused = true)]
937    async fn refresher_transport_error_sends_bye() {
938        let dialog = Arc::new(MockDialog::new(vec![Err("socket closed".into())]));
939        let cancel = CancellationToken::new();
940        let outcome = session_timer_loop(
941            &*dialog,
942            timer(180, true),
943            None,
944            Arc::new(Notify::new()),
945            cancel,
946        )
947        .await;
948        assert_eq!(outcome, SessionTimerOutcome::RefreshFailed);
949        assert_eq!(dialog.events().last().unwrap().1, Event::Bye);
950    }
951
952    #[tokio::test(start_paused = true)]
953    async fn refresher_cancellation_wins_before_first_refresh() {
954        let dialog = Arc::new(MockDialog::new(vec![]));
955        let cancel = CancellationToken::new();
956        cancel.cancel();
957        let outcome = session_timer_loop(
958            &*dialog,
959            timer(180, true),
960            None,
961            Arc::new(Notify::new()),
962            cancel,
963        )
964        .await;
965        assert_eq!(outcome, SessionTimerOutcome::Cancelled);
966        assert!(dialog.events().is_empty(), "no refresh, no BYE");
967    }
968
969    #[tokio::test(start_paused = true)]
970    async fn watchdog_sends_bye_when_session_lapses() {
971        let dialog = Arc::new(MockDialog::new(vec![]));
972        let cancel = CancellationToken::new();
973        let outcome = session_timer_loop(
974            &*dialog,
975            timer(90, false),
976            None,
977            Arc::new(Notify::new()),
978            cancel,
979        )
980        .await;
981        assert_eq!(outcome, SessionTimerOutcome::Expired);
982        let events = dialog.events();
983        assert_eq!(events.len(), 1);
984        // 90 - min(32, 90/3) = 60 s.
985        assert_eq!(events[0], (Duration::from_secs(60), Event::Bye));
986    }
987
988    #[tokio::test(start_paused = true)]
989    async fn watchdog_resets_deadline_on_peer_refresh() {
990        let dialog = Arc::new(MockDialog::new(vec![]));
991        let cancel = CancellationToken::new();
992        let refreshed = Arc::new(Notify::new());
993
994        let loop_task = tokio::spawn({
995            let dialog = dialog.clone();
996            let refreshed = refreshed.clone();
997            let cancel = cancel.clone();
998            async move { session_timer_loop(&*dialog, timer(90, false), None, refreshed, cancel).await }
999        });
1000
1001        // Just before the 60 s deadline, the peer refreshes.
1002        tokio::time::sleep(Duration::from_secs(59)).await;
1003        refreshed.notify_one();
1004        tokio::task::yield_now().await;
1005        // Crossing the original deadline must NOT fire the BYE...
1006        tokio::time::sleep(Duration::from_secs(30)).await;
1007        assert!(dialog.events().is_empty(), "deadline should have reset");
1008        // ...but the rearmed deadline (59 + 60 = 119 s) must.
1009        let outcome = loop_task.await.unwrap();
1010        assert_eq!(outcome, SessionTimerOutcome::Expired);
1011        assert_eq!(
1012            dialog.events(),
1013            vec![(Duration::from_secs(119), Event::Bye)]
1014        );
1015    }
1016
1017    #[tokio::test(start_paused = true)]
1018    async fn watchdog_cancellation_stands_down_without_bye() {
1019        let dialog = Arc::new(MockDialog::new(vec![]));
1020        let cancel = CancellationToken::new();
1021        let loop_task = tokio::spawn({
1022            let dialog = dialog.clone();
1023            let cancel = cancel.clone();
1024            async move {
1025                session_timer_loop(
1026                    &*dialog,
1027                    timer(90, false),
1028                    None,
1029                    Arc::new(Notify::new()),
1030                    cancel,
1031                )
1032                .await
1033            }
1034        });
1035        tokio::time::sleep(Duration::from_secs(10)).await;
1036        cancel.cancel();
1037        assert_eq!(loop_task.await.unwrap(), SessionTimerOutcome::Cancelled);
1038        assert!(dialog.events().is_empty());
1039    }
1040}