Skip to content

fix(jobmanager): recover frozen durable SSH terminals after a send timeout - #3553

Open
kieled wants to merge 2 commits into
wavetermdev:mainfrom
kieled:fix/durable-stream-send-error
Open

kieled wants to merge 2 commits into
wavetermdev:mainfrom
kieled:fix/durable-stream-send-error

Conversation

@kieled

@kieled kieled commented Oct 13, 2026 •

Copy link
Copy Markdown

Fixes #3439, and very likely #2985.

Bug: a durable SSH terminal randomly stops showing output and ignores input while the shield still says Attached. Today the only ways out are restarting Wave or Force Restart Controller, which kills the session. It's been reported with Claude Code, Codex, OpenCode, lazygit and tail -f.

After this PR: the block briefly shows as disconnected and then reattaches by itself through the existing route-down reconnect, with no lost output.

Size/risk: about 60 lines plus tests in jobmanager, wshutil and jobcontroller. There are no new settings or periodic timers. CodeRabbit is clean, and the Codex re-review after the fixes found nothing. Verified in real use: 7 freezes recovered automatically in about 1s each (see Testing).

Problem

A durable SSH block can freeze with no output while still showing Durable Session (Attached). When it happens, the remote job log shows one SendData: error sending stream data: timeout sending request and nothing after it, while the SSH connection stays healthy.

This is a stream deadlock that one failed send causes:

  1. prepareNextPacket increases sentNotAcked before the packet is sent.
  2. If the enqueue in SendComplexRequest blocks for more than 5s, the packet is dropped. routedDataSender.SendData only logged this, so senderLoop carried on as if the bytes had been delivered.
  3. The sender never retransmits.
  4. On the main server, streamclient.Reader parks every later packet (Seq > nextSeq) as out-of-order and doesn't ACK, so nextSeq can never move past the gap.
  5. Once sentNotAcked reaches the 64KB window, the sender waits forever. The job route is still up, so handleRouteDownEvent and auto-reconnect never run.

Fix

The main fix is in the job manager:

  • DataSender.SendData now returns an error.
  • If a send fails, StreamManager goes back to disconnected (buffering) mode, which resets sentNotAcked. It does this only if the failing sender is still the current one, so a client that reattached in the meantime isn't disconnected.
  • routedDataSender closes its MainServerConn when a send fails. The job route then goes down on the main server, the existing route-down auto-reconnect calls ReconnectJob, and PrepareConnect/StartStream resume from the client's last received seq. This is the same path an app restart or a manual Disconnect → Reconnect already takes, and it recovers these frozen blocks with no data loss.

Two small hardening changes for the recovery path:

  • SendComplexRequest returns a recovered send-on-closed-channel panic as an error instead of (nil, nil). Before, closing the connection while another sender (e.g. sendJobExited) was blocked on OutputCh made SendCommand/SendRpcRequest dereference a nil handler and crash the job manager.
  • jobcontroller: a route down inside the 30s auto-reconnect cooldown is deferred to the end of the cooldown instead of dropped (at most one pending per job), so a second send failure in quick succession still recovers.

There's no stream watchdog and no new periodic timers.

Compared with #3460, this is only its job-manager-side approach. It leaves out the 45s output watchdog in jobcontroller, which would also fire on a shell sitting idle at a prompt.

Scope

This fixes the deadlock behind #3439 and recovers through the existing route-down reconnect, so the block shows as disconnected and then reattaches instead of staying falsely Attached. It intentionally does not add end-to-end stream health checks (expected-behavior item 4), and it doesn't change Force Restart Controller, which still terminates the job. I'm happy to follow up on either separately if wanted.

Testing

  • New TestSendErrorFallsBackToDisconnected: a failing DataSender gets exactly one send attempt. A client that connects afterwards receives the full stream starting from seq 0. Both checks fail on main ("got 2 calls" and "client already connected").
  • New TestStaleSendErrorKeepsNewClient: a late error from a previous client's sender does not disconnect the current client.
  • New TestSendOnClosedOutputReturnsError (pkg/wshutil): sending on a closed connection returns an error. It fails with a nil-pointer panic on main.
  • New TestNextAutoReconnectDelay* (pkg/jobcontroller): a route down inside the cooldown is deferred, not dropped, and isn't scheduled twice.
  • go test -race ./pkg/jobmanager/ ./pkg/streamclient/ ./pkg/wshutil/ ./pkg/jobcontroller/, go vet, go build ./... and GOOS=linux go build ./cmd/wsh all pass.

Tested end-to-end in real use. Since 2026-10-14 I've been running a v0.14.5 wsh built with these commits on my Linux host (macOS → Linux over Tailscale, Claude Code running in a durable block). Over the first ~21 hours the 5s enqueue timeout hit 7 times on that block. Each time the job manager logged handleSendError, the main server auto-reconnected about 1s later, and PrepareConnect resumed with clientSeq == serverSeq. The block never froze and I didn't have to restart anything. Before the patch, each of these was a frozen block that needed Force Restart Controller or an app restart.

Only the remote wsh was patched, so the jobcontroller cooldown change wasn't exercised (no two failures came within 30s).

One recovery, both sides

Remote job log:

