@@ -324,17 +324,30 @@ where
324324 . context ( "Failed to initialize snapshot manager" ) ?,
325325 ) ;
326326
327- // Spawn heartbeat task
327+ // Spawn heartbeat task. A failed heartbeat send is our ONLY reliable signal
328+ // that the connection is dead: over a DERP-relayed mesh, a controller that
329+ // vanishes (e.g. pod restart) often produces no TCP FIN/RST on the daemon side,
330+ // so `ws_rx.next()` in the loop below blocks forever and never surfaces the
331+ // drop. The heartbeat write, by contrast, fails. So on send failure we trip
332+ // `dead_tx`, which the select! polls to break the serve loop and let main()
333+ // reconnect — without this the daemon wedges half-open until the pod is deleted.
328334 let ws_tx_clone = Arc :: new ( tokio:: sync:: Mutex :: new ( ws_tx) ) ;
329335 let ws_tx_heartbeat = ws_tx_clone. clone ( ) ;
336+ let ( dead_tx, dead_rx) = tokio:: sync:: oneshot:: channel :: < ( ) > ( ) ;
330337 let heartbeat_handle = tokio:: spawn ( async move {
331338 let mut interval = tokio:: time:: interval ( Duration :: from_secs ( heartbeat_interval) ) ;
339+ let mut dead_tx = Some ( dead_tx) ;
332340 loop {
333341 interval. tick ( ) . await ;
334342 let heartbeat = Message :: Heartbeat ;
335343 if let Ok ( json) = serde_json:: to_string ( & heartbeat) {
336344 let mut tx = ws_tx_heartbeat. lock ( ) . await ;
337345 if tx. send ( WsMessage :: Text ( json) ) . await . is_err ( ) {
346+ // Signal the serve loop that the connection is dead so it
347+ // reconnects instead of blocking forever on a half-open read.
348+ if let Some ( d) = dead_tx. take ( ) {
349+ let _ = d. send ( ( ) ) ;
350+ }
338351 break ;
339352 }
340353 }
@@ -350,6 +363,7 @@ where
350363 // Pin the shutdown future once so it can be polled across loop iterations
351364 // without being moved (it may be `!Unpin`).
352365 tokio:: pin!( shutdown) ;
366+ tokio:: pin!( dead_rx) ;
353367 let outcome = loop {
354368 tokio:: select! {
355369 // Poll shutdown FIRST. With `biased`, tokio checks branches top to
@@ -371,6 +385,14 @@ where
371385 break ServeOutcome :: Shutdown ;
372386 }
373387
388+ // Heartbeat send failed => connection is dead. Reconnect. (The Err
389+ // arm — heartbeat task gone without signalling — is treated the same:
390+ // no live heartbeat means no live connection.)
391+ _ = & mut dead_rx => {
392+ warn!( "Heartbeat send failed, connection is dead; reconnecting" ) ;
393+ break ServeOutcome :: Disconnected ;
394+ }
395+
374396 msg = ws_rx. next( ) => {
375397 let msg = match msg {
376398 Some ( Ok ( WsMessage :: Text ( text) ) ) => text,
0 commit comments