Skip to content

fix(desktop): refresh relay JWT on WS reconnect and stop retrying definitive 403s - #5628

Merged
Kitenite merged 4 commits into
mainfrom
debug-relay-connection
Jul 12, 2026
Merged

Kitenite merged 4 commits into
mainfrom
debug-relay-connection

Conversation

@Kitenite

@Kitenite Kitenite commented Jul 11, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Terminals and the host event bus no longer get permanently stuck "Disconnected" after the hourly relay JWT rotation: the desktop now refreshes the JWT on demand and re-signs the WS URL on every reconnect attempt.
  • A definitive access denial (preflight 403) now stops the terminal reconnect loop with a clear "no access" message, and drops the event bus to a 5-minute poll instead of hammering the relay every 1-30s.

Why / Context

PostHog terminal_connect_failed shows ~25 users/day failing terminal connects with preflight_status: 401 (1,354 events since telemetry landed on 07-09), and relay logs show matching clients retrying _whoowns + terminal WS with a dead token every ~2s. Root cause chain:

  • Relay JWTs expire after 1h (packages/auth/src/server.ts jwt plugin config; the relay even special-cases ERR_JWT_EXPIRED as an expected, common failure).
  • The renderer's cached JWT (auth-client.ts) only updated when some API response happened to carry a set-auth-jwt header.
  • Terminal reconnects reused transport.currentUrl verbatim — the token was baked in at render time — so any terminal older than ~1h that hit a reconnect (laptop sleep/wake, host-service restart, network blip) redialed with an expired token forever. Restarting the app "fixed" it.

Separately, relay logs showed ~99% of cross-region proxied /events upgrades failing 403 (per-user v2UsersHosts access vs the org-wide host list the desktop connects to), each denied client retrying every few seconds indefinitely.

How It Works

  • auth-client.ts: setJwt records the token's exp (lib/jwt-expiry.ts). New ensureFreshJwt() returns the cached token while it has >60s left, otherwise mints a fresh one from better-auth's /api/auth/token (deduped across concurrent callers; falls back to the stale token if the request fails). getJwt() kicks a background refresh when the cached token is near expiry so sync callers (event bus URL builder, tRPC headers) converge on the next attempt.
  • terminal-ws-transport.ts: every relay dial — including reconnects — awaits ensureFreshJwt() and re-signs the URL's token param before the _whoowns preflight and the WebSocket open. Local PSK terminals are untouched. A preflight 403 is now fatal: log the diagnosis, emit terminal_connect_failed, stop the loop (a manual reconnect still re-enters connect()).
  • eventBus.ts: a preflight 403 reschedules at a fixed 5-minute interval instead of the exponential 1-30s loop, so access granted later (host sharing) is still picked up without the retry storm.

Manual QA Checklist

  • Open a remote-host terminal, leave it idle >1h, force a reconnect (toggle wifi or sleep/wake) — terminal reattaches instead of looping on 401
  • Local (PSK) terminals still connect and reconnect normally
  • A host you have no access to shows "You don't have access to this host. Not retrying." in the terminal pane log instead of silent retries
  • Relay logs: no more per-second _whoowns+terminal 401 retries from a stale client after the fix

Testing

  • bun run typecheck (desktop + workspace-client via turbo)
  • bun run lint
  • bun test apps/desktop/src/renderer/lib packages/workspace-client — new jwt-expiry.test.ts passes (and fails when the impl is mutated); the 10 pre-existing font-test failures fail identically on a clean tree
  • Verified https://api.superset.sh/api/auth/token exists in prod (401 unauthenticated) and that better-auth 1.6.13 returns { token } from it, matching how ensureFreshJwt reads the response
  • Not tested end-to-end with a real expired-token reconnect (needs an hour-old session in a dev desktop); the QA checklist above covers it

Known Limitations

  • apps/web has its own TerminalConnection.ts with the same stale-token flaw Correction: web's getAuthToken() already TTL-caches and refetches per connect attempt, so web is not affected. Consolidating all three hand-rolled reconnect loops onto partysocket is tracked in SUPER-1469.
  • The product-level 403 question remains: the desktop connects to every online host in the org (Electric v2Hosts is org-scoped) while relay access is per-user (v2UsersHosts, creator-only by default). This PR makes clients behave sanely when denied; whether teammates should get access automatically needs a team decision.

