authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-18 20:37:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 20:37:20-07:00
log5378f87f99175bded2b9241876319567875aca03
tree79e9e6d2da8288d27f2a49d9cb6ed59acb25057c
parent823119350ee29cca434b1295aa5c560f4c9ef98a
signature Signed by PGP key B5690EEEBB952194

fix(preview): recover host registration after request timeouts (#12535)


4 files changed, 336 insertions(+), 28 deletions(-)

apps/server/src/mcp/PreviewAutomationBroker.test.ts+128-6
...@@ -10,18 +10,22 @@ import {...@@ -10,18 +10,22 @@ import {
10 PreviewTabId,10 PreviewTabId,
11 ProviderInstanceId,11 ProviderInstanceId,
12 ThreadId,12 ThreadId,
13 WS_METHODS,
14 WsRpcGroup,
13 type PreviewAutomationHost,15 type PreviewAutomationHost,
14 type PreviewAutomationRequest,16 type PreviewAutomationRequest,
15 type PreviewAutomationStreamEvent,17 type PreviewAutomationStreamEvent,
16} from "@t3tools/contracts";18} from "@t3tools/contracts";
17import * as Effect from "effect/Effect";19import * as Effect from "effect/Effect";
18import * as Exit from "effect/Exit";20import * as Exit from "effect/Exit";
19import * as Cause from "effect/Cause";
20import * as Deferred from "effect/Deferred";21import * as Deferred from "effect/Deferred";
21import * as Fiber from "effect/Fiber";22import * as Fiber from "effect/Fiber";
22import * as Result from "effect/Result";23import * as Result from "effect/Result";
24import * as Scheduler from "effect/Scheduler";
23import * as Stream from "effect/Stream";25import * as Stream from "effect/Stream";
24import * as TestClock from "effect/testing/TestClock";26import * as TestClock from "effect/testing/TestClock";
27import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
28import * as RpcTest from "effect/unstable/rpc/RpcTest";
2529
26import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";30import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
2731
...@@ -1118,7 +1122,19 @@ it.effect("evicts an unanswered host and lets later calls use a healthy runtime"...@@ -1118,7 +1122,19 @@ it.effect("evicts an unanswered host and lets later calls use a healthy runtime"
1118 const otherReceived = yield* Deferred.make<void>();1122 const otherReceived = yield* Deferred.make<void>();
1119 const otherCompleted = yield* Deferred.make<void>();1123 const otherCompleted = yield* Deferred.make<void>();
1120 const oldTab = PreviewTabId.make("tab-on-frozen-host");1124 const oldTab = PreviewTabId.make("tab-on-frozen-host");
1121 const events = yield* broker.connect(makeHost());1125 const group = RpcGroup.make(
1126 ...Array.from(WsRpcGroup.requests.values()).filter(
1127 (rpc) => rpc._tag === WS_METHODS.previewAutomationConnect,
1128 ),
1129 );
1130 const client = yield* RpcTest.makeClient(group).pipe(
1131 Effect.provide(
1132 group.toLayer({
1133 [WS_METHODS.previewAutomationConnect]: (host) => Stream.unwrap(broker.connect(host)),
1134 }),
1135 ),
1136 );
1137 const events = client[WS_METHODS.previewAutomationConnect](makeHost());
1122 const consumer = yield* Stream.runForEach(events, (event) => {1138 const consumer = yield* Stream.runForEach(events, (event) => {
1123 if (event.type === "connected") return Deferred.succeed(connected, event.connectionId);1139 if (event.type === "connected") return Deferred.succeed(connected, event.connectionId);
1124 const request = { ...event.request, connectionId: event.connectionId };1140 const request = { ...event.request, connectionId: event.connectionId };
...@@ -1183,10 +1199,7 @@ it.effect("evicts an unanswered host and lets later calls use a healthy runtime"...@@ -1183,10 +1199,7 @@ it.effect("evicts an unanswered host and lets later calls use a healthy runtime"
1183 _tag: "PreviewAutomationClientDisconnectedError",1199 _tag: "PreviewAutomationClientDisconnectedError",
1184 });1200 });
1185 const consumerExit = yield* Fiber.await(consumer);1201 const consumerExit = yield* Fiber.await(consumer);
1186 expect(Exit.isFailure(consumerExit)).toBe(true);1202 expect(Exit.isSuccess(consumerExit)).toBe(true);
1187 if (Exit.isFailure(consumerExit)) {
1188 expect(Cause.hasInterruptsOnly(consumerExit.cause)).toBe(true);
1189 }
11901203
1191 // Late traffic from the evicted connection cannot restore its assignment.1204 // Late traffic from the evicted connection cannot restore its assignment.
1192 yield* broker.respond({1205 yield* broker.respond({
...@@ -1209,6 +1222,115 @@ it.effect("evicts an unanswered host and lets later calls use a healthy runtime"...@@ -1209,6 +1222,115 @@ it.effect("evicts an unanswered host and lets later calls use a healthy runtime"
1209 ),1222 ),
1210);1223);
12111224
1225it.effect("discards buffered actions before completing an evicted host stream", () =>
1226 Effect.scoped(
1227 Effect.gen(function* () {
1228 const broker = yield* makeBroker;
1229 const connected = yield* Deferred.make<void>();
1230 const received = yield* Deferred.make<void>();
1231 const actionRouted = yield* Deferred.make<void>();
1232 const releaseConsumer = yield* Deferred.make<void>();
1233 const operations: string[] = [];
1234 const consumer = yield* Stream.runForEach(yield* broker.connect(makeHost()), (event) => {
1235 if (event.type === "connected") return Deferred.succeed(connected, undefined);
1236 operations.push(event.request.operation);
1237 return Deferred.succeed(received, undefined).pipe(
1238 Effect.andThen(Deferred.await(releaseConsumer)),
1239 );
1240 }).pipe(Effect.forkScoped);
1241 yield* Deferred.await(connected);
1242 const timedOut = yield* broker
1243 .invoke<void>({ scope, operation: "snapshot", input: {}, timeoutMs: 1_000 })
1244 .pipe(Effect.flip, Effect.forkScoped);
1245 yield* Deferred.await(received);
1246 const buffered = yield* broker
1247 .invoke<void>({
1248 scope,
1249 operation: "click",
1250 input: {},
1251 timeoutMs: 10_000,
1252 onTargetTab: () => {
1253 Deferred.doneUnsafe(actionRouted, Effect.void);
1254 },
1255 })
1256 .pipe(Effect.flip, Effect.forkScoped);
1257 yield* Deferred.await(actionRouted);
1258 yield* TestClock.adjust(1_000);
1259 expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" });
1260 expect(yield* Fiber.join(buffered)).toMatchObject({
1261 _tag: "PreviewAutomationClientDisconnectedError",
1262 });
1263 yield* Deferred.succeed(releaseConsumer, undefined);
1264 expect(Exit.isSuccess(yield* Fiber.await(consumer))).toBe(true);
1265 expect(operations).toEqual(["snapshot"]);
1266 }),
1267 ),
1268);
1269
1270it.effect("rejects a routed action when its generation is evicted before delivery", () =>
1271 Effect.scoped(
1272 Effect.gen(function* () {
1273 const broker = yield* makeBroker;
1274 const connected = yield* Deferred.make<void>();
1275 const received = yield* Deferred.make<void>();
1276 const actionRouted = yield* Deferred.make<void>();
1277 const operations: string[] = [];
1278 const consumer = yield* Stream.runForEach(yield* broker.connect(makeHost()), (event) => {
1279 if (event.type === "connected") return Deferred.succeed(connected, undefined);
1280 operations.push(event.request.operation);
1281 return Deferred.succeed(received, undefined);
1282 }).pipe(Effect.forkScoped);
1283 yield* Deferred.await(connected);
1284 const timedOut = yield* broker
1285 .invoke<void>({ scope, operation: "snapshot", input: {}, timeoutMs: 1_000 })
1286 .pipe(Effect.flip, Effect.forkScoped);
1287 yield* Deferred.await(received);
1288
1289 // Suspend only this invocation in the gap between route selection and delivery.
1290 const tasks: Array<() => void> = [];
1291 let paused = false;
1292 const dispatcher: Scheduler.SchedulerDispatcher = {
1293 scheduleTask: (task) => tasks.push(task),
1294 flush: () => {
1295 let task: (() => void) | undefined;
1296 while ((task = tasks.shift()) !== undefined) task();
1297 },
1298 };
1299 const scheduler: Scheduler.Scheduler = {
1300 executionMode: "async",
1301 makeDispatcher: () => dispatcher,
1302 shouldYield: () => paused,
1303 };
1304 const action = yield* broker
1305 .invoke<void>({
1306 scope,
1307 operation: "click",
1308 input: {},
1309 onTargetTab: () => {
1310 paused = true;
1311 Deferred.doneUnsafe(actionRouted, Effect.void);
1312 },
1313 })
1314 .pipe(
1315 Effect.flip,
1316 Effect.provideService(Scheduler.Scheduler, scheduler),
1317 Effect.forkScoped,
1318 );
1319 yield* Deferred.await(actionRouted);
1320 yield* TestClock.adjust(1_000);
1321 expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" });
1322 expect(Exit.isSuccess(yield* Fiber.await(consumer))).toBe(true);
1323
1324 paused = false;
1325 dispatcher.flush();
1326 expect(yield* Fiber.join(action)).toMatchObject({
1327 _tag: "PreviewAutomationClientDisconnectedError",
1328 });
1329 expect(operations).toEqual(["snapshot"]);
1330 }),
1331 ),
1332);
1333
1212it.effect("keeps a host that responds with an operation timeout", () =>1334it.effect("keeps a host that responds with an operation timeout", () =>
1213 Effect.scoped(1335 Effect.scoped(
1214 Effect.gen(function* () {1336 Effect.gen(function* () {
apps/server/src/mcp/PreviewAutomationBroker.ts+45-19
...@@ -26,6 +26,7 @@ import {...@@ -26,6 +26,7 @@ import {
26 type PreviewAutomationStreamEvent,26 type PreviewAutomationStreamEvent,
27} from "@t3tools/contracts";27} from "@t3tools/contracts";
28import * as Context from "effect/Context";28import * as Context from "effect/Context";
29import type * as Cause from "effect/Cause";
29import * as Crypto from "effect/Crypto";30import * as Crypto from "effect/Crypto";
30import * as Deferred from "effect/Deferred";31import * as Deferred from "effect/Deferred";
31import * as Effect from "effect/Effect";32import * as Effect from "effect/Effect";
...@@ -73,7 +74,7 @@ interface ClientConnection {...@@ -73,7 +74,7 @@ interface ClientConnection {
73 readonly supportedOperations: ReadonlySet<PreviewAutomationOperation>;74 readonly supportedOperations: ReadonlySet<PreviewAutomationOperation>;
74 readonly focused: boolean;75 readonly focused: boolean;
75 readonly focusOrder: number;76 readonly focusOrder: number;
76 readonly queue: Queue.Queue<PreviewAutomationStreamEvent>;77 readonly queue: Queue.Queue<PreviewAutomationStreamEvent, Cause.Done>;
77}78}
7879
79interface PendingRequest {80interface PendingRequest {
...@@ -326,32 +327,47 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -326,32 +327,47 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
326 const closeConnection = Effect.fn("PreviewAutomationBroker.closeConnection")(function* (327 const closeConnection = Effect.fn("PreviewAutomationBroker.closeConnection")(function* (
327 queue: ClientConnection["queue"],328 queue: ClientConnection["queue"],
328 disconnected: ReadonlyArray<PendingRequest>,329 disconnected: ReadonlyArray<PendingRequest>,
330 completeStream = false,
329 ) {331 ) {
332 if (completeStream) {
333 // Discard this generation's commands and complete the RPC stream so a
334 // responsive desktop can re-register after a timeout eviction.
335 yield* Queue.clear(queue);
336 yield* Queue.end(queue);
337 } else {
338 // Replaced registrations must not reconnect and displace their successor.
339 yield* Queue.shutdown(queue);
340 }
330 yield* Effect.forEach(341 yield* Effect.forEach(
331 disconnected,342 disconnected,
332 ({ deferred, context }) =>343 ({ deferred, context }) =>
333 Deferred.fail(deferred, new PreviewAutomationClientDisconnectedError(context)),344 Deferred.fail(deferred, new PreviewAutomationClientDisconnectedError(context)),
334 { discard: true },345 { discard: true },
335 );346 );
336 yield* Queue.shutdown(queue);
337 });347 });
338348
339 const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* (349 const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* (
340 clientId: string,350 clientId: string,
341 queue: ClientConnection["queue"],351 queue: ClientConnection["queue"],
352 completeStream = false,
342 ) {353 ) {
343 const disconnected = yield* SynchronizedRef.modify(state, (current) => {354 yield* SynchronizedRef.modifyEffect(state, (current) => {
355 // Retired generations were already closed by their replacement or eviction.
356 if (current.clients.get(clientId)?.queue !== queue) {
357 return Effect.succeed([undefined, current] as const);
358 }
344 const removed = removeConnectionFromState(current, clientId, queue);359 const removed = removeConnectionFromState(current, clientId, queue);
345 return [removed.disconnected, removed.state] as const;360 return closeConnection(queue, removed.disconnected, completeStream).pipe(
361 Effect.as([undefined, removed.state] as const),
362 );
346 });363 });
347 yield* closeConnection(queue, disconnected);
348 });364 });
349365
350 const acquireConnection = Effect.fn("PreviewAutomationBroker.acquireConnection")(function* (366 const acquireConnection = Effect.fn("PreviewAutomationBroker.acquireConnection")(function* (
351 host: PreviewAutomationHost,367 host: PreviewAutomationHost,
352 ) {368 ) {
353 const clientId = host.clientId;369 const clientId = host.clientId;
354 const queue = yield* Queue.unbounded<PreviewAutomationStreamEvent>();370 const queue = yield* Queue.unbounded<PreviewAutomationStreamEvent, Cause.Done>();
355 const connectionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie);371 const connectionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie);
356 yield* Queue.offer(queue, { type: "connected", connectionId });372 yield* Queue.offer(queue, { type: "connected", connectionId });
357 const connection: ClientConnection = {373 const connection: ClientConnection = {
...@@ -553,18 +569,28 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -553,18 +569,28 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
553 return { ...next, pending };569 return { ...next, pending };
554 });570 });
555 const awaitResponse = Effect.fn("PreviewAutomationBroker.awaitResponse")(function* () {571 const awaitResponse = Effect.fn("PreviewAutomationBroker.awaitResponse")(function* () {
556 const offered = yield* Queue.offer(connection.queue, {572 const offered = yield* SynchronizedRef.modifyEffect(state, (current) => {
557 type: "request",573 // A route can outlive its generation while another request evicts it.
558 connectionId: connection.connectionId,574 // Serialize the live-generation check and offer with queue closure.
559 request: {575 if (
560 requestId,576 current.clients.get(connection.clientId)?.queue !== connection.queue ||
561 threadId: input.scope.threadId,577 !current.pending.has(requestId)
562 tabId: requestContext.tabId,578 ) {
563 tabIdExplicit: input.tabId !== undefined,579 return Effect.succeed([false, current] as const);
564 operation: input.operation,580 }
565 input: input.input,581 return Queue.offer(connection.queue, {
566 timeoutMs,582 type: "request",
567 },583 connectionId: connection.connectionId,
584 request: {
585 requestId,
586 threadId: input.scope.threadId,
587 tabId: requestContext.tabId,
588 tabIdExplicit: input.tabId !== undefined,
589 operation: input.operation,
590 input: input.input,
591 timeoutMs,
592 },
593 }).pipe(Effect.map((offered) => [offered, current] as const));
568 });594 });
569 if (!offered) {595 if (!offered) {
570 const completion = yield* Deferred.poll(deferred);596 const completion = yield* Deferred.poll(deferred);
...@@ -579,7 +605,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -579,7 +605,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
579 Effect.gen(function* () {605 Effect.gen(function* () {
580 // An unanswered request invalidates this connection. Do not replay606 // An unanswered request invalidates this connection. Do not replay
581 // actions: the client may have applied them before becoming unreachable.607 // actions: the client may have applied them before becoming unreachable.
582 yield* disconnect(connection.clientId, connection.queue);608 yield* disconnect(connection.clientId, connection.queue, true);
583 return yield* new PreviewAutomationTimeoutError(requestContext);609 return yield* new PreviewAutomationTimeoutError(requestContext);
584 }),610 }),
585 onSome: (value) => Effect.succeed(value as A),611 onSome: (value) => Effect.succeed(value as A),
packages/client-runtime/src/rpc/client.test.ts+153
...@@ -1,6 +1,9 @@...@@ -1,6 +1,9 @@
1import {1import {
2 DEFAULT_SERVER_SETTINGS,2 DEFAULT_SERVER_SETTINGS,
3 EnvironmentId,3 EnvironmentId,
4 PreviewTabId,
5 ThreadId,
6 type PreviewAutomationStreamEvent,
4 type RelayClientInstallProgressEvent,7 type RelayClientInstallProgressEvent,
5 type ServerConfigStreamEvent,8 type ServerConfigStreamEvent,
6 type ServerLifecycleStreamEvent,9 type ServerLifecycleStreamEvent,
...@@ -88,6 +91,156 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () {...@@ -88,6 +91,156 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () {
88});91});
8992
90describe("environment RPC", () => {93describe("environment RPC", () => {
94 it.effect("registers a fresh preview host after completion without replaying requests", () =>
95 Effect.gen(function* () {
96 const firstCompleted = yield* Deferred.make<void>();
97 const reconnected = yield* Deferred.make<void>();
98 const requests: string[] = [];
99 const connections: string[] = [];
100 let attempts = 0;
101 const client = {
102 [WS_METHODS.previewAutomationConnect]: () =>
103 Stream.suspend(() => {
104 attempts += 1;
105 const connected: PreviewAutomationStreamEvent = {
106 type: "connected",
107 connectionId: `connection-${attempts}`,
108 };
109 return attempts === 1
110 ? Stream.make(connected, {
111 type: "request",
112 connectionId: connected.connectionId,
113 request: {
114 requestId: "timed-out-action",
115 operation: "click",
116 threadId: ThreadId.make("thread-1"),
117 tabId: PreviewTabId.make("tab-1"),
118 input: {},
119 timeoutMs: 1_000,
120 },
121 } satisfies PreviewAutomationStreamEvent).pipe(
122 Stream.ensuring(Deferred.succeed(firstCompleted, undefined)),
123 )
124 : Stream.succeed(connected).pipe(Stream.concat(Stream.never));
125 }),
126 } as unknown as WsRpcProtocolClient;
127 const { activeSession, supervisor } = yield* makeHarness();
128 yield* SubscriptionRef.set(activeSession, Option.some(session(client)));
129 const consumer = yield* subscribe(WS_METHODS.previewAutomationConnect, {
130 clientId: "preview-host",
131 environmentId: TARGET.environmentId,
132 }).pipe(
133 Stream.runForEach((event) => {
134 if (event.type === "request") {
135 requests.push(event.request.requestId);
136 return Effect.void;
137 }
138 connections.push(event.connectionId);
139 return connections.length === 2 ? Deferred.succeed(reconnected, undefined) : Effect.void;
140 }),
141 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
142 Effect.forkChild,
143 );
144 yield* Deferred.await(firstCompleted);
145 yield* TestClock.adjust(999);
146 expect(attempts).toBe(1);
147 yield* TestClock.adjust(1);
148 expect(attempts).toBe(2);
149 yield* Deferred.await(reconnected);
150 expect(connections).toEqual(["connection-1", "connection-2"]);
151 expect(requests).toEqual(["timed-out-action"]);
152 yield* Fiber.interrupt(consumer);
153 }),
154 );
155
156 it.effect("does not re-register an unmounted preview host during the recovery delay", () =>
157 Effect.gen(function* () {
158 const completed = yield* Deferred.make<void>();
159 let attempts = 0;
160 const client = {
161 [WS_METHODS.previewAutomationConnect]: () =>
162 Stream.suspend(() => {
163 attempts += 1;
164 return Stream.empty.pipe(Stream.ensuring(Deferred.succeed(completed, undefined)));
165 }),
166 } as unknown as WsRpcProtocolClient;
167 const { activeSession, supervisor } = yield* makeHarness();
168 yield* SubscriptionRef.set(activeSession, Option.some(session(client)));
169 const consumer = yield* subscribe(WS_METHODS.previewAutomationConnect, {
170 clientId: "preview-host",
171 environmentId: TARGET.environmentId,
172 }).pipe(
173 Stream.runDrain,
174 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
175 Effect.forkChild,
176 );
177 yield* Deferred.await(completed);
178 yield* Fiber.interrupt(consumer);
179 yield* TestClock.adjust(10_000);
180 expect(attempts).toBe(1);
181 }),
182 );
183
184 it.effect.each(["completion", "transport failure"] as const)(
185 "keeps preview recovery tied to the active session after %s",
186 (reason) =>
187 Effect.gen(function* () {
188 const completed = yield* Deferred.make<void>();
189 const nextConnected = yield* Deferred.make<void>();
190 let oldAttempts = 0;
191 let nextAttempts = 0;
192 const firstClient = {
193 [WS_METHODS.previewAutomationConnect]: () =>
194 Stream.suspend(() => {
195 oldAttempts += 1;
196 return (
197 reason === "completion"
198 ? Stream.empty
199 : Stream.fail(
200 new RpcClientError.RpcClientError({
201 reason: new RpcClientError.RpcClientDefect({
202 message: "socket closed",
203 cause: new Error("socket closed"),
204 }),
205 }),
206 )
207 ).pipe(Stream.ensuring(Deferred.succeed(completed, undefined)));
208 }),
209 } as unknown as WsRpcProtocolClient;
210 const nextClient = {
211 [WS_METHODS.previewAutomationConnect]: () =>
212 Stream.suspend(() => {
213 nextAttempts += 1;
214 return Stream.fromEffect(Deferred.succeed(nextConnected, undefined)).pipe(
215 Stream.drain,
216 Stream.concat(Stream.never),
217 );
218 }),
219 } as unknown as WsRpcProtocolClient;
220 const { activeSession, supervisor } = yield* makeHarness();
221 yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient)));
222 const consumer = yield* subscribe(WS_METHODS.previewAutomationConnect, {
223 clientId: "preview-host",
224 environmentId: TARGET.environmentId,
225 }).pipe(
226 Stream.runDrain,
227 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
228 Effect.forkChild,
229 );
230 yield* Deferred.await(completed);
231 if (reason === "transport failure") {
232 yield* TestClock.adjust(10_000);
233 expect(oldAttempts).toBe(1);
234 }
235 yield* SubscriptionRef.set(activeSession, Option.some(session(nextClient)));
236 yield* Deferred.await(nextConnected);
237 yield* TestClock.adjust(10_000);
238 expect(oldAttempts).toBe(1);
239 expect(nextAttempts).toBe(1);
240 yield* Fiber.interrupt(consumer);
241 }),
242 );
243
91 it.effect("reuses the session config stream instead of opening a duplicate subscription", () =>244 it.effect("reuses the session config stream instead of opening a duplicate subscription", () =>
92 Effect.gen(function* () {245 Effect.gen(function* () {
93 const event: ServerConfigStreamEvent = {246 const event: ServerConfigStreamEvent = {
packages/client-runtime/src/rpc/client.ts+10-3
...@@ -5,6 +5,7 @@ import type * as Duration from "effect/Duration";...@@ -5,6 +5,7 @@ import type * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
6import * as Option from "effect/Option";6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
8import * as Schedule from "effect/Schedule";
8import * as Stream from "effect/Stream";9import * as Stream from "effect/Stream";
9import * as SubscriptionRef from "effect/SubscriptionRef";10import * as SubscriptionRef from "effect/SubscriptionRef";
10import { RpcClientError } from "effect/unstable/rpc";11import { RpcClientError } from "effect/unstable/rpc";
...@@ -233,9 +234,15 @@ function subscribeDynamicMapped<TTag extends EnvironmentSubscriptionRpcTag, A>(...@@ -233,9 +234,15 @@ function subscribeDynamicMapped<TTag extends EnvironmentSubscriptionRpcTag, A>(
233 method: tag,234 method: tag,
234 input,235 input,
235 });236 });
236 return mapStream(session, method(input)).pipe(237 const stream = mapStream(session, method(input));
237 Stream.ensuring(completeObservation),238 // An evicted preview host completes its registration stream.
238 );239 // Re-register only after completion; failures still follow the
240 // session recovery policy and browser actions are never replayed.
241 return (
242 tag === WS_METHODS.previewAutomationConnect
243 ? stream.pipe(Stream.repeat(Schedule.spaced("1 second")))
244 : stream
245 ).pipe(Stream.ensuring(completeObservation));
239 }),246 }),
240 ).pipe(247 ).pipe(
241 Stream.tapCause((cause) =>248 Stream.tapCause((cause) =>