@@ -440,8 +440,10 @@ func (r *RootCmd) ssh() *serpent.Command {
440
440
// Connect to the immortal stream via WebSocket
441
441
rawSSH , err = connectToImmortalStreamWebSocket (ctx , conn , stream .ID , logger )
442
442
if err != nil {
443
- // Clean up the stream if connection fails
444
- _ = immortalStreamClient .deleteStream (ctx , stream .ID )
443
+ // Only clean up the stream if it's a permanent failure
444
+ if ! isNetworkError (err ) {
445
+ _ = immortalStreamClient .deleteStream (ctx , stream .ID )
446
+ }
445
447
return xerrors .Errorf ("connect to immortal stream: %w" , err )
446
448
}
447
449
}
@@ -481,25 +483,25 @@ func (r *RootCmd) ssh() *serpent.Command {
481
483
}
482
484
}
483
485
484
- // Set up cleanup for immortal stream
486
+ // Set up signal-based cleanup for immortal stream
487
+ // Only delete on explicit user termination (SIGINT, SIGTERM), not network errors
485
488
if immortalStreamClient != nil && streamID != nil {
486
- defer func () {
487
- if err := immortalStreamClient .deleteStream (context .Background (), * streamID ); err != nil {
488
- logger .Error (context .Background (), "failed to cleanup immortal stream" , slog .Error (err ))
489
- }
489
+ // Create a signal-only context for cleanup
490
+ signalCtx , signalStop := inv .SignalNotifyContext (context .Background (), StopSignals ... )
491
+ defer signalStop ()
492
+
493
+ go func () {
494
+ <- signalCtx .Done ()
495
+ // User sent termination signal - clean up the stream
496
+ _ = immortalStreamClient .deleteStream (context .Background (), * streamID )
490
497
}()
491
498
}
492
499
493
500
wg .Add (1 )
494
501
go func () {
495
502
defer wg .Done ()
496
503
watchAndClose (ctx , func () error {
497
- // Clean up immortal stream on termination
498
- if immortalStreamClient != nil && streamID != nil {
499
- if err := immortalStreamClient .deleteStream (context .Background (), * streamID ); err != nil {
500
- logger .Error (context .Background (), "failed to cleanup immortal stream on termination" , slog .Error (err ))
501
- }
502
- }
504
+ // Don't delete immortal stream here - let signal handler do it
503
505
stack .close (xerrors .New ("watchAndClose" ))
504
506
return nil
505
507
}, logger , client , workspace , errCh )
@@ -557,8 +559,10 @@ func (r *RootCmd) ssh() *serpent.Command {
557
559
// Connect to the immortal stream and create SSH client
558
560
rawConn , err := connectToImmortalStreamWebSocket (ctx , conn , stream .ID , logger )
559
561
if err != nil {
560
- // Clean up the stream if connection fails
561
- _ = immortalStreamClient .deleteStream (ctx , stream .ID )
562
+ // Only clean up the stream if it's a permanent failure
563
+ if ! isNetworkError (err ) {
564
+ _ = immortalStreamClient .deleteStream (ctx , stream .ID )
565
+ }
562
566
return xerrors .Errorf ("connect to immortal stream: %w" , err )
563
567
}
564
568
@@ -569,7 +573,10 @@ func (r *RootCmd) ssh() *serpent.Command {
569
573
})
570
574
if err != nil {
571
575
rawConn .Close ()
572
- _ = immortalStreamClient .deleteStream (ctx , stream .ID )
576
+ // Only clean up the stream if it's a permanent failure
577
+ if ! isNetworkError (err ) {
578
+ _ = immortalStreamClient .deleteStream (ctx , stream .ID )
579
+ }
573
580
return xerrors .Errorf ("ssh handshake over immortal stream: %w" , err )
574
581
}
575
582
@@ -603,12 +610,17 @@ func (r *RootCmd) ssh() *serpent.Command {
603
610
}
604
611
}
605
612
606
- // Set up cleanup for immortal stream in regular SSH mode
613
+ // Set up signal-based cleanup for immortal stream
614
+ // Only delete on explicit user termination (SIGINT, SIGTERM), not network errors
607
615
if immortalStreamClient != nil && streamID != nil {
608
- defer func () {
609
- if err := immortalStreamClient .deleteStream (context .Background (), * streamID ); err != nil {
610
- logger .Error (context .Background (), "failed to cleanup immortal stream" , slog .Error (err ))
611
- }
616
+ // Create a signal-only context for cleanup
617
+ signalCtx , signalStop := inv .SignalNotifyContext (context .Background (), StopSignals ... )
618
+ defer signalStop ()
619
+
620
+ go func () {
621
+ <- signalCtx .Done ()
622
+ // User sent termination signal - clean up the stream
623
+ _ = immortalStreamClient .deleteStream (context .Background (), * streamID )
612
624
}()
613
625
}
614
626
@@ -618,12 +630,7 @@ func (r *RootCmd) ssh() *serpent.Command {
618
630
watchAndClose (
619
631
ctx ,
620
632
func () error {
621
- // Clean up immortal stream on termination
622
- if immortalStreamClient != nil && streamID != nil {
623
- if err := immortalStreamClient .deleteStream (context .Background (), * streamID ); err != nil {
624
- logger .Error (context .Background (), "failed to cleanup immortal stream on termination" , slog .Error (err ))
625
- }
626
- }
633
+ // Don't delete immortal stream here - let signal handler do it
627
634
stack .close (xerrors .New ("watchAndClose" ))
628
635
return nil
629
636
},
@@ -923,66 +930,63 @@ func (r *RootCmd) ssh() *serpent.Command {
923
930
return cmd
924
931
}
925
932
926
- // connectToImmortalStreamWebSocket connects to an immortal stream via WebSocket and returns a net.Conn
933
+ // connectToImmortalStreamWebSocket connects to an immortal stream via WebSocket
934
+ // The immortal stream infrastructure handles reconnection automatically
927
935
func connectToImmortalStreamWebSocket (ctx context.Context , agentConn * workspacesdk.AgentConn , streamID uuid.UUID , logger slog.Logger ) (net.Conn , error ) {
928
936
// Build the target address for the agent's HTTP API server
929
- // We'll let the WebSocket dialer handle the actual connection through the agent
930
937
apiServerAddr := fmt .Sprintf ("127.0.0.1:%d" , workspacesdk .AgentHTTPAPIServerPort )
931
938
wsURL := fmt .Sprintf ("ws://%s/api/v0/immortal-stream/%s" , apiServerAddr , streamID )
932
939
933
940
// Create WebSocket connection using the agent's tailnet connection
934
- // The key is to use a custom dialer that routes through the agent connection
935
941
dialOptions := & websocket.DialOptions {
936
942
HTTPClient : & http.Client {
937
943
Transport : & http.Transport {
938
944
DialContext : func (dialCtx context.Context , network , addr string ) (net.Conn , error ) {
939
- // Route all connections through the agent connection
940
- // The agent connection will handle routing to the correct internal address
941
-
942
- conn , err := agentConn .DialContext (dialCtx , network , addr )
943
- if err != nil {
944
- return nil , err
945
- }
946
-
947
- return conn , nil
945
+ return agentConn .DialContext (dialCtx , network , addr )
948
946
},
949
947
},
950
948
},
951
- // Disable compression for raw TCP data
952
949
CompressionMode : websocket .CompressionDisabled ,
953
950
}
954
951
955
952
// Connect to the WebSocket endpoint
956
- conn , res , err := websocket .Dial (ctx , wsURL , dialOptions )
953
+ conn , _ , err := websocket .Dial (ctx , wsURL , dialOptions )
957
954
if err != nil {
958
- if res != nil {
959
- logger .Error (ctx , "WebSocket dial failed" ,
960
- slog .F ("stream_id" , streamID ),
961
- slog .F ("websocket_url" , wsURL ),
962
- slog .F ("status" , res .StatusCode ),
963
- slog .F ("status_text" , res .Status ),
964
- slog .Error (err ))
965
- } else {
966
- logger .Error (ctx , "WebSocket dial failed (no response)" ,
967
- slog .F ("stream_id" , streamID ),
968
- slog .F ("websocket_url" , wsURL ),
969
- slog .Error (err ))
970
- }
971
955
return nil , xerrors .Errorf ("dial immortal stream WebSocket: %w" , err )
972
956
}
973
957
974
- logger .Info (ctx , "successfully connected to immortal stream WebSocket" ,
975
- slog .F ("stream_id" , streamID ))
976
-
977
958
// Convert WebSocket to net.Conn for SSH usage
978
- // Use MessageBinary for raw TCP data transport
959
+ // The immortal stream's BackedPipe handles reconnection automatically
979
960
netConn := websocket .NetConn (ctx , conn , websocket .MessageBinary )
980
961
981
- logger .Debug (ctx , "converted WebSocket to net.Conn for SSH usage" )
982
-
983
962
return netConn , nil
984
963
}
985
964
965
+ // isNetworkError checks if an error is a temporary network error
966
+ func isNetworkError (err error ) bool {
967
+ if err == nil {
968
+ return false
969
+ }
970
+
971
+ errStr := err .Error ()
972
+ networkErrors := []string {
973
+ "connection refused" ,
974
+ "network is unreachable" ,
975
+ "connection reset" ,
976
+ "broken pipe" ,
977
+ "timeout" ,
978
+ "no route to host" ,
979
+ }
980
+
981
+ for _ , netErr := range networkErrors {
982
+ if strings .Contains (errStr , netErr ) {
983
+ return true
984
+ }
985
+ }
986
+
987
+ return false
988
+ }
989
+
986
990
// findWorkspaceAndAgentByHostname parses the hostname from the commandline and finds the workspace and agent it
987
991
// corresponds to, taking into account any name prefixes or suffixes configured (e.g. myworkspace.coder, or
988
992
// vscode-coder--myusername--myworkspace).
0 commit comments