Repository navigation
fix(desktop): refresh relay JWT on WS reconnect and stop retrying definitive 403s - #5628
Conversation
…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
📝 WalkthroughWalkthroughJWT 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. ChangesRelay authentication and reconnect flow
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)
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
|
Ready to review this PR? Stage has broken it down into 6 individual chapters for you: Chapters generated by Stage for commit ca76fc1 on Jul 12, 2026 6:09pm UTC. |
Greptile SummaryThis PR improves relay reconnect behavior for desktop clients. The main changes are:
Confidence Score: 5/5This looks safe to merge.
|
| 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
🧹 Preview Cleanup CompleteThe following preview resources have been cleaned up:
Thank you for your contribution! 🎉 |
There was a problem hiding this comment.
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
📒 Files selected for processing (5)
apps/desktop/src/renderer/lib/auth-client.tsapps/desktop/src/renderer/lib/jwt-expiry.test.tsapps/desktop/src/renderer/lib/jwt-expiry.tsapps/desktop/src/renderer/lib/terminal/terminal-ws-transport.tspackages/workspace-client/src/lib/eventBus.ts
There was a problem hiding this comment.
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
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
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.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
plans/20260712-partysocket-ws-consolidation.md (1)
26-32: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winDocument the complete preflight status matrix.
primeRelayAffinitycan returnnull, 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
📒 Files selected for processing (1)
plans/20260712-partysocket-ws-consolidation.md
…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.
…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)
Summary
Why / Context
PostHog
terminal_connect_failedshows ~25 users/day failing terminal connects withpreflight_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:packages/auth/src/server.tsjwt plugin config; the relay even special-casesERR_JWT_EXPIREDas an expected, common failure).auth-client.ts) only updated when some API response happened to carry aset-auth-jwtheader.transport.currentUrlverbatim — 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
/eventsupgrades failing 403 (per-userv2UsersHostsaccess vs the org-wide host list the desktop connects to), each denied client retrying every few seconds indefinitely.How It Works
auth-client.ts:setJwtrecords the token'sexp(lib/jwt-expiry.ts). NewensureFreshJwt()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 — awaitsensureFreshJwt()and re-signs the URL'stokenparam before the_whoownspreflight and the WebSocket open. Local PSK terminals are untouched. A preflight 403 is now fatal: log the diagnosis, emitterminal_connect_failed, stop the loop (a manual reconnect still re-entersconnect()).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
_whoowns+terminal 401 retries from a stale client after the fixTesting
bun run typecheck(desktop + workspace-client via turbo)bun run lintbun test apps/desktop/src/renderer/lib packages/workspace-client— newjwt-expiry.test.tspasses (and fails when the impl is mutated); the 10 pre-existing font-test failures fail identically on a clean treehttps://api.superset.sh/api/auth/tokenexists in prod (401 unauthenticated) and that better-auth 1.6.13 returns{ token }from it, matching howensureFreshJwtreads the responseKnown Limitations
Correction: web'sapps/webhas its ownTerminalConnection.tswith the same stale-token flawgetAuthToken()already TTL-caches and refetches per connect attempt, so web is not affected. Consolidating all three hand-rolled reconnect loops ontopartysocketis tracked in SUPER-1469.v2Hostsis 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
/api/auth/tokengets 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).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
exp, use 60s leeway, and addensureFreshJwt()to mint from better-auth/api/auth/token(deduped, generation-safe).getJwt()starts a background refresh near expiry and logs failures.@superset/workspace-client): on preflight 403, switch to a fixed 5-minute retry instead of exponential 1–30s.apps/relay):_whoownsnow runs cached access checks when this edge owns the tunnel and returns 403 on denial (500 on check error), making denials visible to clients.jwt-expiryutility and unit tests.Refactors
partysocket(SUPER-1469).Written for commit ca76fc1. Summary will update on new commits.
Summary by CodeRabbit