| 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,10 +6,12 @@ import type { WsRpcClient } from "~/rpc/wsRpcClient"; |
| 6 | 6 | ||
| 7 | function createTestClient(options?: { | 7 | function createTestClient(options?: { |
| 8 | readonly getSnapshot?: () => Promise<{ readonly snapshotSequence: number }>; | 8 | readonly getSnapshot?: () => Promise<{ readonly snapshotSequence: number }>; |
| 9 | readonly replayEvents?: () => Promise<ReadonlyArray<any>>; | ||
| 9 | }) { | 10 | }) { |
| 10 | const lifecycleListeners = new Set<(event: any) => void>(); | 11 | const lifecycleListeners = new Set<(event: any) => void>(); |
| 11 | const configListeners = new Set<(event: any) => void>(); | 12 | const configListeners = new Set<(event: any) => void>(); |
| 12 | const terminalListeners = new Set<(event: any) => void>(); | 13 | const terminalListeners = new Set<(event: any) => void>(); |
| 14 | let domainResubscribe: (() => void) | undefined; | ||
| 13 | 15 | ||
| 14 | const getSnapshot = vi.fn( | 16 | const getSnapshot = vi.fn( |
| 15 | options?.getSnapshot ?? | 17 | options?.getSnapshot ?? |
| ... | @@ -20,6 +22,7 @@ function createTestClient(options?: { | ... | @@ -20,6 +22,7 @@ function createTestClient(options?: { |
| 20 | threads: [], | 22 | threads: [], |
| 21 | }) as any), | 23 | }) as any), |
| 22 | ); | 24 | ); |
| 25 | const replayEvents = vi.fn(options?.replayEvents ?? (async () => [])); | ||
| 23 | 26 | ||
| 24 | const client = { | 27 | const client = { |
| 25 | dispose: vi.fn(async () => undefined), | 28 | dispose: vi.fn(async () => undefined), |
| ... | @@ -49,8 +52,15 @@ function createTestClient(options?: { | ... | @@ -49,8 +52,15 @@ function createTestClient(options?: { |
| 49 | dispatchCommand: vi.fn(async () => undefined), | 52 | dispatchCommand: vi.fn(async () => undefined), |
| 50 | getTurnDiff: vi.fn(async () => undefined), | 53 | getTurnDiff: vi.fn(async () => undefined), |
| 51 | getFullThreadDiff: vi.fn(async () => undefined), | 54 | getFullThreadDiff: vi.fn(async () => undefined), |
| 52 | replayEvents: vi.fn(async () => []), | 55 | replayEvents, |
| 53 | onDomainEvent: () => () => undefined, | 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 | terminal: { | 65 | terminal: { |
| 56 | open: vi.fn(async () => undefined), | 66 | open: vi.fn(async () => undefined), |
| ... | @@ -114,6 +124,9 @@ function createTestClient(options?: { | ... | @@ -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,4 +226,63 @@ describe("createEnvironmentConnection", () => { |
| 213 | 226 | ||
| 214 | await connection.dispose(); | 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,6 +128,10 @@ export function createEnvironmentConnection( |
| 128 | queueMicrotask(flushPendingDomainEvents); | 128 | queueMicrotask(flushPendingDomainEvents); |
| 129 | }; | 129 | }; |
| 130 | 130 | ||
| 131 | const scheduleReplayRecovery = (reason: "sequence-gap" | "resubscribe") => { | ||
| 132 | void runReplayRecovery(reason).catch(() => undefined); | ||
| 133 | }; | ||
| 134 | |||
| 131 | const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise<void> => { | 135 | const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise<void> => { |
| 132 | if (!recovery.beginReplayRecovery(reason)) { | 136 | if (!recovery.beginReplayRecovery(reason)) { |
| 133 | return; | 137 | return; |
| ... | @@ -172,7 +176,7 @@ export function createEnvironmentConnection( | ... | @@ -172,7 +176,7 @@ export function createEnvironmentConnection( |
| 172 | return; | 176 | return; |
| 173 | } | 177 | } |
| 174 | } | 178 | } |
| 175 | void runReplayRecovery(reason); | 179 | scheduleReplayRecovery(reason); |
| 176 | } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") { | 180 | } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") { |
| 177 | console.warn( | 181 | console.warn( |
| 178 | "[orchestration-recovery]", | 182 | "[orchestration-recovery]", |
| ... | @@ -198,7 +202,7 @@ export function createEnvironmentConnection( | ... | @@ -198,7 +202,7 @@ export function createEnvironmentConnection( |
| 198 | if (!disposed) { | 202 | if (!disposed) { |
| 199 | input.syncSnapshot(snapshot, environmentId); | 203 | input.syncSnapshot(snapshot, environmentId); |
| 200 | if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) { | 204 | if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) { |
| 201 | void runReplayRecovery("sequence-gap"); | 205 | scheduleReplayRecovery("sequence-gap"); |
| 202 | } | 206 | } |
| 203 | } | 207 | } |
| 204 | } catch (error) { | 208 | } catch (error) { |
| ... | @@ -245,7 +249,7 @@ export function createEnvironmentConnection( | ... | @@ -245,7 +249,7 @@ export function createEnvironmentConnection( |
| 245 | } | 249 | } |
| 246 | if (action === "recover") { | 250 | if (action === "recover") { |
| 247 | flushPendingDomainEvents(); | 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,7 +258,7 @@ export function createEnvironmentConnection( |
| 254 | return; | 258 | return; |
| 255 | } | 259 | } |
| 256 | flushPendingDomainEvents(); | 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,6 +707,39 @@ describe("WsTransport", () => { |
| 707 | await transport.dispose(); | 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 | it("streams finite request events without re-subscribing", async () => { | 743 | it("streams finite request events without re-subscribing", async () => { |
| 711 | const transport = new WsTransport("ws://localhost:3020"); | 744 | const transport = new WsTransport("ws://localhost:3020"); |
| 712 | const listener = vi.fn(); | 745 | const listener = vi.fn(); |
apps/web/src/rpc/wsTransport.ts+8-3| ... | @@ -50,6 +50,7 @@ export class WsTransport { | ... | @@ -50,6 +50,7 @@ export class WsTransport { |
| 50 | private readonly url: WsRpcProtocolSocketUrlProvider; | 50 | private readonly url: WsRpcProtocolSocketUrlProvider; |
| 51 | private readonly lifecycleHandlers: WsProtocolLifecycleHandlers | undefined; | 51 | private readonly lifecycleHandlers: WsProtocolLifecycleHandlers | undefined; |
| 52 | private disposed = false; | 52 | private disposed = false; |
| 53 | private hasReportedTransportDisconnect = false; | ||
| 53 | private reconnectChain: Promise<void> = Promise.resolve(); | 54 | private reconnectChain: Promise<void> = Promise.resolve(); |
| 54 | private session: TransportSession; | 55 | private session: TransportSession; |
| 55 | 56 | ||
| ... | @@ -136,6 +137,7 @@ export class WsTransport { | ... | @@ -136,6 +137,7 @@ export class WsTransport { |
| 136 | listener, | 137 | listener, |
| 137 | () => active, | 138 | () => active, |
| 138 | () => { | 139 | () => { |
| 140 | this.hasReportedTransportDisconnect = false; | ||
| 139 | hasReceivedValue = true; | 141 | hasReceivedValue = true; |
| 140 | }, | 142 | }, |
| 141 | ); | 143 | ); |
| ... | @@ -156,9 +158,12 @@ export class WsTransport { | ... | @@ -156,9 +158,12 @@ export class WsTransport { |
| 156 | return; | 158 | return; |
| 157 | } | 159 | } |
| 158 | 160 | ||
| 159 | console.warn("WebSocket RPC subscription disconnected", { | 161 | if (!this.hasReportedTransportDisconnect) { |
| 160 | error: formattedError, | 162 | console.warn("WebSocket RPC subscription disconnected", { |
| 161 | }); | 163 | error: formattedError, |
| 164 | }); | ||
| 165 | } | ||
| 166 | this.hasReportedTransportDisconnect = true; | ||
| 162 | await sleep(retryDelayMs); | 167 | await sleep(retryDelayMs); |
| 163 | } | 168 | } |
| 164 | } | 169 | } |