Skip to content

fix(desktop): migrate terminal reconnect onto createRelaySocket (forever-retry + retryCount diagnosis) - #5702

Merged
Kitenite merged 4 commits into
mainfrom
spectacled-gander
Jul 15, 2026
Merged

Kitenite merged 4 commits into
mainfrom
spectacled-gander

Conversation

@Kitenite

@Kitenite Kitenite commented Jul 15, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Rebuild the desktop terminal WebSocket transport on createRelaySocket (partysocket) — the same shared reconnecting socket the events channel and web terminal use — so it retries forever instead of giving up after ~66s, gets the fly-affinity preflight, and re-signs a fresh token per dial.
  • Fixes the reported bug: after laptop sleep/wake, a remote workspace terminal fails to reconnect and sits "offline" until an incidental window focus/visibility event.
  • Adds an additive onProbe hook to packages/workspace-client so the transport can classify why a stream is down (host-offline / unauthorized / relay-routing).

Why / Context

The desktop terminal was the one relay socket still hand-rolled on a raw WebSocket with a 10-attempt (~66s) give-up. On a shared sleep/wake, the host-service can take longer than that window to re-register its relay tunnel (its inbound-silence watchdog alone is ~75s), so the client gave up right before the host returned and never retried on its own. PostHog terminal_connect_failed confirmed the pattern: 100% of these failures exhausted all attempts (host-offline bucket: 624 events / 31 users / 30 days).

How It Works

  • Transport → createRelaySocket. Reconnection, backoff, preflight affinity, and per-dial token signing move into the shared socket (partysocket, forever-retry). The transport keeps the pieces partysocket doesn't model: the wall-clock liveness watchdog (the dependable sleep/wake signal — app-suspend doesn't reliably fire focus/visibility), the write coalescer, replay=0 after first bytes, and terminated-on-exit/error.
  • Diagnosis off retryCount, not close-counting. partysocket delivers dial failures (host unreachable, upgrade rejected) as an error + a synthetic close whose code is the string "close". Counting close codes misses those, so a genuinely-offline terminal would retry silently. The offline diagnosis is now driven by partysocket's authoritative retryCount (resets after a stable connection), surfaced after 10 consecutive failures — matching the events channel's relaySocket.ts.
  • Focus gate on the diagnosis, not the retries. A hidden/minimized window keeps retrying (background agents stay live and reattach), but doesn't accrue a spurious "offline" header. On resume, the focus/visibility/online listener force-redials and resets the budget.
  • onProbe hook surfaces the existing _whoowns preflight result to the caller (previously only 403 leaked out via onAccessDenied). No new network calls, no protocol change.

Manual QA — verified live over CDP (dev desktop)

  • Two real terminals connect end-to-end via createRelaySocket → connectionState: "open" (new code confirmed live: _diagnosisLogged present, old _reconnectAttempt gone)
  • Injected a dead-host failure → retryCount climbs 10 → 11 → 12 (forever-retry, never self-closes)
  • Dial-failure diagnosis surfaces at retryCount ≥ 10 (was the silent-failure bug) and keeps retrying
  • Repoint to the real host → recovers to "open" in ~1.6s, diagnosis cleared
  • Terminal streams PTY output / attach / resize intact

Testing

  • bun run typecheck — 35/35 packages ✅
  • bun run lint — clean ✅
  • bunx sherif — no issues ✅
  • bun test apps/desktop/.../terminal-ws-transport.test.ts — 14/14; the new guards (forever-retry, retryCount threshold, dial-failure diagnosis, hidden-window gate, wall-clock watchdog) are each mutation-verified (fail on the broken code)
  • bun test packages/workspace-client/src — 20/20 ✅

Design Decisions

  • Full migration (option A) over a slow-poll patch (option B): deletes the hand-rolled reconnect loop entirely and inherits forever-retry + preflight from the shared socket, killing the whole bug class rather than the one instance.
  • retryCount over close-counting: robust to partysocket's synthetic-close/error double-fire on dial failures (same lesson as relaySocket.ts). Caught by the live CDP run, not the unit tests — the fake initially dispatched numeric closes.
  • Kept retrying while hidden: desktop backgrounds windows while agents run; pausing retries would delay reattach. Only the diagnosis is gated on visibility.

