-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
fix: recover durable SSH sessions stuck falsely-attached after stream timeout (#3439) #3460
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -71,6 +71,12 @@ const JobOutputFileName = "term" | |
| const AutoReconnectDelay = 1 * time.Second | ||
| const AutoReconnectCooldown = 30 * time.Second | ||
|
|
||
| // StreamStallTimeout is how long a job's output stream may go without any | ||
| // data/EOF before the connection is considered stalled. When tripped, the | ||
| // remote job manager is asked to terminate its (dead) client connection so a | ||
| // fresh attach + stream reconnect can proceed -- see upstream issue #3439. | ||
| const StreamStallTimeout = 45 * time.Second | ||
|
|
||
| type connState struct { | ||
| actual bool | ||
| processed bool | ||
|
|
@@ -820,7 +826,7 @@ func runOutputLoop(ctx context.Context, jobId string, streamId string, reader *s | |
| log.Printf("[job:%s] [stream:%s] output loop started", jobId, streamId) | ||
| buf := make([]byte, 4096) | ||
| for { | ||
| n, err := reader.Read(buf) | ||
| n, err := readWithStallTimeout(ctx, jobId, streamId, reader, buf) | ||
| currentStreamId, _ := jobStreamIds.GetEx(jobId) | ||
| if currentStreamId != streamId { | ||
| log.Printf("[job:%s] [stream:%s] stream superseded by [stream:%s], exiting output loop", jobId, streamId, currentStreamId) | ||
|
|
@@ -833,6 +839,12 @@ func runOutputLoop(ctx context.Context, jobId string, streamId string, reader *s | |
| } | ||
| } | ||
|
|
||
| if err == errStreamStalled { | ||
| log.Printf("[job:%s] [stream:%s] stream stalled (no data for %v), forcing job manager reconnect", jobId, streamId, StreamStallTimeout) | ||
| handleStalledStream(jobId) | ||
| break | ||
| } | ||
|
|
||
| if err == io.EOF { | ||
| log.Printf("[job:%s] stream ended (EOF)", jobId) | ||
| updateErr := wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) { | ||
|
|
@@ -861,6 +873,102 @@ func runOutputLoop(ctx context.Context, jobId string, streamId string, reader *s | |
| } | ||
| } | ||
|
|
||
| var errStreamStalled = fmt.Errorf("stream stalled: no data within timeout") | ||
|
|
||
| // readWithStallTimeout wraps reader.Read with a stall watchdog. The remote | ||
| // durable-session job manager can lose its RPC/stream path without the job | ||
| // route ever going down (upstream issue #3439); in that case Read blocks | ||
| // forever while the shield keeps showing "Attached". If no data/EOF arrives | ||
| // within StreamStallTimeout, errStreamStalled is returned so the caller can | ||
| // force a reconnect. | ||
| func readWithStallTimeout(ctx context.Context, jobId string, streamId string, reader *streamclient.Reader, buf []byte) (int, error) { | ||
| type readResult struct { | ||
| n int | ||
| err error | ||
| } | ||
| resultCh := make(chan readResult, 1) | ||
| go func() { | ||
| n, err := reader.Read(buf) | ||
| resultCh <- readResult{n: n, err: err} | ||
| }() | ||
|
|
||
| timer := time.NewTimer(StreamStallTimeout) | ||
| defer timer.Stop() | ||
|
|
||
| select { | ||
| case res := <-resultCh: | ||
| return res.n, res.err | ||
| case <-timer.C: | ||
| return 0, errStreamStalled | ||
| case <-ctx.Done(): | ||
| return 0, ctx.Err() | ||
| } | ||
| } | ||
|
|
||
| // handleStalledStream recovers a job whose output stream went stale. It asks | ||
| // the remote connection to drop the job manager's dead client connection, | ||
| // waits for the job route to re-register, then restarts streaming from the | ||
| // last persisted offset. The remote shell and its scrollback survive. | ||
| func handleStalledStream(jobId string) { | ||
| defer func() { | ||
| panichandler.PanicHandler("jobcontroller:handleStalledStream", recover()) | ||
| }() | ||
|
|
||
| ctx, cancelFn := context.WithTimeout(context.Background(), 30*time.Second) | ||
| defer cancelFn() | ||
|
|
||
| job, err := wstore.DBMustGet[*waveobj.Job](ctx, jobId) | ||
| if err != nil { | ||
| log.Printf("[job:%s] stalled stream: failed to load job: %v", jobId, err) | ||
| return | ||
| } | ||
|
|
||
| if job.JobManagerStatus != JobManagerStatus_Running { | ||
| log.Printf("[job:%s] stalled stream: job manager not running, skipping recovery", jobId) | ||
| return | ||
| } | ||
|
|
||
| // prevent overlapping recovery attempts for the same job | ||
| lastAttempt, exists := lastAutoReconnectAttempt.GetEx(jobId) | ||
| now := time.Now().Unix() | ||
| if exists && time.Duration(now-lastAttempt)*time.Second < AutoReconnectCooldown { | ||
| log.Printf("[job:%s] stalled stream: recovery attempted recently, skipping", jobId) | ||
| return | ||
| } | ||
| lastAutoReconnectAttempt.Set(jobId, now) | ||
|
|
||
| isConnected, err := conncontroller.IsConnected(job.Connection) | ||
| if err != nil || !isConnected { | ||
| log.Printf("[job:%s] stalled stream: connection %q is down, cannot recover", jobId, job.Connection) | ||
| return | ||
| } | ||
|
|
||
| log.Printf("[job:%s] stalled stream: requesting remote disconnect of stale client", jobId) | ||
| disconnectData := wshrpc.CommandRemoteDisconnectFromJobManagerData{ | ||
| JobId: jobId, | ||
| } | ||
| rpcOpts := &wshrpc.RpcOpts{ | ||
| Route: wshutil.MakeConnectionRouteId(job.Connection), | ||
| Timeout: 5000, | ||
| } | ||
| err = wshclient.RemoteDisconnectFromJobManagerCommand(wshclient.GetBareRpcClient(), disconnectData, rpcOpts) | ||
| if err != nil { | ||
| log.Printf("[job:%s] stalled stream: remote disconnect failed: %v", jobId, err) | ||
| return | ||
| } | ||
|
|
||
| // the route should drop and re-register as the job manager re-attaches; | ||
| // reconnect restarts streaming from the persisted offset | ||
| SetJobConnStatus(jobId, JobConnStatus_Disconnected) | ||
| reconnectCtx, reconnectCancel := context.WithTimeout(context.Background(), 15*time.Second) | ||
| defer reconnectCancel() | ||
| if err := ReconnectJob(reconnectCtx, jobId, nil); err != nil { | ||
| log.Printf("[job:%s] stalled stream: reconnect failed: %v", jobId, err) | ||
| } else { | ||
| log.Printf("[job:%s] stalled stream: reconnect succeeded", jobId) | ||
| } | ||
|
Comment on lines
+960
to
+969
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Inspect CheckJobConnected and the remote job-manager disconnect handler.
rg -n -C 15 'func CheckJobConnected' pkg/jobcontroller
rg -n -C 20 'RemoteDisconnectFromJobManager' --glob '!**/*_test.go'Repository: wavetermdev/waveterm Length of output: 2160 🏁 Script executed: #!/bin/bash
set -u
# Locate and inspect the relevant functions and call graph candidates.
printf 'Files containing jobcontroller.go:\n'
fd -a 'jobcontroller\.go$' .
printf '\nRelevant CheckJobConnected section:\n'
sed -n '520,635p' pkg/jobcontroller/jobcontroller.go 2>/dev/null || true
printf '\nRelevant doReconnectJob/handleStalledStream section:\n'
sed -n '900,1220p' pkg/jobcontroller/jobcontroller.go 2>/dev/null || true
printf '\nSearch ReconnectJob / restartStreaming / jobconn status / disconnect command:\n'
rg -n -C 12 'func doReconnectJob|handleStalledStream|SetJobConnStatus|GetJobConnStatus|restartStreaming|ReconnectJob|RestartStreaming|RemoteDisconnectFromJobManager|DisconnectFromJobManager|RegisterJobRoute|UnregisterJobRoute' pkg --glob '!**/*_test.go'Repository: wavetermdev/waveterm Length of output: 50376 🏁 Script executed: #!/bin/bash
set -u
printf 'Candidates for RemoteDisconnectFromJobManager definition:\n'
rg -n -C 12 'RemoteDisconnectFromJobManagerCommand|remotedisconnectfromjobmanager|Remote Disconnect|DisconnectFromJobManager' pkg --glob '!**/*_test.go' --glob '!pkg/wshrpc/wshclient/wshclient.go' --glob '!pkg/wshrpc/wshserver/api_handler.go' --max-count 50
printf '\nCheckJobManagerStatus update candidates:\n'
rg -n -C lo 'CheckJobManagerStatus|JobManagerStatus|stream.*loop|output.*loop|runOutputLoop|runStream' pkg --glob '!**/*_test.go' --max-count 80
printf '\nInspect likely handler files:\n'
fd -a '.*\.go$' pkg | rg 'job|manager|wshserver|stream|output' | sed -n '1,120p'Repository: wavetermdev/waveterm Length of output: 12988 🏁 Script executed: #!/bin/bash
set -u
printf 'Inspect remote disconnect and cleanup definitions:\n'
sed -n '200,350p' pkg/wshrpc/wshremote/wshremote_job.go
printf '\nInspect JobManagerConnection and CleanupFn definitions/usages:\n'
rg -n -C 12 'type .*JobManagerConnection|JobManagerConnection|CleanupFn|OnSessionRemoved|OnRoute*|RemoveRoute|Unregister|WshRpcAddRoute|WaitForRegister|Register' pkg/wshrpc pkg/wshutil pkg/jobcontroller --glob '!**/*_test.go'
printf '\nInspect doReconnectJob complete implementation:\n'
sed -n '1210,1278p' pkg/jobcontroller/jobcontroller.goRepository: wavetermdev/waveterm Length of output: 41882 Wait for the route to unregister before reconnecting.
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| func HandleCmdJobExited(ctx context.Context, jobId string, data wshrpc.CommandJobCmdExitedData) error { | ||
| var updatedJob *waveobj.Job | ||
| err := wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -55,6 +55,9 @@ func SetupJobManager(clientId string, jobId string, publicKeyBytes []byte, jobAu | |
| WshCmdJobManager.JobAuthToken = jobAuthToken | ||
| WshCmdJobManager.StreamManager = MakeStreamManager() | ||
| WshCmdJobManager.InputQueue = utilds.MakeQuickReorderQueue[wshrpc.CommandJobInputData](JobInputQueueSize, JobInputQueueTimeout) | ||
| WshCmdJobManager.StreamManager.OnSendError = func(err error) { | ||
| go WshCmdJobManager.handleStreamSendError(err) | ||
| } | ||
| err := wavejwt.SetPublicKey(publicKeyBytes) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to set public key: %w", err) | ||
|
|
@@ -208,6 +211,23 @@ func (jm *JobManager) disconnectFromStreamHelper(mainServerConn *MainServerConn) | |
| jm.connectedStreamClient = nil | ||
| } | ||
|
|
||
| // handleStreamSendError is invoked (async) when the stream manager fails to | ||
| // push data to the main server. The attached client connection is stale at | ||
| // this point: keep the job + buffered stream alive, but close the stale | ||
| // socket so the main server observes the route drop and auto-reconnects. | ||
| func (jm *JobManager) handleStreamSendError(sendErr error) { | ||
| jm.lock.Lock() | ||
| client := jm.connectedStreamClient | ||
| if client != nil { | ||
| jm.connectedStreamClient = nil | ||
| } | ||
| jm.lock.Unlock() | ||
| if client != nil { | ||
| log.Printf("handleStreamSendError: closing stale client connection after send error: %v\n", sendErr) | ||
| client.Close() | ||
| } | ||
| } | ||
|
Comment on lines
+214
to
+229
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Locate every read/write of connectedStreamClient and the stream attach handler.
rg -n -C 8 'connectedStreamClient' pkg/jobmanager
rg -n -C 10 'ClientConnected\(' pkg/jobmanagerRepository: wavetermdev/waveterm Length of output: 18730 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect StreamManager lock ordering, send error callback, and JobManager callback wiring without executing repo code.
sed -n '1,240p' pkg/jobmanager/streammanager.go
printf '\n--- jobmanager relevant sections ---\n'
sed -n '160,240p' pkg/jobmanager/jobmanager.go
printf '%s\n' '--- OnSendError callsigns ---'
rg -n -C 5 'OnSendError|HandleStreamSendError|handleStreamSendError|ClientConnected\(' pkg/jobmanager -g '*.go'Repository: wavetermdev/waveterm Length of output: 20512 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect senderLoop context around the asynchronous OnSendError callback.
sed -n '330,375p' pkg/jobmanager/streammanager.goRepository: wavetermdev/waveterm Length of output: 1291 Pass the failed sender identity to
🤖 Prompt for AI Agents |
||
|
|
||
| func (jm *JobManager) SetAttachedClient(msc *MainServerConn) { | ||
| jm.lock.Lock() | ||
| defer jm.lock.Unlock() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,7 +21,7 @@ const ( | |
| ) | ||
|
|
||
| type DataSender interface { | ||
| SendData(dataPk wshrpc.CommandStreamData) | ||
| SendData(dataPk wshrpc.CommandStreamData) error | ||
| } | ||
|
|
||
| type streamTerminalEvent struct { | ||
|
|
@@ -60,6 +60,10 @@ type StreamManager struct { | |
| // terminal state - once true, stream is complete | ||
| terminalEventAcked bool | ||
| closed bool | ||
|
|
||
| // OnSendError, if set, is called (asynchronously) when a SendData call fails. | ||
| // Used to tear down the stale client connection so a fresh attach can proceed. | ||
| OnSendError func(err error) | ||
|
Comment on lines
+63
to
+66
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Guard
Prefer a setter that takes the lock and a locked read in 🔒 Proposed fix: private field plus locked accessors- // OnSendError, if set, is called (asynchronously) when a SendData call fails.
- // Used to tear down the stale client connection so a fresh attach can proceed.
- OnSendError func(err error)
+ // onSendError, if set, is called (asynchronously) when a SendData call fails.
+ // Used to tear down the stale client connection so a fresh attach can proceed.
+ onSendError func(err error)
}
+
+func (sm *StreamManager) SetOnSendError(fn func(err error)) {
+ sm.lock.Lock()
+ defer sm.lock.Unlock()
+ sm.onSendError = fn
+}
+
+func (sm *StreamManager) getOnSendError() func(err error) {
+ sm.lock.Lock()
+ defer sm.lock.Unlock()
+ return sm.onSendError
+} err := sender.SendData(*pkt)
if err != nil {
log.Printf("senderLoop: send error (seq=%d): %v -- marking client disconnected\n", pkt.Seq, err)
sm.ClientDisconnected()
- if sm.OnSendError != nil {
- sm.OnSendError(err)
+ if cb := sm.getOnSendError(); cb != nil {
+ cb(err)
}
}Also applies to: 359-366 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| func MakeStreamManager() *StreamManager { | ||
|
|
@@ -352,7 +356,14 @@ func (sm *StreamManager) senderLoop() { | |
| if pkt == nil { | ||
| continue | ||
| } | ||
| sender.SendData(*pkt) | ||
| err := sender.SendData(*pkt) | ||
| if err != nil { | ||
| log.Printf("senderLoop: send error (seq=%d): %v -- marking client disconnected\n", pkt.Seq, err) | ||
| sm.ClientDisconnected() | ||
| if sm.OnSendError != nil { | ||
| sm.OnSendError(err) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Reuse
shouldAttemptAutoReconnectinstead of duplicating the cooldown logic.Lines 932-938 reimplement the body of
shouldAttemptAutoReconnect(Lines 387-403) against the samelastAutoReconnectAttemptmap. The two paths now suppress each other: a stall recovery blocks a normal auto-reconnect forAutoReconnectCooldown, and the reverse also applies.If shared suppression is intended, call the existing helper. If stall recovery needs an independent cooldown, use a separate map.
♻️ Proposed fix: call the existing helper
📝 Committable suggestion
🤖 Prompt for AI Agents