2026/10/15 11:06:16 SendData: error sending stream data, closing connection: timeout sending request
2026/10/15 11:06:16 handleSendError: send failed at seq=91796057, switching to disconnected mode: timeout sending request
2026/10/15 11:06:17 SetAttachedClient: kicking out existing client
2026/10/15 11:06:17 PrepareConnect: streamid=9aa37d3d-eba8-482b-8048-60c6637ba065 clientSeq=91796057 serverSeq=91796057 streamDone=false streamError="" hasExited=false

Main server (waveapp.log):

11:06:16.280 [job:bc45057e-...] connection status changed to disconnected
11:06:17.282 [job:bc45057e-...] connection ... still up after route down, attempting auto-reconnect to determine job manager status
11:06:17.305 [job:bc45057e-...] connection status changed to connected
11:06:17.357 [job:bc45057e-...] auto-reconnect succeeded

When the job manager failed to enqueue a stream data packet (e.g.
"timeout sending request" after 5s of backpressure), the error was only
logged. The packet was already counted in sentNotAcked and is never
retransmitted, while the reader parks every later packet as
out-of-order without ACKing. The stream deadlocks once the 64KB window
fills, but the job route stays up, so no auto-reconnect happens and the
block shows "Attached" with no output.

On send error, drop the stream back to disconnected (buffering) mode and
close the main server connection. The job route goes down, the existing
route-down auto-reconnect reattaches, and the stream resumes from the
client's last received seq.
@CLAassistant

CLAassistant commented Oct 13, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-14T14:25:59.305131Z dc6214f Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitai Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: a6122778-75cd-4a0a-b603-19efd1616d92
📥 Commits

Reviewing files that changed from the base of the PR and between 9bb1718 and dc6214f.

📒 Files selected for processing: 4
  • pkg/jobcontroller/jobcontroller.go
  • pkg/jobcontroller/jobcontroller_test.go
  • pkg/wshutil/wshrpc.go
  • pkg/wshutil/wshrpc_test.go

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.

Walkthrough

The DataSender.SendData contract now returns an error. The routed sender closes its main-server connection when an RPC send fails. The stream manager handles failed sends by disconnecting the sender only if it is still active. Auto-reconnect attempts can be deferred until after the cooldown. SendComplexRequest returns recovered send panics as errors. Tests cover these behaviors.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to dc621

The change is mergeable after normal checks; a recovered, connected job is not reconnected by the delayed attempt.

Pre-merge checks: Passed 3 · Failed 2

Failed Failed checks: 2 warnings

Check name Status Explanation Resolution
Linked Issues check Warning The PR implements the failed-send recovery path in #3439. DataSender.SendData returns errors. StreamManager enters disconnected buffering mode for the current sender. routedDataSender closes the… Implement and test the required end-to-end output and input health checks. Change Force Restart Controller to reconnect without terminating the preserved job, or clearly label the action as destructive. If these requirements are intentional…
Docstring Coverage Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 9 functions across 9 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 3 passed
Check name Status Explanation
Out of Scope Changes check Passed The changes remain connected to the failed-send recovery path in #3439. The DataSender error propagation, connection close, disconnected buffering, deferred route reconnect, closed-channel error han…
Title check Passed The title clearly summarizes the main change: recovering frozen durable SSH terminals after send failures.
Description check Passed The description directly explains the durable SSH stream failure, the recovery behavior, implementation scope, tests, and known limitations.
Full details: Linked Issues check

Explanation

The PR implements the failed-send recovery path in #3439. DataSender.SendData returns errors. StreamManager enters disconnected buffering mode for the current sender. routedDataSender closes the main-server connection. The route-down path schedules reconnection. The added tests cover recovery and stale sender errors. However, #3439 also requires end-to-end output and input health checks. The PR adds no such checks. The issue also requires non-destructive handling or clear labeling for Force Restart Controller. The PR does not change or test that behavior.

Resolution

Implement and test the required end-to-end output and input health checks. Change Force Restart Controller to reconnect without terminating the preserved job, or clearly label the action as destructive. If these requirements are intentionally separate, separate or update the linked issue scope before merge.

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9bb171844e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread pkg/jobmanager/mainserverconn.go
Comment thread pkg/jobmanager/mainserverconn.go
…reconnects

SendComplexRequest recovered a send-on-closed-channel panic but returned
(nil, nil), so SendCommand/SendRpcRequest dereferenced a nil handler.
That happens when the connection closes while a sender is blocked on
OutputCh (e.g. sendJobExited during the close after a failed stream
send), and would crash the job manager. Return the panic as an error.

A route down inside the 30s auto-reconnect cooldown was dropped, so a
job whose route went down twice in quick succession stayed disconnected.
Defer it to the end of the cooldown instead, with at most one deferred
attempt pending per job.
@kieled

kieled commented Oct 14, 2026

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: dc6214f99a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@kieled

kieled commented Oct 14, 2026

Copy link
Copy Markdown
Author

@andrewinalaska @sawka

@kieled kieled changed the title Recover durable stream after a failed send instead of stalling fix(jobmanager): recover frozen durable SSH terminals after a send timeout Oct 15, 2026

This branch has not been deployed

No deployments
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