Risks / Rollout

  • Risk: /api/auth/token gets called on token expiry from WS reconnect paths — deduped and low-volume (bounded by reconnect backoff), so no auth-endpoint storm (cf. the 07-08 self-DDoS incident; this reduces relay retry traffic overall).
  • Rollback: revert the commit; behavior returns to current prod (401 loops until app restart).

Open in Stage

Summary by cubic

Refreshes relay JWTs on every WebSocket reconnect and stops futile retries when access is definitively denied. Adds race guards so token refreshes and reconnects can’t clobber newer state.

  • Bug Fixes

    • JWT handling (desktop): track exp, use 60s leeway, and add ensureFreshJwt() to mint from better-auth /api/auth/token (deduped, generation-safe). getJwt() starts a background refresh near expiry and logs failures.
    • Terminal transport (desktop): re-sign the relay WS URL with a fresh token on every dial; treat preflight 403 as fatal with a clear “no access” message and emit telemetry; epoch-guard preflight callbacks to avoid duplicate sockets or terminating newer attempts. Local PSK terminals unchanged.
    • Event bus (@superset/workspace-client): on preflight 403, switch to a fixed 5-minute retry instead of exponential 1–30s.
    • Relay (apps/relay): _whoowns now runs cached access checks when this edge owns the tunnel and returns 403 on denial (500 on check error), making denials visible to clients.
    • Tests: add jwt-expiry utility and unit tests.
  • Refactors

    • Docs: add a design plan to consolidate reconnecting WS clients on partysocket (SUPER-1469).

Written for commit ca76fc1. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added expiry-aware JWT caching with background refresh to keep sessions valid.
    • Terminal reconnects on relay routes now request a fresh JWT before retrying.
    • Relay preflight now surfaces clearer access outcomes (including definitive denials).
  • Bug Fixes
    • Improved handling of missing or malformed JWT expiry data.
    • Stops auto-reconnect loops after explicit access denial (403).
  • Reliability
    • Uses a slower fixed reconnect delay when access is denied (403).
  • Tests
    • Added coverage for JWT expiry decoding behavior.

…initive 403s

Relay JWTs rotate hourly, but the renderer's cached JWT only updated when an
API response happened to carry set-auth-jwt, and terminal reconnects reused
the URL signed at render time. Any terminal older than ~1h that hit a
reconnect (sleep/wake, host restart) redialed with an expired token and
looped on 401 until app restart (~25 users/day in PostHog).

- auth-client: track token exp; ensureFreshJwt() mints from /api/auth/token
  (deduped); getJwt() kicks a background refresh when near expiry
- terminal transport: re-sign the WS URL with a fresh token on every dial;
  treat a preflight 403 as fatal (surface "no access", emit telemetry,
  stop the reconnect loop)
- eventBus: retry preflight 403 every 5 minutes instead of the 1-30s
  exponential loop, so unauthorized teammate-hosts stop hammering the relay
@coderabbitai

coderabbitai Bot commented Jul 11, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

JWT caching now tracks expiry and refreshes stale tokens. Relay terminal connections sign preflight URLs with fresh JWTs, relay preflight reports explicit access outcomes, and clients stop or delay retries for access-denied responses.

Changes

Relay authentication and reconnect flow

Layer / File(s) Summary
JWT expiry-aware refresh
apps/desktop/src/renderer/lib/jwt-expiry.ts, apps/desktop/src/renderer/lib/jwt-expiry.test.ts, apps/desktop/src/renderer/lib/auth-client.ts
JWT expiry decoding, freshness checks, background refresh, refresh deduplication, generation guards, and stale-token fallback are added and tested.
Relay access preflight responses
apps/relay/src/index.ts
The _whoowns endpoint returns 503 for disconnected hosts, 200 for allowed access, 403 for denials, and 500 for access-check errors.
Signed relay connection preflight
apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts
Relay connections refresh the JWT before preflight, append it to the URL, guard superseded connection attempts, and close on a 403 probe response.
Access-denied reconnect scheduling
packages/workspace-client/src/lib/eventBus.ts
Relay 403 responses use a fixed reconnect delay instead of exponential backoff.
Shared WebSocket migration plan
plans/20260712-partysocket-ws-consolidation.md
A phased plan defines shared partysocket reconnect behavior, per-attempt token generation, preflight status handling, buffering limits, and transport configuration.

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

