authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-04 17:12:49-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-04 17:12:49-07:00
logf2cd53f232198f597bf492504cab5d9151a5abca
tree9e9c1d477d6620aaebff6868ad7db92adc180179
parent04a1ae77e6b120c6d9b6a51768a1e411afe970be
signature Signed by PGP key B5690EEEBB952194

Add WebSocket disconnect recovery and slow RPC toast UX (#1730)

Co-authored-by: Hwanseo Choi <hwanseoc@nvidia.com> Co-authored-by: codex <codex@users.noreply.github.com>

20 files changed, 1836 insertions(+), 103 deletions(-)

apps/web/src/components/ChatView.tsx+9-7
...@@ -197,6 +197,7 @@ import {...@@ -197,6 +197,7 @@ import {
197 useServerConfig,197 useServerConfig,
198 useServerKeybindings,198 useServerKeybindings,
199} from "~/rpc/serverState";199} from "~/rpc/serverState";
200import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
200201
201const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000;202const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000;
202const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;203const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
...@@ -1599,17 +1600,18 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1599,17 +1600,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
1599 const setThreadError = useCallback(1600 const setThreadError = useCallback(
1600 (targetThreadId: ThreadId | null, error: string | null) => {1601 (targetThreadId: ThreadId | null, error: string | null) => {
1601 if (!targetThreadId) return;1602 if (!targetThreadId) return;
1603 const nextError = sanitizeThreadErrorMessage(error);
1602 if (useStore.getState().threads.some((thread) => thread.id === targetThreadId)) {1604 if (useStore.getState().threads.some((thread) => thread.id === targetThreadId)) {
1603 setStoreThreadError(targetThreadId, error);1605 setStoreThreadError(targetThreadId, nextError);
1604 return;1606 return;
1605 }1607 }
1606 setLocalDraftErrorsByThreadId((existing) => {1608 setLocalDraftErrorsByThreadId((existing) => {
1607 if ((existing[targetThreadId] ?? null) === error) {1609 if ((existing[targetThreadId] ?? null) === nextError) {
1608 return existing;1610 return existing;
1609 }1611 }
1610 return {1612 return {
1611 ...existing,1613 ...existing,
1612 [targetThreadId]: error,1614 [targetThreadId]: nextError,
1613 };1615 };
1614 });1616 });
1615 },1617 },
...@@ -3153,14 +3155,14 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3153,14 +3155,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
3153 createdAt: new Date().toISOString(),3155 createdAt: new Date().toISOString(),
3154 })3156 })
3155 .catch((err: unknown) => {3157 .catch((err: unknown) => {
3156 setStoreThreadError(3158 setThreadError(
3157 activeThreadId,3159 activeThreadId,
3158 err instanceof Error ? err.message : "Failed to submit approval decision.",3160 err instanceof Error ? err.message : "Failed to submit approval decision.",
3159 );3161 );
3160 });3162 });
3161 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));3163 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
3162 },3164 },
3163 [activeThreadId, setStoreThreadError],3165 [activeThreadId, setThreadError],
3164 );3166 );
31653167
3166 const onRespondToUserInput = useCallback(3168 const onRespondToUserInput = useCallback(
...@@ -3181,14 +3183,14 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3181,14 +3183,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
3181 createdAt: new Date().toISOString(),3183 createdAt: new Date().toISOString(),
3182 })3184 })
3183 .catch((err: unknown) => {3185 .catch((err: unknown) => {
3184 setStoreThreadError(3186 setThreadError(
3185 activeThreadId,3187 activeThreadId,
3186 err instanceof Error ? err.message : "Failed to submit user input.",3188 err instanceof Error ? err.message : "Failed to submit user input.",
3187 );3189 );
3188 });3190 });
3189 setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));3191 setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));
3190 },3192 },
3191 [activeThreadId, setStoreThreadError],3193 [activeThreadId, setThreadError],
3192 );3194 );
31933195
3194 const setActivePendingUserInputQuestionIndex = useCallback(3196 const setActivePendingUserInputQuestionIndex = useCallback(
apps/web/src/components/WebSocketConnectionSurface.logic.test.ts created+83
...@@ -0,0 +1,83 @@
1import { describe, expect, it } from "vitest";
2
3import type { WsConnectionStatus } from "../rpc/wsConnectionState";
4import { shouldAutoReconnect } from "./WebSocketConnectionSurface";
5
6function makeStatus(overrides: Partial<WsConnectionStatus> = {}): WsConnectionStatus {
7 return {
8 attemptCount: 0,
9 closeCode: null,
10 closeReason: null,
11 connectedAt: null,
12 disconnectedAt: null,
13 hasConnected: false,
14 lastError: null,
15 lastErrorAt: null,
16 nextRetryAt: null,
17 online: true,
18 phase: "idle",
19 reconnectAttemptCount: 0,
20 reconnectMaxAttempts: 8,
21 reconnectPhase: "idle",
22 socketUrl: null,
23 ...overrides,
24 };
25}
26
27describe("WebSocketConnectionSurface.logic", () => {
28 it("forces reconnect on online when the app was offline", () => {
29 expect(
30 shouldAutoReconnect(
31 makeStatus({
32 disconnectedAt: "2026-04-03T20:00:00.000Z",
33 online: false,
34 phase: "disconnected",
35 }),
36 "online",
37 ),
38 ).toBe(true);
39 });
40
41 it("forces reconnect on focus only for previously connected disconnected states", () => {
42 expect(
43 shouldAutoReconnect(
44 makeStatus({
45 hasConnected: true,
46 online: true,
47 phase: "disconnected",
48 reconnectAttemptCount: 3,
49 reconnectPhase: "waiting",
50 }),
51 "focus",
52 ),
53 ).toBe(true);
54
55 expect(
56 shouldAutoReconnect(
57 makeStatus({
58 hasConnected: false,
59 online: true,
60 phase: "disconnected",
61 reconnectAttemptCount: 1,
62 reconnectPhase: "waiting",
63 }),
64 "focus",
65 ),
66 ).toBe(false);
67 });
68
69 it("forces reconnect on focus for exhausted reconnect loops", () => {
70 expect(
71 shouldAutoReconnect(
72 makeStatus({
73 hasConnected: true,
74 online: true,
75 phase: "disconnected",
76 reconnectAttemptCount: 8,
77 reconnectPhase: "exhausted",
78 }),
79 "focus",
80 ),
81 ).toBe(true);
82 });
83});
apps/web/src/components/WebSocketConnectionSurface.tsx created+544
...@@ -0,0 +1,544 @@
1import { AlertTriangle, CloudOff, LoaderCircle, RotateCw } from "lucide-react";
2import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react";
3
4import { APP_DISPLAY_NAME } from "../branding";
5import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState";
6import { useServerConfig } from "../rpc/serverState";
7import {
8 exhaustWsReconnectIfStillWaiting,
9 getWsConnectionStatus,
10 getWsConnectionUiState,
11 setBrowserOnlineStatus,
12 type WsConnectionStatus,
13 type WsConnectionUiState,
14 useWsConnectionStatus,
15 WS_RECONNECT_MAX_ATTEMPTS,
16} from "../rpc/wsConnectionState";
17import { Button } from "./ui/button";
18import { toastManager } from "./ui/toast";
19import { getWsRpcClient } from "~/wsRpcClient";
20
21const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000;
22type WsAutoReconnectTrigger = "focus" | "online";
23
24const connectionTimeFormatter = new Intl.DateTimeFormat(undefined, {
25 day: "numeric",
26 hour: "numeric",
27 minute: "2-digit",
28 month: "short",
29 second: "2-digit",
30});
31
32function formatConnectionMoment(isoDate: string | null): string | null {
33 if (!isoDate) {
34 return null;
35 }
36
37 return connectionTimeFormatter.format(new Date(isoDate));
38}
39
40function formatRetryCountdown(nextRetryAt: string, nowMs: number): string {
41 const remainingMs = Math.max(0, new Date(nextRetryAt).getTime() - nowMs);
42 return `${Math.max(1, Math.ceil(remainingMs / 1000))}s`;
43}
44
45function describeOfflineToast(): string {
46 return "WebSocket disconnected. Waiting for network.";
47}
48
49function formatReconnectAttemptLabel(status: WsConnectionStatus): string {
50 const reconnectAttempt = Math.max(
51 1,
52 Math.min(status.reconnectAttemptCount, WS_RECONNECT_MAX_ATTEMPTS),
53 );
54 return `Attempt ${reconnectAttempt}/${status.reconnectMaxAttempts}`;
55}
56
57function describeExhaustedToast(): string {
58 return "Retries exhausted trying to reconnect";
59}
60
61function buildReconnectTitle(status: WsConnectionStatus): string {
62 if (status.nextRetryAt === null) {
63 return "Disconnected from T3 Server";
64 }
65
66 return "Disconnected from T3 Server";
67}
68
69function describeRecoveredToast(
70 previousDisconnectedAt: string | null,
71 connectedAt: string | null,
72): string {
73 const reconnectedAtLabel = formatConnectionMoment(connectedAt);
74 const disconnectedAtLabel = formatConnectionMoment(previousDisconnectedAt);
75
76 if (disconnectedAtLabel && reconnectedAtLabel) {
77 return `Disconnected at ${disconnectedAtLabel} and reconnected at ${reconnectedAtLabel}.`;
78 }
79
80 if (reconnectedAtLabel) {
81 return `Connection restored at ${reconnectedAtLabel}.`;
82 }
83
84 return "Connection restored.";
85}
86
87function describeSlowRpcAckToast(requests: ReadonlyArray<SlowRpcAckRequest>): ReactNode {
88 const count = requests.length;
89 const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000);
90
91 return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`;
92}
93
94export function shouldAutoReconnect(
95 status: WsConnectionStatus,
96 trigger: WsAutoReconnectTrigger,
97): boolean {
98 const uiState = getWsConnectionUiState(status);
99
100 if (trigger === "online") {
101 return (
102 uiState === "offline" ||
103 uiState === "reconnecting" ||
104 uiState === "error" ||
105 status.reconnectPhase === "exhausted"
106 );
107 }
108
109 return (
110 status.online &&
111 status.hasConnected &&
112 (uiState === "reconnecting" || status.reconnectPhase === "exhausted")
113 );
114}
115
116function buildBlockingCopy(
117 uiState: WsConnectionUiState,
118 status: WsConnectionStatus,
119): {
120 readonly description: string;
121 readonly eyebrow: string;
122 readonly title: string;
123} {
124 if (uiState === "connecting") {
125 return {
126 description: `Opening the WebSocket connection to the ${APP_DISPLAY_NAME} server and waiting for the initial config snapshot.`,
127 eyebrow: "Starting Session",
128 title: `Connecting to ${APP_DISPLAY_NAME}`,
129 };
130 }
131
132 if (uiState === "offline") {
133 return {
134 description:
135 "Your browser is offline, so the web client cannot reach the T3 server. Reconnect to the network and the app will retry automatically.",
136 eyebrow: "Offline",
137 title: "WebSocket connection unavailable",
138 };
139 }
140
141 if (status.lastError?.trim()) {
142 return {
143 description: `${status.lastError} Verify that the T3 server is running and reachable, then reload the app if needed.`,
144 eyebrow: "Connection Error",
145 title: "Cannot reach the T3 server",
146 };
147 }
148
149 return {
150 description:
151 "The web client could not complete its initial WebSocket connection to the T3 server. It will keep retrying in the background.",
152 eyebrow: "Connection Error",
153 title: "Cannot reach the T3 server",
154 };
155}
156
157function buildConnectionDetails(status: WsConnectionStatus, uiState: WsConnectionUiState): string {
158 const details = [
159 `state: ${uiState}`,
160 `online: ${status.online ? "yes" : "no"}`,
161 `attempts: ${status.attemptCount}`,
162 ];
163
164 if (status.socketUrl) {
165 details.push(`socket: ${status.socketUrl}`);
166 }
167 if (status.connectedAt) {
168 details.push(`connectedAt: ${status.connectedAt}`);
169 }
170 if (status.disconnectedAt) {
171 details.push(`disconnectedAt: ${status.disconnectedAt}`);
172 }
173 if (status.lastErrorAt) {
174 details.push(`lastErrorAt: ${status.lastErrorAt}`);
175 }
176 if (status.lastError) {
177 details.push(`lastError: ${status.lastError}`);
178 }
179 if (status.closeCode !== null) {
180 details.push(`closeCode: ${status.closeCode}`);
181 }
182 if (status.closeReason) {
183 details.push(`closeReason: ${status.closeReason}`);
184 }
185
186 return details.join("\n");
187}
188
189function WebSocketBlockingState({
190 status,
191 uiState,
192}: {
193 readonly status: WsConnectionStatus;
194 readonly uiState: WsConnectionUiState;
195}) {
196 const copy = buildBlockingCopy(uiState, status);
197 const disconnectedAt = formatConnectionMoment(status.disconnectedAt ?? status.lastErrorAt);
198 const Icon =
199 uiState === "connecting" ? LoaderCircle : uiState === "offline" ? CloudOff : AlertTriangle;
200
201 return (
202 <div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-10 text-foreground sm:px-6">
203 <div className="pointer-events-none absolute inset-0 opacity-90">
204 <div className="absolute inset-x-0 top-0 h-56 bg-[radial-gradient(48rem_18rem_at_top,color-mix(in_srgb,var(--color-amber-500)_16%,transparent),transparent)]" />
205 <div className="absolute inset-0 bg-[linear-gradient(145deg,color-mix(in_srgb,var(--background)_92%,var(--color-black))_0%,var(--background)_56%)]" />
206 </div>
207
208 <section className="relative w-full max-w-xl rounded-[1.75rem] border border-border/80 bg-card/92 p-6 shadow-2xl shadow-black/20 backdrop-blur-md sm:p-8">
209 <div className="flex items-start justify-between gap-4">
210 <div>
211 <p className="text-[11px] font-semibold tracking-[0.18em] text-muted-foreground uppercase">
212 {copy.eyebrow}
213 </p>
214 <h1 className="mt-3 text-2xl font-semibold tracking-tight sm:text-3xl">{copy.title}</h1>
215 </div>
216 <div className="rounded-2xl border border-border/70 bg-background/80 p-3 text-foreground shadow-sm">
217 <Icon className={uiState === "connecting" ? "size-5 animate-spin" : "size-5"} />
218 </div>
219 </div>
220
221 <p className="mt-3 text-sm leading-relaxed text-muted-foreground">{copy.description}</p>
222
223 <div className="mt-5 grid gap-3 rounded-2xl border border-border/70 bg-background/60 p-4 text-sm sm:grid-cols-2">
224 <div>
225 <p className="text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase">
226 Connection
227 </p>
228 <p className="mt-1 font-medium text-foreground">
229 {uiState === "connecting"
230 ? "Opening WebSocket"
231 : uiState === "offline"
232 ? "Waiting for network"
233 : "Retrying server connection"}
234 </p>
235 </div>
236 <div>
237 <p className="text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase">
238 Latest Event
239 </p>
240 <p className="mt-1 font-medium text-foreground">{disconnectedAt ?? "Pending"}</p>
241 </div>
242 </div>
243
244 <div className="mt-5 flex flex-wrap gap-2">
245 <Button size="sm" onClick={() => window.location.reload()}>
246 <RotateCw />
247 Reload app
248 </Button>
249 </div>
250
251 <details className="group mt-5 overflow-hidden rounded-lg border border-border/70 bg-background/55">
252 <summary className="cursor-pointer list-none px-3 py-2 text-xs font-medium text-muted-foreground">
253 <span className="group-open:hidden">Show connection details</span>
254 <span className="hidden group-open:inline">Hide connection details</span>
255 </summary>
256 <pre className="max-h-56 overflow-auto border-t border-border/70 bg-background/80 px-3 py-2 text-xs text-foreground/85">
257 {buildConnectionDetails(status, uiState)}
258 </pre>
259 </details>
260 </section>
261 </div>
262 );
263}
264
265export function WebSocketConnectionCoordinator() {
266 const status = useWsConnectionStatus();
267 const [nowMs, setNowMs] = useState(() => Date.now());
268 const lastForcedReconnectAtRef = useRef(0);
269 const toastIdRef = useRef<ReturnType<typeof toastManager.add> | null>(null);
270 const toastResetTimerRef = useRef<number | null>(null);
271 const previousUiStateRef = useRef<WsConnectionUiState>(getWsConnectionUiState(status));
272 const previousDisconnectedAtRef = useRef<string | null>(status.disconnectedAt);
273
274 const runReconnect = useEffectEvent((showFailureToast: boolean) => {
275 if (toastResetTimerRef.current !== null) {
276 window.clearTimeout(toastResetTimerRef.current);
277 toastResetTimerRef.current = null;
278 }
279 lastForcedReconnectAtRef.current = Date.now();
280 void getWsRpcClient()
281 .reconnect()
282 .catch((error) => {
283 if (!showFailureToast) {
284 console.warn("Automatic WebSocket reconnect failed", { error });
285 return;
286 }
287 toastManager.add({
288 type: "error",
289 title: "Reconnect failed",
290 description: error instanceof Error ? error.message : "Unable to restart the WebSocket.",
291 data: {
292 dismissAfterVisibleMs: 8_000,
293 hideCopyButton: true,
294 },
295 });
296 });
297 });
298 const syncBrowserOnlineStatus = useEffectEvent(() => {
299 setBrowserOnlineStatus(navigator.onLine !== false);
300 });
301 const triggerManualReconnect = useEffectEvent(() => {
302 runReconnect(true);
303 });
304 const triggerAutoReconnect = useEffectEvent((trigger: WsAutoReconnectTrigger) => {
305 const currentStatus =
306 trigger === "online" ? setBrowserOnlineStatus(true) : getWsConnectionStatus();
307
308 if (!shouldAutoReconnect(currentStatus, trigger)) {
309 return;
310 }
311 if (Date.now() - lastForcedReconnectAtRef.current < FORCED_WS_RECONNECT_DEBOUNCE_MS) {
312 return;
313 }
314
315 runReconnect(false);
316 });
317
318 useEffect(() => {
319 const handleOnline = () => {
320 triggerAutoReconnect("online");
321 };
322 const handleFocus = () => {
323 triggerAutoReconnect("focus");
324 };
325
326 syncBrowserOnlineStatus();
327 window.addEventListener("online", handleOnline);
328 window.addEventListener("offline", syncBrowserOnlineStatus);
329 window.addEventListener("focus", handleFocus);
330 return () => {
331 window.removeEventListener("online", handleOnline);
332 window.removeEventListener("offline", syncBrowserOnlineStatus);
333 window.removeEventListener("focus", handleFocus);
334 };
335 }, []);
336
337 useEffect(() => {
338 if (status.reconnectPhase !== "waiting" || status.nextRetryAt === null) {
339 return;
340 }
341
342 setNowMs(Date.now());
343 const intervalId = window.setInterval(() => {
344 setNowMs(Date.now());
345 }, 1_000);
346
347 return () => {
348 window.clearInterval(intervalId);
349 };
350 }, [status.nextRetryAt, status.reconnectPhase]);
351
352 useEffect(() => {
353 if (
354 status.reconnectPhase !== "waiting" ||
355 status.nextRetryAt === null ||
356 !status.online ||
357 !status.hasConnected
358 ) {
359 return;
360 }
361
362 const nextRetryAt = status.nextRetryAt;
363 const timeoutMs = Math.max(0, new Date(nextRetryAt).getTime() - Date.now()) + 1_500;
364 const timeoutId = window.setTimeout(() => {
365 exhaustWsReconnectIfStillWaiting(nextRetryAt);
366 }, timeoutMs);
367
368 return () => {
369 window.clearTimeout(timeoutId);
370 };
371 }, [
372 status.hasConnected,
373 status.nextRetryAt,
374 status.online,
375 status.reconnectAttemptCount,
376 status.reconnectPhase,
377 ]);
378
379 useEffect(() => {
380 const uiState = getWsConnectionUiState(status);
381 const previousUiState = previousUiStateRef.current;
382 const previousDisconnectedAt = previousDisconnectedAtRef.current;
383 const shouldShowReconnectToast = status.hasConnected && uiState === "reconnecting";
384 const shouldShowOfflineToast = uiState === "offline" && status.disconnectedAt !== null;
385 const shouldShowExhaustedToast = status.hasConnected && status.reconnectPhase === "exhausted";
386
387 if (
388 toastResetTimerRef.current !== null &&
389 (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast)
390 ) {
391 window.clearTimeout(toastResetTimerRef.current);
392 toastResetTimerRef.current = null;
393 }
394
395 if (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) {
396 const toastPayload = shouldShowOfflineToast
397 ? {
398 description: describeOfflineToast(),
399 timeout: 0,
400 title: "Offline",
401 type: "warning" as const,
402 data: {
403 hideCopyButton: true,
404 },
405 }
406 : shouldShowExhaustedToast
407 ? {
408 actionProps: {
409 children: "Retry",
410 onClick: triggerManualReconnect,
411 },
412 description: describeExhaustedToast(),
413 timeout: 0,
414 title: "Disconnected from T3 Server",
415 type: "error" as const,
416 data: {
417 hideCopyButton: true,
418 },
419 }
420 : {
421 actionProps: {
422 children: "Retry now",
423 onClick: triggerManualReconnect,
424 },
425 description:
426 status.nextRetryAt === null
427 ? `Reconnecting... ${formatReconnectAttemptLabel(status)}`
428 : `Reconnecting in ${formatRetryCountdown(status.nextRetryAt, nowMs)}... ${formatReconnectAttemptLabel(status)}`,
429 timeout: 0,
430 title: buildReconnectTitle(status),
431 type: "loading" as const,
432 data: {
433 hideCopyButton: true,
434 },
435 };
436
437 if (toastIdRef.current) {
438 toastManager.update(toastIdRef.current, toastPayload);
439 } else {
440 toastIdRef.current = toastManager.add(toastPayload);
441 }
442 } else if (toastIdRef.current) {
443 toastManager.close(toastIdRef.current);
444 toastIdRef.current = null;
445 }
446
447 if (
448 uiState === "connected" &&
449 (previousUiState === "offline" || previousUiState === "reconnecting") &&
450 previousDisconnectedAt !== null
451 ) {
452 const successToast = {
453 description: describeRecoveredToast(previousDisconnectedAt, status.connectedAt),
454 title: "Reconnected to T3 Server",
455 type: "success" as const,
456 timeout: 0,
457 data: {
458 dismissAfterVisibleMs: 8_000,
459 hideCopyButton: true,
460 },
461 };
462
463 if (toastIdRef.current) {
464 toastManager.update(toastIdRef.current, successToast);
465 } else {
466 toastIdRef.current = toastManager.add(successToast);
467 }
468
469 toastResetTimerRef.current = window.setTimeout(() => {
470 toastIdRef.current = null;
471 toastResetTimerRef.current = null;
472 }, 8_250);
473 }
474
475 previousUiStateRef.current = uiState;
476 previousDisconnectedAtRef.current = status.disconnectedAt;
477 }, [nowMs, status]);
478
479 useEffect(() => {
480 return () => {
481 if (toastResetTimerRef.current !== null) {
482 window.clearTimeout(toastResetTimerRef.current);
483 }
484 };
485 }, []);
486
487 return null;
488}
489
490export function SlowRpcAckToastCoordinator() {
491 const slowRequests = useSlowRpcAckRequests();
492 const status = useWsConnectionStatus();
493 const toastIdRef = useRef<ReturnType<typeof toastManager.add> | null>(null);
494
495 useEffect(() => {
496 if (getWsConnectionUiState(status) !== "connected") {
497 if (toastIdRef.current) {
498 toastManager.close(toastIdRef.current);
499 toastIdRef.current = null;
500 }
501 return;
502 }
503
504 if (slowRequests.length === 0) {
505 if (toastIdRef.current) {
506 toastManager.close(toastIdRef.current);
507 toastIdRef.current = null;
508 }
509 return;
510 }
511
512 const nextToast = {
513 description: describeSlowRpcAckToast(slowRequests),
514 timeout: 0,
515 title: "Some requests are slow",
516 type: "warning" as const,
517 };
518
519 if (toastIdRef.current) {
520 toastManager.update(toastIdRef.current, nextToast);
521 } else {
522 toastIdRef.current = toastManager.add(nextToast);
523 }
524 }, [slowRequests, status]);
525
526 return null;
527}
528
529export function WebSocketConnectionSurface({ children }: { readonly children: ReactNode }) {
530 const serverConfig = useServerConfig();
531 const status = useWsConnectionStatus();
532
533 if (serverConfig === null) {
534 const uiState = getWsConnectionUiState(status);
535 return (
536 <WebSocketBlockingState
537 status={status}
538 uiState={uiState === "connected" ? "connecting" : uiState}
539 />
540 );
541 }
542
543 return children;
544}
apps/web/src/components/ui/toast.tsx+13-10
...@@ -23,6 +23,7 @@ export type ThreadToastData = {...@@ -23,6 +23,7 @@ export type ThreadToastData = {
23 threadId?: ThreadId | null;23 threadId?: ThreadId | null;
24 tooltipStyle?: boolean;24 tooltipStyle?: boolean;
25 dismissAfterVisibleMs?: number;25 dismissAfterVisibleMs?: number;
26 hideCopyButton?: boolean;
26};27};
2728
28const toastManager = Toast.createToastManager<ThreadToastData>();29const toastManager = Toast.createToastManager<ThreadToastData>();
...@@ -308,15 +309,15 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {...@@ -308,15 +309,15 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
308 <div className="flex min-w-0 flex-1 flex-col gap-0.5">309 <div className="flex min-w-0 flex-1 flex-col gap-0.5">
309 <div className="flex items-center justify-between gap-1">310 <div className="flex items-center justify-between gap-1">
310 <Toast.Title311 <Toast.Title
311 className="min-w-0 break-words font-medium"312 className="min-w-0 wrap-break-word font-medium"
312 data-slot="toast-title"313 data-slot="toast-title"
313 />314 />
314 {toast.type === "error" && typeof toast.description === "string" && (315 {toast.type === "error" &&
315 <CopyErrorButton text={toast.description} />316 typeof toast.description === "string" &&
316 )}317 !toast.data?.hideCopyButton && <CopyErrorButton text={toast.description} />}
317 </div>318 </div>
318 <Toast.Description319 <Toast.Description
319 className="min-w-0 select-text break-words text-muted-foreground"320 className="min-w-0 select-text wrap-break-word text-muted-foreground"
320 data-slot="toast-description"321 data-slot="toast-description"
321 />322 />
322 </div>323 </div>
...@@ -402,15 +403,17 @@ function AnchoredToasts() {...@@ -402,15 +403,17 @@ function AnchoredToasts() {
402 <div className="flex min-w-0 flex-1 flex-col gap-0.5">403 <div className="flex min-w-0 flex-1 flex-col gap-0.5">
403 <div className="flex items-center gap-1">404 <div className="flex items-center gap-1">
404 <Toast.Title405 <Toast.Title
405 className="min-w-0 break-words font-medium"406 className="min-w-0 wrap-break-word font-medium"
406 data-slot="toast-title"407 data-slot="toast-title"
407 />408 />
408 {toast.type === "error" && typeof toast.description === "string" && (409 {toast.type === "error" &&
409 <CopyErrorButton text={toast.description} />410 typeof toast.description === "string" &&
410 )}411 !toast.data?.hideCopyButton && (
412 <CopyErrorButton text={toast.description} />
413 )}
411 </div>414 </div>
412 <Toast.Description415 <Toast.Description
413 className="min-w-0 select-text break-words text-muted-foreground"416 className="min-w-0 select-text wrap-break-word text-muted-foreground"
414 data-slot="toast-description"417 data-slot="toast-description"
415 />418 />
416 </div>419 </div>
apps/web/src/orchestrationRecovery.ts+5-1
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1export type OrchestrationRecoveryReason = "bootstrap" | "sequence-gap" | "replay-failed";1export type OrchestrationRecoveryReason =
2 | "bootstrap"
3 | "sequence-gap"
4 | "resubscribe"
5 | "replay-failed";
26
3export interface OrchestrationRecoveryPhase {7export interface OrchestrationRecoveryPhase {
4 kind: "snapshot" | "replay";8 kind: "snapshot" | "replay";
apps/web/src/routes/__root.tsx+39-19
...@@ -16,6 +16,11 @@ import { Throttler } from "@tanstack/react-pacer";...@@ -16,6 +16,11 @@ import { Throttler } from "@tanstack/react-pacer";
1616
17import { APP_DISPLAY_NAME } from "../branding";17import { APP_DISPLAY_NAME } from "../branding";
18import { AppSidebarLayout } from "../components/AppSidebarLayout";18import { AppSidebarLayout } from "../components/AppSidebarLayout";
19import {
20 SlowRpcAckToastCoordinator,
21 WebSocketConnectionCoordinator,
22 WebSocketConnectionSurface,
23} from "../components/WebSocketConnectionSurface";
19import { Button } from "../components/ui/button";24import { Button } from "../components/ui/button";
20import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";25import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";
21import { resolveAndPersistPreferredEditor } from "../editorPreferences";26import { resolveAndPersistPreferredEditor } from "../editorPreferences";
...@@ -73,9 +78,13 @@ function RootRouteView() {...@@ -73,9 +78,13 @@ function RootRouteView() {
73 <AnchoredToastProvider>78 <AnchoredToastProvider>
74 <ServerStateBootstrap />79 <ServerStateBootstrap />
75 <EventRouter />80 <EventRouter />
76 <AppSidebarLayout>81 <WebSocketConnectionCoordinator />
77 <Outlet />82 <SlowRpcAckToastCoordinator />
78 </AppSidebarLayout>83 <WebSocketConnectionSurface>
84 <AppSidebarLayout>
85 <Outlet />
86 </AppSidebarLayout>
87 </WebSocketConnectionSurface>
79 </AnchoredToastProvider>88 </AnchoredToastProvider>
80 </ToastProvider>89 </ToastProvider>
81 );90 );
...@@ -428,8 +437,8 @@ function EventRouter() {...@@ -428,8 +437,8 @@ function EventRouter() {
428 queueMicrotask(flushPendingDomainEvents);437 queueMicrotask(flushPendingDomainEvents);
429 };438 };
430439
431 const recoverFromSequenceGap = async (): Promise<void> => {440 const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise<void> => {
432 if (!recovery.beginReplayRecovery("sequence-gap")) {441 if (!recovery.beginReplayRecovery(reason)) {
433 return;442 return;
434 }443 }
435444
...@@ -466,7 +475,7 @@ function EventRouter() {...@@ -466,7 +475,7 @@ function EventRouter() {
466 return;475 return;
467 }476 }
468 }477 }
469 void recoverFromSequenceGap();478 void runReplayRecovery(reason);
470 } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") {479 } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") {
471 console.warn(480 console.warn(
472 "[orchestration-recovery]",481 "[orchestration-recovery]",
...@@ -505,7 +514,7 @@ function EventRouter() {...@@ -505,7 +514,7 @@ function EventRouter() {
505 syncServerReadModel(snapshot);514 syncServerReadModel(snapshot);
506 reconcileSnapshotDerivedState();515 reconcileSnapshotDerivedState();
507 if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) {516 if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) {
508 void recoverFromSequenceGap();517 void runReplayRecovery("sequence-gap");
509 }518 }
510 }519 }
511 } catch {520 } catch {
...@@ -522,18 +531,29 @@ function EventRouter() {...@@ -522,18 +531,29 @@ function EventRouter() {
522 const fallbackToSnapshotRecovery = async (): Promise<void> => {531 const fallbackToSnapshotRecovery = async (): Promise<void> => {
523 await runSnapshotRecovery("replay-failed");532 await runSnapshotRecovery("replay-failed");
524 };533 };
525 const unsubDomainEvent = api.orchestration.onDomainEvent((event) => {534 const unsubDomainEvent = api.orchestration.onDomainEvent(
526 const action = recovery.classifyDomainEvent(event.sequence);535 (event) => {
527 if (action === "apply") {536 const action = recovery.classifyDomainEvent(event.sequence);
528 pendingDomainEvents.push(event);537 if (action === "apply") {
529 schedulePendingDomainEventFlush();538 pendingDomainEvents.push(event);
530 return;539 schedulePendingDomainEventFlush();
531 }540 return;
532 if (action === "recover") {541 }
533 flushPendingDomainEvents();542 if (action === "recover") {
534 void recoverFromSequenceGap();543 flushPendingDomainEvents();
535 }544 void runReplayRecovery("sequence-gap");
536 });545 }
546 },
547 {
548 onResubscribe: () => {
549 if (disposed) {
550 return;
551 }
552 flushPendingDomainEvents();
553 void runReplayRecovery("resubscribe");
554 },
555 },
556 );
537 const unsubTerminalEvent = api.terminal.onEvent((event) => {557 const unsubTerminalEvent = api.terminal.onEvent((event) => {
538 const thread = useStore.getState().threads.find((entry) => entry.id === event.threadId);558 const thread = useStore.getState().threads.find((entry) => entry.id === event.threadId);
539 if (thread && thread.archivedAt !== null) {559 if (thread && thread.archivedAt !== null) {
apps/web/src/rpc/protocol.ts+82-5
...@@ -1,9 +1,22 @@...@@ -1,9 +1,22 @@
1import { WsRpcGroup } from "@t3tools/contracts";1import { WsRpcGroup } from "@t3tools/contracts";
2import { Effect, Layer } from "effect";2import { Duration, Effect, Layer, Schedule } from "effect";
3import { RpcClient, RpcSerialization } from "effect/unstable/rpc";3import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
4import * as Socket from "effect/unstable/socket/Socket";4import * as Socket from "effect/unstable/socket/Socket";
55
6import { resolveServerUrl } from "../lib/utils";6import { resolveServerUrl } from "../lib/utils";
7import {
8 acknowledgeRpcRequest,
9 clearAllTrackedRpcRequests,
10 trackRpcRequestSent,
11} from "./requestLatencyState";
12import {
13 getWsReconnectDelayMsForRetry,
14 recordWsConnectionAttempt,
15 recordWsConnectionClosed,
16 recordWsConnectionErrored,
17 recordWsConnectionOpened,
18 WS_RECONNECT_MAX_RETRIES,
19} from "./wsConnectionState";
720
8export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup);21export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup);
922
...@@ -17,11 +30,75 @@ export function createWsRpcProtocolLayer(url?: string) {...@@ -17,11 +30,75 @@ export function createWsRpcProtocolLayer(url?: string) {
17 protocol: window.location.protocol === "https:" ? "wss" : "ws",30 protocol: window.location.protocol === "https:" ? "wss" : "ws",
18 pathname: "/ws",31 pathname: "/ws",
19 });32 });
33 const trackingWebSocketConstructorLayer = Layer.succeed(
34 Socket.WebSocketConstructor,
35 (socketUrl, protocols) => {
36 recordWsConnectionAttempt(socketUrl);
37 const socket = new globalThis.WebSocket(socketUrl, protocols);
38
39 socket.addEventListener(
40 "open",
41 () => {
42 recordWsConnectionOpened();
43 },
44 { once: true },
45 );
46 socket.addEventListener(
47 "error",
48 () => {
49 clearAllTrackedRpcRequests();
50 recordWsConnectionErrored("Unable to connect to the T3 server WebSocket.");
51 },
52 { once: true },
53 );
54 socket.addEventListener(
55 "close",
56 (event) => {
57 clearAllTrackedRpcRequests();
58 recordWsConnectionClosed({
59 code: event.code,
60 reason: event.reason,
61 });
62 },
63 { once: true },
64 );
65
66 return socket;
67 },
68 );
20 const socketLayer = Socket.layerWebSocket(resolvedUrl).pipe(69 const socketLayer = Socket.layerWebSocket(resolvedUrl).pipe(
21 Layer.provide(Socket.layerWebSocketConstructorGlobal),70 Layer.provide(trackingWebSocketConstructorLayer),
22 );71 );
2372 const retryPolicy = Schedule.addDelay(Schedule.recurs(WS_RECONNECT_MAX_RETRIES), (retryCount) =>
24 return RpcClient.layerProtocolSocket({ retryTransientErrors: true }).pipe(73 Effect.succeed(Duration.millis(getWsReconnectDelayMsForRetry(retryCount) ?? 0)),
25 Layer.provide(Layer.mergeAll(socketLayer, RpcSerialization.layerJson)),74 );
75 const protocolLayer = Layer.effect(
76 RpcClient.Protocol,
77 Effect.map(
78 RpcClient.makeProtocolSocket({
79 retryPolicy,
80 retryTransientErrors: true,
81 }),
82 (protocol) => ({
83 ...protocol,
84 run: (writeResponse) =>
85 protocol.run((response) => {
86 if (response._tag === "Chunk" || response._tag === "Exit") {
87 acknowledgeRpcRequest(response.requestId);
88 } else if (response._tag === "ClientProtocolError" || response._tag === "Defect") {
89 clearAllTrackedRpcRequests();
90 }
91 return writeResponse(response);
92 }),
93 send: (request, transferables) => {
94 if (request._tag === "Request") {
95 trackRpcRequestSent(request.id, request.tag);
96 }
97 return protocol.send(request, transferables);
98 },
99 }),
100 ),
26 );101 );
102
103 return protocolLayer.pipe(Layer.provide(Layer.mergeAll(socketLayer, RpcSerialization.layerJson)));
27}104}
apps/web/src/rpc/requestLatencyState.test.ts created+65
...@@ -0,0 +1,65 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
3import {
4 acknowledgeRpcRequest,
5 getSlowRpcAckRequests,
6 resetRequestLatencyStateForTests,
7 trackRpcRequestSent,
8 SLOW_RPC_ACK_THRESHOLD_MS,
9 MAX_TRACKED_RPC_ACK_REQUESTS,
10} from "./requestLatencyState";
11
12describe("requestLatencyState", () => {
13 beforeEach(() => {
14 vi.useFakeTimers();
15 resetRequestLatencyStateForTests();
16 });
17
18 afterEach(() => {
19 vi.useRealTimers();
20 });
21
22 it("marks unary requests as slow when the ack threshold is exceeded", () => {
23 trackRpcRequestSent("1", "server.getConfig");
24 vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS - 1);
25 expect(getSlowRpcAckRequests()).toEqual([]);
26
27 vi.advanceTimersByTime(1);
28 expect(getSlowRpcAckRequests()).toMatchObject([
29 {
30 requestId: "1",
31 tag: "server.getConfig",
32 thresholdMs: SLOW_RPC_ACK_THRESHOLD_MS,
33 },
34 ]);
35 });
36
37 it("clears the slow request once the server acknowledges it", () => {
38 trackRpcRequestSent("1", "git.status");
39 vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS);
40 expect(getSlowRpcAckRequests()).toHaveLength(1);
41
42 acknowledgeRpcRequest("1");
43 expect(getSlowRpcAckRequests()).toEqual([]);
44 });
45
46 it("ignores long-lived subscribe requests", () => {
47 trackRpcRequestSent("1", "subscribeServerConfig");
48 vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2);
49
50 expect(getSlowRpcAckRequests()).toEqual([]);
51 });
52
53 it("evicts the oldest pending requests once the tracker reaches capacity", () => {
54 for (let index = 0; index < MAX_TRACKED_RPC_ACK_REQUESTS + 1; index += 1) {
55 trackRpcRequestSent(String(index), "server.getConfig");
56 }
57
58 vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS);
59
60 const slowRequests = getSlowRpcAckRequests();
61 expect(slowRequests).toHaveLength(MAX_TRACKED_RPC_ACK_REQUESTS);
62 expect(slowRequests[0]?.requestId).toBe("1");
63 expect(slowRequests.at(-1)?.requestId).toBe(String(MAX_TRACKED_RPC_ACK_REQUESTS));
64 });
65});
apps/web/src/rpc/requestLatencyState.ts created+127
...@@ -0,0 +1,127 @@
1import { useAtomValue } from "@effect/atom-react";
2import { Atom } from "effect/unstable/reactivity";
3
4import { appAtomRegistry } from "./atomRegistry";
5
6export const SLOW_RPC_ACK_THRESHOLD_MS = 2_500;
7export const MAX_TRACKED_RPC_ACK_REQUESTS = 256;
8
9export interface SlowRpcAckRequest {
10 readonly requestId: string;
11 readonly startedAt: string;
12 readonly startedAtMs: number;
13 readonly tag: string;
14 readonly thresholdMs: number;
15}
16
17interface PendingRpcAckRequest {
18 readonly request: SlowRpcAckRequest;
19 readonly timeoutId: ReturnType<typeof setTimeout>;
20}
21
22const pendingRpcAckRequests = new Map<string, PendingRpcAckRequest>();
23
24const slowRpcAckRequestsAtom = Atom.make<ReadonlyArray<SlowRpcAckRequest>>([]).pipe(
25 Atom.keepAlive,
26 Atom.withLabel("slow-rpc-ack-requests"),
27);
28
29function setSlowRpcAckRequests(requests: ReadonlyArray<SlowRpcAckRequest>) {
30 appAtomRegistry.set(slowRpcAckRequestsAtom, [...requests]);
31}
32
33function getSlowRpcAckRequestsValue(): ReadonlyArray<SlowRpcAckRequest> {
34 return appAtomRegistry.get(slowRpcAckRequestsAtom);
35}
36
37function shouldTrackRpcAck(tag: string): boolean {
38 return !tag.startsWith("subscribe");
39}
40
41export function getSlowRpcAckRequests(): ReadonlyArray<SlowRpcAckRequest> {
42 return getSlowRpcAckRequestsValue();
43}
44
45export function trackRpcRequestSent(requestId: string, tag: string): void {
46 if (!shouldTrackRpcAck(tag)) {
47 return;
48 }
49
50 clearTrackedRpcRequest(requestId);
51 evictOldestPendingRpcRequestIfNeeded();
52
53 const startedAtMs = Date.now();
54 const request: SlowRpcAckRequest = {
55 requestId,
56 startedAt: new Date(startedAtMs).toISOString(),
57 startedAtMs,
58 tag,
59 thresholdMs: SLOW_RPC_ACK_THRESHOLD_MS,
60 };
61 const timeoutId = setTimeout(() => {
62 pendingRpcAckRequests.delete(requestId);
63 appendSlowRpcAckRequest(request);
64 }, SLOW_RPC_ACK_THRESHOLD_MS);
65
66 pendingRpcAckRequests.set(requestId, {
67 request,
68 timeoutId,
69 });
70}
71
72export function acknowledgeRpcRequest(requestId: string): void {
73 clearTrackedRpcRequest(requestId);
74 const slowRequests = getSlowRpcAckRequestsValue();
75 if (!slowRequests.some((request) => request.requestId === requestId)) {
76 return;
77 }
78
79 setSlowRpcAckRequests(slowRequests.filter((request) => request.requestId !== requestId));
80}
81
82export function clearAllTrackedRpcRequests(): void {
83 for (const pending of pendingRpcAckRequests.values()) {
84 clearTimeout(pending.timeoutId);
85 }
86 pendingRpcAckRequests.clear();
87 setSlowRpcAckRequests([]);
88}
89
90function clearTrackedRpcRequest(requestId: string): void {
91 const pending = pendingRpcAckRequests.get(requestId);
92 if (!pending) {
93 return;
94 }
95
96 clearTimeout(pending.timeoutId);
97 pendingRpcAckRequests.delete(requestId);
98}
99
100function appendSlowRpcAckRequest(request: SlowRpcAckRequest): void {
101 const requests = [...getSlowRpcAckRequestsValue(), request];
102 if (requests.length <= MAX_TRACKED_RPC_ACK_REQUESTS) {
103 setSlowRpcAckRequests(requests);
104 return;
105 }
106
107 setSlowRpcAckRequests(requests.slice(-MAX_TRACKED_RPC_ACK_REQUESTS));
108}
109
110function evictOldestPendingRpcRequestIfNeeded(): void {
111 while (pendingRpcAckRequests.size >= MAX_TRACKED_RPC_ACK_REQUESTS) {
112 const oldestRequestId = pendingRpcAckRequests.keys().next().value;
113 if (oldestRequestId === undefined) {
114 return;
115 }
116
117 clearTrackedRpcRequest(oldestRequestId);
118 }
119}
120
121export function resetRequestLatencyStateForTests(): void {
122 clearAllTrackedRpcRequests();
123}
124
125export function useSlowRpcAckRequests(): ReadonlyArray<SlowRpcAckRequest> {
126 return useAtomValue(slowRpcAckRequestsAtom);
127}
apps/web/src/rpc/transportError.test.ts created+24
...@@ -0,0 +1,24 @@
1import { describe, expect, it } from "vitest";
2
3import { isTransportConnectionErrorMessage, sanitizeThreadErrorMessage } from "./transportError";
4
5describe("transportError", () => {
6 it("detects websocket transport failures", () => {
7 expect(isTransportConnectionErrorMessage("SocketCloseError: 1006")).toBe(true);
8 expect(isTransportConnectionErrorMessage("Unable to connect to the T3 server WebSocket.")).toBe(
9 true,
10 );
11 expect(isTransportConnectionErrorMessage("SocketOpenError: Timeout")).toBe(true);
12 });
13
14 it("preserves non-transport thread errors", () => {
15 expect(sanitizeThreadErrorMessage("Turn failed")).toBe("Turn failed");
16 expect(sanitizeThreadErrorMessage("Select a base branch before sending.")).toBe(
17 "Select a base branch before sending.",
18 );
19 });
20
21 it("drops transport failures from thread surfaces", () => {
22 expect(sanitizeThreadErrorMessage("SocketCloseError: 1006")).toBeNull();
23 });
24});
apps/web/src/rpc/transportError.ts created+23
...@@ -0,0 +1,23 @@
1const TRANSPORT_ERROR_PATTERNS = [
2 /\bSocketCloseError\b/i,
3 /\bSocketOpenError\b/i,
4 /Unable to connect to the T3 server WebSocket\./i,
5 /\bping timeout\b/i,
6] as const;
7
8export function isTransportConnectionErrorMessage(message: string | null | undefined): boolean {
9 if (typeof message !== "string") {
10 return false;
11 }
12
13 const normalizedMessage = message.trim();
14 if (normalizedMessage.length === 0) {
15 return false;
16 }
17
18 return TRANSPORT_ERROR_PATTERNS.some((pattern) => pattern.test(normalizedMessage));
19}
20
21export function sanitizeThreadErrorMessage(message: string | null | undefined): string | null {
22 return isTransportConnectionErrorMessage(message) ? null : (message ?? null);
23}
apps/web/src/rpc/wsConnectionState.test.ts created+112
...@@ -0,0 +1,112 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
3import {
4 exhaustWsReconnectIfStillWaiting,
5 getWsConnectionStatus,
6 getWsReconnectDelayMsForRetry,
7 getWsConnectionUiState,
8 recordWsConnectionAttempt,
9 recordWsConnectionClosed,
10 recordWsConnectionErrored,
11 recordWsConnectionOpened,
12 resetWsConnectionStateForTests,
13 setBrowserOnlineStatus,
14 WS_RECONNECT_MAX_ATTEMPTS,
15} from "./wsConnectionState";
16
17describe("wsConnectionState", () => {
18 beforeEach(() => {
19 vi.useFakeTimers();
20 vi.setSystemTime(new Date("2026-04-03T20:30:00.000Z"));
21 resetWsConnectionStateForTests();
22 });
23
24 afterEach(() => {
25 vi.useRealTimers();
26 });
27
28 it("treats a disconnected browser as offline once the websocket drops", () => {
29 recordWsConnectionAttempt("ws://localhost:3020/ws");
30 recordWsConnectionOpened();
31 recordWsConnectionClosed({ code: 1006, reason: "offline" });
32 setBrowserOnlineStatus(false);
33
34 expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("offline");
35 });
36
37 it("stays in the initial connecting state until the first disconnect", () => {
38 recordWsConnectionAttempt("ws://localhost:3020/ws");
39
40 expect(getWsConnectionStatus()).toMatchObject({
41 attemptCount: 1,
42 hasConnected: false,
43 phase: "connecting",
44 });
45 expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("connecting");
46 });
47
48 it("schedules the next retry after a failed websocket attempt", () => {
49 recordWsConnectionAttempt("ws://localhost:3020/ws");
50 recordWsConnectionErrored("Unable to connect to the T3 server WebSocket.");
51
52 const firstRetryDelayMs = getWsReconnectDelayMsForRetry(0);
53 if (firstRetryDelayMs === null) {
54 throw new Error("Expected an initial retry delay.");
55 }
56
57 expect(getWsConnectionStatus()).toMatchObject({
58 nextRetryAt: new Date(Date.now() + firstRetryDelayMs).toISOString(),
59 reconnectAttemptCount: 1,
60 reconnectPhase: "waiting",
61 });
62 });
63
64 it("marks the reconnect cycle as exhausted after the final attempt fails", () => {
65 for (let attempt = 0; attempt < WS_RECONNECT_MAX_ATTEMPTS; attempt += 1) {
66 recordWsConnectionAttempt("ws://localhost:3020/ws");
67 recordWsConnectionErrored("Unable to connect to the T3 server WebSocket.");
68 }
69
70 expect(getWsConnectionStatus()).toMatchObject({
71 nextRetryAt: null,
72 reconnectAttemptCount: WS_RECONNECT_MAX_ATTEMPTS,
73 reconnectPhase: "exhausted",
74 });
75 });
76
77 it("can exhaust a stalled final retry window when no new attempt starts", () => {
78 recordWsConnectionAttempt("ws://localhost:3020/ws");
79 recordWsConnectionOpened();
80
81 for (let attempt = 0; attempt < WS_RECONNECT_MAX_ATTEMPTS - 1; attempt += 1) {
82 recordWsConnectionAttempt("ws://localhost:3020/ws");
83 recordWsConnectionErrored("Unable to connect to the T3 server WebSocket.");
84 }
85
86 const finalRetryDelayMs = getWsReconnectDelayMsForRetry(WS_RECONNECT_MAX_ATTEMPTS - 2);
87 if (finalRetryDelayMs === null) {
88 throw new Error("Expected a final retry delay.");
89 }
90
91 const statusBeforeExhaust = getWsConnectionStatus();
92 expect(statusBeforeExhaust).toMatchObject({
93 nextRetryAt: new Date(Date.now() + finalRetryDelayMs).toISOString(),
94 reconnectAttemptCount: 7,
95 reconnectPhase: "waiting",
96 });
97
98 const nextRetryAt = statusBeforeExhaust.nextRetryAt;
99 if (!nextRetryAt) {
100 throw new Error("Expected a scheduled retry.");
101 }
102
103 vi.setSystemTime(new Date(Date.now() + finalRetryDelayMs + 1_000));
104 exhaustWsReconnectIfStillWaiting(nextRetryAt);
105
106 expect(getWsConnectionStatus()).toMatchObject({
107 nextRetryAt: null,
108 reconnectAttemptCount: WS_RECONNECT_MAX_ATTEMPTS,
109 reconnectPhase: "exhausted",
110 });
111 });
112});
apps/web/src/rpc/wsConnectionState.ts created+219
...@@ -0,0 +1,219 @@
1import { useAtomValue } from "@effect/atom-react";
2import { Atom } from "effect/unstable/reactivity";
3
4import { appAtomRegistry } from "./atomRegistry";
5
6export type WsConnectionUiState = "connected" | "connecting" | "error" | "offline" | "reconnecting";
7export type WsReconnectPhase = "attempting" | "exhausted" | "idle" | "waiting";
8
9export const WS_RECONNECT_INITIAL_DELAY_MS = 1_000;
10export const WS_RECONNECT_BACKOFF_FACTOR = 2;
11export const WS_RECONNECT_MAX_DELAY_MS = 64_000;
12export const WS_RECONNECT_MAX_RETRIES = 7;
13export const WS_RECONNECT_MAX_ATTEMPTS = WS_RECONNECT_MAX_RETRIES + 1;
14
15export interface WsConnectionStatus {
16 readonly attemptCount: number;
17 readonly closeCode: number | null;
18 readonly closeReason: string | null;
19 readonly connectedAt: string | null;
20 readonly disconnectedAt: string | null;
21 readonly hasConnected: boolean;
22 readonly lastError: string | null;
23 readonly lastErrorAt: string | null;
24 readonly nextRetryAt: string | null;
25 readonly online: boolean;
26 readonly phase: "idle" | "connecting" | "connected" | "disconnected";
27 readonly reconnectAttemptCount: number;
28 readonly reconnectMaxAttempts: number;
29 readonly reconnectPhase: WsReconnectPhase;
30 readonly socketUrl: string | null;
31}
32
33const INITIAL_WS_CONNECTION_STATUS = Object.freeze<WsConnectionStatus>({
34 attemptCount: 0,
35 closeCode: null,
36 closeReason: null,
37 connectedAt: null,
38 disconnectedAt: null,
39 hasConnected: false,
40 lastError: null,
41 lastErrorAt: null,
42 nextRetryAt: null,
43 online: typeof navigator === "undefined" ? true : navigator.onLine !== false,
44 phase: "idle",
45 reconnectAttemptCount: 0,
46 reconnectMaxAttempts: WS_RECONNECT_MAX_ATTEMPTS,
47 reconnectPhase: "idle",
48 socketUrl: null,
49});
50
51export const wsConnectionStatusAtom = Atom.make(INITIAL_WS_CONNECTION_STATUS).pipe(
52 Atom.keepAlive,
53 Atom.withLabel("ws-connection-status"),
54);
55
56function isoNow() {
57 return new Date().toISOString();
58}
59
60function updateWsConnectionStatus(
61 updater: (current: WsConnectionStatus) => WsConnectionStatus,
62): WsConnectionStatus {
63 const nextStatus = updater(getWsConnectionStatus());
64 appAtomRegistry.set(wsConnectionStatusAtom, nextStatus);
65 return nextStatus;
66}
67
68export function getWsConnectionStatus(): WsConnectionStatus {
69 return appAtomRegistry.get(wsConnectionStatusAtom);
70}
71
72export function getWsConnectionUiState(status: WsConnectionStatus): WsConnectionUiState {
73 if (status.phase === "connected") {
74 return "connected";
75 }
76
77 if (!status.online && (status.disconnectedAt !== null || status.phase === "disconnected")) {
78 return "offline";
79 }
80
81 if (!status.hasConnected) {
82 return status.phase === "disconnected" ? "error" : "connecting";
83 }
84
85 return "reconnecting";
86}
87
88export function recordWsConnectionAttempt(socketUrl: string): WsConnectionStatus {
89 return updateWsConnectionStatus((current) => ({
90 ...current,
91 attemptCount: current.attemptCount + 1,
92 nextRetryAt: null,
93 phase: "connecting",
94 reconnectAttemptCount: current.phase === "connected" ? 1 : current.reconnectAttemptCount + 1,
95 reconnectPhase: "attempting",
96 socketUrl,
97 }));
98}
99
100export function recordWsConnectionOpened(): WsConnectionStatus {
101 return updateWsConnectionStatus((current) => ({
102 ...current,
103 closeCode: null,
104 closeReason: null,
105 connectedAt: isoNow(),
106 disconnectedAt: null,
107 hasConnected: true,
108 nextRetryAt: null,
109 phase: "connected",
110 reconnectAttemptCount: 0,
111 reconnectPhase: "idle",
112 }));
113}
114
115export function recordWsConnectionErrored(message?: string | null): WsConnectionStatus {
116 return updateWsConnectionStatus((current) =>
117 applyDisconnectState(current, {
118 lastError: message?.trim() ? message : current.lastError,
119 lastErrorAt: isoNow(),
120 }),
121 );
122}
123
124export function recordWsConnectionClosed(details?: {
125 readonly code?: number;
126 readonly reason?: string;
127}): WsConnectionStatus {
128 return updateWsConnectionStatus((current) =>
129 applyDisconnectState(current, {
130 closeCode: details?.code ?? current.closeCode,
131 closeReason: details?.reason?.trim() ? details.reason : current.closeReason,
132 }),
133 );
134}
135
136export function setBrowserOnlineStatus(online: boolean): WsConnectionStatus {
137 return updateWsConnectionStatus((current) => ({
138 ...current,
139 online,
140 }));
141}
142
143export function resetWsReconnectBackoff(): WsConnectionStatus {
144 return updateWsConnectionStatus((current) => ({
145 ...current,
146 nextRetryAt: null,
147 reconnectAttemptCount: 0,
148 reconnectPhase: "idle",
149 }));
150}
151
152export function exhaustWsReconnectIfStillWaiting(expectedNextRetryAt: string): WsConnectionStatus {
153 return updateWsConnectionStatus((current) => {
154 if (
155 current.reconnectPhase !== "waiting" ||
156 current.nextRetryAt !== expectedNextRetryAt ||
157 !current.online ||
158 !current.hasConnected
159 ) {
160 return current;
161 }
162
163 return {
164 ...current,
165 nextRetryAt: null,
166 reconnectAttemptCount: current.reconnectMaxAttempts,
167 reconnectPhase: "exhausted",
168 };
169 });
170}
171
172export function resetWsConnectionStateForTests(): void {
173 appAtomRegistry.set(wsConnectionStatusAtom, INITIAL_WS_CONNECTION_STATUS);
174}
175
176export function useWsConnectionStatus(): WsConnectionStatus {
177 return useAtomValue(wsConnectionStatusAtom);
178}
179
180export function getWsReconnectDelayMsForRetry(retryIndex: number): number | null {
181 if (!Number.isInteger(retryIndex) || retryIndex < 0 || retryIndex >= WS_RECONNECT_MAX_RETRIES) {
182 return null;
183 }
184
185 return Math.min(
186 Math.round(WS_RECONNECT_INITIAL_DELAY_MS * WS_RECONNECT_BACKOFF_FACTOR ** retryIndex),
187 WS_RECONNECT_MAX_DELAY_MS,
188 );
189}
190
191function applyDisconnectState(
192 current: WsConnectionStatus,
193 updates: Partial<
194 Pick<WsConnectionStatus, "closeCode" | "closeReason" | "lastError" | "lastErrorAt">
195 >,
196): WsConnectionStatus {
197 const disconnectedAt = current.disconnectedAt ?? isoNow();
198 const nextRetryDelayMs =
199 current.nextRetryAt !== null || current.reconnectPhase === "exhausted"
200 ? null
201 : getWsReconnectDelayMsForRetry(Math.max(0, current.reconnectAttemptCount - 1));
202
203 return {
204 ...current,
205 ...updates,
206 disconnectedAt,
207 nextRetryAt:
208 nextRetryDelayMs === null
209 ? current.nextRetryAt
210 : new Date(Date.now() + nextRetryDelayMs).toISOString(),
211 phase: "disconnected",
212 reconnectPhase:
213 current.reconnectPhase === "waiting" || current.reconnectPhase === "exhausted"
214 ? current.reconnectPhase
215 : nextRetryDelayMs === null
216 ? "exhausted"
217 : "waiting",
218 };
219}
apps/web/src/store.ts+3-2
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 derivePendingApprovals,19 derivePendingApprovals,
20 derivePendingUserInputs,20 derivePendingUserInputs,
21} from "./session-logic";21} from "./session-logic";
22import { sanitizeThreadErrorMessage } from "./rpc/transportError";
22import { type ChatMessage, type Project, type SidebarThreadSummary, type Thread } from "./types";23import { type ChatMessage, type Project, type SidebarThreadSummary, type Thread } from "./types";
2324
24// ── State ────────────────────────────────────────────────────────────25// ── State ────────────────────────────────────────────────────────────
...@@ -165,7 +166,7 @@ function mapThread(thread: OrchestrationThread): Thread {...@@ -165,7 +166,7 @@ function mapThread(thread: OrchestrationThread): Thread {
165 session: thread.session ? mapSession(thread.session) : null,166 session: thread.session ? mapSession(thread.session) : null,
166 messages: thread.messages.map(mapMessage),167 messages: thread.messages.map(mapMessage),
167 proposedPlans: thread.proposedPlans.map(mapProposedPlan),168 proposedPlans: thread.proposedPlans.map(mapProposedPlan),
168 error: thread.session?.lastError ?? null,169 error: sanitizeThreadErrorMessage(thread.session?.lastError),
169 createdAt: thread.createdAt,170 createdAt: thread.createdAt,
170 archivedAt: thread.archivedAt,171 archivedAt: thread.archivedAt,
171 updatedAt: thread.updatedAt,172 updatedAt: thread.updatedAt,
...@@ -894,7 +895,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve...@@ -894,7 +895,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
894 return updateThreadState(state, event.payload.threadId, (thread) => ({895 return updateThreadState(state, event.payload.threadId, (thread) => ({
895 ...thread,896 ...thread,
896 session: mapSession(event.payload.session),897 session: mapSession(event.payload.session),
897 error: event.payload.session.lastError ?? null,898 error: sanitizeThreadErrorMessage(event.payload.session.lastError),
898 latestTurn:899 latestTurn:
899 event.payload.session.status === "running" && event.payload.session.activeTurnId !== null900 event.payload.session.status === "running" && event.payload.session.activeTurnId !== null
900 ? buildLatestTurn({901 ? buildLatestTurn({
apps/web/src/wsNativeApi.test.ts+14
...@@ -243,6 +243,20 @@ describe("wsNativeApi", () => {...@@ -243,6 +243,20 @@ describe("wsNativeApi", () => {
243 expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent);243 expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent);
244 });244 });
245245
246 it("forwards orchestration stream subscription options to the RPC client", async () => {
247 const { createWsNativeApi } = await import("./wsNativeApi");
248
249 const api = createWsNativeApi();
250 const onDomainEvent = vi.fn();
251 const onResubscribe = vi.fn();
252
253 api.orchestration.onDomainEvent(onDomainEvent, { onResubscribe });
254
255 expect(rpcClientMock.orchestration.onDomainEvent).toHaveBeenCalledWith(onDomainEvent, {
256 onResubscribe,
257 });
258 });
259
246 it("sends orchestration dispatch commands as the direct RPC payload", async () => {260 it("sends orchestration dispatch commands as the direct RPC payload", async () => {
247 rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 });261 rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 });
248 const { createWsNativeApi } = await import("./wsNativeApi");262 const { createWsNativeApi } = await import("./wsNativeApi");
apps/web/src/wsNativeApi.ts+6-1
...@@ -1,7 +1,9 @@...@@ -1,7 +1,9 @@
1import { type ContextMenuItem, type NativeApi } from "@t3tools/contracts";1import { type ContextMenuItem, type NativeApi } from "@t3tools/contracts";
22
3import { showContextMenuFallback } from "./contextMenuFallback";3import { showContextMenuFallback } from "./contextMenuFallback";
4import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";
4import { resetServerStateForTests } from "./rpc/serverState";5import { resetServerStateForTests } from "./rpc/serverState";
6import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState";
5import { __resetWsRpcClientForTests, getWsRpcClient } from "./wsRpcClient";7import { __resetWsRpcClientForTests, getWsRpcClient } from "./wsRpcClient";
68
7let instance: { api: NativeApi } | null = null;9let instance: { api: NativeApi } | null = null;
...@@ -9,7 +11,9 @@ let instance: { api: NativeApi } | null = null;...@@ -9,7 +11,9 @@ let instance: { api: NativeApi } | null = null;
9export function __resetWsNativeApiForTests() {11export function __resetWsNativeApiForTests() {
10 instance = null;12 instance = null;
11 __resetWsRpcClientForTests();13 __resetWsRpcClientForTests();
14 resetRequestLatencyStateForTests();
12 resetServerStateForTests();15 resetServerStateForTests();
16 resetWsConnectionStateForTests();
13}17}
1418
15export function createWsNativeApi(): NativeApi {19export function createWsNativeApi(): NativeApi {
...@@ -98,7 +102,8 @@ export function createWsNativeApi(): NativeApi {...@@ -98,7 +102,8 @@ export function createWsNativeApi(): NativeApi {
98 rpcClient.orchestration102 rpcClient.orchestration
99 .replayEvents({ fromSequenceExclusive })103 .replayEvents({ fromSequenceExclusive })
100 .then((events) => [...events]),104 .then((events) => [...events]),
101 onDomainEvent: (callback) => rpcClient.orchestration.onDomainEvent(callback),105 onDomainEvent: (callback, options) =>
106 rpcClient.orchestration.onDomainEvent(callback, options),
102 },107 },
103 };108 };
104109
apps/web/src/wsRpcClient.ts+31-8
...@@ -10,12 +10,17 @@ import {...@@ -10,12 +10,17 @@ import {
10import { Effect, Stream } from "effect";10import { Effect, Stream } from "effect";
1111
12import { type WsRpcProtocolClient } from "./rpc/protocol";12import { type WsRpcProtocolClient } from "./rpc/protocol";
13import { resetWsReconnectBackoff } from "./rpc/wsConnectionState";
13import { WsTransport } from "./wsTransport";14import { WsTransport } from "./wsTransport";
1415
15type RpcTag = keyof WsRpcProtocolClient & string;16type RpcTag = keyof WsRpcProtocolClient & string;
16type RpcMethod<TTag extends RpcTag> = WsRpcProtocolClient[TTag];17type RpcMethod<TTag extends RpcTag> = WsRpcProtocolClient[TTag];
17type RpcInput<TTag extends RpcTag> = Parameters<RpcMethod<TTag>>[0];18type RpcInput<TTag extends RpcTag> = Parameters<RpcMethod<TTag>>[0];
1819
20interface StreamSubscriptionOptions {
21 readonly onResubscribe?: () => void;
22}
23
19type RpcUnaryMethod<TTag extends RpcTag> =24type RpcUnaryMethod<TTag extends RpcTag> =
20 RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any>25 RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any>
21 ? (input: RpcInput<TTag>) => Promise<TSuccess>26 ? (input: RpcInput<TTag>) => Promise<TSuccess>
...@@ -28,7 +33,7 @@ type RpcUnaryNoArgMethod<TTag extends RpcTag> =...@@ -28,7 +33,7 @@ type RpcUnaryNoArgMethod<TTag extends RpcTag> =
2833
29type RpcStreamMethod<TTag extends RpcTag> =34type RpcStreamMethod<TTag extends RpcTag> =
30 RpcMethod<TTag> extends (input: any, options?: any) => Stream.Stream<infer TEvent, any, any>35 RpcMethod<TTag> extends (input: any, options?: any) => Stream.Stream<infer TEvent, any, any>
31 ? (listener: (event: TEvent) => void) => () => void36 ? (listener: (event: TEvent) => void, options?: StreamSubscriptionOptions) => () => void
32 : never;37 : never;
3338
34interface GitRunStackedActionOptions {39interface GitRunStackedActionOptions {
...@@ -37,6 +42,7 @@ interface GitRunStackedActionOptions {...@@ -37,6 +42,7 @@ interface GitRunStackedActionOptions {
3742
38export interface WsRpcClient {43export interface WsRpcClient {
39 readonly dispose: () => Promise<void>;44 readonly dispose: () => Promise<void>;
45 readonly reconnect: () => Promise<void>;
40 readonly terminal: {46 readonly terminal: {
41 readonly open: RpcUnaryMethod<typeof WS_METHODS.terminalOpen>;47 readonly open: RpcUnaryMethod<typeof WS_METHODS.terminalOpen>;
42 readonly write: RpcUnaryMethod<typeof WS_METHODS.terminalWrite>;48 readonly write: RpcUnaryMethod<typeof WS_METHODS.terminalWrite>;
...@@ -113,6 +119,10 @@ export async function __resetWsRpcClientForTests() {...@@ -113,6 +119,10 @@ export async function __resetWsRpcClientForTests() {
113export function createWsRpcClient(transport = new WsTransport()): WsRpcClient {119export function createWsRpcClient(transport = new WsTransport()): WsRpcClient {
114 return {120 return {
115 dispose: () => transport.dispose(),121 dispose: () => transport.dispose(),
122 reconnect: async () => {
123 resetWsReconnectBackoff();
124 await transport.reconnect();
125 },
116 terminal: {126 terminal: {
117 open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)),127 open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)),
118 write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)),128 write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)),
...@@ -120,8 +130,12 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient {...@@ -120,8 +130,12 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient {
120 clear: (input) => transport.request((client) => client[WS_METHODS.terminalClear](input)),130 clear: (input) => transport.request((client) => client[WS_METHODS.terminalClear](input)),
121 restart: (input) => transport.request((client) => client[WS_METHODS.terminalRestart](input)),131 restart: (input) => transport.request((client) => client[WS_METHODS.terminalRestart](input)),
122 close: (input) => transport.request((client) => client[WS_METHODS.terminalClose](input)),132 close: (input) => transport.request((client) => client[WS_METHODS.terminalClose](input)),
123 onEvent: (listener) =>133 onEvent: (listener, options) =>
124 transport.subscribe((client) => client[WS_METHODS.subscribeTerminalEvents]({}), listener),134 transport.subscribe(
135 (client) => client[WS_METHODS.subscribeTerminalEvents]({}),
136 listener,
137 options,
138 ),
125 },139 },
126 projects: {140 projects: {
127 searchEntries: (input) =>141 searchEntries: (input) =>
...@@ -179,10 +193,18 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient {...@@ -179,10 +193,18 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient {
179 getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})),193 getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})),
180 updateSettings: (patch) =>194 updateSettings: (patch) =>
181 transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })),195 transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })),
182 subscribeConfig: (listener) =>196 subscribeConfig: (listener, options) =>
183 transport.subscribe((client) => client[WS_METHODS.subscribeServerConfig]({}), listener),197 transport.subscribe(
184 subscribeLifecycle: (listener) =>198 (client) => client[WS_METHODS.subscribeServerConfig]({}),
185 transport.subscribe((client) => client[WS_METHODS.subscribeServerLifecycle]({}), listener),199 listener,
200 options,
201 ),
202 subscribeLifecycle: (listener, options) =>
203 transport.subscribe(
204 (client) => client[WS_METHODS.subscribeServerLifecycle]({}),
205 listener,
206 options,
207 ),
186 },208 },
187 orchestration: {209 orchestration: {
188 getSnapshot: () =>210 getSnapshot: () =>
...@@ -197,10 +219,11 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient {...@@ -197,10 +219,11 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient {
197 transport219 transport
198 .request((client) => client[ORCHESTRATION_WS_METHODS.replayEvents](input))220 .request((client) => client[ORCHESTRATION_WS_METHODS.replayEvents](input))
199 .then((events) => [...events]),221 .then((events) => [...events]),
200 onDomainEvent: (listener) =>222 onDomainEvent: (listener, options) =>
201 transport.subscribe(223 transport.subscribe(
202 (client) => client[WS_METHODS.subscribeOrchestrationDomainEvents]({}),224 (client) => client[WS_METHODS.subscribeOrchestrationDomainEvents]({}),
203 listener,225 listener,
226 options,
204 ),227 ),
205 },228 },
206 };229 };
apps/web/src/wsTransport.test.ts+261-5
...@@ -5,6 +5,12 @@ import {...@@ -5,6 +5,12 @@ import {
5 __resetClientTracingForTests,5 __resetClientTracingForTests,
6 configureClientTracing,6 configureClientTracing,
7} from "./observability/clientTracing";7} from "./observability/clientTracing";
8import { getSlowRpcAckRequests, resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";
9import {
10 getWsConnectionStatus,
11 getWsConnectionUiState,
12 resetWsConnectionStateForTests,
13} from "./rpc/wsConnectionState";
8import { WsTransport } from "./wsTransport";14import { WsTransport } from "./wsTransport";
915
10type WsEventType = "open" | "message" | "close" | "error";16type WsEventType = "open" | "message" | "close" | "error";
...@@ -57,6 +63,10 @@ class MockWebSocket {...@@ -57,6 +63,10 @@ class MockWebSocket {
57 this.emit("message", { data, type: "message" });63 this.emit("message", { data, type: "message" });
58 }64 }
5965
66 error() {
67 this.emit("error", { type: "error" });
68 }
69
60 private emit(type: WsEventType, event?: WsEvent) {70 private emit(type: WsEventType, event?: WsEvent) {
61 const listeners = this.listeners.get(type);71 const listeners = this.listeners.get(type);
62 if (!listeners) return;72 if (!listeners) return;
...@@ -93,7 +103,10 @@ async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise<void>...@@ -93,7 +103,10 @@ async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise<void>
93}103}
94104
95beforeEach(() => {105beforeEach(() => {
106 vi.useRealTimers();
96 sockets.length = 0;107 sockets.length = 0;
108 resetRequestLatencyStateForTests();
109 resetWsConnectionStateForTests();
97110
98 Object.defineProperty(globalThis, "window", {111 Object.defineProperty(globalThis, "window", {
99 configurable: true,112 configurable: true,
...@@ -107,14 +120,20 @@ beforeEach(() => {...@@ -107,14 +120,20 @@ beforeEach(() => {
107 desktopBridge: undefined,120 desktopBridge: undefined,
108 },121 },
109 });122 });
123 Object.defineProperty(globalThis, "navigator", {
124 configurable: true,
125 value: { onLine: true },
126 });
110127
111 globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;128 globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
112});129});
113130
114afterEach(() => {131afterEach(async () => {
115 globalThis.WebSocket = originalWebSocket;132 globalThis.WebSocket = originalWebSocket;
116 globalThis.fetch = originalFetch;133 globalThis.fetch = originalFetch;
117 void __resetClientTracingForTests();134 resetRequestLatencyStateForTests();
135 resetWsConnectionStateForTests();
136 await __resetClientTracingForTests();
118 vi.restoreAllMocks();137 vi.restoreAllMocks();
119});138});
120139
...@@ -148,6 +167,184 @@ describe("WsTransport", () => {...@@ -148,6 +167,184 @@ describe("WsTransport", () => {
148 await transport.dispose();167 await transport.dispose();
149 });168 });
150169
170 it("tracks initial connection failures for the app error state", async () => {
171 const transport = new WsTransport("ws://localhost:3020");
172
173 await waitFor(() => {
174 expect(sockets).toHaveLength(1);
175 });
176
177 const socket = getSocket();
178 expect(getWsConnectionStatus()).toMatchObject({
179 attemptCount: 1,
180 phase: "connecting",
181 socketUrl: "ws://localhost:3020/ws",
182 });
183
184 socket.error();
185 socket.close(1006, "server unavailable");
186
187 await waitFor(() => {
188 expect(getWsConnectionStatus()).toMatchObject({
189 closeCode: 1006,
190 closeReason: "server unavailable",
191 hasConnected: false,
192 lastError: "Unable to connect to the T3 server WebSocket.",
193 phase: "disconnected",
194 });
195 });
196 expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("error");
197
198 await transport.dispose();
199 });
200
201 it("surfaces reconnecting state after a live socket disconnects", async () => {
202 const transport = new WsTransport("ws://localhost:3020");
203
204 await waitFor(() => {
205 expect(sockets).toHaveLength(1);
206 });
207
208 const socket = getSocket();
209 socket.open();
210
211 await waitFor(() => {
212 expect(getWsConnectionStatus()).toMatchObject({
213 hasConnected: true,
214 phase: "connected",
215 });
216 });
217
218 socket.close(1013, "try again later");
219
220 await waitFor(() => {
221 expect(getWsConnectionStatus()).toMatchObject({
222 closeReason: "try again later",
223 hasConnected: true,
224 });
225 });
226 expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("reconnecting");
227
228 await transport.dispose();
229 });
230
231 it("reconnects the websocket session without disposing the transport", async () => {
232 const transport = new WsTransport("ws://localhost:3020");
233
234 await waitFor(() => {
235 expect(sockets).toHaveLength(1);
236 });
237
238 const firstSocket = getSocket();
239 firstSocket.open();
240
241 await waitFor(() => {
242 expect(getWsConnectionStatus()).toMatchObject({
243 hasConnected: true,
244 phase: "connected",
245 });
246 });
247
248 await transport.reconnect();
249
250 await waitFor(() => {
251 expect(sockets).toHaveLength(2);
252 });
253
254 const secondSocket = getSocket();
255 expect(secondSocket).not.toBe(firstSocket);
256 expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED);
257
258 const requestPromise = transport.request((client) =>
259 client[WS_METHODS.serverUpsertKeybinding]({
260 command: "terminal.toggle",
261 key: "ctrl+k",
262 }),
263 );
264
265 secondSocket.open();
266
267 await waitFor(() => {
268 expect(secondSocket.sent).toHaveLength(1);
269 });
270
271 const requestMessage = JSON.parse(secondSocket.sent[0] ?? "{}") as { id: string };
272 secondSocket.serverMessage(
273 JSON.stringify({
274 _tag: "Exit",
275 requestId: requestMessage.id,
276 exit: {
277 _tag: "Success",
278 value: {
279 keybindings: [],
280 issues: [],
281 },
282 },
283 }),
284 );
285
286 await expect(requestPromise).resolves.toEqual({
287 keybindings: [],
288 issues: [],
289 });
290
291 await transport.dispose();
292 });
293
294 it("marks unary requests as slow until the first server ack arrives", async () => {
295 const transport = new WsTransport("ws://localhost:3020");
296
297 const requestPromise = transport.request((client) =>
298 client[WS_METHODS.serverUpsertKeybinding]({
299 command: "terminal.toggle",
300 key: "ctrl+k",
301 }),
302 );
303
304 await waitFor(() => {
305 expect(sockets).toHaveLength(1);
306 });
307
308 const socket = getSocket();
309 socket.open();
310
311 await waitFor(() => {
312 expect(socket.sent).toHaveLength(1);
313 });
314
315 const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string };
316 await waitFor(() => {
317 expect(getSlowRpcAckRequests()).toMatchObject([
318 {
319 requestId: requestMessage.id,
320 tag: WS_METHODS.serverUpsertKeybinding,
321 },
322 ]);
323 }, 5_000);
324
325 socket.serverMessage(
326 JSON.stringify({
327 _tag: "Exit",
328 requestId: requestMessage.id,
329 exit: {
330 _tag: "Success",
331 value: {
332 keybindings: [],
333 issues: [],
334 },
335 },
336 }),
337 );
338
339 await expect(requestPromise).resolves.toEqual({
340 keybindings: [],
341 issues: [],
342 });
343 expect(getSlowRpcAckRequests()).toEqual([]);
344
345 await transport.dispose();
346 }, 10_000);
347
151 it("sends unary RPC requests and resolves successful exits", async () => {348 it("sends unary RPC requests and resolves successful exits", async () => {
152 const transport = new WsTransport("ws://localhost:3020");349 const transport = new WsTransport("ws://localhost:3020");
153350
...@@ -257,10 +454,12 @@ describe("WsTransport", () => {...@@ -257,10 +454,12 @@ describe("WsTransport", () => {
257 it("re-subscribes stream listeners after the stream exits", async () => {454 it("re-subscribes stream listeners after the stream exits", async () => {
258 const transport = new WsTransport("ws://localhost:3020");455 const transport = new WsTransport("ws://localhost:3020");
259 const listener = vi.fn();456 const listener = vi.fn();
457 const onResubscribe = vi.fn();
260458
261 const unsubscribe = transport.subscribe(459 const unsubscribe = transport.subscribe(
262 (client) => client[WS_METHODS.subscribeServerLifecycle]({}),460 (client) => client[WS_METHODS.subscribeServerLifecycle]({}),
263 listener,461 listener,
462 { onResubscribe },
264 );463 );
265 await waitFor(() => {464 await waitFor(() => {
266 expect(sockets).toHaveLength(1);465 expect(sockets).toHaveLength(1);
...@@ -308,6 +507,7 @@ describe("WsTransport", () => {...@@ -308,6 +507,7 @@ describe("WsTransport", () => {
308 .find((message) => message._tag === "Request" && message.id !== firstRequest.id);507 .find((message) => message._tag === "Request" && message.id !== firstRequest.id);
309 expect(nextRequest).toBeDefined();508 expect(nextRequest).toBeDefined();
310 });509 });
510 expect(onResubscribe).toHaveBeenCalledOnce();
311511
312 const secondRequest = socket.sent512 const secondRequest = socket.sent
313 .map((message) => JSON.parse(message) as { _tag?: string; id?: string; tag?: string })513 .map((message) => JSON.parse(message) as { _tag?: string; id?: string; tag?: string })
...@@ -346,6 +546,52 @@ describe("WsTransport", () => {...@@ -346,6 +546,52 @@ describe("WsTransport", () => {
346 await transport.dispose();546 await transport.dispose();
347 });547 });
348548
549 it("does not fire onResubscribe when the first stream attempt exits before any value", async () => {
550 const transport = new WsTransport("ws://localhost:3020");
551 const listener = vi.fn();
552 const onResubscribe = vi.fn();
553
554 const unsubscribe = transport.subscribe(
555 (client) => client[WS_METHODS.subscribeServerLifecycle]({}),
556 listener,
557 { onResubscribe },
558 );
559 await waitFor(() => {
560 expect(sockets).toHaveLength(1);
561 });
562
563 const socket = getSocket();
564 socket.open();
565
566 await waitFor(() => {
567 expect(socket.sent).toHaveLength(1);
568 });
569
570 const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string };
571 socket.serverMessage(
572 JSON.stringify({
573 _tag: "Exit",
574 requestId: firstRequest.id,
575 exit: {
576 _tag: "Success",
577 value: null,
578 },
579 }),
580 );
581
582 await waitFor(() => {
583 const nextRequest = socket.sent
584 .map((message) => JSON.parse(message) as { _tag?: string; id?: string })
585 .find((message) => message._tag === "Request" && message.id !== firstRequest.id);
586 expect(nextRequest).toBeDefined();
587 });
588 expect(onResubscribe).not.toHaveBeenCalled();
589 expect(listener).not.toHaveBeenCalled();
590
591 unsubscribe();
592 await transport.dispose();
593 });
594
349 it("streams finite request events without re-subscribing", async () => {595 it("streams finite request events without re-subscribing", async () => {
350 const transport = new WsTransport("ws://localhost:3020");596 const transport = new WsTransport("ws://localhost:3020");
351 const listener = vi.fn();597 const listener = vi.fn();
...@@ -429,11 +675,21 @@ describe("WsTransport", () => {...@@ -429,11 +675,21 @@ describe("WsTransport", () => {
429 };675 };
430 const transport = {676 const transport = {
431 disposed: false,677 disposed: false,
432 clientScope: {} as never,678 session: {
433 runtime,679 clientScope: {} as never,
680 runtime,
681 },
682 closeSession: (
683 WsTransport.prototype as unknown as {
684 closeSession: (session: {
685 clientScope: unknown;
686 runtime: { dispose: () => Promise<void>; runPromise: () => Promise<void> };
687 }) => Promise<void>;
688 }
689 ).closeSession,
434 } as unknown as WsTransport;690 } as unknown as WsTransport;
435691
436 WsTransport.prototype.dispose.call(transport);692 void WsTransport.prototype.dispose.call(transport);
437693
438 expect(runtime.runPromise).toHaveBeenCalledTimes(1);694 expect(runtime.runPromise).toHaveBeenCalledTimes(1);
439 expect(runtime.dispose).not.toHaveBeenCalled();695 expect(runtime.dispose).not.toHaveBeenCalled();
apps/web/src/wsTransport.ts+170-44
...@@ -1,15 +1,26 @@...@@ -1,15 +1,26 @@
1import { Duration, Effect, Exit, Layer, ManagedRuntime, Option, Scope, Stream } from "effect";1import {
2 Cause,
3 Duration,
4 Effect,
5 Exit,
6 Layer,
7 ManagedRuntime,
8 Option,
9 Scope,
10 Stream,
11} from "effect";
12import { RpcClient } from "effect/unstable/rpc";
213
14import { ClientTracingLive, configureClientTracing } from "./observability/clientTracing";
3import {15import {
4 createWsRpcProtocolLayer,16 createWsRpcProtocolLayer,
5 makeWsRpcProtocolClient,17 makeWsRpcProtocolClient,
6 type WsRpcProtocolClient,18 type WsRpcProtocolClient,
7} from "./rpc/protocol";19} from "./rpc/protocol";
8import { RpcClient } from "effect/unstable/rpc";
9import { ClientTracingLive, configureClientTracing } from "./observability/clientTracing";
1020
11interface SubscribeOptions {21interface SubscribeOptions {
12 readonly retryDelay?: Duration.Input;22 readonly retryDelay?: Duration.Input;
23 readonly onResubscribe?: () => void;
13}24}
1425
15interface RequestOptions {26interface RequestOptions {
...@@ -17,6 +28,13 @@ interface RequestOptions {...@@ -17,6 +28,13 @@ interface RequestOptions {
17}28}
1829
19const DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS = Duration.millis(250);30const DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS = Duration.millis(250);
31const NOOP: () => void = () => undefined;
32
33interface TransportSession {
34 readonly clientPromise: Promise<WsRpcProtocolClient>;
35 readonly clientScope: Scope.Closeable;
36 readonly runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never>;
37}
2038
21function formatErrorMessage(error: unknown): string {39function formatErrorMessage(error: unknown): string {
22 if (error instanceof Error && error.message.trim().length > 0) {40 if (error instanceof Error && error.message.trim().length > 0) {
...@@ -26,21 +44,16 @@ function formatErrorMessage(error: unknown): string {...@@ -26,21 +44,16 @@ function formatErrorMessage(error: unknown): string {
26}44}
2745
28export class WsTransport {46export class WsTransport {
29 private readonly runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never>;
30 private readonly clientScope: Scope.Closeable;
31 private readonly clientPromise: Promise<WsRpcProtocolClient>;
32 private readonly tracingReady: Promise<void>;47 private readonly tracingReady: Promise<void>;
48 private readonly url: string | undefined;
33 private disposed = false;49 private disposed = false;
50 private reconnectChain: Promise<void> = Promise.resolve();
51 private session: TransportSession;
3452
35 constructor(url?: string) {53 constructor(url?: string) {
54 this.url = url;
36 this.tracingReady = configureClientTracing();55 this.tracingReady = configureClientTracing();
37 this.runtime = ManagedRuntime.make(56 this.session = this.createSession();
38 Layer.mergeAll(createWsRpcProtocolLayer(url), ClientTracingLive),
39 );
40 this.clientScope = this.runtime.runSync(Scope.make());
41 this.clientPromise = this.runtime.runPromise(
42 Scope.provide(this.clientScope)(makeWsRpcProtocolClient),
43 );
44 }57 }
4558
46 async request<TSuccess>(59 async request<TSuccess>(
...@@ -52,8 +65,9 @@ export class WsTransport {...@@ -52,8 +65,9 @@ export class WsTransport {
52 }65 }
5366
54 await this.tracingReady;67 await this.tracingReady;
55 const client = await this.clientPromise;68 const session = this.session;
56 return await this.runtime.runPromise(Effect.suspend(() => execute(client)));69 const client = await session.clientPromise;
70 return await session.runtime.runPromise(Effect.suspend(() => execute(client)));
57 }71 }
5872
59 async requestStream<TValue>(73 async requestStream<TValue>(
...@@ -65,8 +79,9 @@ export class WsTransport {...@@ -65,8 +79,9 @@ export class WsTransport {
65 }79 }
6680
67 await this.tracingReady;81 await this.tracingReady;
68 const client = await this.clientPromise;82 const session = this.session;
69 await this.runtime.runPromise(83 const client = await session.clientPromise;
84 await session.runtime.runPromise(
70 Stream.runForEach(connect(client), (value) =>85 Stream.runForEach(connect(client), (value) =>
71 Effect.sync(() => {86 Effect.sync(() => {
72 try {87 try {
...@@ -89,16 +104,132 @@ export class WsTransport {...@@ -89,16 +104,132 @@ export class WsTransport {
89 }104 }
90105
91 let active = true;106 let active = true;
92 const retryDelayMs = options?.retryDelay ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS;107 let hasReceivedValue = false;
93 const cancel = this.runtime.runCallback(108 const retryDelayMs = Duration.toMillis(
109 Duration.fromInputUnsafe(options?.retryDelay ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS),
110 );
111 let cancelCurrentStream: () => void = NOOP;
112
113 void (async () => {
114 for (;;) {
115 if (!active || this.disposed) {
116 return;
117 }
118
119 try {
120 if (hasReceivedValue) {
121 try {
122 options?.onResubscribe?.();
123 } catch {
124 // Swallow reconnect hook errors so the stream can recover.
125 }
126 }
127
128 const session = this.session;
129 const runningStream = this.runStreamOnSession(
130 session,
131 connect,
132 listener,
133 () => active,
134 () => {
135 hasReceivedValue = true;
136 },
137 );
138 cancelCurrentStream = runningStream.cancel;
139 await runningStream.completed;
140 cancelCurrentStream = NOOP;
141 } catch (error) {
142 cancelCurrentStream = NOOP;
143 if (!active || this.disposed) {
144 return;
145 }
146
147 console.warn("WebSocket RPC subscription disconnected", {
148 error: formatErrorMessage(error),
149 });
150 await sleep(retryDelayMs);
151 }
152 }
153 })();
154
155 return () => {
156 active = false;
157 cancelCurrentStream();
158 };
159 }
160
161 async reconnect() {
162 if (this.disposed) {
163 throw new Error("Transport disposed");
164 }
165
166 const reconnectOperation = this.reconnectChain.then(async () => {
167 if (this.disposed) {
168 throw new Error("Transport disposed");
169 }
170
171 const previousSession = this.session;
172 this.session = this.createSession();
173 await this.closeSession(previousSession);
174 });
175
176 this.reconnectChain = reconnectOperation.catch(() => undefined);
177 await reconnectOperation;
178 }
179
180 async dispose() {
181 if (this.disposed) {
182 return;
183 }
184 this.disposed = true;
185 await this.closeSession(this.session);
186 }
187
188 private closeSession(session: TransportSession) {
189 return session.runtime.runPromise(Scope.close(session.clientScope, Exit.void)).finally(() => {
190 session.runtime.dispose();
191 });
192 }
193
194 private createSession(): TransportSession {
195 const runtime = ManagedRuntime.make(
196 Layer.mergeAll(createWsRpcProtocolLayer(this.url), ClientTracingLive),
197 );
198 const clientScope = runtime.runSync(Scope.make());
199 return {
200 runtime,
201 clientScope,
202 clientPromise: runtime.runPromise(Scope.provide(clientScope)(makeWsRpcProtocolClient)),
203 };
204 }
205
206 private runStreamOnSession<TValue>(
207 session: TransportSession,
208 connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>,
209 listener: (value: TValue) => void,
210 isActive: () => boolean,
211 markValueReceived: () => void,
212 ): {
213 readonly cancel: () => void;
214 readonly completed: Promise<void>;
215 } {
216 let resolveCompleted!: () => void;
217 let rejectCompleted!: (error: unknown) => void;
218 const completed = new Promise<void>((resolve, reject) => {
219 resolveCompleted = resolve;
220 rejectCompleted = reject;
221 });
222 const cancel = session.runtime.runCallback(
94 Effect.promise(() => this.tracingReady).pipe(223 Effect.promise(() => this.tracingReady).pipe(
95 Effect.flatMap(() => Effect.promise(() => this.clientPromise)),224 Effect.flatMap(() => Effect.promise(() => session.clientPromise)),
96 Effect.flatMap((client) =>225 Effect.flatMap((client) =>
97 Stream.runForEach(connect(client), (value) =>226 Stream.runForEach(connect(client), (value) =>
98 Effect.sync(() => {227 Effect.sync(() => {
99 if (!active) {228 if (!isActive()) {
100 return;229 return;
101 }230 }
231
232 markValueReceived();
102 try {233 try {
103 listener(value);234 listener(value);
104 } catch {235 } catch {
...@@ -107,33 +238,28 @@ export class WsTransport {...@@ -107,33 +238,28 @@ export class WsTransport {
107 }),238 }),
108 ),239 ),
109 ),240 ),
110 Effect.catch((error) => {
111 if (!active || this.disposed) {
112 return Effect.interrupt;
113 }
114 return Effect.sync(() => {
115 console.warn("WebSocket RPC subscription disconnected", {
116 error: formatErrorMessage(error),
117 });
118 }).pipe(Effect.andThen(Effect.sleep(retryDelayMs)));
119 }),
120 Effect.forever,
121 ),241 ),
242 {
243 onExit: (exit) => {
244 if (Exit.isSuccess(exit)) {
245 resolveCompleted();
246 return;
247 }
248
249 rejectCompleted(Cause.squash(exit.cause));
250 },
251 },
122 );252 );
123253
124 return () => {254 return {
125 active = false;255 cancel,
126 cancel();256 completed,
127 };257 };
128 }258 }
259}
129260
130 async dispose() {261function sleep(ms: number): Promise<void> {
131 if (this.disposed) {262 return new Promise((resolve) => {
132 return;263 setTimeout(resolve, ms);
133 }264 });
134 this.disposed = true;
135 await this.runtime.runPromise(Scope.close(this.clientScope, Exit.void)).finally(() => {
136 this.runtime.dispose();
137 });
138 }
139}265}
packages/contracts/src/ipc.ts+6-1
...@@ -180,6 +180,11 @@ export interface NativeApi {...@@ -180,6 +180,11 @@ export interface NativeApi {
180 input: OrchestrationGetFullThreadDiffInput,180 input: OrchestrationGetFullThreadDiffInput,
181 ) => Promise<OrchestrationGetFullThreadDiffResult>;181 ) => Promise<OrchestrationGetFullThreadDiffResult>;
182 replayEvents: (fromSequenceExclusive: number) => Promise<OrchestrationEvent[]>;182 replayEvents: (fromSequenceExclusive: number) => Promise<OrchestrationEvent[]>;
183 onDomainEvent: (callback: (event: OrchestrationEvent) => void) => () => void;183 onDomainEvent: (
184 callback: (event: OrchestrationEvent) => void,
185 options?: {
186 onResubscribe?: () => void;
187 },
188 ) => () => void;
184 };189 };
185}190}