Known Limitations / Follow-ups

  • Web terminal (apps/web) shares the give-up-at-12 shape but is an unused surface (desktop is the real one) — left for a parity pass.
  • ACP subscribeToSession lacks the affinity preflight — separate, deferred.
  • 401/unauthorized give-up storm (larger bucket in telemetry) is a token-refresh-on-wake issue — separate investigation.

Risks / Rollout

  • Blast radius: desktop + web only. No server/hosted service consumes @superset/workspace-client (confirmed: relay, api, host-service, electric-proxy, mobile, marketing, admin, docs = 0). No server code, endpoint, or protocol change.
  • Risk: the one server-facing effect is a load-pattern change — a client stuck against an offline host now hits the relay's _whoowns + WS upgrade every ≤10s indefinitely (per open terminal) instead of giving up. Bounded by the 10s backoff cap and identical to the pattern the events channel already sustains in prod; not a Vercel-auth-storm class. If relay metrics ever flag it, we can pause retries (not just the diagnosis) while hidden.
  • Rollback: revert the two commits; no data or schema involved.

Open in Stage

Summary by cubic

Migrated the desktop terminal to the shared createRelaySocket with forever-retry, fresh token per dial, and preflight affinity to fix sleep/wake reconnects and provide clear, thresholded offline diagnosis. Added a reconnect() path and guarded late socket events so manual retries and teardown behave correctly.

  • Bug Fixes

    • Replaced the hand-rolled WebSocket with createRelaySocket for indefinite retries and parity with events/web terminal.
    • Drove outage diagnosis off retryCount after 10 consecutive failures; keeps retrying and logs once.
    • Keeps retrying while hidden but suppresses the “offline” banner until focus/visibility returns.
    • Wall-clock watchdog forces reconnect after sleep/wake gaps.
    • Preserves write coalescing, replay=0 after first bytes, and termination on PTY exit/fatal attach.
    • Exposed reconnect() (used in the desktop registry) and guarded late events from detached sockets to prevent state/log flips after disconnect/teardown.
    • Hardened tests by stubbing only createRelaySocket in @superset/workspace-client/relay-socket while preserving other exports; added regressions for never-open dial failures and teardown event handling.
  • New Features

    • @superset/workspace-client/relay-socket: added onProbe to expose relay _whoowns preflight results for better outage classification (host offline/unauthorized/routing), with an epoch guard to avoid publishing stale probes after URL swaps or reconnects.

Written for commit 53ffa82. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Improved terminal reconnection across temporary outages, sleep/wake gaps, and stalled connections.
    • Prevented unnecessary reconnect/teardown when tokens rotate.
    • Ensured queued terminal output flushes correctly on close.
    • Reworked relay-driven close/error handling to avoid state resurrection from late events.
  • Improvements

    • Enhanced “connection outage” diagnosis: logged once per outage cycle and suppressed while the app is hidden.
  • Tests

    • Updated terminal transport tests to use a deterministic relay-socket simulation and expanded reconnect/diagnosis coverage.

Kitenite added 2 commits July 14, 2026 23:17
createRelaySocket already runs the _whoowns affinity preflight before every
dial but only surfaced a 403 (via onAccessDenied). Add an optional onProbe
callback so callers can read the full preflight status (503 host-offline,
401 unauthorized, 502/200 relay-routing) to classify why a stream is down —
the WS upgrade status is otherwise invisible to browser clients. Additive and
opt-in; existing consumers are unaffected.
The desktop terminal was the one relay socket still hand-rolled on a raw
WebSocket with a 10-attempt (~66s) give-up. After a laptop sleep/wake the host
can take longer than that to re-register its relay tunnel, so the terminal gave
up right before the host came back and sat "offline" until an incidental DOM
resume event — the reported "won't reconnect after sleep" bug.

Rebuild the transport on createRelaySocket (partysocket), same as the events
channel and web terminal: forever-retry, fly-affinity preflight, fresh token
per dial. Keep the desktop-specific pieces partysocket doesn't model — the
wall-clock liveness watchdog (sleep/wake), write coalescer, replay=0 after first
bytes, terminated-on-exit/error. Drive the offline diagnosis off partysocket's
retryCount (not close-event counting), so dial failures that arrive as an error
+ synthetic string-code close still surface after the threshold instead of
retrying silently. Gate the diagnosis (not the retries) on document.hidden so a
backgrounded window doesn't flash a spurious "offline".
@ghost

