From dbf923fb16f5fcbddaf9cfdd209ce47e7bb92a04 Mon Sep 17 00:00:00 2001 From: ospab Date: Sat, 18 Jul 2026 17:46:14 +0300 Subject: [PATCH] fix(client): stop leaking a socket+task per bypassed UDP flow start_udp_bypass_session (the TUN-mode path for UDP from apps/IPs the user has excluded from the tunnel) spawned a separate task to read from the physical-interface-bound socket, holding its own Arc clone. Nothing ever cancelled that task when the outer function returned (e.g. once session_rx closed) - it just kept running, and its socket clone kept the OS fd alive, for the lifetime of the process. Every distinct bypassed UDP flow (any excluded app's DNS query, game session, etc.) leaked one socket and one task permanently. The sibling function right below it, start_udp_session, already does this correctly: one tokio::select! loop combining both directions in a single task that exits (and drops the socket) as soon as either side closes. Rewrote start_udp_bypass_session to match that pattern instead of spawning a detached reader task. --- ostp-client/src/tunnel/udp_nat.rs | 43 ++++++++++++++++++------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/ostp-client/src/tunnel/udp_nat.rs b/ostp-client/src/tunnel/udp_nat.rs index 13a6406..0daac60 100644 --- a/ostp-client/src/tunnel/udp_nat.rs +++ b/ostp-client/src/tunnel/udp_nat.rs @@ -138,27 +138,34 @@ async fn start_udp_bypass_session( let _ = crate::tunnel::proxy::bind_socket_to_interface(&socket, name); } - let socket = Arc::new(socket); - let socket_rx = socket.clone(); - - // Spawn a task to read from physical socket and send back to smoltcp - let tx_clone = smoltcp_tx.clone(); - tokio::spawn(async move { - use futures::SinkExt; - let mut buf = [0u8; 65536]; - loop { - match socket_rx.recv_from(&mut buf).await { - Ok((n, peer)) => { - let mut lock = tx_clone.lock().await; - let _ = lock.send((buf[..n].to_vec(), peer, client_src)).await; + // A single select! loop over both directions, rather than spawning a + // separate task for the read side, so the whole session - physical + // socket included - is torn down the moment this function returns + // (e.g. when session_rx closes). The previous spawned-task version left + // that task (and its Arc clone, keeping the OS socket fd + // alive) running forever after this function returned: nothing ever + // cancelled it, so every bypassed UDP flow (any excluded app/IP in TUN + // mode) leaked one socket + one task for the lifetime of the process. + use futures::SinkExt; + let mut buf = [0u8; 65536]; + loop { + tokio::select! { + outbound = session_rx.recv() => { + match outbound { + Some((payload, dst)) => { socket.send_to(&payload, dst).await?; } + None => break, + } + } + inbound = socket.recv_from(&mut buf) => { + match inbound { + Ok((n, peer)) => { + let mut lock = smoltcp_tx.lock().await; + let _ = lock.send((buf[..n].to_vec(), peer, client_src)).await; + } + Err(_) => break, } - Err(_) => break, } } - }); - - while let Some((payload, dst)) = session_rx.recv().await { - socket.send_to(&payload, dst).await?; } Ok(())