@@ -23,6 +23,7 @@ use tracing::{debug, error, info, warn};
2323const TUNNEL_SOCKS_PROXY : & str = "127.0.0.1:1055" ;
2424
2525/// Why the serve loop returned, so main() knows whether to reconnect or exit.
26+ #[ derive( Debug , PartialEq , Eq ) ]
2627enum ServeOutcome {
2728 /// The connection dropped (server closed, socket error). main() reconnects.
2829 Disconnected ,
@@ -220,7 +221,7 @@ async fn connect_and_serve(
220221 . await
221222 . context ( "tunnel: WebSocket handshake over SOCKS5 failed" ) ?;
222223 log_negotiated_protocol ( & response) ;
223- return serve ( ws_stream, daemon_id, heartbeat_interval, labels) . await ;
224+ return serve ( ws_stream, daemon_id, heartbeat_interval, labels, shutdown_signal ( ) ) . await ;
224225 }
225226
226227 let ( ws_stream, response) = match tokio_tungstenite:: connect_async ( request) . await {
@@ -231,7 +232,7 @@ async fn connect_and_serve(
231232 }
232233 } ;
233234 log_negotiated_protocol ( & response) ;
234- serve ( ws_stream, daemon_id, heartbeat_interval, labels) . await
235+ serve ( ws_stream, daemon_id, heartbeat_interval, labels, shutdown_signal ( ) ) . await
235236}
236237
237238/// Log the WebSocket subprotocol the server negotiated (shared by both transports).
@@ -248,14 +249,21 @@ fn log_negotiated_protocol(
248249/// Run the daemon session over an established WebSocket stream. Generic over the
249250/// transport so the direct (connect_async) and tunnel (SOCKS5) paths share one
250251/// implementation.
251- async fn serve < S > (
252+ ///
253+ /// `shutdown` is the future that, once resolved, triggers a graceful close: in
254+ /// production it is `shutdown_signal()` (SIGTERM/SIGINT); tests inject a future
255+ /// they control so the shutdown path can be exercised without raising a real,
256+ /// process-wide signal mid-connection.
257+ async fn serve < S , F > (
252258 ws_stream : tokio_tungstenite:: WebSocketStream < S > ,
253259 daemon_id : & str ,
254260 heartbeat_interval : u64 ,
255261 labels : HashMap < String , String > ,
262+ shutdown : F ,
256263) -> Result < ServeOutcome >
257264where
258265 S : tokio:: io:: AsyncRead + tokio:: io:: AsyncWrite + Unpin + Send + ' static ,
266+ F : std:: future:: Future < Output = ( ) > ,
259267{
260268 info ! ( "WebSocket connection established" ) ;
261269
@@ -334,6 +342,10 @@ where
334342 // controller removes a daemon the moment it sees that Close (server.rs
335343 // handle_websocket), so a graceful pod deletion deregisters immediately
336344 // instead of waiting out the ~90s heartbeat-timeout reaper.
345+ //
346+ // Pin the shutdown future once so it can be polled across loop iterations
347+ // without being moved (it may be `!Unpin`).
348+ tokio:: pin!( shutdown) ;
337349 let outcome = loop {
338350 tokio:: select! {
339351 // Prefer draining inbound messages; the signal branch still fires
@@ -380,7 +392,7 @@ where
380392 }
381393 }
382394
383- _ = shutdown_signal ( ) => {
395+ _ = & mut shutdown => {
384396 // Best-effort clean close so the controller deregisters us now.
385397 let mut tx = ws_tx_clone. lock( ) . await ;
386398 if let Err ( e) = tx. send( WsMessage :: Close ( None ) ) . await {
@@ -807,3 +819,154 @@ async fn setup_tunnel(args: &Args) -> Result<()> {
807819
808820 Err ( anyhow:: anyhow!( "Timeout waiting for mesh IP assignment" ) )
809821}
822+
823+ #[ cfg( test) ]
824+ mod shutdown_tests {
825+ //! Tests for the graceful-shutdown path added to `serve`: on a shutdown
826+ //! signal the daemon must send a WebSocket Close frame and return
827+ //! `ServeOutcome::Shutdown` (so `main` exits instead of reconnecting). The
828+ //! Close is what lets the controller deregister the daemon immediately
829+ //! rather than waiting out its ~90s heartbeat-timeout reaper.
830+ //!
831+ //! `serve` takes the shutdown future as a parameter precisely so these tests
832+ //! can trigger it deterministically, without raising a real, process-wide
833+ //! SIGTERM in the middle of a test run.
834+
835+ use super :: * ;
836+ use futures_util:: { SinkExt , StreamExt } ;
837+ use tokio:: net:: { TcpListener , TcpStream } ;
838+ use tokio_tungstenite:: tungstenite:: protocol:: Message as WsMessage ;
839+ use tokio_tungstenite:: { accept_async, WebSocketStream } ;
840+
841+ /// Stand up an in-process WebSocket server on localhost and connect a client
842+ /// to it. Returns (client_stream_for_serve, accepted_server_stream). The
843+ /// server side lets a test act as the controller: ack registration, then
844+ /// observe what the daemon sends (e.g. the Close frame on shutdown).
845+ async fn ws_pair ( ) -> (
846+ WebSocketStream < tokio_tungstenite:: MaybeTlsStream < TcpStream > > ,
847+ WebSocketStream < TcpStream > ,
848+ ) {
849+ let listener = TcpListener :: bind ( "127.0.0.1:0" ) . await . unwrap ( ) ;
850+ let addr = listener. local_addr ( ) . unwrap ( ) ;
851+
852+ // Accept concurrently with the client dial so neither side blocks.
853+ let server = tokio:: spawn ( async move {
854+ let ( stream, _) = listener. accept ( ) . await . unwrap ( ) ;
855+ accept_async ( stream) . await . unwrap ( )
856+ } ) ;
857+
858+ let url = format ! ( "ws://{}/ws" , addr) ;
859+ let ( client, _resp) = tokio_tungstenite:: connect_async ( url) . await . unwrap ( ) ;
860+ let server = server. await . unwrap ( ) ;
861+ ( client, server)
862+ }
863+
864+ /// Play the controller: read the daemon's Register and reply RegisterAck so
865+ /// `serve` proceeds past registration into its main loop.
866+ async fn ack_registration ( server : & mut WebSocketStream < TcpStream > ) {
867+ let reg = server. next ( ) . await . unwrap ( ) . unwrap ( ) ;
868+ let text = reg. into_text ( ) . unwrap ( ) ;
869+ let msg: Message = serde_json:: from_str ( & text) . unwrap ( ) ;
870+ assert ! (
871+ matches!( msg, Message :: Register { .. } ) ,
872+ "expected Register first, got: {:?}" ,
873+ msg
874+ ) ;
875+ let ack = Message :: RegisterAck {
876+ success : true ,
877+ message : "ok" . to_string ( ) ,
878+ } ;
879+ server
880+ . send ( WsMessage :: Text ( serde_json:: to_string ( & ack) . unwrap ( ) ) )
881+ . await
882+ . unwrap ( ) ;
883+ }
884+
885+ /// The core regression: when the shutdown future fires, `serve` returns
886+ /// `Shutdown` AND the peer receives a Close frame.
887+ ///
888+ /// `serve`'s future is not `Send` (SessionManager is !Sync), so it can't be
889+ /// `tokio::spawn`ed; instead we run it concurrently with the controller side
890+ /// via `join!` on the current task.
891+ #[ tokio:: test]
892+ async fn shutdown_sends_close_and_returns_shutdown ( ) {
893+ let ( client, mut server) = ws_pair ( ) . await ;
894+
895+ // Fire shutdown shortly after serve starts its loop. A ready future
896+ // would race registration; a short delay keeps the test deterministic
897+ // without depending on wall-clock timing for correctness.
898+ let shutdown = async {
899+ tokio:: time:: sleep ( Duration :: from_millis ( 50 ) ) . await ;
900+ } ;
901+
902+ let daemon = serve ( client, "test-daemon" , 3600 , HashMap :: new ( ) , shutdown) ;
903+
904+ let controller = async {
905+ ack_registration ( & mut server) . await ;
906+ // The controller side should observe a Close frame. Tolerate any
907+ // pre-close traffic (e.g. a heartbeat), though the 3600s interval
908+ // makes that unlikely in-test.
909+ let mut saw_close = false ;
910+ while let Some ( frame) = server. next ( ) . await {
911+ match frame {
912+ Ok ( WsMessage :: Close ( _) ) => {
913+ saw_close = true ;
914+ break ;
915+ }
916+ Ok ( _) => continue ,
917+ Err ( _) => break ,
918+ }
919+ }
920+ saw_close
921+ } ;
922+
923+ let ( outcome, saw_close) = tokio:: join!( daemon, controller) ;
924+ assert ! ( saw_close, "daemon did not send a Close frame on shutdown" ) ;
925+ assert_eq ! ( outcome. unwrap( ) , ServeOutcome :: Shutdown ) ;
926+ }
927+
928+ /// The counterpart: if the controller closes the connection, `serve` returns
929+ /// `Disconnected` (so `main` reconnects) — NOT `Shutdown`. Guards against the
930+ /// shutdown branch swallowing ordinary disconnects.
931+ #[ tokio:: test]
932+ async fn server_close_returns_disconnected ( ) {
933+ let ( client, mut server) = ws_pair ( ) . await ;
934+
935+ // A shutdown future that never resolves: only the server-close path can
936+ // end this session.
937+ let shutdown = std:: future:: pending :: < ( ) > ( ) ;
938+
939+ let daemon = serve ( client, "test-daemon" , 3600 , HashMap :: new ( ) , shutdown) ;
940+
941+ let controller = async {
942+ ack_registration ( & mut server) . await ;
943+ // Controller closes the connection.
944+ server. close ( None ) . await . unwrap ( ) ;
945+ } ;
946+
947+ let ( outcome, ( ) ) = tokio:: join!( daemon, controller) ;
948+ assert_eq ! ( outcome. unwrap( ) , ServeOutcome :: Disconnected ) ;
949+ }
950+
951+ /// `shutdown_signal()` must resolve when the process receives SIGTERM (what
952+ /// `kubectl delete pod` / `docker stop` send). Uses a real self-signal; unix
953+ /// only. This asserts the wiring, not the WebSocket behavior above.
954+ #[ cfg( unix) ]
955+ #[ tokio:: test]
956+ async fn shutdown_signal_resolves_on_sigterm ( ) {
957+ // Raise SIGTERM to our own process after a short delay, then confirm the
958+ // helper's future completes rather than hanging.
959+ tokio:: spawn ( async {
960+ tokio:: time:: sleep ( Duration :: from_millis ( 50 ) ) . await ;
961+ // SAFETY: raising a signal to our own PID is sound; kill(2) with a
962+ // valid signal number has no memory-safety implications.
963+ unsafe {
964+ libc:: raise ( libc:: SIGTERM ) ;
965+ }
966+ } ) ;
967+
968+ tokio:: time:: timeout ( Duration :: from_secs ( 5 ) , shutdown_signal ( ) )
969+ . await
970+ . expect ( "shutdown_signal did not resolve on SIGTERM" ) ;
971+ }
972+ }
0 commit comments