ghost commented Jul 15, 2026 •

Copy link
Copy Markdown

Ready to review this PR? Stage has broken it down into 4 individual chapters for you:

Title
1 Add onProbe hook to workspace-client
2 Migrate terminal transport to createRelaySocket
3 Wire reconnect through the runtime registry
4 Update terminal transport tests for RelaySocket
Open in Stage

Chapters generated by Stage for commit 53ffa82 on Jul 15, 2026 6:35am UTC.

@coderabbitai

coderabbitai Bot commented Jul 15, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The terminal transport now uses shared RelaySocket retrying and token handling, exposes relay probe results for diagnosis, updates reconnect and lifecycle controls, and expands tests for retry thresholds, hidden-window behavior, sleep/wake recovery, output flushing, and disconnect handling.

Changes

Terminal relay reconnection

Layer / File(s) Summary
Relay probe callback contract
packages/workspace-client/src/lib/relaySocket/relaySocket.ts
RelaySocketOptions exposes onProbe, which receives affinity probe results for current connection attempts.
RelaySocket transport connection
apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts
Terminal connections use RelaySocket state, token providers, idempotent URL updates, and relay control-message handling.
Reconnect and diagnosis lifecycle
apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts
Reconnect, resume, watchdog, close, error, disconnect, send, and disposal paths use relay socket state and retry-based diagnosis gating.
Runtime integration and behavior coverage
apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts, apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts
Runtime retries delegate to reconnect, and tests cover FakeRelaySocket retry, diagnosis, reconnect, watchdog, output flushing, teardown, and disconnect behavior.

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

Sequence Diagram(s)

sequenceDiagram
  participant TerminalTransport
  participant createRelaySocket
  participant primeRelayAffinity
  participant RelaySocket
  TerminalTransport->>createRelaySocket: create relay-backed transport
  createRelaySocket->>primeRelayAffinity: probe relay affinity
  primeRelayAffinity-->>createRelaySocket: return probe result
  createRelaySocket->>RelaySocket: dial and retry connection
  RelaySocket-->>TerminalTransport: deliver open, attached, data, close, or error events
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.63% 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
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is conventional, concise, and accurately summarizes the main change to terminal reconnect behavior.
Description check ✅ Passed The description covers the change, rationale, implementation, testing, and checklist items, with only minor template-heading differences.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch spectacled-gander

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.

@github-actions

github-actions Bot commented Jul 15, 2026 •

Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch

Thank you for your contribution! 🎉

@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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts (1)

530-539: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate parsed control messages before reading type.

The assertion does not validate runtime data. A valid payload such as null makes message.type throw instead of reaching the invalid-payload handling. Parse to unknown and use a type guard or schema before dispatching.

As per coding guidelines, “Avoid any unless it is necessary; preserve type safety.”

🤖 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 `@apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts` around lines
530 - 539, Update the message parsing in the terminal WebSocket handler to parse
JSON as unknown and validate the result before accessing message.type. Add or
reuse a type guard/schema for TerminalServerMessage, routing null and other
malformed payloads through the existing invalid-payload handling while
preserving dispatch for valid messages.

Source: Coding guidelines

🤖 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 `@apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts`:
- Around line 423-436: Update the “surfaces the diagnosis for dial failures that
never open” test to create the transport via connect() without opening the
socket first, then invoke socket.dialFail() for all attempts before any
socket.open() call. Preserve the existing 10-failure threshold assertions and
avoid using connectAttached(), which opens and attaches the session.

In `@apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts`:
- Around line 513-514: Guard the WebSocket event handlers in the transport
around the message, close, and error listener callbacks so they return
immediately when the event’s socket is no longer the active _socket. Preserve
the existing disconnected state and prevent detached sockets from mutating logs
or terminal state after disconnect() or disposeTransport().

---

Outside diff comments:
In `@apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts`:
- Around line 530-539: Update the message parsing in the terminal WebSocket
handler to parse JSON as unknown and validate the result before accessing
message.type. Add or reuse a type guard/schema for TerminalServerMessage,
routing null and other malformed payloads through the existing invalid-payload
handling while preserving dispatch for valid messages.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0010ba4e-bdd1-4a1b-a92f-0897629838cf

