From 708fbefcc0a32fabfce2ca4f5425da1f7d1dddc2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:55:22 +0000 Subject: [PATCH 1/2] test(server): a timed-out preview request evicts the shared server browser host Reproduces #16921: one thread's stalled snapshot fails another thread's in-flight open on the same server-browser host. AI-assisted; verified with the tests and tools listed above. --- .../src/mcp/PreviewAutomationBroker.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 1c193096db273..177217a31f63e 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -20,6 +20,7 @@ import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { PreviewBrowserRootError } from "../preview/PreviewBrowserHost.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; @@ -372,6 +373,65 @@ it.effect("names the click target that matched nothing when the server browser t ), ); +it.effect("keeps the server browser host for other threads when one request times out", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const clientId = SERVER_BROWSER_AUTOMATION_CLIENT_ID; + const otherScope = { + ...scope, + requestNamespace: "provider-session-2", + thread: { + threadId: ThreadId.make("thread-2"), + providerSessionId: "provider-session-2", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + }; + const openDelivered = yield* Deferred.make(); + const requests = requestsFrom(yield* broker.connect(makeHost({ clientId, preferred: true }))); + yield* Stream.runForEach(requests, (request) => { + if (request.operation === "open") return Deferred.succeed(openDelivered, request); + if (request.operation === "snapshot") return Effect.void; + return broker.respond({ + clientId, + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: { available: true }, + }); + }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + const stalledSnapshot = yield* broker + .invoke({ scope, operation: "snapshot", input: {} }) + .pipe(Effect.flip, Effect.forkScoped); + const unrelatedOpen = yield* broker + .invoke<{ available: boolean }>({ scope: otherScope, operation: "open", input: {} }) + .pipe(Effect.result, Effect.forkScoped); + const openRequest = yield* Deferred.await(openDelivered); + + yield* TestClock.adjust("17 seconds"); + expect((yield* Fiber.join(stalledSnapshot))._tag).toBe("PreviewAutomationTimeoutError"); + + yield* broker.respond({ + clientId, + connectionId: openRequest.connectionId, + requestId: openRequest.requestId, + ok: true, + result: { available: true }, + }); + expect(yield* Fiber.join(unrelatedOpen)).toEqual(Result.succeed({ available: true })); + expect( + yield* broker.invoke<{ available: boolean }>({ + scope: otherScope, + operation: "status", + input: {}, + }), + ).toEqual({ available: true }); + }), + ), +); + it.effect("distinguishes malformed remote failures", () => Effect.scoped( Effect.gen(function* () { From 176a0403a1497e7ba0acef2c158cfe61335c22ee Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 08:04:52 +0000 Subject: [PATCH 2/2] fix(server): a timed-out preview request no longer evicts the shared server browser A foreground preview request that got no reply within its budget made the broker disconnect its host. The server browser is one in-process host shared by every thread, so that failed other threads' in-flight calls and left new calls with no host until it re-registered. Timeouts now fail only the request for the server browser; desktop hosts are still evicted so a dead connection can recover. The repro test now passes `preferred` through the connect options, as ServerBrowser does. Fixes pingdotgg/t3code#16921 AI-assisted; verified with the tests and tools listed above. --- apps/server/src/mcp/PreviewAutomationBroker.test.ts | 4 +++- apps/server/src/mcp/PreviewAutomationBroker.ts | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 177217a31f63e..21aab31897cc9 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -388,7 +388,9 @@ it.effect("keeps the server browser host for other threads when one request time }, }; const openDelivered = yield* Deferred.make(); - const requests = requestsFrom(yield* broker.connect(makeHost({ clientId, preferred: true }))); + const requests = requestsFrom( + yield* broker.connect(makeHost({ clientId }), { preferred: true }), + ); yield* Stream.runForEach(requests, (request) => { if (request.operation === "open") return Deferred.succeed(openDelivered, request); if (request.operation === "snapshot") return Effect.void; diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 85646d387160d..e721cf6a88962 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -662,7 +662,11 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { // The enclosing operation owns the span for this inline handler. onNone: Effect.fnUntraced(function* () { // Never replay possibly applied actions; slow metadata reads must not evict the host. - if (input.updateCurrentTab !== false) { + // The in-process server browser is shared by every thread, so one stall must not evict it. + if ( + input.updateCurrentTab !== false && + connection.clientId !== SERVER_BROWSER_AUTOMATION_CLIENT_ID + ) { yield* disconnect(connection.clientId, connection.queue, true); } return yield* new PreviewAutomationTimeoutError(requestContext);