Skip to content

fix: recover durable SSH sessions stuck falsely-attached after stream timeout (#3439) - #3460

Open
akademic123 wants to merge 1 commit into
wavetermdev:mainfrom
akademic123:durable-stall-fix
Open

fix: recover durable SSH sessions stuck falsely-attached after stream timeout (#3439)#3460
akademic123 wants to merge 1 commit into
wavetermdev:mainfrom
akademic123:durable-stall-fix

Conversation

@akademic123

Copy link
Copy Markdown

Summary

Fixes #3439 (and the root cause behind #2985): a durable SSH terminal intermittently becomes completely non-interactive while the shield keeps showing Durable Session (Attached). Recovery previously required restarting Wave (or Force Restart Controller).

Root cause (two-sided)

1. Remote job manager swallows stream send errors.

routedDataSender.SendData (pkg/jobmanager/mainserverconn.go) logged SendData: error sending stream data: timeout sending request and then discarded the error. The StreamManager therefore kept the stream in connected state forever:

  • the sender loop kept "sending" into a dead RPC path and never fell back to buffering mode,
  • the remote shell and PTY stayed alive but their output went nowhere,
  • the job manager kept reporting the client as attached, so the main server saw a healthy route and never reconnected.

2. Main server has no stall detection.

jobcontroller.runOutputLoop blocks in reader.Read indefinitely. The only auto-reconnect trigger is the RouteDown event (handleRouteDownEvent) — but in this failure mode the job route never goes down, so no reconnect is ever attempted. The block stays frozen until the whole app (or the connection controller) is restarted.

Fix

Remote job manager (pkg/jobmanager):

  • DataSender.SendData now returns an error instead of swallowing it.
  • StreamManager.senderLoop: on send error, the stream drops back to disconnected (buffering) mode via ClientDisconnected() — the 2 MB circular buffer keeps capturing PTY output — and invokes a new OnSendError callback.
  • JobManager.handleStreamSendError: closes the stale client socket. This makes the job route drop on the main server, which triggers the existing auto-reconnect path (attemptAutoReconnectReconnectJob → new attach → stream resumes from the buffered offset with no data loss, exactly like after an app restart).

Main server (pkg/jobcontroller/jobcontroller.go):

  • runOutputLoop reads are wrapped in a 45 s stall watchdog (StreamStallTimeout). If no data/EOF arrives within the timeout, handleStalledStream runs: it asks the remote connection to drop the job manager's dead client (RemoteDisconnectFromJobManagerCommand) and then performs a ReconnectJob, which does a fresh attach + PrepareConnect/StartStream from the persisted offset. Remote shell state and scrollback survive. A 30 s cooldown (AutoReconnectCooldown, shared with the existing reconnect path) prevents reconnect flapping.

Tests

  • New TestSendErrorDisconnectsClient in pkg/jobmanager/streammanager_test.go: verifies that a send failure (1) drops the stream to disconnected mode, (2) fires OnSendError, and (3) a new client can attach and receives the buffered data.
  • go build ./... clean, go test ./pkg/jobmanager/ ./pkg/streamclient/ pass.

Notes / trade-offs

  • A completely silent shell (no output for >45 s) will trigger a seamless reconnect cycle roughly every timeout period. It's invisible to the user (same PIDs, same buffer, totalGap=0) but shows up in logs. Happy to raise the default or make it configurable if preferred.
  • The wsh binary must be re-deployed to remote hosts for the job-manager half of the fix to take effect (Wave handles this automatically on connect with the new build).
  • Interface change is internal-only: jobmanager.DataSender (implemented by routedDataSender and the cmd/test-streammanager helper, both updated).

Reproduction (for validation)

  1. Durable SSH session, run a long-lived TUI with sustained output (Codex CLI / lazygit / claude).
  2. Leave for hours until the RPC stream write times out (SendData: timeout sending request in the remote durable-session log).
  3. Before: block frozen, shield says Attached, only app restart helps.
  4. After: job manager closes the stale client, wavesrv reconnects automatically (log: stream stalled ... forcing job manager reconnect / closing stale client connection), same PIDs, session responsive again.

… timeout (upstream wavetermdev#3439)

Remote jobmanager:
- DataSender.SendData now returns error instead of swallowing it
- StreamManager senderLoop: on send error, drop client to disconnected
  (buffering) mode and invoke OnSendError callback
- JobManager closes the stale client socket so the main server observes
  the route drop and auto-reconnects; remote shell + 2MB scrollback survive

Main server (wavesrv):
- runOutputLoop wrapped with 45s stall watchdog; on stall, force remote
  disconnect of the dead client and ReconnectJob (fresh attach + stream
  resume from persisted offset)

Tests: TestSendErrorDisconnectsClient covers disconnect + reattach flow
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The change adds error returns to stream sender interfaces and implementations. Failed sends now disconnect stale clients, invoke asynchronous cleanup, and preserve buffered stream data for reconnection. Job output reads now use a 45-second stall timeout. Stalled streams trigger job and connection validation, stale-client disconnection, job disconnection marking, and reconnection.

Estimated code review effort: 4 (Complex) | ~45 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes recovery of durable SSH sessions after stream timeouts.
Description check ✅ Passed The description directly explains the failure, root cause, implemented recovery, tests, and operational considerations.
Linked Issues check ✅ Passed The changes address issue #3439 by detecting stream failures, disconnecting stale clients, reconnecting automatically, and preserving session data.
Out of Scope Changes check ✅ Passed The changes remain within the linked issue scope and include only related implementation updates, interface changes, and tests.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (3)
pkg/jobmanager/streammanager.go (1)

359-366: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Confirm OnSendError callers never block the sender loop.

senderLoop calls OnSendError inline. The comment on Line 64 states the call is asynchronous. SetupJobManager satisfies that by starting a goroutine, but the contract is not enforced here. A blocking callback stops all further stream progress for this stream.

Consider invoking the callback in a goroutine inside senderLoop, so the documented contract holds for every caller.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/jobmanager/streammanager.go` around lines 359 - 366, Update the
OnSendError invocation in senderLoop so the callback always runs asynchronously
in a separate goroutine, preventing a blocking handler from stopping sender-loop
progress. Preserve the existing nil check, error argument, and
client-disconnection handling.
pkg/jobcontroller/jobcontroller.go (1)

876-906: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Avoid one goroutine per read and shared ownership of buf.

readWithStallTimeout starts a goroutine for every read iteration. For a busy stream this creates a goroutine per 4 KB chunk. The goroutine also writes into the caller's buf, which runOutputLoop owns across iterations. On the timeout path and on the ctx.Done path the function returns while that goroutine is still running, so buf has two owners and the bytes it later reads are dropped.

runOutputLoop currently breaks out of the loop in both paths, so buf is not read again. The ownership rule is still fragile against future changes to the loop.

Prefer a single long-lived read goroutine per output loop that sends results over a channel, with its own buffer per read. The loop then applies the stall timer to the channel receive.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/jobcontroller/jobcontroller.go` around lines 876 - 906, Refactor
runOutputLoop and readWithStallTimeout to use one long-lived read goroutine per
output loop instead of spawning a goroutine for each read. Have that goroutine
perform sequential reader.Read calls into a private buffer and send read results
through a channel, while runOutputLoop applies StreamStallTimeout and context
cancellation when receiving results; never let asynchronous reads write into the
caller-owned buf or continue using it after timeout/cancellation.
pkg/jobmanager/streammanager_test.go (1)

361-368: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the unused failingWriter type.

TestSendErrorDisconnectsClient uses testWriter.SetSendError, and failingWriter has no references elsewhere in the repository. Delete the dead code unless it is needed for another test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/jobmanager/streammanager_test.go` around lines 361 - 368, Remove the
unused failingWriter type and its SendData method from the test file; preserve
TestSendErrorDisconnectsClient’s existing testWriter.SetSendError setup and make
no other changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/jobcontroller/jobcontroller.go`:
- Around line 931-938: Replace the duplicated cooldown check in the
stalled-stream recovery path with a call to shouldAttemptAutoReconnect, reusing
its shared lastAutoReconnectAttempt state and preserving the existing skip
behavior and logging for recently attempted recoveries.
- Around line 960-969: Update handleStalledStream around SetJobConnStatus and
ReconnectJob to wait until the job route is actually unregistered before
reconnecting, ensuring reconnect does not exit early through CheckJobConnected
and restartStreaming is invoked. Alternatively, propagate a reconnect result
that reflects whether streaming restarted, and only log success when
reconnection truly occurred.

In `@pkg/jobmanager/jobmanager.go`:
- Around line 214-229: The asynchronous send-error path must identify the failed
sender before closing a connection. Update handleStreamSendError and its callers
to accept or retain the failed MainServerConn identity, then clear and close
jm.connectedStreamClient only when it still matches that sender; leave a newer
connected client untouched.

In `@pkg/jobmanager/streammanager.go`:
- Around line 63-66: Make OnSendError private and add a lock-protected
setter/accessor for updating and reading the callback. Update senderLoop to
retrieve the callback while holding sm.lock, and update callers such as
TestSendErrorDisconnectsClient to use the setter so assignments cannot race with
the sender loop.

---

Nitpick comments:
In `@pkg/jobcontroller/jobcontroller.go`:
- Around line 876-906: Refactor runOutputLoop and readWithStallTimeout to use
one long-lived read goroutine per output loop instead of spawning a goroutine
for each read. Have that goroutine perform sequential reader.Read calls into a
private buffer and send read results through a channel, while runOutputLoop
applies StreamStallTimeout and context cancellation when receiving results;
never let asynchronous reads write into the caller-owned buf or continue using
it after timeout/cancellation.

In `@pkg/jobmanager/streammanager_test.go`:
- Around line 361-368: Remove the unused failingWriter type and its SendData
method from the test file; preserve TestSendErrorDisconnectsClient’s existing
testWriter.SetSendError setup and make no other changes.

In `@pkg/jobmanager/streammanager.go`:
- Around line 359-366: Update the OnSendError invocation in senderLoop so the
callback always runs asynchronously in a separate goroutine, preventing a
blocking handler from stopping sender-loop progress. Preserve the existing nil
check, error argument, and client-disconnection handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c143535a-472f-4efa-b14b-70cee6975b37

📥 Commits

Reviewing files that changed from the base of the PR and between a4447c1 and b542e7d.

📒 Files selected for processing (6)
  • cmd/test-streammanager/main-test-streammanager.go
  • pkg/jobcontroller/jobcontroller.go
  • pkg/jobmanager/jobmanager.go
  • pkg/jobmanager/mainserverconn.go
  • pkg/jobmanager/streammanager.go
  • pkg/jobmanager/streammanager_test.go

Comment on lines +931 to +938
// 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)

Copy link
Copy Markdown
Contributor

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 shouldAttemptAutoReconnect instead of duplicating the cooldown logic.

Lines 932-938 reimplement the body of shouldAttemptAutoReconnect (Lines 387-403) against the same lastAutoReconnectAttempt map. The two paths now suppress each other: a stall recovery blocks a normal auto-reconnect for AutoReconnectCooldown, 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
-	// 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)
+	// prevent overlapping recovery attempts for the same job
+	if !shouldAttemptAutoReconnect(jobId) {
+		log.Printf("[job:%s] stalled stream: recovery attempted recently, skipping", jobId)
+		return
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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)
// prevent overlapping recovery attempts for the same job
if !shouldAttemptAutoReconnect(jobId) {
log.Printf("[job:%s] stalled stream: recovery attempted recently, skipping", jobId)
return
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/jobcontroller/jobcontroller.go` around lines 931 - 938, Replace the
duplicated cooldown check in the stalled-stream recovery path with a call to
shouldAttemptAutoReconnect, reusing its shared lastAutoReconnectAttempt state
and preserving the existing skip behavior and logging for recently attempted
recoveries.

Comment on lines +960 to +969
// 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)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.go

Repository: wavetermdev/waveterm

Length of output: 41882


Wait for the route to unregister before reconnecting.

handleStalledStream clears ConnStatus_Connected immediately after dispatching RemoteDisconnectFromJobManagerCommand, but that RPC only calls CleanupFn() on the remote side. If ReconnectJob runs before the route unregisters, CheckJobConnected sees ConnStatus_Connected, doReconnectJob returns nil, and restartStreaming is never called. Wait for the job route to be unregistered or return a reconnect result instead of logging success.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/jobcontroller/jobcontroller.go` around lines 960 - 969, Update
handleStalledStream around SetJobConnStatus and ReconnectJob to wait until the
job route is actually unregistered before reconnecting, ensuring reconnect does
not exit early through CheckJobConnected and restartStreaming is invoked.
Alternatively, propagate a reconnect result that reflects whether streaming
restarted, and only log success when reconnection truly occurred.

Comment on lines +214 to +229
// 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()
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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/jobmanager

Repository: 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.go

Repository: wavetermdev/waveterm

Length of output: 1291


Pass the failed sender identity to handleStreamSendError.

handleStreamSendError runs asynchronously after sm.ClientDisconnected() and then closes any non-nil jm.connectedStreamClient. If the stream reconnects during that window, jm.connectedStreamClient points to the new MainServerConn, so the handler clears and closes the healthy session. Keep the failed MainServerConn or sender identity and close only if it still matches the current jm.connectedStreamClient.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/jobmanager/jobmanager.go` around lines 214 - 229, The asynchronous
send-error path must identify the failed sender before closing a connection.
Update handleStreamSendError and its callers to accept or retain the failed
MainServerConn identity, then clear and close jm.connectedStreamClient only when
it still matches that sender; leave a newer connected client untouched.

Comment on lines +63 to +66

// 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard OnSendError with the existing lock, or document that it must be set before the sender loop starts.

senderLoop reads sm.OnSendError at Line 363 without holding sm.lock. The field is exported, so callers can assign it at any time. TestSendErrorDisconnectsClient assigns sm.OnSendError after ClientConnected has started the sender loop (pkg/jobmanager/streammanager_test.go Lines 405-410), so go test -race on that test can report a data race on this field.

Prefer a setter that takes the lock and a locked read in senderLoop.

🔒 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/jobmanager/streammanager.go` around lines 63 - 66, Make OnSendError
private and add a lock-protected setter/accessor for updating and reading the
callback. Update senderLoop to retrieve the callback while holding sm.lock, and
update callers such as TestSendErrorDisconnectsClient to use the setter so
assignments cannot race with the sender loop.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Durable SSH terminal remains falsely Attached and non-interactive after RPC stream timeout

2 participants