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
20pub(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 pub leg: LegShared,
91 pub media_stream: Arc<MediaStream>,
92 pub terminated_reason: Option<TerminatedReason>,
93 pub has_early_media: bool,
94 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 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 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 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 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 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 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
444async 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 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 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 #[tokio::test]
612 async fn test_early_sdp_stored_in_leg_progress() {
613 let mut states = make_states(false);
614
615 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 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 #[tokio::test]
645 async fn test_confirmed_empty_body_keeps_early_sdp() {
646 let mut states = make_states(false);
647
648 states.has_early_media = true;
650 states
651 .leg
652 .update_progress(|p| p.try_set_answer(EARLY_MEDIA_SDP));
653
654 states
656 .leg
657 .update_progress(|p| p.on_confirmed("dialog-1".to_string()));
658 let confirmed_answer = String::new();
661 assert!(
662 confirmed_answer.trim().is_empty(),
663 "empty body must not be applied"
664 );
665
666 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 #[tokio::test]
687 async fn test_answer_fallback_to_early_sdp_when_200ok_empty() {
688 let states = make_states(true);
690 states
691 .leg
692 .update_progress(|p| p.try_set_answer(EARLY_MEDIA_SDP));
693
694 let early = states.leg.progress.load_full().answer.clone();
696 let raw_answer: Option<Vec<u8>> = Some(vec![]); let (answer, already_applied) =
699 crate::call::state::resolve_final_answer(raw_answer, early.as_ref()).unwrap();
700
701 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 #[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 #[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 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 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 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
789
790 drop(states);
792
793 stop.store(true, Ordering::Relaxed);
794 let _ = writer.await;
795
796 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 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}