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