From 9884db282f38851a5bd32c7c2a48a4dc23d419d6 Mon Sep 17 00:00:00 2001 From: IcyHot09 Date: Tue, 5 May 2026 17:06:03 -0400 Subject: [PATCH 1/5] fix(server): throttle high-volume streams to reduce SSH window saturation Co-Authored-By: Claude Sonnet 4.6 --- apps/server/src/ws.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 592097b6d92..7940d6b55e1 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -709,6 +709,13 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => snapshot, }), liveStream, + ).pipe( + Stream.throttle({ + cost: () => 1, + units: 50, + duration: "100 millis", + strategy: "shape", + }), ); }), { "rpc.aggregate": "orchestration" }, @@ -940,6 +947,13 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ), }), ), + ).pipe( + Stream.throttle({ + cost: () => 1, + units: 10, + duration: "100 millis", + strategy: "shape", + }), ), { "rpc.aggregate": "vcs" }, ), @@ -1027,6 +1041,13 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => terminalManager.subscribe((event) => Queue.offer(queue, event)), (unsubscribe) => Effect.sync(unsubscribe), ), + ).pipe( + Stream.throttle({ + cost: () => 1, + units: 20, + duration: "50 millis", + strategy: "shape", + }), ), { "rpc.aggregate": "terminal" }, ), From e39f4e7e04a30b81abcd5930efb035e715786d32 Mon Sep 17 00:00:00 2001 From: IcyHot09 Date: Tue, 5 May 2026 17:06:03 -0400 Subject: [PATCH 2/5] fix(web): add congestion delay and visibility wake to subscription retry Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/rpc/wsTransport.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/web/src/rpc/wsTransport.ts b/apps/web/src/rpc/wsTransport.ts index 316be9db9ee..5768f1dbbc5 100644 --- a/apps/web/src/rpc/wsTransport.ts +++ b/apps/web/src/rpc/wsTransport.ts @@ -66,6 +66,8 @@ export class WsTransport { private session: TransportSession; private lastHeartbeatPongAt = 0; private readonly streamRequestStartListeners = new Set<(info: StreamRequestStartInfo) => void>(); + private _wakeReconnect: (() => void) | null = null; + private _visibilityHandler: (() => void) | null = null; constructor( url: WsRpcProtocolSocketUrlProvider, @@ -74,6 +76,12 @@ export class WsTransport { this.url = url; this.lifecycleHandlers = lifecycleHandlers; this.session = this.createSession(); + if (typeof document !== 'undefined') { + this._visibilityHandler = () => { + if (document.visibilityState === 'visible') this._wakeReconnect?.(); + }; + document.addEventListener('visibilitychange', this._visibilityHandler); + } } async request( @@ -187,7 +195,16 @@ export class WsTransport { }); } this.hasReportedTransportDisconnect = true; - await sleep(retryDelayMs); + const isLikelyCongestion = + formattedError.includes('heartbeat timed out') || formattedError.includes('ping timeout'); + const effectiveDelay = isLikelyCongestion ? Math.max(retryDelayMs, 8_000) : retryDelayMs; + await Promise.race([ + sleep(effectiveDelay), + new Promise((resolve) => { + this._wakeReconnect = resolve; + }), + ]); + this._wakeReconnect = null; } } })(); @@ -227,6 +244,12 @@ export class WsTransport { if (this.disposed) { return; } + if (this._visibilityHandler) { + document.removeEventListener('visibilitychange', this._visibilityHandler); + this._visibilityHandler = null; + } + this._wakeReconnect?.(); + this._wakeReconnect = null; this.disposed = true; await this.closeSession(this.session); } From d9e07bd3314c156ca3504fdea77e8d5b8e2b3340 Mon Sep 17 00:00:00 2001 From: IcyHot09 Date: Tue, 5 May 2026 17:06:03 -0400 Subject: [PATCH 3/5] fix(web): remove reconnect limit and add visibility-aware retry logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the 7-retry cap (Schedule.recurs → Schedule.forever) so the WebSocket protocol never enters the "exhausted" dead state. Add a visibility guard in onPingTimeout to suppress false-positive heartbeat timeouts when the browser tab is backgrounded. Remove the "exhausted" phase from WsReconnectPhase and all related UI logic. Co-Authored-By: Claude Sonnet 4.6 --- .../WebSocketConnectionSurface.logic.test.ts | 8 +- .../components/WebSocketConnectionSurface.tsx | 76 ++++++------------- apps/web/src/rpc/protocol.ts | 6 +- apps/web/src/rpc/wsConnectionState.test.ts | 11 ++- apps/web/src/rpc/wsConnectionState.ts | 19 ++--- apps/web/src/rpc/wsTransport.ts | 11 +-- 6 files changed, 47 insertions(+), 84 deletions(-) diff --git a/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts b/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts index 9a7e484dc20..348f19df99a 100644 --- a/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts +++ b/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts @@ -18,7 +18,7 @@ function makeStatus(overrides: Partial = {}): WsConnectionSt online: true, phase: "idle", reconnectAttemptCount: 0, - reconnectMaxAttempts: 8, + reconnectMaxAttempts: null, reconnectPhase: "idle", socketUrl: null, ...overrides, @@ -67,15 +67,15 @@ describe("WebSocketConnectionSurface.logic", () => { ).toBe(false); }); - it("forces reconnect on focus for exhausted reconnect loops", () => { + it("forces reconnect on focus for high reconnect attempt counts in waiting phase", () => { expect( shouldAutoReconnect( makeStatus({ hasConnected: true, online: true, phase: "disconnected", - reconnectAttemptCount: 8, - reconnectPhase: "exhausted", + reconnectAttemptCount: 20, + reconnectPhase: "waiting", }), "focus", ), diff --git a/apps/web/src/components/WebSocketConnectionSurface.tsx b/apps/web/src/components/WebSocketConnectionSurface.tsx index b54bd865c8b..725b9ba66da 100644 --- a/apps/web/src/components/WebSocketConnectionSurface.tsx +++ b/apps/web/src/components/WebSocketConnectionSurface.tsx @@ -8,7 +8,6 @@ import { type WsConnectionStatus, type WsConnectionUiState, useWsConnectionStatus, - WS_RECONNECT_MAX_ATTEMPTS, } from "../rpc/wsConnectionState"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { getPrimaryEnvironmentConnection } from "../environments/runtime"; @@ -42,15 +41,8 @@ function describeOfflineToast(): string { } function formatReconnectAttemptLabel(status: WsConnectionStatus): string { - const reconnectAttempt = Math.max( - 1, - Math.min(status.reconnectAttemptCount, WS_RECONNECT_MAX_ATTEMPTS), - ); - return `Attempt ${reconnectAttempt}/${status.reconnectMaxAttempts}`; -} - -function describeExhaustedToast(): string { - return "Retries exhausted trying to reconnect"; + const reconnectAttempt = Math.max(1, status.reconnectAttemptCount); + return `Attempt ${reconnectAttempt}`; } function getConnectionDisplayName(status: WsConnectionStatus): string { @@ -118,19 +110,10 @@ export function shouldAutoReconnect( const uiState = getWsConnectionUiState(status); if (trigger === "online") { - return ( - uiState === "offline" || - uiState === "reconnecting" || - uiState === "error" || - status.reconnectPhase === "exhausted" - ); + return uiState === "offline" || uiState === "reconnecting" || uiState === "error"; } - return ( - status.online && - status.hasConnected && - (uiState === "reconnecting" || status.reconnectPhase === "exhausted") - ); + return status.online && status.hasConnected && uiState === "reconnecting"; } export function shouldRestartStalledReconnect( @@ -273,17 +256,16 @@ export function WebSocketConnectionCoordinator() { const previousDisconnectedAt = previousDisconnectedAtRef.current; const shouldShowReconnectToast = status.hasConnected && uiState === "reconnecting"; const shouldShowOfflineToast = uiState === "offline" && status.disconnectedAt !== null; - const shouldShowExhaustedToast = status.hasConnected && status.reconnectPhase === "exhausted"; if ( toastResetTimerRef.current !== null && - (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) + (shouldShowReconnectToast || shouldShowOfflineToast) ) { window.clearTimeout(toastResetTimerRef.current); toastResetTimerRef.current = null; } - if (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) { + if (shouldShowReconnectToast || shouldShowOfflineToast) { const toastPayload = shouldShowOfflineToast ? stackedThreadToast({ data: { @@ -294,36 +276,22 @@ export function WebSocketConnectionCoordinator() { title: "Offline", type: "warning", }) - : shouldShowExhaustedToast - ? stackedThreadToast({ - actionProps: { - children: "Retry", - onClick: triggerManualReconnect, - }, - data: { - hideCopyButton: true, - }, - description: describeExhaustedToast(), - timeout: 0, - title: buildReconnectTitle(status), - type: "error", - }) - : stackedThreadToast({ - actionProps: { - children: "Retry now", - onClick: triggerManualReconnect, - }, - data: { - hideCopyButton: true, - }, - description: - status.nextRetryAt === null - ? `Reconnecting... ${formatReconnectAttemptLabel(status)}` - : `Reconnecting in ${formatRetryCountdown(status.nextRetryAt, nowMs)}... ${formatReconnectAttemptLabel(status)}`, - timeout: 0, - title: buildReconnectTitle(status), - type: "loading", - }); + : stackedThreadToast({ + actionProps: { + children: "Retry now", + onClick: triggerManualReconnect, + }, + data: { + hideCopyButton: true, + }, + description: + status.nextRetryAt === null + ? `Reconnecting... ${formatReconnectAttemptLabel(status)}` + : `Reconnecting in ${formatRetryCountdown(status.nextRetryAt, nowMs)}... ${formatReconnectAttemptLabel(status)}`, + timeout: 0, + title: buildReconnectTitle(status), + type: "loading", + }); if (toastIdRef.current) { toastManager.update(toastIdRef.current, toastPayload); diff --git a/apps/web/src/rpc/protocol.ts b/apps/web/src/rpc/protocol.ts index 3c52764a0aa..c083e000ca4 100644 --- a/apps/web/src/rpc/protocol.ts +++ b/apps/web/src/rpc/protocol.ts @@ -15,7 +15,6 @@ import { recordWsConnectionErrored, recordWsConnectionOpened, type WsConnectionMetadata, - WS_RECONNECT_MAX_RETRIES, } from "./wsConnectionState"; export interface WsProtocolCloseContext { @@ -213,7 +212,7 @@ export function createWsRpcProtocolLayer( const socketLayer = Socket.layerWebSocket(resolvedUrl).pipe( Layer.provide(trackingWebSocketConstructorLayer), ); - const retryPolicy = Schedule.addDelay(Schedule.recurs(WS_RECONNECT_MAX_RETRIES), (retryCount) => + const retryPolicy = Schedule.addDelay(Schedule.forever, (retryCount) => Effect.succeed(Duration.millis(getWsReconnectDelayMsForRetry(retryCount) ?? 0)), ); const protocolLayer = Layer.effect( @@ -304,6 +303,9 @@ export function createWsRpcProtocolLayer( }), onPingTimeout: Effect.sync(() => { if (lifecycle.isActive()) { + if (typeof document !== "undefined" && document.visibilityState === "hidden") { + return; + } clearAllTrackedRpcRequests(); recordWsConnectionErrored( "WebSocket heartbeat timed out.", diff --git a/apps/web/src/rpc/wsConnectionState.test.ts b/apps/web/src/rpc/wsConnectionState.test.ts index b836789609e..9f39f79b997 100644 --- a/apps/web/src/rpc/wsConnectionState.test.ts +++ b/apps/web/src/rpc/wsConnectionState.test.ts @@ -10,7 +10,6 @@ import { recordWsConnectionOpened, resetWsConnectionStateForTests, setBrowserOnlineStatus, - WS_RECONNECT_MAX_ATTEMPTS, } from "./wsConnectionState"; describe("wsConnectionState", () => { @@ -92,16 +91,16 @@ describe("wsConnectionState", () => { }); }); - it("marks the reconnect cycle as exhausted after the final attempt fails", () => { - for (let attempt = 0; attempt < WS_RECONNECT_MAX_ATTEMPTS; attempt += 1) { + it("keeps retrying indefinitely and stays in the waiting phase after many failed attempts", () => { + const manyAttempts = 20; + for (let attempt = 0; attempt < manyAttempts; attempt += 1) { recordWsConnectionAttempt("ws://localhost:3020/ws"); recordWsConnectionErrored("Unable to connect to the T3 server WebSocket."); } expect(getWsConnectionStatus()).toMatchObject({ - nextRetryAt: null, - reconnectAttemptCount: WS_RECONNECT_MAX_ATTEMPTS, - reconnectPhase: "exhausted", + reconnectAttemptCount: manyAttempts, + reconnectPhase: "waiting", }); }); }); diff --git a/apps/web/src/rpc/wsConnectionState.ts b/apps/web/src/rpc/wsConnectionState.ts index bc8a5a78607..6b09bd69969 100644 --- a/apps/web/src/rpc/wsConnectionState.ts +++ b/apps/web/src/rpc/wsConnectionState.ts @@ -4,13 +4,11 @@ import { Atom } from "effect/unstable/reactivity"; import { appAtomRegistry } from "./atomRegistry"; export type WsConnectionUiState = "connected" | "connecting" | "error" | "offline" | "reconnecting"; -export type WsReconnectPhase = "attempting" | "exhausted" | "idle" | "waiting"; +export type WsReconnectPhase = "attempting" | "idle" | "waiting"; export const WS_RECONNECT_INITIAL_DELAY_MS = 1_000; export const WS_RECONNECT_BACKOFF_FACTOR = 2; export const WS_RECONNECT_MAX_DELAY_MS = 64_000; -export const WS_RECONNECT_MAX_RETRIES = 7; -export const WS_RECONNECT_MAX_ATTEMPTS = WS_RECONNECT_MAX_RETRIES + 1; export interface WsConnectionStatus { readonly attemptCount: number; @@ -26,7 +24,7 @@ export interface WsConnectionStatus { readonly online: boolean; readonly phase: "idle" | "connecting" | "connected" | "disconnected"; readonly reconnectAttemptCount: number; - readonly reconnectMaxAttempts: number; + readonly reconnectMaxAttempts: null; readonly reconnectPhase: WsReconnectPhase; readonly socketUrl: string | null; } @@ -45,7 +43,7 @@ const INITIAL_WS_CONNECTION_STATUS = Object.freeze({ online: typeof navigator === "undefined" ? true : navigator.onLine !== false, phase: "idle", reconnectAttemptCount: 0, - reconnectMaxAttempts: WS_RECONNECT_MAX_ATTEMPTS, + reconnectMaxAttempts: null, reconnectPhase: "idle", socketUrl: null, }); @@ -201,7 +199,7 @@ export function useWsConnectionStatus(): WsConnectionStatus { } export function getWsReconnectDelayMsForRetry(retryIndex: number): number | null { - if (!Number.isInteger(retryIndex) || retryIndex < 0 || retryIndex >= WS_RECONNECT_MAX_RETRIES) { + if (!Number.isInteger(retryIndex) || retryIndex < 0) { return null; } @@ -220,7 +218,7 @@ function applyDisconnectState( ): WsConnectionStatus { const disconnectedAt = current.disconnectedAt ?? isoNow(); const nextRetryDelayMs = - current.nextRetryAt !== null || current.reconnectPhase === "exhausted" + current.nextRetryAt !== null ? null : getWsReconnectDelayMsForRetry(Math.max(0, current.reconnectAttemptCount - 1)); @@ -234,11 +232,6 @@ function applyDisconnectState( ? current.nextRetryAt : new Date(Date.now() + nextRetryDelayMs).toISOString(), phase: "disconnected", - reconnectPhase: - current.reconnectPhase === "waiting" || current.reconnectPhase === "exhausted" - ? current.reconnectPhase - : nextRetryDelayMs === null - ? "exhausted" - : "waiting", + reconnectPhase: current.reconnectPhase === "waiting" ? current.reconnectPhase : "waiting", }; } diff --git a/apps/web/src/rpc/wsTransport.ts b/apps/web/src/rpc/wsTransport.ts index 5768f1dbbc5..55198ca0ba7 100644 --- a/apps/web/src/rpc/wsTransport.ts +++ b/apps/web/src/rpc/wsTransport.ts @@ -76,11 +76,11 @@ export class WsTransport { this.url = url; this.lifecycleHandlers = lifecycleHandlers; this.session = this.createSession(); - if (typeof document !== 'undefined') { + if (typeof document !== "undefined") { this._visibilityHandler = () => { - if (document.visibilityState === 'visible') this._wakeReconnect?.(); + if (document.visibilityState === "visible") this._wakeReconnect?.(); }; - document.addEventListener('visibilitychange', this._visibilityHandler); + document.addEventListener("visibilitychange", this._visibilityHandler); } } @@ -196,7 +196,8 @@ export class WsTransport { } this.hasReportedTransportDisconnect = true; const isLikelyCongestion = - formattedError.includes('heartbeat timed out') || formattedError.includes('ping timeout'); + formattedError.includes("heartbeat timed out") || + formattedError.includes("ping timeout"); const effectiveDelay = isLikelyCongestion ? Math.max(retryDelayMs, 8_000) : retryDelayMs; await Promise.race([ sleep(effectiveDelay), @@ -245,7 +246,7 @@ export class WsTransport { return; } if (this._visibilityHandler) { - document.removeEventListener('visibilitychange', this._visibilityHandler); + document.removeEventListener("visibilitychange", this._visibilityHandler); this._visibilityHandler = null; } this._wakeReconnect?.(); From c8f0143a9e58f9ec62b5f3fa502932ca34316120 Mon Sep 17 00:00:00 2001 From: IcyHot09 Date: Tue, 5 May 2026 17:22:50 -0400 Subject: [PATCH 4/5] fix(server): enable WebSocket perMessageDeflate compression via platform-bun patch Reduces JSON payload size 60-80% to prevent SSH window saturation through devtunnel. Adds @effect/platform-bun patch that sets perMessageDeflate: true in the Bun.serve() websocket handler. Co-Authored-By: Claude Sonnet 4.6 --- bun.lock | 9 +++++---- package.json | 3 ++- patches/@effect+platform-bun@4.0.0-beta.59.patch | 11 +++++++++++ 3 files changed, 18 insertions(+), 5 deletions(-) create mode 100644 patches/@effect+platform-bun@4.0.0-beta.59.patch diff --git a/bun.lock b/bun.lock index a87ac77094b..835cf3cc986 100644 --- a/bun.lock +++ b/bun.lock @@ -16,7 +16,7 @@ }, "apps/desktop": { "name": "@t3tools/desktop", - "version": "0.0.21", + "version": "0.0.22", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -49,7 +49,7 @@ }, "apps/server": { "name": "t3", - "version": "0.0.21", + "version": "0.0.22", "bin": { "t3": "./dist/bin.mjs", }, @@ -82,7 +82,7 @@ }, "apps/web": { "name": "@t3tools/web", - "version": "0.0.21", + "version": "0.0.22", "dependencies": { "@base-ui/react": "^1.2.0", "@dnd-kit/core": "^6.3.1", @@ -148,7 +148,7 @@ }, "packages/contracts": { "name": "@t3tools/contracts", - "version": "0.0.21", + "version": "0.0.22", "dependencies": { "effect": "catalog:", }, @@ -261,6 +261,7 @@ ], "patchedDependencies": { "effect@4.0.0-beta.59": "patches/effect@4.0.0-beta.59.patch", + "@effect/platform-bun@4.0.0-beta.59": "patches/@effect+platform-bun@4.0.0-beta.59.patch", }, "overrides": { "@effect/atom-react": "catalog:", diff --git a/package.json b/package.json index 82be8bdf927..29c8a7d2115 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,8 @@ ] }, "patchedDependencies": { - "effect@4.0.0-beta.59": "patches/effect@4.0.0-beta.59.patch" + "effect@4.0.0-beta.59": "patches/effect@4.0.0-beta.59.patch", + "@effect/platform-bun@4.0.0-beta.59": "patches/@effect+platform-bun@4.0.0-beta.59.patch" }, "trustedDependencies": [ "node-pty", diff --git a/patches/@effect+platform-bun@4.0.0-beta.59.patch b/patches/@effect+platform-bun@4.0.0-beta.59.patch new file mode 100644 index 00000000000..8cf840af2e9 --- /dev/null +++ b/patches/@effect+platform-bun@4.0.0-beta.59.patch @@ -0,0 +1,11 @@ +diff --git a/dist/BunHttpServer.js b/dist/BunHttpServer.js +--- a/dist/BunHttpServer.js ++++ b/dist/BunHttpServer.js +@@ -43,6 +43,7 @@ + ...options, + fetch: handlerStack[0], + websocket: { ++ perMessageDeflate: true, + open(ws) { + Deferred.doneUnsafe(ws.data.deferred, Exit.succeed(ws)); + }, From b31889376c4cb3607c2955bfc2386a84401d8330 Mon Sep 17 00:00:00 2001 From: IcyHot09 Date: Wed, 6 May 2026 02:30:04 -0400 Subject: [PATCH 5/5] fix(web): use Set for wake callbacks so all subscriptions wake on visibility Replaces the single _wakeReconnect slot with a Set so every subscription sleeping through a congestion delay gets woken when the tab becomes visible, not just the last one to register. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/rpc/wsTransport.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/web/src/rpc/wsTransport.ts b/apps/web/src/rpc/wsTransport.ts index 55198ca0ba7..e52b40ed803 100644 --- a/apps/web/src/rpc/wsTransport.ts +++ b/apps/web/src/rpc/wsTransport.ts @@ -66,7 +66,7 @@ export class WsTransport { private session: TransportSession; private lastHeartbeatPongAt = 0; private readonly streamRequestStartListeners = new Set<(info: StreamRequestStartInfo) => void>(); - private _wakeReconnect: (() => void) | null = null; + private readonly _wakeReconnect = new Set<() => void>(); private _visibilityHandler: (() => void) | null = null; constructor( @@ -78,7 +78,9 @@ export class WsTransport { this.session = this.createSession(); if (typeof document !== "undefined") { this._visibilityHandler = () => { - if (document.visibilityState === "visible") this._wakeReconnect?.(); + if (document.visibilityState === "visible") { + for (const wake of this._wakeReconnect) wake(); + } }; document.addEventListener("visibilitychange", this._visibilityHandler); } @@ -202,10 +204,10 @@ export class WsTransport { await Promise.race([ sleep(effectiveDelay), new Promise((resolve) => { - this._wakeReconnect = resolve; + this._wakeReconnect.add(resolve); + void sleep(effectiveDelay).then(() => this._wakeReconnect.delete(resolve)); }), ]); - this._wakeReconnect = null; } } })(); @@ -249,8 +251,8 @@ export class WsTransport { document.removeEventListener("visibilitychange", this._visibilityHandler); this._visibilityHandler = null; } - this._wakeReconnect?.(); - this._wakeReconnect = null; + for (const wake of this._wakeReconnect) wake(); + this._wakeReconnect.clear(); this.disposed = true; await this.closeSession(this.session); }