| author | |
| committer | |
| log | 9bedd7140ce22f7f05d3990cdb50cafcceae37de |
| tree | bf46c838f432519aa5bef0553e0a79cf976a1520 |
| parent | 70f5dfcef78a8e153ab698c0b83661974a4fc033 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 123 insertions(+), 9 deletions(-)
apps/web/src/environments/runtime/connection.test.ts+74-2| ... | ... | @@ -6,10 +6,12 @@ import type { WsRpcClient } from "~/rpc/wsRpcClient"; |
| 6 | 6 | |
| 7 | 7 | function createTestClient(options?: { |
| 8 | 8 | readonly getSnapshot?: () => Promise<{ readonly snapshotSequence: number }>; |
| 9 | readonly replayEvents?: () => Promise<ReadonlyArray<any>>; | |
| 9 | 10 | }) { |
| 10 | 11 | const lifecycleListeners = new Set<(event: any) => void>(); |
| 11 | 12 | const configListeners = new Set<(event: any) => void>(); |
| 12 | 13 | const terminalListeners = new Set<(event: any) => void>(); |
| 14 | let domainResubscribe: (() => void) | undefined; | |
| 13 | 15 | |
| 14 | 16 | const getSnapshot = vi.fn( |
| 15 | 17 | options?.getSnapshot ?? |
| ... | ... | @@ -20,6 +22,7 @@ function createTestClient(options?: { |
| 20 | 22 | threads: [], |
| 21 | 23 | }) as any), |
| 22 | 24 | ); |
| 25 | const replayEvents = vi.fn(options?.replayEvents ?? (async () => [])); | |
| 23 | 26 | |
| 24 | 27 | const client = { |
| 25 | 28 | dispose: vi.fn(async () => undefined), |
| ... | ... | @@ -49,8 +52,15 @@ function createTestClient(options?: { |
| 49 | 52 | dispatchCommand: vi.fn(async () => undefined), |
| 50 | 53 | getTurnDiff: vi.fn(async () => undefined), |
| 51 | 54 | getFullThreadDiff: vi.fn(async () => undefined), |
| 52 | replayEvents: vi.fn(async () => []), | |
| 53 | onDomainEvent: () => () => undefined, | |
| 55 | replayEvents, | |
| 56 | onDomainEvent: vi.fn((_: (event: any) => void, options?: { onResubscribe?: () => void }) => { | |
| 57 | domainResubscribe = options?.onResubscribe; | |
| 58 | return () => { | |
| 59 | if (domainResubscribe === options?.onResubscribe) { | |
| 60 | domainResubscribe = undefined; | |
| 61 | } | |
| 62 | }; | |
| 63 | }), | |
| 54 | 64 | }, |
| 55 | 65 | terminal: { |
| 56 | 66 | open: vi.fn(async () => undefined), |
| ... | ... | @@ -114,6 +124,9 @@ function createTestClient(options?: { |
| 114 | 124 | }); |
| 115 | 125 | } |
| 116 | 126 | }, |
| 127 | triggerDomainResubscribe: () => { | |
| 128 | domainResubscribe?.(); | |
| 129 | }, | |
| 117 | 130 | }; |
| 118 | 131 | } |
| 119 | 132 | |
| ... | ... | @@ -213,4 +226,63 @@ describe("createEnvironmentConnection", () => { |
| 213 | 226 | |
| 214 | 227 | await connection.dispose(); |
| 215 | 228 | }); |
| 229 | ||
| 230 | it("swallows replay recovery failures triggered by resubscribe", async () => { | |
| 231 | const environmentId = EnvironmentId.makeUnsafe("env-1"); | |
| 232 | const snapshotError = new Error("snapshot failed"); | |
| 233 | let snapshotCalls = 0; | |
| 234 | const { client, triggerDomainResubscribe } = createTestClient({ | |
| 235 | getSnapshot: async () => { | |
| 236 | snapshotCalls += 1; | |
| 237 | if (snapshotCalls === 1) { | |
| 238 | return { | |
| 239 | snapshotSequence: 1, | |
| 240 | projects: [], | |
| 241 | threads: [], | |
| 242 | } as any; | |
| 243 | } | |
| 244 | ||
| 245 | throw snapshotError; | |
| 246 | }, | |
| 247 | replayEvents: async () => { | |
| 248 | throw new Error("SocketCloseError: 1006"); | |
| 249 | }, | |
| 250 | }); | |
| 251 | ||
| 252 | const connection = createEnvironmentConnection({ | |
| 253 | kind: "saved", | |
| 254 | knownEnvironment: { | |
| 255 | id: "env-1", | |
| 256 | label: "Remote env", | |
| 257 | source: "manual", | |
| 258 | target: { | |
| 259 | httpBaseUrl: "http://example.test", | |
| 260 | wsBaseUrl: "ws://example.test", | |
| 261 | }, | |
| 262 | environmentId, | |
| 263 | }, | |
| 264 | client, | |
| 265 | applyEventBatch: vi.fn(), | |
| 266 | syncSnapshot: vi.fn(), | |
| 267 | applyTerminalEvent: vi.fn(), | |
| 268 | }); | |
| 269 | ||
| 270 | await Promise.resolve(); | |
| 271 | await Promise.resolve(); | |
| 272 | ||
| 273 | const onUnhandledRejection = vi.fn(); | |
| 274 | process.on("unhandledRejection", onUnhandledRejection); | |
| 275 | ||
| 276 | try { | |
| 277 | triggerDomainResubscribe(); | |
| 278 | await new Promise((resolve) => setTimeout(resolve, 0)); | |
| 279 | await new Promise((resolve) => setTimeout(resolve, 0)); | |
| 280 | } finally { | |
| 281 | process.off("unhandledRejection", onUnhandledRejection); | |
| 282 | } | |
| 283 | ||
| 284 | expect(onUnhandledRejection).not.toHaveBeenCalled(); | |
| 285 | ||
| 286 | await connection.dispose(); | |
| 287 | }); | |
| 216 | 288 | }); |
apps/web/src/environments/runtime/connection.ts+8-4| ... | ... | @@ -128,6 +128,10 @@ export function createEnvironmentConnection( |
| 128 | 128 | queueMicrotask(flushPendingDomainEvents); |
| 129 | 129 | }; |
| 130 | 130 | |
| 131 | const scheduleReplayRecovery = (reason: "sequence-gap" | "resubscribe") => { | |
| 132 | void runReplayRecovery(reason).catch(() => undefined); | |
| 133 | }; | |
| 134 | ||
| 131 | 135 | const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise<void> => { |
| 132 | 136 | if (!recovery.beginReplayRecovery(reason)) { |
| 133 | 137 | return; |
| ... | ... | @@ -172,7 +176,7 @@ export function createEnvironmentConnection( |
| 172 | 176 | return; |
| 173 | 177 | } |
| 174 | 178 | } |
| 175 | void runReplayRecovery(reason); | |
| 179 | scheduleReplayRecovery(reason); | |
| 176 | 180 | } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") { |
| 177 | 181 | console.warn( |
| 178 | 182 | "[orchestration-recovery]", |
| ... | ... | @@ -198,7 +202,7 @@ export function createEnvironmentConnection( |
| 198 | 202 | if (!disposed) { |
| 199 | 203 | input.syncSnapshot(snapshot, environmentId); |
| 200 | 204 | if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) { |
| 201 | void runReplayRecovery("sequence-gap"); | |
| 205 | scheduleReplayRecovery("sequence-gap"); | |
| 202 | 206 | } |
| 203 | 207 | } |
| 204 | 208 | } catch (error) { |
| ... | ... | @@ -245,7 +249,7 @@ export function createEnvironmentConnection( |
| 245 | 249 | } |
| 246 | 250 | if (action === "recover") { |
| 247 | 251 | flushPendingDomainEvents(); |
| 248 | void runReplayRecovery("sequence-gap"); | |
| 252 | scheduleReplayRecovery("sequence-gap"); | |
| 249 | 253 | } |
| 250 | 254 | }, |
| 251 | 255 | { |
| ... | ... | @@ -254,7 +258,7 @@ export function createEnvironmentConnection( |
| 254 | 258 | return; |
| 255 | 259 | } |
| 256 | 260 | flushPendingDomainEvents(); |
| 257 | void runReplayRecovery("resubscribe"); | |
| 261 | scheduleReplayRecovery("resubscribe"); | |
| 258 | 262 | }, |
| 259 | 263 | }, |
| 260 | 264 | ); |
apps/web/src/rpc/wsTransport.test.ts+33| ... | ... | @@ -707,6 +707,39 @@ describe("WsTransport", () => { |
| 707 | 707 | await transport.dispose(); |
| 708 | 708 | }); |
| 709 | 709 | |
| 710 | it("logs a transport disconnect once even when multiple subscriptions fail together", async () => { | |
| 711 | const transport = new WsTransport("ws://localhost:3020"); | |
| 712 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); | |
| 713 | ||
| 714 | const unsubscribeA = transport.subscribe( | |
| 715 | () => Stream.fail(new Error("SocketCloseError: 1006")), | |
| 716 | vi.fn(), | |
| 717 | { retryDelay: 10 }, | |
| 718 | ); | |
| 719 | const unsubscribeB = transport.subscribe( | |
| 720 | () => Stream.fail(new Error("SocketCloseError: 1006")), | |
| 721 | vi.fn(), | |
| 722 | { retryDelay: 10 }, | |
| 723 | ); | |
| 724 | ||
| 725 | await waitFor(() => { | |
| 726 | expect(sockets).toHaveLength(1); | |
| 727 | }); | |
| 728 | ||
| 729 | getSocket().open(); | |
| 730 | ||
| 731 | await waitFor(() => { | |
| 732 | expect(warnSpy).toHaveBeenCalledTimes(1); | |
| 733 | }); | |
| 734 | expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription disconnected", { | |
| 735 | error: "SocketCloseError: 1006", | |
| 736 | }); | |
| 737 | ||
| 738 | unsubscribeA(); | |
| 739 | unsubscribeB(); | |
| 740 | await transport.dispose(); | |
| 741 | }); | |
| 742 | ||
| 710 | 743 | it("streams finite request events without re-subscribing", async () => { |
| 711 | 744 | const transport = new WsTransport("ws://localhost:3020"); |
| 712 | 745 | const listener = vi.fn(); |
apps/web/src/rpc/wsTransport.ts+8-3| ... | ... | @@ -50,6 +50,7 @@ export class WsTransport { |
| 50 | 50 | private readonly url: WsRpcProtocolSocketUrlProvider; |
| 51 | 51 | private readonly lifecycleHandlers: WsProtocolLifecycleHandlers | undefined; |
| 52 | 52 | private disposed = false; |
| 53 | private hasReportedTransportDisconnect = false; | |
| 53 | 54 | private reconnectChain: Promise<void> = Promise.resolve(); |
| 54 | 55 | private session: TransportSession; |
| 55 | 56 | |
| ... | ... | @@ -136,6 +137,7 @@ export class WsTransport { |
| 136 | 137 | listener, |
| 137 | 138 | () => active, |
| 138 | 139 | () => { |
| 140 | this.hasReportedTransportDisconnect = false; | |
| 139 | 141 | hasReceivedValue = true; |
| 140 | 142 | }, |
| 141 | 143 | ); |
| ... | ... | @@ -156,9 +158,12 @@ export class WsTransport { |
| 156 | 158 | return; |
| 157 | 159 | } |
| 158 | 160 | |
| 159 | console.warn("WebSocket RPC subscription disconnected", { | |
| 160 | error: formattedError, | |
| 161 | }); | |
| 161 | if (!this.hasReportedTransportDisconnect) { | |
| 162 | console.warn("WebSocket RPC subscription disconnected", { | |
| 163 | error: formattedError, | |
| 164 | }); | |
| 165 | } | |
| 166 | this.hasReportedTransportDisconnect = true; | |
| 162 | 167 | await sleep(retryDelayMs); |
| 163 | 168 | } |
| 164 | 169 | } |