📥 Commits

Reviewing files that changed from the base of the PR and between a5ea51c and 48e69a0.

📒 Files selected for processing (4)
  • apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts
  • apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts
  • apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts
  • packages/workspace-client/src/lib/relaySocket/relaySocket.ts

@greptile-apps

greptile-apps Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR migrates the desktop terminal WebSocket transport from a hand-rolled socket with a hard 10-attempt give-up to createRelaySocket (partysocket), matching the shared reconnect infrastructure used by the events channel. It also adds an onProbe hook to workspace-client so the transport can surface the _whoowns preflight result for failure diagnosis.

  • Terminal transport rebuilt on createRelaySocket: forever-retry with exponential backoff, per-dial JWT re-signing, fly affinity preflight, and wall-clock liveness watchdog (sleep/wake detection) are all wired in. Diagnosis is now driven off partysocket's retryCount rather than counting close events, which correctly handles dial failures that arrive as synthetic string-code closes.
  • retryConnect simplified in the registry: the old manual _reconnectAttempt = 0 hack is replaced by the new exported reconnect() function.
  • onProbe hook added to RelaySocketOptions (additive, no breaking change) and consumed by the terminal transport to keep _lastProbe current for failure classification.

Confidence Score: 3/5

Safe to merge for the core reconnect fix; one latent issue with replay suppression on workspace switches needs a one-line fix before this is fully correct.

The transport correctly resets _terminated, _diagnosisLogged, lastDiagnosis, and the write coalescer when pointed at a new endpoint, but skips _hasReceivedBytes. A user switching workspaces while a terminal is active would land on a blank terminal after reconnect because the server skips initial scrollback. The fix is a single-line addition; no existing test covers this path.

apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts — the _hasReceivedBytes reset in the URL-change path of connect()

Important Files Changed

Filename Overview
apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts Full migration to createRelaySocket; introduces forever-retry, per-dial token signing, wall-clock liveness watchdog, and retryCount-based diagnosis — but _hasReceivedBytes is not reset when the transport is pointed at a new endpoint, suppressing replay for the new session.
apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts Simplified retryConnect() to delegate to the new reconnect() export instead of manually resetting _reconnectAttempt; clean and correct.
packages/workspace-client/src/lib/relaySocket/relaySocket.ts Adds additive onProbe hook to RelaySocketOptions; correctly sequenced before the 403 branch so _lastProbe is set before onAccessDenied reads it.
apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts Comprehensive test suite covering forever-retry, retryCount threshold, dial-failure diagnosis, hidden-window gate, wall-clock watchdog, and write coalescing; FakeRelaySocket accurately models partysocket's synthetic-close behavior for dial failures.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant UI as "Terminal UI"
    participant TR as "TerminalTransport"
    participant RS as "createRelaySocket (partysocket)"
    participant PA as "primeRelayAffinity"
    participant WS as "WebSocket (relay/host)"

    UI->>TR: connect(transport, terminal, wsUrl)
    TR->>TR: stripToken(url), reset state fields
    TR->>RS: createRelaySocket with buildUrl/getToken/onProbe/onAccessDenied
    Note over RS: Socket created once, reused forever

    loop Every dial attempt
        RS->>PA: primeRelayAffinity(signedUrl)
        PA-->>RS: RelayAffinityProbe or null
        RS->>TR: onProbe(probe) sets _lastProbe
        RS->>WS: WebSocket upgrade attempt
    end

    WS-->>TR: message type attached
    TR->>UI: connectionState open

    WS-->>TR: close with numeric code
    TR->>TR: setConnectionState closed
    TR->>TR: "maybeSurfaceDiagnosis checks retryCount >= 10"

    Note over TR: Wall-clock watchdog fires every 5s
    TR->>TR: "gap > 20s detected sleep/wake"
    TR->>RS: socket.reconnect() immediate re-dial
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant UI as "Terminal UI"
    participant TR as "TerminalTransport"
    participant RS as "createRelaySocket (partysocket)"
    participant PA as "primeRelayAffinity"
    participant WS as "WebSocket (relay/host)"

    UI->>TR: connect(transport, terminal, wsUrl)
    TR->>TR: stripToken(url), reset state fields
    TR->>RS: createRelaySocket with buildUrl/getToken/onProbe/onAccessDenied
    Note over RS: Socket created once, reused forever

    loop Every dial attempt
        RS->>PA: primeRelayAffinity(signedUrl)
        PA-->>RS: RelayAffinityProbe or null
        RS->>TR: onProbe(probe) sets _lastProbe
        RS->>WS: WebSocket upgrade attempt
    end

    WS-->>TR: message type attached
    TR->>UI: connectionState open

    WS-->>TR: close with numeric code
    TR->>TR: setConnectionState closed
    TR->>TR: "maybeSurfaceDiagnosis checks retryCount >= 10"

    Note over TR: Wall-clock watchdog fires every 5s
    TR->>TR: "gap > 20s detected sleep/wake"
    TR->>RS: socket.reconnect() immediate re-dial
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts:416-457
**`_hasReceivedBytes` not reset on URL change, suppressing replay for new sessions**

