Skip to content

Commit 521a356

Browse files
myleshortonAdam Fiskclaude
authored
fix: pool shutdown hang + per-connection leak from the peer-state callback (#4)
PR #3 added an `on_peer_connection_state_change` callback that captures a clone of the per-session `SupervisorEvent` sender and a strong `Arc` back to the `RTCPeerConnection`. Two regressions followed: 1. Shutdown hang. The pool's per-worker forwarder ran `while let Some(event) = worker_events_rx.recv().await`, which only ends when every sender clone drops. The clone captured by the WebRTC callback outlives the session, so the channel never closed — the pool future never completed after cancellation and `SharingHandle::stop` hung forever. (Surfaced by spark-sharing's `starts_emits_and_stops_the_unprivileged_pool` once its pin advanced onto a main containing #3; unbounded-rs's own tests missed it because they drive a mock `run_session` that never clones the sender into a real WebRTC callback.) 2. Per-connection memory leak. `state_connection = connection.clone()` inside the callback forms an `Arc<RTCPeerConnection>` <-> handler reference cycle, so the connection (and its captured sender) is never freed even after `close()` — one leaked connection per session over a long-running pool. Fixes: - supervisor.rs: the worker forwards inline via `tokio::select!` and stops when `supervise_peer_proxy` completes, draining the queue once (the final `Stopped` is enqueued before it returns, so nothing is lost). Shutdown no longer depends on a leaked sender clone dropping. - peer_proxy.rs: the callback holds `Weak<RTCPeerConnection>` and upgrades it on the Connected transition, breaking the cycle so the connection and its captured sender are freed when the session ends. Adds `pool_shuts_down_when_a_session_registers_a_state_callback`, which drives the real peer proxy through the pool and asserts shutdown completes after cancellation — it hangs (5s timeout) without either fix. Co-authored-by: Adam Fisk <a@lantern.io> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a00a15c commit 521a356

2 files changed

Lines changed: 87 additions & 9 deletions

File tree

src/peer_proxy.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,11 @@ pub async fn run_peer_proxy_until_cancelled(
188188
// so a session that fails straight to Failed/Closed emits nothing.
189189
let connect_emitted = Arc::new(AtomicBool::new(false));
190190

191-
let state_connection = connection.clone();
191+
// Hold a `Weak` here, not a strong `Arc`: a strong clone captured by the
192+
// state-change callback (which the connection itself owns) forms a reference
193+
// cycle that leaks the whole `RTCPeerConnection` — and keeps the `events`
194+
// sender clone below alive — for every session, hanging pool shutdown.
195+
let state_connection = Arc::downgrade(&connection);
192196
let state_events = events.clone();
193197
let state_session_id = session_id.clone();
194198
let state_disconnect_emitted = disconnect_emitted.clone();
@@ -220,7 +224,12 @@ pub async fn run_peer_proxy_until_cancelled(
220224
match state {
221225
RTCPeerConnectionState::Connected => {
222226
connect_emitted.store(true, Ordering::SeqCst);
223-
let selected_pair = selected_candidate_pair(&connection).await;
227+
// The connection is normally still alive here; read the selected
228+
// pair when it is, otherwise fall back to `None` (remote unknown).
229+
let selected_pair = match connection.upgrade() {
230+
Some(connection) => selected_candidate_pair(&connection).await,
231+
None => None,
232+
};
224233
let _ = events.send(peer_connected_event(session_id, selected_pair.as_ref()));
225234
}
226235
// Emit a disconnect only for a session that reached Connected, and

src/supervisor.rs

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,15 +104,35 @@ pub async fn supervise_peer_proxy_pool(
104104
let events = events.clone();
105105
async move {
106106
let (worker_events_tx, mut worker_events_rx) = mpsc::unbounded_channel();
107-
let forwarder = tokio::spawn(async move {
108-
while let Some(event) = worker_events_rx.recv().await {
109-
if let Some(events) = &events {
110-
let _ = events.send(PoolEvent { slot, event });
107+
let mut supervise = std::pin::pin!(supervise_peer_proxy(
108+
config,
109+
cancellation,
110+
Some(worker_events_tx)
111+
));
112+
// Forward inline and terminate when the supervised session finishes —
113+
// not when the channel closes. A session's WebRTC state-change callback
114+
// can outlive the session while still holding a clone of the event
115+
// sender; waiting for that clone to drop would hang shutdown. The final
116+
// `Stopped` event is enqueued before `supervise_peer_proxy` returns, so
117+
// draining once it completes forwards every event without loss.
118+
let summary = loop {
119+
tokio::select! {
120+
biased;
121+
summary = &mut supervise => {
122+
while let Ok(event) = worker_events_rx.try_recv() {
123+
if let Some(events) = &events {
124+
let _ = events.send(PoolEvent { slot, event });
125+
}
126+
}
127+
break summary;
128+
}
129+
Some(event) = worker_events_rx.recv() => {
130+
if let Some(events) = &events {
131+
let _ = events.send(PoolEvent { slot, event });
132+
}
111133
}
112134
}
113-
});
114-
let summary = supervise_peer_proxy(config, cancellation, Some(worker_events_tx)).await;
115-
let _ = forwarder.await;
135+
};
116136
(slot, summary)
117137
}
118138
});
@@ -411,6 +431,55 @@ mod tests {
411431
assert!(second_retry.unwrap() <= Duration::from_millis(12));
412432
}
413433

434+
#[tokio::test]
435+
async fn pool_shuts_down_when_a_session_registers_a_state_callback() {
436+
// Regression: `run_peer_proxy_until_cancelled` registers an
437+
// `on_peer_connection_state_change` callback that captures a clone of the
438+
// per-session `SupervisorEvent` sender (and, before the fix, a strong
439+
// `Arc` back to the connection). WebRTC keeps that callback alive past the
440+
// session, so the sender clone outlived the session and kept the worker's
441+
// forwarder channel open — the pool future never completed after
442+
// cancellation and `SharingHandle::stop` hung. The pool must shut down
443+
// regardless of a leaked event-sender clone.
444+
let cancellation = CancellationToken::new();
445+
let (events_tx, mut events_rx) = mpsc::unbounded_channel::<PoolEvent>();
446+
let mut config = test_config();
447+
// Park in backoff after the first failure so cancellation, not the retry
448+
// cadence, drives shutdown.
449+
config.initial_backoff = Duration::from_secs(30);
450+
config.max_backoff = Duration::from_secs(30);
451+
452+
let pool = tokio::spawn(supervise_peer_proxy_pool(
453+
config,
454+
1,
455+
cancellation.clone(),
456+
Some(events_tx),
457+
));
458+
459+
// Wait until the real peer proxy has attempted and failed — this proves it
460+
// built the RTCPeerConnection and registered the state-change callback.
461+
let mut saw_failure = false;
462+
while let Some(PoolEvent { event, .. }) =
463+
tokio::time::timeout(Duration::from_secs(5), events_rx.recv())
464+
.await
465+
.expect("expected peer-proxy events before timeout")
466+
{
467+
if matches!(event, SupervisorEvent::AttemptFailed { .. }) {
468+
saw_failure = true;
469+
break;
470+
}
471+
}
472+
assert!(saw_failure, "peer proxy should have attempted and failed");
473+
474+
cancellation.cancel();
475+
let summary = tokio::time::timeout(Duration::from_secs(5), pool)
476+
.await
477+
.expect("pool did not shut down within 5s of cancellation")
478+
.expect("pool task panicked");
479+
assert_eq!(summary.workers.len(), 1);
480+
assert!(summary.attempts() >= 1);
481+
}
482+
414483
#[test]
415484
fn pool_summary_aggregates_worker_counts() {
416485
let summary = SupervisorPoolSummary {

0 commit comments

Comments
 (0)