Sequence Diagram(s)

sequenceDiagram
  participant TerminalWsTransport
  participant AuthClient
  participant RelayAffinity
  participant Relay
  participant WebSocket
  TerminalWsTransport->>AuthClient: ensureFreshJwt()
  AuthClient-->>TerminalWsTransport: fresh JWT
  TerminalWsTransport->>RelayAffinity: primeRelayAffinity(signedUrl)
  RelayAffinity->>Relay: GET /hosts/:hostId/_whoowns
  Relay-->>RelayAffinity: 200, 403, 503, or 500
  RelayAffinity-->>TerminalWsTransport: probe status
  TerminalWsTransport->>WebSocket: openSocket(signedUrl)
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 concise and accurately reflects the main change: JWT refresh on reconnect and stopping retries on definitive 403s.
Description check ✅ Passed The description is detailed and covers summary, context, implementation, testing, risks, and limitations, though it omits the template's Related Issues and Type of Change sections.
✨ 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 debug-relay-connection

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.

@ghost

ghost commented Jul 11, 2026 •

Copy link
Copy Markdown

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

Title
1 Add JWT expiry decoding utility
2 Implement on-demand JWT refresh in auth client
3 Return 403 for definitive access denials in relay
4 Refresh JWT and handle 403s in terminal transport
5 Slow-poll event bus on access denial
6 Document future WebSocket consolidation plan
Open in Stage

Chapters generated by Stage for commit ca76fc1 on Jul 12, 2026 6:09pm UTC.

@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR improves relay reconnect behavior for desktop clients. The main changes are:

  • JWT expiry tracking and on-demand refresh in the desktop auth client.
  • Fresh relay URL signing before terminal WebSocket reconnects.
  • Terminal retry shutdown for definitive relay access denial.
  • Slower event bus polling after relay preflight access denial.
  • Unit coverage for JWT expiry decoding.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
apps/desktop/src/renderer/lib/auth-client.ts Adds cached JWT expiry tracking, background refresh, and deduped async refresh for reconnect callers.
apps/desktop/src/renderer/lib/jwt-expiry.ts Adds best-effort JWT exp decoding with safe fallback for malformed tokens.
apps/desktop/src/renderer/lib/jwt-expiry.test.ts Adds focused tests for JWT expiry decoding and malformed input handling.
apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts Refreshes relay JWTs before terminal reconnects and stops retrying definitive access denials.
packages/workspace-client/src/lib/eventBus.ts Adds fixed slow polling for event bus preflight access denials.

Reviews (1): Last reviewed commit: "fix(desktop): refresh relay JWT on WS re..." | Re-trigger Greptile

@github-actions

github-actions Bot commented Jul 11, 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: 1

🤖 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 `@packages/workspace-client/src/lib/eventBus.ts`:
- Around line 222-227: Update the primeRelayAffinity preflight handling in the
event bus so a 403 re-signs or fetches a fresh WebSocket token before treating
the denial as definitive. Use the refreshed token when retrying the preflight,
and only call scheduleReconnect with ACCESS_DENIED_RETRY_MS after confirming the
fresh-token attempt still returns 403.
🪄 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: b62f3780-092f-4cdb-b954-85af46934d06

📥 Commits

Reviewing files that changed from the base of the PR and between 211206f and 8a51dde.

📒 Files selected for processing (5)
  • apps/desktop/src/renderer/lib/auth-client.ts
  • apps/desktop/src/renderer/lib/jwt-expiry.test.ts
  • apps/desktop/src/renderer/lib/jwt-expiry.ts
  • apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts
  • packages/workspace-client/src/lib/eventBus.ts

Comment thread packages/workspace-client/src/lib/eventBus.ts

@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 5 files