When `connect()` is called with a different endpoint (new base URL), several fields are reset — `_terminated`, `_diagnosisLogged`, `lastDiagnosis`, `_writeCoalescer` — but `_hasReceivedBytes` is NOT. The `buildUrl` closure reads it live and appends `?replay=0` when true. If a previous session received bytes and the transport is then pointed at a new endpoint (e.g., via `registry.reconnect()` after a workspace switch), the server skips the initial scrollback for the new session and the user sees a blank terminal on connect.

The fix is to add `transport._hasReceivedBytes = false;` alongside the other resets in the non-idempotent path, before the early return for the existing-socket URL-change case.

### Issue 2 of 2
apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts:437-442
`_hasReceivedBytes` is not reset here when the transport is pointed at a new endpoint. The `buildUrl` closure reads it live and will append `?replay=0` on the very first dial to the new session, so the server skips initial scrollback and the user sees a blank terminal. Adding the reset alongside the other state resets fixes this.

```suggestion
	transport.currentUrl = wsUrl;
	transport._localToken = extractToken(wsUrl);
	transport._terminal = terminal;
	transport._terminated = false;
	transport._diagnosisLogged = false;
	transport.lastDiagnosis = null;
	transport._hasReceivedBytes = false;
```

Reviews (1): Last reviewed commit: "fix(desktop): migrate terminal reconnect..." | Re-trigger Greptile

