Skip to main content

active_call/call/
sip.rs

1use crate::call::state::{CallProgress, LegShared};
2use crate::callrecord::CallRecordHangupReason;
3use crate::event::EventSender;
4use crate::media::TrackId;
5use crate::media::stream::MediaStream;
6use crate::useragent::invitation::PendingDialog;
7use anyhow::Result;
8use chrono::Utc;
9use rsipstack::dialog::DialogId;
10use rsipstack::dialog::dialog::{
11    Dialog, DialogState, DialogStateReceiver, DialogStateSender, TerminatedReason,
12};
13use rsipstack::dialog::dialog_layer::DialogLayer;
14use rsipstack::dialog::invitation::InviteOption;
15use std::collections::HashMap;
16use std::sync::Arc;
17use tokio_util::sync::CancellationToken;
18use tracing::{info, warn};
19
20/// Remove `id` from the dialog layer and return the dialog, ready to be
21/// hung up. Shared by the dialog guards and `Invitation::hangup`.
22pub(crate) fn remove_dialog(layer: &DialogLayer, id: &DialogId) -> Option<Dialog> {
23    let dialog = layer.get_dialog(id)?;
24    layer.remove_dialog(id);
25    Some(dialog)
26}
27
28pub struct DialogStateReceiverGuard {
29    pub(super) dialog_layer: Arc<DialogLayer>,
30    pub(super) receiver: DialogStateReceiver,
31    pub(super) dialog_id: Option<DialogId>,
32    pub(super) hangup_headers: Option<Vec<rsipstack::rsip::Header>>,
33}
34
35impl DialogStateReceiverGuard {
36    pub fn new(
37        dialog_layer: Arc<DialogLayer>,
38        receiver: DialogStateReceiver,
39        hangup_headers: Option<Vec<rsipstack::rsip::Header>>,
40    ) -> Self {
41        Self {
42            dialog_layer,
43            receiver,
44            dialog_id: None,
45            hangup_headers,
46        }
47    }
48    pub async fn recv(&mut self) -> Option<DialogState> {
49        let state = self.receiver.recv().await;
50        if let Some(ref s) = state {
51            self.dialog_id = Some(s.id().clone());
52        }
53        state
54    }
55
56    fn take_dialog(&mut self) -> Option<Dialog> {
57        let id = self.dialog_id.take()?;
58        info!(%id, "dialog removed on  drop");
59        if let Some(dialog) = remove_dialog(&self.dialog_layer, &id) {
60            return Some(dialog);
61        }
62        // A client dialog is only re-registered under its tag-bearing id once
63        // do_invite's process_invite() fully completes; while still ringing
64        // it's registered under the pre-response id (empty remote tag). Our
65        // tracked `id` already reflects whatever tag the latest DialogState
66        // event carried (e.g. a 183's), so the exact-key lookup above misses
67        // it for any dialog hung up before it's confirmed. Fall back to a
68        // call-id scan, which is unaffected by that id mutation.
69        self.dialog_layer
70            .get_client_dialog_by_call_id(&id.call_id)
71            .into_iter()
72            .next()
73            .map(Dialog::Invite)
74    }
75
76    pub async fn drop_async(&mut self) {
77        if let Some(dialog) = self.take_dialog() {
78            if let Err(e) = dialog.hangup_with_headers(self.hangup_headers.take()).await {
79                warn!(id=%dialog.id(), "error hanging up dialog on drop: {}", e);
80            }
81        }
82    }
83}
84
85impl Drop for DialogStateReceiverGuard {
86    fn drop(&mut self) {
87        if let Some(dialog) = self.take_dialog() {
88            crate::spawn(async move {
89                if let Err(e) = dialog.hangup().await {
90                    warn!(id=%dialog.id(), "error hanging up dialog on drop: {}", e);
91                }
92            });
93        }
94    }
95}
96
97pub(super) struct InviteDialogStates {
98    pub is_client: bool,
99    pub session_id: String,
100    pub track_id: TrackId,
101    pub cancel_token: CancellationToken,
102    pub event_sender: EventSender,
103    /// Lock-free shared state of the leg this dialog belongs to.
104    pub leg: LegShared,
105    pub media_stream: Arc<MediaStream>,
106    pub terminated_reason: Option<TerminatedReason>,
107    pub has_early_media: bool,
108    /// Set once the initial INVITE's answer has been applied via
109    /// `DialogState::Confirmed`. rsipstack reuses the same `Confirmed` event
110    /// for the ACK of a re-INVITE we received (see `handle_reinvite`), but
111    /// in that case the body is the PBX's own locally-generated answer, not
112    /// a remote description — that re-invite was already fully handled via
113    /// `DialogState::Updated`/`handshake()`. Re-applying it here corrupts
114    /// the peer connection's remote SSRC/address with our own values.
115    initial_confirmed: bool,
116    /// Hangup intent carried by this leg (refer legs with `auto_hangup`),
117    /// reported on the leg's TrackEnd so the call actor can hang up.
118    pub hangup_reason: Option<CallRecordHangupReason>,
119}
120
121impl InviteDialogStates {
122    pub(super) fn new(
123        is_client: bool,
124        session_id: String,
125        track_id: TrackId,
126        event_sender: EventSender,
127        media_stream: Arc<MediaStream>,
128        leg: LegShared,
129        cancel_token: CancellationToken,
130        hangup_reason: Option<CallRecordHangupReason>,
131    ) -> Self {
132        Self {
133            is_client,
134            session_id,
135            track_id,
136            cancel_token,
137            event_sender,
138            leg,
139            media_stream,
140            terminated_reason: None,
141            has_early_media: false,
142            initial_confirmed: false,
143            hangup_reason,
144        }
145    }
146}
147
148impl InviteDialogStates {
149    /// Called from `Drop` (synchronous context): everything used here is
150    /// lock-free (ArcSwap rcu/load + broadcast send), so no state or events
151    /// can be lost the way a failed `try_write` used to lose them.
152    pub(super) fn on_terminated(&mut self) {
153        let term = CallProgress::termination(self.terminated_reason.as_ref());
154        let status_code = term.status_code;
155        let reason = term.hangup_reason;
156        self.leg.update_progress(|p| {
157            p.last_status_code = status_code;
158            p.set_hangup_reason(reason.clone());
159        });
160        let progress = self.leg.progress.load_full();
161
162        self.event_sender
163            .send(crate::event::SessionEvent::TrackEnd {
164                track_id: self.track_id.clone(),
165                timestamp: crate::media::get_timestamp(),
166                duration: progress
167                    .answer_time
168                    .map(|t| (Utc::now() - t).num_milliseconds())
169                    .unwrap_or_default() as u64,
170                ssrc: self.leg.ssrc,
171                play_id: None,
172                auto_hangup: self.hangup_reason.clone(),
173            })
174            .ok();
175        let hangup_event = self
176            .leg
177            .build_hangup_event(self.track_id.clone(), Some(term.initiator.to_string()));
178        self.event_sender.send(hangup_event).ok();
179    }
180}
181
182impl Drop for InviteDialogStates {
183    fn drop(&mut self) {
184        self.on_terminated();
185        self.cancel_token.cancel();
186    }
187}
188
189impl DialogStateReceiverGuard {
190    pub(self) async fn dialog_event_loop(&mut self, states: &mut InviteDialogStates) -> Result<()> {
191        while let Some(event) = self.recv().await {
192            match event {
193                DialogState::Calling(dialog_id) => {
194                    info!(session_id=states.session_id, %dialog_id, "dialog calling");
195                    states
196                        .leg
197                        .update_progress(|p| p.session_id = dialog_id.to_string());
198                }
199                DialogState::Trying(_) => {}
200                DialogState::Early(dialog_id, resp) => {
201                    let code = resp.status_code.code();
202                    let body = resp.body();
203                    let answer = String::from_utf8_lossy(body);
204                    let has_sdp = !answer.is_empty();
205                    info!(session_id=states.session_id, %dialog_id, has_sdp=%has_sdp, "dialog early ({}): \n{}", code, answer);
206
207                    states.leg.update_progress(|p| p.on_early(code));
208
209                    if !states.is_client {
210                        continue;
211                    }
212
213                    states
214                        .event_sender
215                        .send(crate::event::SessionEvent::Ringing {
216                            track_id: states.track_id.clone(),
217                            timestamp: crate::media::get_timestamp(),
218                            early_media: has_sdp,
219                            refer: Some(states.leg.is_refer),
220                        })?;
221
222                    if has_sdp {
223                        states.has_early_media = true;
224                        states.leg.update_progress(|p| p.try_set_answer(&answer));
225                        states
226                            .media_stream
227                            .update_remote_description_provisional(
228                                &states.track_id,
229                                &answer.to_string(),
230                            )
231                            .await?;
232                    }
233                }
234                DialogState::Confirmed(dialog_id, msg) => {
235                    info!(session_id=states.session_id, %dialog_id, has_early_media=%states.has_early_media, "dialog confirmed");
236                    states
237                        .leg
238                        .update_progress(|p| p.on_confirmed(dialog_id.to_string()));
239                    if states.is_client && !states.initial_confirmed {
240                        states.initial_confirmed = true;
241                        let answer = String::from_utf8_lossy(msg.body());
242                        let answer = answer.trim();
243                        if !answer.is_empty() {
244                            if states.has_early_media {
245                                info!(
246                                    session_id = states.session_id,
247                                    "updating remote description with final answer after early media (force=true)"
248                                );
249                                // Force update when transitioning from early media (183) to confirmed (200 OK)
250                                // This ensures media parameters are properly updated even if SDP appears similar
251                                if let Err(e) = states
252                                    .media_stream
253                                    .update_remote_description_force(
254                                        &states.track_id,
255                                        &answer.to_string(),
256                                    )
257                                    .await
258                                {
259                                    tracing::warn!(
260                                        session_id = states.session_id,
261                                        "failed to force update remote description on confirmed: {}",
262                                        e
263                                    );
264                                }
265                            } else {
266                                if let Err(e) = states
267                                    .media_stream
268                                    .update_remote_description(
269                                        &states.track_id,
270                                        &answer.to_string(),
271                                    )
272                                    .await
273                                {
274                                    tracing::warn!(
275                                        session_id = states.session_id,
276                                        "failed to update remote description on confirmed: {}",
277                                        e
278                                    );
279                                }
280                            }
281                        }
282                    }
283                }
284                DialogState::Info(dialog_id, req, tx_handle) => {
285                    let body_str = String::from_utf8_lossy(req.body());
286                    info!(session_id=states.session_id, %dialog_id, body=%body_str, "dialog info received");
287                    if body_str.starts_with("Signal=") {
288                        let digit = body_str.trim_start_matches("Signal=").chars().next();
289                        if let Some(digit) = digit {
290                            states.event_sender.send(crate::event::SessionEvent::Dtmf {
291                                track_id: states.track_id.clone(),
292                                timestamp: crate::media::get_timestamp(),
293                                digit: digit.to_string(),
294                                refer: Some(states.leg.is_refer),
295                            })?;
296                        }
297                    }
298                    tx_handle.reply(rsipstack::rsip::StatusCode::OK).await.ok();
299                }
300                DialogState::Message(dialog_id, req, tx_handle) => {
301                    let body_str = String::from_utf8_lossy(req.body()).to_string();
302                    let content_type = req.headers.iter().find_map(|h| {
303                        if let rsipstack::rsip::Header::ContentType(content_type) = h {
304                            Some(content_type.value().to_string())
305                        } else {
306                            None
307                        }
308                    });
309                    info!(
310                        session_id=states.session_id,
311                        %dialog_id,
312                        content_type=content_type.as_deref(),
313                        body=%body_str,
314                        "dialog message received"
315                    );
316                    states
317                        .event_sender
318                        .send(crate::event::SessionEvent::Message {
319                            track_id: states.track_id.clone(),
320                            timestamp: crate::media::get_timestamp(),
321                            body: body_str,
322                            content_type,
323                            refer: Some(states.leg.is_refer),
324                        })
325                        .ok();
326                    tx_handle.reply(rsipstack::rsip::StatusCode::OK).await.ok();
327                }
328                DialogState::Updated(dialog_id, _req, tx_handle) => {
329                    info!(session_id = states.session_id, %dialog_id, "dialog update received");
330                    let mut answer_sdp = None;
331                    if let Some(sdp_body) = _req.body().get(..) {
332                        let sdp_str = String::from_utf8_lossy(sdp_body);
333                        if !sdp_str.is_empty()
334                            && (_req.method == rsipstack::rsip::Method::Invite
335                                || _req.method == rsipstack::rsip::Method::Update)
336                        {
337                            info!(session_id=states.session_id, %dialog_id, method=%_req.method, "handling re-invite/update offer");
338
339                            // Detect hold state from SDP
340                            let is_on_hold =
341                                crate::media::negotiate::detect_hold_state_from_sdp(&sdp_str);
342                            info!(session_id=states.session_id, %dialog_id, is_on_hold=%is_on_hold, "detected hold state from re-invite SDP");
343
344                            // Update media stream hold state + emit hold event
345                            apply_hold_state(states, is_on_hold).await;
346
347                            match states
348                                .media_stream
349                                .handshake(&states.track_id, sdp_str.to_string(), None)
350                                .await
351                            {
352                                Ok(sdp) => answer_sdp = Some(sdp),
353                                Err(e) => {
354                                    warn!(
355                                        session_id = states.session_id,
356                                        "failed to handle re-invite: {}", e
357                                    );
358                                }
359                            }
360                        } else {
361                            info!(session_id=states.session_id, %dialog_id, "updating remote description:\n{}", sdp_str);
362
363                            // Also check hold state for non-INVITE/UPDATE messages with SDP
364                            let is_on_hold =
365                                crate::media::negotiate::detect_hold_state_from_sdp(&sdp_str);
366                            apply_hold_state(states, is_on_hold).await;
367
368                            states
369                                .media_stream
370                                .update_remote_description(&states.track_id, &sdp_str.to_string())
371                                .await?;
372                        }
373                    }
374
375                    if let Some(sdp) = answer_sdp {
376                        tx_handle
377                            .respond(
378                                rsipstack::rsip::StatusCode::OK,
379                                Some(vec![rsipstack::rsip::Header::ContentType(
380                                    "application/sdp".to_string().into(),
381                                )]),
382                                Some(sdp.into()),
383                            )
384                            .await
385                            .ok();
386                    } else {
387                        tx_handle.reply(rsipstack::rsip::StatusCode::OK).await.ok();
388                    }
389                }
390                DialogState::Options(dialog_id, _req, tx_handle) => {
391                    info!(session_id = states.session_id, %dialog_id, "dialog options received");
392                    tx_handle.reply(rsipstack::rsip::StatusCode::OK).await.ok();
393                }
394                DialogState::Refer(dialog_id, req, tx_handle) => {
395                    let refer_to = req
396                        .headers
397                        .iter()
398                        .find_map(|h| {
399                            if let rsipstack::rsip::Header::ReferTo(h) = h {
400                                return Some(h.value().to_string());
401                            }
402                            None
403                        })
404                        .unwrap_or_default();
405                    let referred_by = req.headers.iter().find_map(|h| {
406                        if let rsipstack::rsip::Header::ReferredBy(h) = h {
407                            return Some(h.value().to_string());
408                        }
409                        None
410                    });
411                    info!(session_id = states.session_id, %dialog_id, %refer_to, "received REFER");
412                    tx_handle
413                        .reply(rsipstack::rsip::StatusCode::Other(202, "Accepted".into()))
414                        .await
415                        .ok();
416                    states
417                        .event_sender
418                        .send(crate::event::SessionEvent::TransferRequest {
419                            track_id: states.track_id.clone(),
420                            timestamp: crate::media::get_timestamp(),
421                            refer_to,
422                            referred_by,
423                            refer: Some(states.leg.is_refer),
424                        })
425                        .ok();
426                }
427                DialogState::Terminated(dialog_id, reason) => {
428                    info!(
429                        session_id = states.session_id,
430                        ?dialog_id,
431                        ?reason,
432                        "dialog terminated"
433                    );
434                    states.terminated_reason = Some(reason.clone());
435                    return Ok(());
436                }
437                other_state => {
438                    info!(
439                        session_id = states.session_id,
440                        %other_state,
441                        "dialog received other state"
442                    );
443                }
444            }
445        }
446        Ok(())
447    }
448
449    pub(super) async fn process_dialog(&mut self, mut states: InviteDialogStates) {
450        let token = states.cancel_token.clone();
451        tokio::select! {
452            _ = token.cancelled() => {
453                states.terminated_reason = Some(TerminatedReason::UacCancel);
454            }
455            _ = self.dialog_event_loop(&mut states) => {}
456        };
457
458        // Update hangup headers from the leg extras if available
459        let extras = states.leg.extras.load_full();
460        if let Some(headers) = crate::sip_util::hangup_headers_from_extras(&extras) {
461            match &mut self.hangup_headers {
462                Some(existing) => existing.extend(headers),
463                None => self.hangup_headers = Some(headers),
464            }
465        }
466
467        self.drop_async().await;
468    }
469}
470
471/// Apply a hold/resume transition to the media track and emit the Hold event.
472async fn apply_hold_state(states: &mut InviteDialogStates, is_on_hold: bool) {
473    if is_on_hold {
474        states
475            .media_stream
476            .hold_track(Some(states.track_id.clone()))
477            .await;
478    } else {
479        states
480            .media_stream
481            .resume_track(Some(states.track_id.clone()))
482            .await;
483    }
484    states
485        .event_sender
486        .send(crate::event::SessionEvent::Hold {
487            track_id: states.track_id.clone(),
488            timestamp: crate::media::get_timestamp(),
489            on_hold: is_on_hold,
490            refer: Some(states.leg.is_refer),
491        })
492        .ok();
493}
494
495#[derive(Clone)]
496pub struct Invitation {
497    pub dialog_layer: Arc<DialogLayer>,
498    pub pending_dialogs: Arc<std::sync::Mutex<HashMap<DialogId, PendingDialog>>>,
499}
500
501impl Invitation {
502    pub fn new(dialog_layer: Arc<DialogLayer>) -> Self {
503        Self {
504            dialog_layer,
505            pending_dialogs: Arc::new(std::sync::Mutex::new(HashMap::new())),
506        }
507    }
508
509    pub fn add_pending(&self, dialog_id: DialogId, pending: PendingDialog) {
510        self.pending_dialogs
511            .lock()
512            .map(|mut ps| ps.insert(dialog_id, pending))
513            .ok();
514    }
515
516    pub fn get_pending_call(&self, dialog_id: &DialogId) -> Option<PendingDialog> {
517        self.pending_dialogs
518            .lock()
519            .ok()
520            .and_then(|mut ps| ps.remove(dialog_id))
521    }
522
523    pub fn has_pending_call(&self, dialog_id: &DialogId) -> bool {
524        self.pending_dialogs
525            .lock()
526            .ok()
527            .map(|ps| ps.contains_key(dialog_id))
528            .unwrap_or(false)
529    }
530
531    pub fn find_dialog_id_by_session_id(&self, session_id: &str) -> Option<DialogId> {
532        self.pending_dialogs.lock().ok().and_then(|ps| {
533            ps.iter()
534                .find(|(id, _)| id.to_string() == session_id)
535                .map(|(id, _)| id.clone())
536        })
537    }
538
539    /// Reject a pending dialog or hang up an established one.
540    pub async fn hangup(
541        &self,
542        dialog_id: DialogId,
543        code: Option<rsipstack::rsip::StatusCode>,
544        reason: Option<String>,
545    ) -> Result<()> {
546        if let Some(call) = self.get_pending_call(&dialog_id) {
547            call.dialog.reject(code, reason).ok();
548        }
549        if let Some(dialog) = remove_dialog(&self.dialog_layer, &dialog_id) {
550            dialog.hangup().await.ok();
551        }
552        Ok(())
553    }
554
555    pub async fn invite(
556        &self,
557        invite_option: InviteOption,
558        state_sender: DialogStateSender,
559    ) -> Result<(DialogId, Option<Vec<u8>>), rsipstack::Error> {
560        let (dialog, resp) = self
561            .dialog_layer
562            .do_invite(invite_option, state_sender)
563            .await?;
564
565        let offer = match resp {
566            Some(resp) => match resp.status_code.kind() {
567                rsipstack::rsip::StatusCodeKind::Successful => {
568                    let offer = resp.body.clone();
569                    Some(offer)
570                }
571                _ => {
572                    let reason = resp
573                        .reason_phrase()
574                        .unwrap_or(&resp.status_code.to_string())
575                        .to_string();
576                    return Err(rsipstack::Error::DialogError(
577                        reason,
578                        dialog.id(),
579                        resp.status_code,
580                    ));
581                }
582            },
583            None => {
584                return Err(rsipstack::Error::DialogError(
585                    "no response received".to_string(),
586                    dialog.id(),
587                    rsipstack::rsip::StatusCode::NotAcceptableHere,
588                ));
589            }
590        };
591        Ok((dialog.id(), offer))
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598    use crate::call::state::CallProgress;
599    use crate::media::stream::MediaStreamBuilder;
600
601    // SDP used to simulate an early-media 183 Session Progress response.
602    const EARLY_MEDIA_SDP: &str = "v=0\r\n\
603        o=- 1000 1 IN IP4 192.168.1.100\r\n\
604        s=SIP Call\r\n\
605        t=0 0\r\n\
606        m=audio 10000 RTP/AVP 0\r\n\
607        c=IN IP4 192.168.1.100\r\n\
608        a=rtpmap:0 PCMU/8000\r\n\
609        a=sendrecv\r\n";
610
611    fn make_states(has_early_media: bool) -> InviteDialogStates {
612        let (event_tx, _event_rx) = tokio::sync::broadcast::channel(16);
613        let media_stream = Arc::new(
614            MediaStreamBuilder::new(event_tx.clone())
615                .with_id("test-stream".to_string())
616                .build(),
617        );
618        let cancel_token = CancellationToken::new();
619        let leg = LegShared::new(1000, true, CallProgress::default());
620
621        InviteDialogStates {
622            is_client: true,
623            session_id: "test-session".to_string(),
624            track_id: "test-track".to_string(),
625            cancel_token: cancel_token.clone(),
626            event_sender: event_tx.clone(),
627            leg,
628            media_stream,
629            terminated_reason: None,
630            has_early_media,
631            initial_confirmed: false,
632            hangup_reason: None,
633        }
634    }
635
636    /// Verify that when a 183 Session Progress with SDP arrives (`DialogState::Early`),
637    /// the early SDP is stored in the leg progress so it can serve as a fallback
638    /// when the final 200 OK has an empty body.
639    #[tokio::test]
640    async fn test_early_sdp_stored_in_leg_progress() {
641        let mut states = make_states(false);
642
643        // Simulate DialogState::Early with SDP body (183 Session Progress):
644        // same steps the Early branch performs.
645        let answer = EARLY_MEDIA_SDP.to_string();
646        let has_sdp = !answer.is_empty();
647        if states.is_client && has_sdp {
648            states.has_early_media = true;
649            states.leg.update_progress(|p| p.try_set_answer(&answer));
650        }
651
652        // Assert: early SDP is stored
653        let progress = states.leg.progress.load_full();
654        assert!(
655            progress.answer.is_some(),
656            "leg progress answer should be set after 183 with SDP"
657        );
658        assert_eq!(
659            progress.answer.as_deref().unwrap(),
660            EARLY_MEDIA_SDP,
661            "leg progress answer should contain the early SDP"
662        );
663        assert!(states.has_early_media, "has_early_media should be true");
664    }
665
666    /// Verify that when a 200 OK arrives with an empty body after early media has been
667    /// negotiated, the leg progress retains the early SDP (not overwritten with "").
668    ///
669    /// This is the regression test for the bug where a late 200 OK with empty body would
670    /// cause `SessionEvent::Answer { sdp: "" }` to be emitted, making the answer event
671    /// appear as if no SDP was negotiated.
672    #[tokio::test]
673    async fn test_confirmed_empty_body_keeps_early_sdp() {
674        let mut states = make_states(false);
675
676        // Step 1: simulate 183 with SDP → set has_early_media and progress answer
677        states.has_early_media = true;
678        states
679            .leg
680            .update_progress(|p| p.try_set_answer(EARLY_MEDIA_SDP));
681
682        // Step 2: simulate 200 OK with empty body (Confirmed handler logic)
683        states
684            .leg
685            .update_progress(|p| p.on_confirmed("dialog-1".to_string()));
686        // The Confirmed handler only calls update_remote_description when the body
687        // is non-empty; it does NOT overwrite the progress answer.
688        let confirmed_answer = String::new();
689        assert!(
690            confirmed_answer.trim().is_empty(),
691            "empty body must not be applied"
692        );
693
694        // Assert: leg progress still holds the early SDP
695        let progress = states.leg.progress.load_full();
696        assert!(
697            progress.answer.is_some(),
698            "answer must not be None after 200 OK with empty body"
699        );
700        let stored_answer = progress.answer.as_deref().unwrap();
701        assert!(
702            !stored_answer.is_empty(),
703            "answer must not be empty after 200 OK with empty body"
704        );
705        assert_eq!(
706            stored_answer, EARLY_MEDIA_SDP,
707            "answer should still be the early SDP after 200 OK with empty body"
708        );
709    }
710
711    /// Verify that `create_outgoing_sip_track`'s fallback logic works:
712    /// when the 200 OK body is empty but the leg progress has the early SDP,
713    /// the fallback path is taken and the early SDP is returned (not an empty string).
714    #[tokio::test]
715    async fn test_answer_fallback_to_early_sdp_when_200ok_empty() {
716        // Simulate what the Early (183) handler does: store the early SDP.
717        let states = make_states(true);
718        states
719            .leg
720            .update_progress(|p| p.try_set_answer(EARLY_MEDIA_SDP));
721
722        // Simulate what create_outgoing_sip_track does when 200 OK has empty body.
723        let early = states.leg.progress.load_full().answer.clone();
724        let raw_answer: Option<Vec<u8>> = Some(vec![]); // empty body from 200 OK
725
726        let (answer, already_applied) =
727            crate::call::state::resolve_final_answer(raw_answer, early.as_ref()).unwrap();
728
729        // The answer returned to setup_caller_track (and used in SessionEvent::Answer)
730        // must be the early SDP, not an empty string.
731        assert!(
732            !answer.is_empty(),
733            "Resolved answer must not be empty — should contain the early SDP"
734        );
735        assert_eq!(
736            answer, EARLY_MEDIA_SDP,
737            "Resolved answer should be the early SDP from the 183 handler"
738        );
739        assert!(
740            already_applied,
741            "remote_description_already_applied should be true when using early SDP fallback"
742        );
743    }
744
745    /// Verify the normal case: when 200 OK carries its own SDP body,
746    /// that SDP is used directly (not the early SDP) and remote description
747    /// should be applied.
748    #[tokio::test]
749    async fn test_answer_uses_200ok_sdp_when_present() {
750        const FINAL_SDP: &str = "v=0\r\n\
751            o=- 2000 2 IN IP4 10.0.0.1\r\n\
752            s=SIP Call\r\n\
753            t=0 0\r\n\
754            m=audio 20000 RTP/AVP 0\r\n\
755            c=IN IP4 10.0.0.1\r\n\
756            a=rtpmap:0 PCMU/8000\r\n\
757            a=sendrecv\r\n";
758
759        let states = make_states(true);
760        states
761            .leg
762            .update_progress(|p| p.try_set_answer(EARLY_MEDIA_SDP));
763
764        let early = states.leg.progress.load_full().answer.clone();
765        let (answer, already_applied) = crate::call::state::resolve_final_answer(
766            Some(FINAL_SDP.as_bytes().to_vec()),
767            early.as_ref(),
768        )
769        .unwrap();
770
771        assert_eq!(
772            answer, FINAL_SDP,
773            "When 200 OK has SDP, it should be used (not the early SDP)"
774        );
775        assert!(
776            !already_applied,
777            "remote_description_already_applied should be false when 200 OK has SDP body"
778        );
779    }
780
781    /// Regression: `on_terminated` runs in a synchronous `Drop` context.
782    /// The old implementation used `try_write` and silently dropped the
783    /// status/reason updates AND the TrackEnd/Hangup events when the lock was
784    /// contended. The lock-free (ArcSwap) implementation must always emit both
785    /// events and record the termination, even while another task keeps
786    /// mutating the progress concurrently.
787    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
788    async fn test_on_terminated_always_emits_events_and_records_state() {
789        use crate::event::SessionEvent;
790        use std::sync::atomic::{AtomicBool, Ordering};
791
792        let mut states = make_states(false);
793        states.terminated_reason = Some(TerminatedReason::UacCancel);
794        let leg = states.leg.clone();
795        let event_sender = states.event_sender.clone();
796        let mut event_receiver = event_sender.subscribe();
797
798        // Hammer the progress concurrently, as a busy actor would.
799        let stop = Arc::new(AtomicBool::new(false));
800        let stop2 = stop.clone();
801        let progress = leg.progress.clone();
802        let writer = crate::spawn(async move {
803            while !stop2.load(Ordering::Relaxed) {
804                // Touch unrelated fields, like a busy actor would (never the
805                // termination fields), so writers don't clobber each other.
806                progress.rcu(|p| {
807                    let mut p = CallProgress::clone(p);
808                    p.answer_time.get_or_insert_with(chrono::Utc::now);
809                    p
810                });
811                tokio::task::yield_now().await;
812            }
813        });
814
815        // Give the writer a moment to start contending.
816        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
817
818        // Synchronous drop, exactly like the real dialog task teardown.
819        drop(states);
820
821        stop.store(true, Ordering::Relaxed);
822        let _ = writer.await;
823
824        // Both events must have been emitted despite the concurrent writer.
825        let mut saw_track_end = false;
826        let mut saw_hangup = false;
827        while let Ok(event) = event_receiver.try_recv() {
828            match event {
829                SessionEvent::TrackEnd { .. } => saw_track_end = true,
830                SessionEvent::Hangup { refer, .. } => {
831                    assert_eq!(refer, Some(true), "refer flag comes from the leg");
832                    saw_hangup = true;
833                }
834                _ => {}
835            }
836        }
837        assert!(
838            saw_track_end,
839            "TrackEnd must be emitted from on_terminated even under contention"
840        );
841        assert!(
842            saw_hangup,
843            "Hangup must be emitted from on_terminated even under contention"
844        );
845
846        // The termination must be recorded (487 for UacCancel); the concurrent
847        // writer only ever writes 100, so observing 487 proves the rcu landed.
848        let progress = leg.progress.load_full();
849        assert_eq!(progress.last_status_code, 487);
850        assert_eq!(
851            progress.hangup_reason,
852            Some(crate::callrecord::CallRecordHangupReason::Canceled)
853        );
854    }
855
856    /// A minimal counting track that records how many times its remote
857    /// description was (force-)updated, without touching any real media.
858    struct CountingTrack {
859        id: TrackId,
860        config: crate::media::track::TrackConfig,
861        processor_chain: crate::media::processor::ProcessorChain,
862        updates: std::sync::Arc<std::sync::atomic::AtomicUsize>,
863    }
864
865    #[async_trait::async_trait]
866    impl crate::media::track::Track for CountingTrack {
867        fn ssrc(&self) -> u32 {
868            0
869        }
870        fn id(&self) -> &TrackId {
871            &self.id
872        }
873        fn config(&self) -> &crate::media::track::TrackConfig {
874            &self.config
875        }
876        fn processor_chain(&mut self) -> &mut crate::media::processor::ProcessorChain {
877            &mut self.processor_chain
878        }
879        async fn handshake(
880            &mut self,
881            _offer: String,
882            _timeout: Option<tokio::time::Duration>,
883        ) -> Result<String> {
884            Ok(String::new())
885        }
886        async fn update_remote_description(&mut self, _answer: &String) -> Result<()> {
887            self.updates
888                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
889            Ok(())
890        }
891        async fn update_remote_description_force(&mut self, _answer: &String) -> Result<()> {
892            self.updates
893                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
894            Ok(())
895        }
896        async fn start(
897            &mut self,
898            _event_sender: EventSender,
899            _packet_sender: crate::media::track::TrackPacketSender,
900        ) -> Result<()> {
901            Ok(())
902        }
903        async fn stop(&self) -> Result<()> {
904            Ok(())
905        }
906        async fn send_packet(&mut self, _packet: &crate::media::AudioFrame) -> Result<()> {
907            Ok(())
908        }
909    }
910
911    /// Regression: a client dialog must apply its remote SDP answer exactly
912    /// once. The `initial_confirmed` guard exists because the final `Confirmed`
913    /// event fires both when the 200 OK is processed *and* again when the ACK
914    /// for the re-INVITE completes; without the guard the answer would be
915    /// re-applied (and a duplicate `TrackStart`/`SessionEvent::Answer` emitted).
916    #[tokio::test]
917    async fn test_confirmed_applies_remote_answer_only_once() {
918        let mut states = make_states(false);
919
920        let updates = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
921        let track = CountingTrack {
922            id: states.track_id.clone(),
923            config: crate::media::track::TrackConfig::default(),
924            processor_chain: crate::media::processor::ProcessorChain::new(16000),
925            updates: updates.clone(),
926        };
927        states
928            .media_stream
929            .update_track(Box::new(track), None)
930            .await;
931
932        let endpoint = {
933            let mut builder = rsipstack::EndpointBuilder::new();
934            builder.build()
935        };
936        let dialog_layer = Arc::new(DialogLayer::new(endpoint.inner.clone()));
937
938        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<DialogState>();
939        let mut guard = DialogStateReceiverGuard::new(dialog_layer, rx, None);
940
941        let dialog_id = DialogId {
942            call_id: "test-call-id".to_string(),
943            local_tag: "test-local-tag".to_string(),
944            remote_tag: "test-remote-tag".to_string(),
945        };
946
947        let mut resp = rsipstack::rsip::Response::default();
948        resp.body = EARLY_MEDIA_SDP.as_bytes().to_vec();
949
950        // Two Confirmed events (200 OK + re-INVITE ACK) must only apply once.
951        tx.send(DialogState::Confirmed(dialog_id.clone(), resp.clone()))
952            .unwrap();
953        tx.send(DialogState::Confirmed(dialog_id, resp)).unwrap();
954        drop(tx);
955
956        guard
957            .dialog_event_loop(&mut states)
958            .await
959            .expect("dialog event loop must complete");
960
961        assert_eq!(
962            updates.load(std::sync::atomic::Ordering::SeqCst),
963            1,
964            "remote answer must be applied exactly once, not on the re-INVITE ACK Confirmed event"
965        );
966    }
967}