From 1667731c6ae661d1587152c2f120e2f39575c414 Mon Sep 17 00:00:00 2001 From: ScottN-PV Date: Mon, 19 Oct 2026 11:27:18 -0400 Subject: [PATCH 1/2] fix(server): report synchronous HTML browser spawn failures --- apps/server/src/htmlRender/HtmlRender.test.ts | 64 ++++++++++++++++++- apps/server/src/htmlRender/headlessChrome.ts | 7 ++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/apps/server/src/htmlRender/HtmlRender.test.ts b/apps/server/src/htmlRender/HtmlRender.test.ts index c317e6881e65..f246cf02dc37 100644 --- a/apps/server/src/htmlRender/HtmlRender.test.ts +++ b/apps/server/src/htmlRender/HtmlRender.test.ts @@ -1,11 +1,12 @@ // @effect-diagnostics nodeBuiltinImport:off - plain local servers stand in for LAN services. import * as NodeServices from "@effect/platform-node/NodeServices"; -import { describe, expect, it } from "@effect/vitest"; +import { assert, describe, expect, it } from "@effect/vitest"; import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Base64 from "effect/encoding/Base64"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; @@ -19,6 +20,7 @@ import * as NodeURL from "node:url"; import { resolveAttachmentPathById } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; import * as HtmlRender from "./HtmlRender.ts"; +import { HtmlRenderBrowserError } from "./headlessChrome.ts"; import * as PreviewBrowser from "../preview/PreviewBrowser.ts"; const TEST_BROWSER_ENV = "T3CODE_TEST_HEADLESS_SHELL"; @@ -287,6 +289,66 @@ describe("HtmlRender", () => { }), ); + // Windows reports ENOENT asynchronously for this path instead of throwing ENOTDIR. + describe.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "when the browser spawn throws synchronously", + () => { + it.live("reports a typed preview error", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const file = yield* fileSystem.makeTempFileScoped({ prefix: "t3-browser-spawn-" }); + const outcome = yield* HtmlRender.HtmlRender.use((htmlRender) => + htmlRender.preview({ html: "

x

" }).pipe(Effect.flip), + ).pipe( + Effect.provide(layerHtmlRender(path.join(file, "chrome-headless-shell"))), + Effect.exit, + ); + assert(Exit.isSuccess(outcome), "Expected a typed preview error."); + const error = outcome.value; + expect(error).toBeInstanceOf(HtmlRenderBrowserError); + expect(error.message).toBe( + "Headless Chrome could not render the page: the browser could not be started.", + ); + expect(error).toMatchObject({ cause: { code: "ENOTDIR" } }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.live("publishes the page without measured heights", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const file = yield* fileSystem.makeTempFileScoped({ prefix: "t3-browser-spawn-" }); + yield* Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const htmlRender = yield* HtmlRender.HtmlRender; + const outcome = yield* htmlRender + .publish({ + threadId: ThreadId.make("thread-html-spawn"), + html: "

Browser unavailable

", + title: "Saved page", + height: 300, + }) + .pipe(Effect.exit); + assert(Exit.isSuccess(outcome), "Expected a published attachment."); + const reference = outcome.value; + expect(reference).toEqual({ + attachmentId: expect.any(String), + title: "Saved page", + height: 300, + }); + const stored = resolveAttachmentPathById({ + attachmentsDir: config.attachmentsDir, + attachmentId: reference.attachmentId, + }); + expect(yield* fileSystem.readFileString(stored ?? "")).toContain( + "

Browser unavailable

", + ); + }).pipe(Effect.provide(layerHtmlRender(path.join(file, "chrome-headless-shell")))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + }, + ); + it.live("keeps every local file but the page itself out of the browser", (ctx) => Effect.gen(function* () { const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV]; diff --git a/apps/server/src/htmlRender/headlessChrome.ts b/apps/server/src/htmlRender/headlessChrome.ts index 4ad26a4aa683..d9339b9c4694 100644 --- a/apps/server/src/htmlRender/headlessChrome.ts +++ b/apps/server/src/htmlRender/headlessChrome.ts @@ -229,6 +229,13 @@ const launchBrowser = Effect.fnUntraced(function* (input: { (cause) => new HtmlRenderBrowserError({ reason: "the browser could not be started", cause }), ), + // Node throws some spawn errors synchronously, such as `spawn UNKNOWN` + // when policy blocks the executable; `mapError` cannot catch those. + Effect.catchDefect((cause) => + Effect.fail( + new HtmlRenderBrowserError({ reason: "the browser could not be started", cause }), + ), + ), ); let stderrTail = ""; From 8212834b065f4460d8e3668143e121c97d5fd5bd Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 21 Oct 2026 06:20:52 -0700 Subject: [PATCH 2/2] test(server): keep only the typed preview error test for browser spawn failures Co-Authored-By: Claude Opus 5.5 --- apps/server/src/htmlRender/HtmlRender.test.ts | 33 ------------------- 1 file changed, 33 deletions(-) diff --git a/apps/server/src/htmlRender/HtmlRender.test.ts b/apps/server/src/htmlRender/HtmlRender.test.ts index f246cf02dc37..1bbdf0e483ee 100644 --- a/apps/server/src/htmlRender/HtmlRender.test.ts +++ b/apps/server/src/htmlRender/HtmlRender.test.ts @@ -313,39 +313,6 @@ describe("HtmlRender", () => { expect(error).toMatchObject({ cause: { code: "ENOTDIR" } }); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.live("publishes the page without measured heights", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const file = yield* fileSystem.makeTempFileScoped({ prefix: "t3-browser-spawn-" }); - yield* Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const htmlRender = yield* HtmlRender.HtmlRender; - const outcome = yield* htmlRender - .publish({ - threadId: ThreadId.make("thread-html-spawn"), - html: "

Browser unavailable

", - title: "Saved page", - height: 300, - }) - .pipe(Effect.exit); - assert(Exit.isSuccess(outcome), "Expected a published attachment."); - const reference = outcome.value; - expect(reference).toEqual({ - attachmentId: expect.any(String), - title: "Saved page", - height: 300, - }); - const stored = resolveAttachmentPathById({ - attachmentsDir: config.attachmentsDir, - attachmentId: reference.attachmentId, - }); - expect(yield* fileSystem.readFileString(stored ?? "")).toContain( - "

Browser unavailable

", - ); - }).pipe(Effect.provide(layerHtmlRender(path.join(file, "chrome-headless-shell")))); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); }, );