Architecture diagram
sequenceDiagram
    participant UI as Terminal UI / Event Bus Client
    participant Auth as auth-client.ts (JWT Manager)
    participant BA as better-auth /api/auth/token
    participant TT as terminal-ws-transport.ts
    participant EB as eventBus.ts (@superset/workspace-client)
    participant Relay as Relay (fly edge)
    participant Telemetry as PostHog Telemetry

    Note over UI,Relay: Terminal WebSocket Connection Flow

    UI->>TT: connect(wsUrl with stale token)
    TT->>Auth: ensureFreshJwt()
    alt Cached JWT fresh (>60s leeway)
        Auth-->>TT: Return cached JWT
    else JWT expired or near expiry
        Auth->>BA: POST /api/auth/token (deduped concurrent calls)
        BA-->>Auth: { token }
        Auth->>Auth: setJwt() → stores token + decoded exp
        Auth-->>TT: Return fresh JWT
    else Refresh network failure
        Auth-->>TT: Fall back to stale JWT
    end
    TT->>TT: Re-sign wsUrl with fresh token param
    TT->>Relay: preflight _whoowns (GET with signed token)
    alt Preflight returns 200
        Relay-->>TT: Probe success (region affinity)
        TT->>Relay: WebSocket open (fresh token)
        Relay-->>TT: Session established
        Note over TT,UI: Normal WS communication continues
    else Preflight returns 403 (definitive denial)
        Relay-->>TT: Probe: status=403
        TT->>TT: classifyTerminalFailure(probe)
        TT->>UI: Push log "You don't have access... Not retrying."
        TT->>Telemetry: capture("terminal_connect_failed", { preflight_status:403, ... })
        TT->>TT: Set _terminated=true, connectionState=closed
        Note over TT,UI: No more reconnect attempts (manual reconnect re-enters)
    end

    Note over UI,Relay: Event Bus Connection Flow (host event bus)

    UI->>EB: connect(hostUrl, wsToken)
    alt Relay-routed host
        EB->>Relay: primeRelayAffinity (preflight probe)
        alt Preflight returns 200
            EB->>Relay: WebSocket open
            Relay-->>EB: Connected
        else Preflight returns 403
            Note over EB: Switch to slow retry schedule
            EB->>EB: scheduleReconnect(ACCESS_DENIED_RETRY_MS = 5min)
            Note over EB: Exponential 1-30s loop replaced by fixed 5min poll
        end
    else Local/PSK host
        EB->>EB: Direct WebSocket connect (no relay, no token refresh)
    end

    Note over UI,Relay: Background JWT Refresh (getJwt called by sync callers)

    UI->>Auth: getJwt()
    alt JWT near expiry
        Auth->>Auth: Kicks off ensureFreshJwt() in background (fire-and-forget)
        Auth-->>UI: Returns current (stale) JWT
        Note over Auth: Next caller will likely get refreshed token
    else JWT fresh
        Auth-->>UI: Returns cached JWT immediately
    end

    Note over UI,Relay: Telemetry & Diagnostics

    TT->>Telemetry: capture("terminal_connect_failed", { endpoint, preflight_status, region, reconnect_attempts, category })
    Note over Telemetry: New telemetry for 403 cases replaces silent infinite loop
Loading

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

Re-trigger cubic