Comment on lines 416 to +457
export function connect(
transport: TerminalTransport,
terminal: XTerm,
wsUrl: string,
) {
// Idempotent: skip if already connected/connecting to the same endpoint.
const isActive =
transport.connectionState === "open" ||
transport.connectionState === "connecting";
if (isActive && transport.currentUrl === wsUrl) return;

if (transport.socket) {
transport.socket.close();
transport.socket = null;
const base = stripToken(wsUrl);

// Idempotent: a live socket already pointed at this endpoint just needs the
// latest token-bearing URL refreshed (the socket re-signs per dial anyway) —
// don't tear the connection down when only the rotating token changed.
if (
transport._socket &&
!transport._terminated &&
transport.currentUrl &&
stripToken(transport.currentUrl) === base
) {
transport.currentUrl = wsUrl;
transport._localToken = extractToken(wsUrl);
return;
}

cancelReconnect(transport);
const epoch = ++transport._connectEpoch;
transport.currentUrl = wsUrl;
transport._localToken = extractToken(wsUrl);
transport._terminal = terminal;
// Recreate per connect so the coalescer always targets the current
// terminal; dispose flushes anything the previous socket left pending.
transport._terminated = false;
transport._diagnosisLogged = false;
transport.lastDiagnosis = null;
// Recreate per connect so the coalescer always targets the current terminal;
// dispose flushes anything the previous socket left pending.
transport._writeCoalescer?.dispose();
transport._writeCoalescer = createWriteCoalescer((data) =>
terminal.write(data),
);
transport._terminated = false;
transport.lastDiagnosis = null;
setupLiveness(transport);
setConnectionState(transport, "connecting");
const actualUrl = transport._hasReceivedBytes
? appendQueryParam(wsUrl, "replay", "0")
: wsUrl;

const openSocket = (targetUrl: string) => {
// Bail if the transport raced into a different URL, was disconnected, or
// a newer connect() superseded this attempt while the pre-flight was in
// flight.
if (
transport._connectEpoch !== epoch ||
transport.currentUrl !== wsUrl ||
transport.connectionState !== "connecting"
) {
return;
}
let socket: WebSocket;
try {
socket = new WebSocket(targetUrl);
} catch (err) {

// Endpoint changed on an existing socket: re-point (buildUrl reads
// currentUrl live) and re-dial.
if (transport._socket) {
transport._socket.reconnect();
return;
}

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.

P1 _hasReceivedBytes not reset on URL change, suppressing replay for new sessions

When connect() is called with a different endpoint (new base URL), several fields are reset — _terminated, _diagnosisLogged, lastDiagnosis, _writeCoalescer — but _hasReceivedBytes is NOT. The buildUrl closure reads it live and appends ?replay=0 when true. If a previous session received bytes and the transport is then pointed at a new endpoint (e.g., via registry.reconnect() after a workspace switch), the server skips the initial scrollback for the new session and the user sees a blank terminal on connect.

The fix is to add transport._hasReceivedBytes = false; alongside the other resets in the non-idempotent path, before the early return for the existing-socket URL-change case.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts
Line: 416-457

Comment:
**`_hasReceivedBytes` not reset on URL change, suppressing replay for new sessions**

When `connect()` is called with a different endpoint (new base URL), several fields are reset — `_terminated`, `_diagnosisLogged`, `lastDiagnosis`, `_writeCoalescer` — but `_hasReceivedBytes` is NOT. The `buildUrl` closure reads it live and appends `?replay=0` when true. If a previous session received bytes and the transport is then pointed at a new endpoint (e.g., via `registry.reconnect()` after a workspace switch), the server skips the initial scrollback for the new session and the user sees a blank terminal on connect.

The fix is to add `transport._hasReceivedBytes = false;` alongside the other resets in the non-idempotent path, before the early return for the existing-socket URL-change case.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 437 to +442
transport.currentUrl = wsUrl;
transport._localToken = extractToken(wsUrl);
transport._terminal = terminal;
// Recreate per connect so the coalescer always targets the current
// terminal; dispose flushes anything the previous socket left pending.
transport._terminated = false;
transport._diagnosisLogged = false;
transport.lastDiagnosis = null;

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.

P1 _hasReceivedBytes is not reset here when the transport is pointed at a new endpoint. The buildUrl closure reads it live and will append ?replay=0 on the very first dial to the new session, so the server skips initial scrollback and the user sees a blank terminal. Adding the reset alongside the other state resets fixes this.

Suggested change
transport.currentUrl = wsUrl;
transport._localToken = extractToken(wsUrl);
transport._terminal = terminal;
// Recreate per connect so the coalescer always targets the current
// terminal; dispose flushes anything the previous socket left pending.
transport._terminated = false;
transport._diagnosisLogged = false;
transport.lastDiagnosis = null;
transport.currentUrl = wsUrl;
transport._localToken = extractToken(wsUrl);
transport._terminal = terminal;
transport._terminated = false;
transport._diagnosisLogged = false;
transport.lastDiagnosis = null;
transport._hasReceivedBytes = false;
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts
Line: 437-442

Comment:
`_hasReceivedBytes` is not reset here when the transport is pointed at a new endpoint. The `buildUrl` closure reads it live and will append `?replay=0` on the very first dial to the new session, so the server skips initial scrollback and the user sees a blank terminal. Adding the reset alongside the other state resets fixes this.

```suggestion
	transport.currentUrl = wsUrl;
	transport._localToken = extractToken(wsUrl);
	transport._terminal = terminal;
	transport._terminated = false;
	transport._diagnosisLogged = false;
	transport.lastDiagnosis = null;
	transport._hasReceivedBytes = false;
```

How can I resolve this? If you propose a fix, please make it concise.

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 4 files

Architecture diagram
sequenceDiagram
    participant Renderer as Desktop Renderer
    participant Transport as Terminal Transport
    participant RelaySocket as createRelaySocket
    participant RelayHost as Relay / Host Service
    participant Auth as ensureFreshJwt
    participant Terminal as XTerm Terminal

    Note over Renderer,Terminal: NEW: Forever-retry via shared partysocket

    Renderer->>Transport: connect(url, terminal)
    Transport->>Transport: createTransport() if new

    Transport->>RelaySocket: NEW: createRelaySocket(options)
    Note over RelaySocket: onProbe, onAccessDenied, etc.

    loop Every reconnection attempt (forever)
        RelaySocket->>Auth: buildUrl() → sign per-dial token
        Auth-->>RelaySocket: fresh JWT
        RelaySocket->>RelayHost: preflight _whoowns
        RelayHost-->>RelaySocket: probe status (e.g., 200, 503, 401)
        RelaySocket->>Transport: NEW: onProbe(probe) hook
        Transport->>Transport: store _lastProbe

        RelaySocket->>RelayHost: WebSocket upgrade (with token)
        alt Upgrade succeeds
            RelayHost-->>RelaySocket: OPEN
            RelaySocket-->>Transport: open event
            Transport->>Transport: set state to "connecting"
            Transport->>RelaySocket: send {type:"attach", terminalId,…}
            RelayHost-->>RelaySocket: {type:"attached", …}
            RelaySocket-->>Transport: message event
            Transport->>Transport: set state to "connected", clear diagnosis
            Transport->>Terminal: write PTY output (coalesced)

            Note over Transport: Wall-clock liveness watchdog starts
            loop Every 5s
                Transport->>Transport: check _lastLivenessTick
                alt gap > 20s (sleep/wake)
                    Transport->>RelaySocket: CHANGED: forceReconnect() via socket.reconnect()
                    RelaySocket->>Auth: new token, new preflight
                end
            end
        else Upgrade fails (dial failure)
            RelayHost-->>RelaySocket: close / error
            Note over RelaySocket: retryCount++ (partysocket)
            RelaySocket-->>Transport: close event (code="close" on dial fail)
            Transport->>Transport: CHANGED: evaluate retryCount, not close codes

            alt retryCount >= 10 AND window NOT hidden
                Transport->>Transport: CHANGED: surface diagnosis (host-offline/unauthorized/routing)
                Transport->>Transport: log "Still retrying" once, capture PostHog event
            else window hidden
                Transport->>Transport: skip diagnosis (keep retrying silently)
            end

            RelaySocket->>RelayHost: next retry (exponential backoff, capped at 10s)
        end
    end

    Note over Transport: Focus / visibility / online gates
    rect rgb(240, 240, 240)
        Note over Transport,RelaySocket: On window regain focus
        Renderer->>Transport: visibilitychange / focus / online
        Transport->>RelaySocket: forceReconnect() – resets retry budget
    end

    Note over Transport: PTY exit or fatal attach error
    Transport->>Transport: set _terminated = true
    Transport->>RelaySocket: close socket (no more retries)
    Transport->>Transport: surface permanent diagnosis, log telemetry
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

bun's mock.module is process-global, so the partial `@superset/workspace-client
/relay-socket` stub (createRelaySocket only) clobbered the real module for every
later desktop test file, dropping setRelaySocketTelemetry and crashing unrelated
tests with "export not found". Spread the real module and override only
createRelaySocket, and drop the unnecessary auth-client/posthog mocks (getToken
never runs with a faked socket; posthog.capture pre-init is a no-op). Full
desktop suite: 2137 pass / 0 fail.
Address review feedback:
- Restore the stale-socket guard (`if (transport._socket !== socket) return`)
  on the message/close/error handlers — dropped in the rewrite. Without it a
  late close after disconnect()/disposeTransport() flips "disconnected" back to
  "closed" and mutates logs on a torn-down transport. Regression-tested.
- Epoch-guard onProbe in createRelaySocket so a slow preflight from a superseded
  dial can't publish a stale probe that misclassifies the current endpoint.
- Make the "dial failures that never open" test start before open() so it
  actually exercises the never-opened path.
- Add a regression test asserting the relay-socket mock preserves the module's
  other exports (setRelaySocketTelemetry), so a future partial mock.module stub
  fails fast instead of leaking suite-wide (the earlier CI break).

This branch was previously deployed

1 inactive deployment
preview — 53ffa82c Deployed Jul 15, 2026 by Kitenite via Deploy Docs #6542
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.

1 participant