Skip to content

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

Description

@andrewinalaska

Current Behavior

A durable SSH terminal intermittently becomes completely non-interactive:

  • Terminal output stops updating.
  • Keyboard input has no effect.
  • The shield continues to show Durable Session (Attached).
  • Wave reports the SSH connection as connected.
  • The remote shell and foreground application remain healthy.
  • Fully quitting and reopening Wave successfully reattaches to the original session and restores interactivity.

In the captured occurrence, a Codex process was running in the terminal. After the Wave block stopped responding, Codex continued working remotely and completed its task. The original processes remained alive:

PID 1964  wsh jobmanager
PID 1973  bash
PID 3100  codex

After restarting Wave, the same PIDs were preserved and the existing session became responsive again.

The remote durable-session log recorded:

2026/07/20 16:09:19 SendData: error sending stream data: timeout sending request

Wave did not mark the job disconnected after this error. The shield remained Attached and automatic reconnection did not occur.

When Wave was restarted, the remote log showed:

2026/07/20 18:57:45 AuthenticateToJobManager: authentication successful
2026/07/20 18:57:45 SetAttachedClient: kicking out existing client
2026/07/20 18:57:45 connectToStreamHelper: disconnecting existing client
2026/07/20 18:57:45 connectToStreamHelper: detached old stream id=cf519bf0-ae5e-4a93-9119-53c63534e019
2026/07/20 18:57:45 PrepareConnect: streamid=b21b548f-887d-4e49-8d3d-025521da369c clientSeq=19863439 serverSeq=19863439 streamDone=false streamError="" hasExited=false
2026/07/20 18:57:45 StartStream: streamid=b21b548f-887d-4e49-8d3d-025521da369c rwnd=65536 streaming started

clientSeq and serverSeq were identical and totalGap=0 in the local Wave log, indicating the backend had the complete terminal stream even though the terminal block was frozen.

There was an earlier local WebSocket timeout for the exact tab and block:

2026-07-20 15:35:21.507 [websocket] WritePump error
write tcp 127.0.0.1:45359->127.0.0.1:33439: i/o timeout

wshrouter unbind route "feblock:777680bf-221f-4bba-9cb3-5689b245b1f3"
wshrouter unregister link 6#[ws:tab:9c4d3ecd-3fda-4dd1-99bc-dc1aa0879e74]

Wave immediately created another WebSocket link and rebound the block, but the terminal later became unresponsive.

Similar remote SendData: timeout sending request errors occurred on July 15, July 19, and twice on July 20 across durable sessions.

Expected Behavior

When the durable job's RPC or stream path stops accepting data:

  1. Wave should mark the durable job Detached or Stalled.
  2. The shield should not continue showing Attached.
  3. Wave should close the stale route and automatically reconnect to the existing job manager.
  4. Output and input health should be checked end-to-end rather than relying only on route registration.
  5. Recovery should not require restarting the entire Wave application.

Steps To Reproduce

The failure is intermittent:

  1. Run Wave v0.14.5 on Windows.
  2. Open an SSH terminal to a Linux host.
  3. Enable Durable Session.
  4. Run a long-lived interactive TUI that produces sustained output, such as Codex CLI.
  5. Leave the session running for several hours.
  6. Eventually, the terminal may stop updating and accepting input.
  7. Observe that the shield still says Durable Session (Attached) and the SSH connection still says connected.
  8. Verify from another SSH session that the remote shell/application remains alive.
  9. Fully quit and reopen Wave.
  10. Observe that Wave reattaches to the original shell and application and the terminal becomes responsive.

Environment details

Wave Version: v0.14.5 (build 202604161539), Electron 41.1.0, remote wsh v0.14.5

Platform: Windows x64

Remote SSH host: Linux 6.8.0-136-generic x86_64

Additional investigation

The v0.14.5 source appears to explain why the state remains falsely Attached.

routedDataSender.SendData logs and ignores errors.

The RPC enqueue times out after five seconds.

The frontend displays Attached solely when the stored job status is connected.

The observed sequence suggests:

  1. The job manager's bounded outbound channel or downstream Unix-socket writer stopped draining.
  2. SendData timed out.
  3. The error was logged but did not close the stale connection.
  4. No route-down event was generated.
  5. The job status remained connected, leaving the shield falsely Attached.
  6. Restarting Wave rebuilt the router and reconnected the original job successfully.

There is also a related recovery problem: the documentation recommends Advanced -> Force Restart Controller for a session that will not reconnect, but in v0.14.5 that action destroys the controller. Destroying a durable controller calls TerminateAndDetachJob.

