use active_call::app::AppStateBuilder;
use active_call::call::active_call::CallSpec;
use active_call::call::{ActiveCall, ActiveCallType};
use active_call::config::Config;
use active_call::event::SessionEvent;
use active_call::media::engine::StreamEngine;
use active_call::media::track::TrackConfig;
use anyhow::Result;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::info;
fn rss_bytes() -> u64 {
let pid = std::process::id();
let out = std::process::Command::new("ps")
.args(["-o", "rss=", "-p", &pid.to_string()])
.output()
.expect("failed to run ps");
let kb: u64 = String::from_utf8_lossy(&out.stdout)
.trim()
.parse()
.expect("ps returned non-numeric rss");
kb * 1024
}
fn test_config() -> Config {
let mut config = Config::default();
config.udp_port = 0; config.media_cache_path = "./target/tmp_leaktest".to_string();
config
}
async fn run_call_cycle(
app_state: &active_call::app::AppState,
record_rx: &mut mpsc::UnboundedReceiver<active_call::callrecord::CallRecord>,
seed_events: usize,
) -> Result<()> {
let cancel_token = CancellationToken::new();
let call = Arc::new(ActiveCall::new(CallSpec {
call_type: ActiveCallType::WebSocket,
cancel_token: cancel_token.clone(),
session_id: format!("leak-{}", uuid::Uuid::new_v4()),
invitation: app_state.invitation.clone(),
app_state: app_state.clone(),
track_config: TrackConfig::default(),
audio_receiver: None,
dump_events: false,
server_side_track_id: None,
extras: None,
}));
let _guard = active_call::call::active_call::ActiveCallGuard::new(call.clone());
let receiver = call.new_receiver();
let serve_handle = tokio::spawn({
let call = call.clone();
async move { call.serve(receiver).await }
});
call.enqueue_command(active_call::call::Command::Custom {
sender: Some("leak-test".to_string()),
data: serde_json::json!({}),
})
.await?;
for i in 0..seed_events {
let _ = call.event_sender.send(SessionEvent::Speaking {
track_id: call.server_side_track_id.clone(),
timestamp: active_call::media::get_timestamp(),
start_time: i as u64,
is_filler: None,
confidence: None,
refer: None,
});
}
tokio::time::sleep(Duration::from_millis(5)).await;
cancel_token.cancel();
tokio::time::timeout(Duration::from_secs(10), serve_handle)
.await
.expect("serve did not finish")??;
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
while Arc::strong_count(&call) > 3 && tokio::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(
Arc::strong_count(&call),
3,
"leaked ActiveCall references after serve() finished"
);
drop(_guard);
drop(call);
let record = tokio::time::timeout(Duration::from_secs(5), record_rx.recv())
.await
.expect("timed out waiting for call record")
.expect("call record channel closed: Drop did not run");
assert!(
!record.call_id.is_empty(),
"call record has no call id (snapshot was empty)"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_call_lifecycle_no_leak() -> Result<()> {
let _ = tracing_subscriber::fmt().with_env_filter("warn").try_init();
let (record_tx, mut record_rx) = mpsc::unbounded_channel();
let app_state = AppStateBuilder::new()
.with_config(test_config())
.with_stream_engine(Arc::new(StreamEngine::default()))
.with_callrecord_sender(record_tx)
.build()
.await?;
const WARMUP: usize = 50;
const MEASURED: usize = 300;
for _ in 0..WARMUP {
run_call_cycle(&app_state, &mut record_rx, 4).await?;
}
let rss_before = rss_bytes();
for i in 0..MEASURED {
run_call_cycle(&app_state, &mut record_rx, 4).await?;
if i % 100 == 0 {
info!(cycle = i, rss = rss_bytes(), "leak test progress");
}
}
let rss_after = rss_bytes();
let growth = rss_after.saturating_sub(rss_before);
let per_call = growth / MEASURED as u64;
assert!(
app_state.active_calls.lock().unwrap().is_empty(),
"active_calls registry leaked entries"
);
assert!(
app_state
.invitation
.pending_dialogs
.lock()
.unwrap()
.is_empty(),
"pending_dialogs leaked entries"
);
info!(
rss_before = rss_before,
rss_after = rss_after,
growth = growth,
per_call = per_call,
"leak test summary"
);
assert!(
per_call < 64 * 1024,
"RSS grew by {} bytes/cycle — likely per-call leak",
per_call
);
assert!(
growth < 64 * 1024 * 1024,
"total RSS growth {} bytes is too high",
growth
);
Ok(())
}
#[tokio::test]
async fn test_refer_leg_references_released() -> Result<()> {
let app_state = AppStateBuilder::new()
.with_config(test_config())
.with_stream_engine(Arc::new(StreamEngine::default()))
.build()
.await?;
let call = Arc::new(ActiveCall::new(CallSpec {
call_type: ActiveCallType::Sip,
cancel_token: CancellationToken::new(),
session_id: "refer-leak".to_string(),
invitation: app_state.invitation.clone(),
app_state: app_state.clone(),
track_config: TrackConfig::default(),
audio_receiver: None,
dump_events: false,
server_side_track_id: None,
extras: None,
}));
let leg = active_call::call::state::LegShared::new(7, true, Default::default());
assert_eq!(Arc::strong_count(&leg.progress), 1);
call.set_refer_leg(Some(leg.clone()));
assert_eq!(
Arc::strong_count(&leg.progress),
2,
"unexpected reference count while refer leg is set"
);
call.set_refer_leg(None);
assert_eq!(
Arc::strong_count(&leg.progress),
1,
"ArcSwap refer_leg kept a reference after clear"
);
drop(leg);
Ok(())
}