Comment thread apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts
Comment thread apps/desktop/src/renderer/lib/auth-client.ts Outdated
Comment thread apps/desktop/src/renderer/lib/auth-client.ts Outdated
The WS upgrade's 403 is invisible to browser clients (1006 close), and
_whoowns skipped the access check entirely, so a denied client could never
distinguish "no access" from a routing hiccup — which is why denied clients
reconnect-loop forever. Run the cached checkHostAccess when this machine owns
the tunnel and return 403 on denial (500 when the check itself errored, so a
transient API outage doesn't read as a permanent denial).
Review follow-ups: generation-check the /token result so a refresh resolving
after logout (or a newer set-auth-jwt header) can't repopulate a stale token;
log refresh failures; epoch-guard connect() preflight callbacks so a
superseded attempt can't open a duplicate socket or fatally terminate the
newer one.

@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.

🧹 Nitpick comments (1)
plans/20260712-partysocket-ws-consolidation.md (1)

26-32: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Document the complete preflight status matrix.

primeRelayAffinity can return null, while the relay also returns 500 and 503. Define handling for 200, 401, 403, 500, 503, and network/timeouts so outages are not mistaken for access denial or retried with the wrong cadence.

🤖 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 `@plans/20260712-partysocket-ws-consolidation.md` around lines 26 - 32, Expand
the preflight section to document handling for all outcomes from
primeRelayAffinity: 200, null, 401, 403, 500, 503, and network or timeout
failures. Clearly distinguish successful, expired-token, access-denied, and
relay-outage cases, including the retry or termination behavior and cadence for
each, without treating outages as access denial.
🤖 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.

Nitpick comments:
In `@plans/20260712-partysocket-ws-consolidation.md`:
- Around line 26-32: Expand the preflight section to document handling for all
outcomes from primeRelayAffinity: 200, null, 401, 403, 500, 503, and network or
timeout failures. Clearly distinguish successful, expired-token, access-denied,
and relay-outage cases, including the retry or termination behavior and cadence
for each, without treating outages as access denial.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 654d7444-4665-4716-8ddf-c8e1b2f410ea

📥 Commits

Reviewing files that changed from the base of the PR and between 4ab51e6 and ca76fc1.

📒 Files selected for processing (1)
  • plans/20260712-partysocket-ws-consolidation.md

@Kitenite
Kitenite merged commit 93244cc into main Jul 12, 2026
19 checks passed
@Kitenite
Kitenite deleted the debug-relay-connection branch July 12, 2026 18:31
Kitenite added a commit that referenced this pull request Jul 12, 2026
…lConnection

eventBus gets its first tests (listener routing, per-host connection sharing,
fs:watch refcounting, watch replay after reconnect, last-listener cleanup).
TerminalConnection takes its env wiring (getToken/relayUrl) via constructor
deps — WebTerminal injects the real ones — so the class is importable in bun
tests without web's env/posthog modules; tests cover dial shape, binary +
control delivery, fresh-token + replay=0 redial, and permanent stop on exit.
The fresh-token redial test fails if the original #5628 stale-URL bug is
reintroduced (mutation-verified). Web tsconfig gains bun test types.
Kitenite added a commit that referenced this pull request Jul 12, 2026
…n partysocket (#5637)

* refactor(workspace-client,web): standardize reconnecting WS clients on partysocket (SUPER-1469)

Adds createRelaySocket — a thin wrapper over partysocket's generic
reconnecting WebSocket that re-signs the URL with a fresh token and runs the
_whoowns preflight before every attempt, treats a preflight 403 as a
definitive denial (permanent close, or slow-poll via accessDeniedRetryMs),
and keeps send semantics unbuffered.

Migrates the two simplest hand-rolled reconnect loops onto it with zero
consumer changes:
- workspace-client eventBus (desktop host events)
- apps/web WebTerminal TerminalConnection (kept attempt budget, visibility
  resume, and replay-suppression semantics; web now also gets the fly-affinity
  preflight)

The desktop terminal transport is deferred by design — see
plans/done/20260712-partysocket-ws-consolidation.md.

Spike result: a rejecting async url provider surfaces as an error event and
re-enters partysocket's backoff (verified in source and empirically), so the
preflight can safely live inside the url provider.

* refactor(workspace-client,web): fold ws-scheme conversion into relaySocket, drop no-op error listeners

* test(workspace-client,web): regression tests for eventBus and TerminalConnection

eventBus gets its first tests (listener routing, per-host connection sharing,
fs:watch refcounting, watch replay after reconnect, last-listener cleanup).
TerminalConnection takes its env wiring (getToken/relayUrl) via constructor
deps — WebTerminal injects the real ones — so the class is importable in bun
tests without web's env/posthog modules; tests cover dial shape, binary +
control delivery, fresh-token + replay=0 redial, and permanent stop on exit.
The fresh-token redial test fails if the original #5628 stale-URL bug is
reintroduced (mutation-verified). Web tsconfig gains bun test types.

* docs(plans): record SUPER-1469 verification outcome (tests, perf, E2E, stress)

* fix(workspace-client): default relaySocket to unbuffered sends (review)

This branch was previously deployed

1 inactive deployment
preview — ca76fc13 Deployed Jul 12, 2026 by Kitenite via Deploy API #6407
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