In practice, Force Restart Controller terminates the preserved session and opens a new bash shell. This should either reconnect non-destructively or be labeled as terminating the existing session.

Full logs are available, but the raw waveapp.log contains Wave routing tokens and would need to be redacted before attachment.

Activity

  1. CitrusYL commented on Jul 22, 2026

    @CitrusYL

    Same issue on Wave v0.14.5, macOS arm64. Durable SSH + tmux + codex becomes non-interactive, and Force Restart Controller restores it.

  2. YVin3D commented on Aug 28, 2026

    @YVin3D

    fwiw it's not codex only. i get the same issue (Wave v0.14.5, macOS arm64) in codex, claude, and other durable SSH terminal blocks.

    tail -f on remote logs that are rapidly outputting lines seems to trigger it a lot more consistently if anyone wants to try and replicate it. (helps if your remote server's network connection is bad)

    the one block will freeze while other blocks on the same SSH connection and/or the same tab are just fine.

    remote process stays alive, and the stalled job-manager log prints:

    SendData: error sending stream data: timeout sending request;

    Reattaching that job’s stream restores it. Seems to be from Wave’s per-job durable stream/RPC

  3. kieled commented on Oct 13, 2026

    @kieled

    Also hitting this on v0.14.5 (macOS arm64 → Linux x64 over Tailscale, fish, term:durable: true), several times a day. Each frozen job's remote log has exactly one SendData: error sending stream data: timeout sending request and nothing after it, while the SSH connection stays healthy (no stalled/disconnected events).

    I traced it in the source, and I think the issue is slightly more specific than "the error is swallowed": a single failed send permanently deadlocks the stream, because a dropped packet is never retransmitted and the receiver never ACKs past the gap. (Links are to current main; these files are unchanged since v0.14.5.)

    1. prepareNextPacket advances sentNotAcked before the send happens (streammanager.go#L409-L410).
    2. If the enqueue in SendComplexRequest blocks for more than DefaultTimeoutMs (5s), the packet is dropped (wshrpc.go#L802-L808). routedDataSender.SendData only logs this (mainserverconn.go#L44-L51), so senderLoop carries on as if the bytes had been delivered.
    3. The sender has no retransmit timer. RecvAck only advances on cumulative ACKs.
    4. On the main-server side, streamclient.Reader.RecvData parks every later packet (Seq > nextSeq) in oooPackets and returns without sending an ACK (streamreader.go#L71-L74). nextSeq can never move past the lost packet.
    5. The sender keeps sending until sentNotAcked reaches the 64 KB window, then waits on drainCond forever (streammanager.go#L386-L390). The job route is still up, so handleRouteDownEvent / auto-reconnect never fire, and the shield keeps saying Attached.

    This fits the reports here and in #2985: only the affected block freezes, other blocks on the same connection keep working, the remote process keeps running, and a full reattach (app restart, or Disconnect → Reconnect) restores everything with no data loss, since ClientDisconnected() resets sentNotAcked and PrepareConnect resumes from the client's nextSeq. Bursty TUI output (Claude Code, Codex, tail -f) makes a 5s enqueue stall more likely, which would explain why those workloads trigger it.

    Also worth noting for anyone who lands here: Advanced → Force Restart Controller does not reattach. It terminates the job manager and starts a new shell (my log shows terminating job manager followed by RemoteStartJobCommand), so the session is lost. Use Disconnect → Reconnect instead.

    On #3460: I think its job-manager half (return the error from SendData, then ClientDisconnected() + close the stale client so the route drops and the existing auto-reconnect runs) is enough to fix this on its own. I'm less sure about the 45s output watchdog in jobcontroller. A shell idle at a prompt produces no output, so it would get a disconnect/reattach cycle every ~45s. Also, runOutputLoop breaks and closes the reader before handleStalledStream decides whether to act. When that returns early (cooldown, connection reported down, the remote-disconnect RPC timing out), nothing restarts the output loop, and the block freezes again with no recovery. Raising the timeout (as #3551 does) makes drops rarer but doesn't remove the deadlock.

    I just opened #3553 with just the job-manager-side fix: on a send error the stream drops back to buffering mode (only if the failing sender is still the current one) and the stale connection is closed, so the existing route-down auto-reconnect reattaches from the client's last received seq. It includes streammanager_test.go cases with a failing DataSender, which fail on main. On the main-server side it only changes the auto-reconnect cooldown, so a second failure inside it is deferred instead of dropped. There's no new watchdog.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions