Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cmd/test-streammanager/main-test-streammanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,9 @@ type BrokerDataSender struct {
broker *streamclient.Broker
}

func (s *BrokerDataSender) SendData(dataPk wshrpc.CommandStreamData) {
func (s *BrokerDataSender) SendData(dataPk wshrpc.CommandStreamData) error {
s.broker.SendData(dataPk)
return nil
}

// MetricsWriter wraps an io.Writer and records bytes written to metrics
Expand Down
110 changes: 109 additions & 1 deletion pkg/jobcontroller/jobcontroller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Comment on lines +931 to +938

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.


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

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.

}

func HandleCmdJobExited(ctx context.Context, jobId string, data wshrpc.CommandJobCmdExitedData) error {
var updatedJob *waveobj.Job
err := wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) {
Expand Down
20 changes: 20 additions & 0 deletions pkg/jobmanager/jobmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

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.


func (jm *JobManager) SetAttachedClient(msc *MainServerConn) {
jm.lock.Lock()
defer jm.lock.Unlock()
Expand Down
4 changes: 3 additions & 1 deletion pkg/jobmanager/mainserverconn.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,15 @@ type routedDataSender struct {
route string
}

func (rds *routedDataSender) SendData(dataPk wshrpc.CommandStreamData) {
func (rds *routedDataSender) SendData(dataPk wshrpc.CommandStreamData) error {
// log.Printf("SendData: sending seq=%d, len=%d, eof=%t, error=%s, route=%s",
// dataPk.Seq, len(dataPk.Data64), dataPk.Eof, dataPk.Error, rds.route)
err := wshclient.StreamDataCommand(rds.wshRpc, dataPk, &wshrpc.RpcOpts{NoResponse: true, Route: rds.route})
if err != nil {
log.Printf("SendData: error sending stream data: %v\n", err)
return err
}
return nil
}

func (msc *MainServerConn) authenticateSelfToServer(jobAuthToken string) error {
Expand Down
15 changes: 13 additions & 2 deletions pkg/jobmanager/streammanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const (
)

type DataSender interface {
SendData(dataPk wshrpc.CommandStreamData)
SendData(dataPk wshrpc.CommandStreamData) error
}

type streamTerminalEvent struct {
Expand Down Expand Up @@ -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

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.

}

func MakeStreamManager() *StreamManager {
Expand Down Expand Up @@ -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)
}
}
}
}

Expand Down
104 changes: 103 additions & 1 deletion pkg/jobmanager/streammanager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,23 @@ import (
type testWriter struct {
mu sync.Mutex
packets []wshrpc.CommandStreamData
sendErr error
}

func (tw *testWriter) SendData(pkt wshrpc.CommandStreamData) {
func (tw *testWriter) SendData(pkt wshrpc.CommandStreamData) error {
tw.mu.Lock()
defer tw.mu.Unlock()
if tw.sendErr != nil {
return tw.sendErr
}
tw.packets = append(tw.packets, pkt)
return nil
}

func (tw *testWriter) SetSendError(err error) {
tw.mu.Lock()
defer tw.mu.Unlock()
tw.sendErr = err
}

func (tw *testWriter) GetPackets() []wshrpc.CommandStreamData {
Expand Down Expand Up @@ -346,3 +357,94 @@ func (sr *slowReader) Read(p []byte) (n int, err error) {

return n, nil
}

// failingWriter always fails SendData, to simulate a stale RPC/stream path.
type failingWriter struct {
err error
}

func (fw *failingWriter) SendData(pkt wshrpc.CommandStreamData) error {
return fw.err
}

// TestSendErrorDisconnectsClient verifies that when the data path to the main
// server fails (e.g. RPC stream timeout), the stream manager drops the client
// back to disconnected mode (buffering) instead of staying falsely attached.
func TestSendErrorDisconnectsClient(t *testing.T) {
tw := &testWriter{}
sm := MakeStreamManager()
defer sm.Close()

// pipe stays open (no EOF) so the sender loop remains active
pr, pw := io.Pipe()
defer pr.Close()
defer pw.Close()

err := sm.AttachReader(pr)
if err != nil {
t.Fatalf("AttachReader failed: %v", err)
}

_, err = sm.ClientConnected("stream-1", tw, CwndSize, 0)
if err != nil {
t.Fatalf("ClientConnected failed: %v", err)
}

if _, err := pw.Write([]byte("first chunk")); err != nil {
t.Fatalf("pipe write failed: %v", err)
}
time.Sleep(100 * time.Millisecond)
if len(tw.GetPackets()) == 0 {
t.Fatal("expected packets to flow after ClientConnected")
}

// now the link goes stale: every send fails with a timeout-like error
tw.SetSendError(io.ErrClosedPipe)

onSendErrCh := make(chan error, 1)
sm.OnSendError = func(err error) {
select {
case onSendErrCh <- err:
default:
}
}

// new output forces another SendData call, which now fails
if _, err := pw.Write([]byte("second chunk")); err != nil {
t.Fatalf("pipe write failed: %v", err)
}

deadline := time.Now().Add(2 * time.Second)
for {
sm.lock.Lock()
connected := sm.connected
sm.lock.Unlock()
if !connected {
break
}
if time.Now().After(deadline) {
t.Fatal("stream manager did not mark client disconnected after send error")
}
time.Sleep(5 * time.Millisecond)
}

select {
case err := <-onSendErrCh:
if err == nil {
t.Fatal("expected non-nil error from OnSendError")
}
default:
t.Fatal("OnSendError callback was not invoked")
}

// a new client must be able to attach and receive the buffered data
tw2 := &testWriter{}
_, err = sm.ClientConnected("stream-2", tw2, CwndSize, 0)
if err != nil {
t.Fatalf("reconnect after send error failed: %v", err)
}
time.Sleep(100 * time.Millisecond)
if len(tw2.GetPackets()) == 0 {
t.Fatal("expected buffered data to be delivered to reconnected client")
}
}