From 227bf0c8a58a19b3a2489c020edca56b9ae7b6b3 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Mon, 20 Jul 2026 14:03:02 -0700 Subject: [PATCH 1/2] Avoid packaged Windows console pipe crashes --- .../src/app/DesktopObservability.test.ts | 44 +++++++++++++++---- apps/desktop/src/app/DesktopObservability.ts | 18 ++++++-- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index 9438175f602..6e55692cd62 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -1,6 +1,7 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -36,40 +37,64 @@ const TraceRecordLine = Schema.Struct({ const decodeTraceRecordLine = Schema.decodeUnknownSync(Schema.fromJsonString(TraceRecordLine)); -const environmentInput = (baseDir: string) => +const environmentInput = ( + baseDir: string, + options: { readonly development?: boolean; readonly platform?: NodeJS.Platform } = {}, +) => ({ dirname: "/repo/apps/desktop/dist-electron", homeDirectory: baseDir, - platform: "darwin", + platform: options.platform ?? "darwin", processArch: "arm64", appVersion: "1.2.3", appPath: "/repo", - isPackaged: false, + isPackaged: options.development === false, resourcesPath: "/repo/resources", runningUnderArm64Translation: false, }) satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; -const makeEnvironmentLayer = (baseDir: string) => - DesktopEnvironment.layer(environmentInput(baseDir)).pipe( +const makeEnvironmentLayer = ( + baseDir: string, + options: { readonly development?: boolean; readonly platform?: NodeJS.Platform } = {}, +) => + DesktopEnvironment.layer(environmentInput(baseDir, options)).pipe( Layer.provide( Layer.mergeAll( NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir, - VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", + ...(options.development === false + ? {} + : { VITE_DEV_SERVER_URL: "http://127.0.0.1:5733" }), }), ), ), ); describe("DesktopObservability", () => { - it.effect("persists desktop Effect logs as span events in desktop.trace.ndjson", () => + it.effect("avoids console writes while persisting packaged Windows logs", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-observability-test-", }); - const environmentLayer = makeEnvironmentLayer(baseDir); + const environmentLayer = makeEnvironmentLayer(baseDir, { + development: false, + platform: "win32", + }); + let consoleLogCalls = 0; + const throwingConsole = new Proxy(globalThis.console, { + get(target, property, receiver) { + if (property === "log") { + return () => { + consoleLogCalls += 1; + throw Object.assign(new Error("EPIPE: broken pipe, write"), { code: "EPIPE" }); + }; + } + const value = Reflect.get(target, property, receiver) as unknown; + return typeof value === "function" ? value.bind(target) : value; + }, + }) as Console.Console; const tracePath = yield* Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; return environment.path.join(environment.logDir, "desktop.trace.ndjson"); @@ -86,9 +111,12 @@ describe("DesktopObservability", () => { }).pipe( Effect.withSpan("desktop-observability-test"), Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + Effect.provideService(Console.Console, throwingConsole), ), ); + assert.equal(consoleLogCalls, 0); + const records = (yield* fileSystem.readFileString(tracePath)) .trim() .split("\n") diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 42451e45ded..384d3eaa865 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -438,9 +438,21 @@ const backendOutputLogFactoryLayer = Layer.effect( }), ); -const desktopLoggerLayer = Layer.mergeAll( - Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { mergeWithExisting: false }), - Layer.succeed(References.MinimumLogLevel, "Info"), +const desktopLoggerLayer = Layer.unwrap( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + // Packaged Windows launches can inherit a short-lived stdout pipe whose closed reader makes + // synchronous console writes throw EPIPE. The tracer still persists these logs locally. + const loggers = + environment.platform === "win32" && !environment.isDevelopment + ? [Logger.tracerLogger] + : [Logger.consolePretty(), Logger.tracerLogger]; + + return Layer.mergeAll( + Logger.layer(loggers, { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, "Info"), + ); + }), ); const tracerLayer = Layer.unwrap( From d5ba212f0224fa1d766366f444dec1a98f9a7e00 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Mon, 20 Jul 2026 16:36:30 -0700 Subject: [PATCH 2/2] Guard Windows console logging against broken pipes --- .../src/app/DesktopObservability.test.ts | 65 +++++++++++++++---- apps/desktop/src/app/DesktopObservability.ts | 31 +++++++-- 2 files changed, 76 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index 6e55692cd62..fa20cd5d10a 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -1,8 +1,10 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -37,6 +39,15 @@ const TraceRecordLine = Schema.Struct({ const decodeTraceRecordLine = Schema.decodeUnknownSync(Schema.fromJsonString(TraceRecordLine)); +const consoleWithLog = (log: Console.Console["log"]): Console.Console => + new Proxy(globalThis.console, { + get(target, property, receiver) { + if (property === "log") return log; + const value = Reflect.get(target, property, receiver) as unknown; + return typeof value === "function" ? value.bind(target) : value; + }, + }) as Console.Console; + const environmentInput = ( baseDir: string, options: { readonly development?: boolean; readonly platform?: NodeJS.Platform } = {}, @@ -72,7 +83,7 @@ const makeEnvironmentLayer = ( ); describe("DesktopObservability", () => { - it.effect("avoids console writes while persisting packaged Windows logs", () => + it.effect("keeps the packaged Windows console logger and tolerates a broken pipe", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ @@ -83,18 +94,10 @@ describe("DesktopObservability", () => { platform: "win32", }); let consoleLogCalls = 0; - const throwingConsole = new Proxy(globalThis.console, { - get(target, property, receiver) { - if (property === "log") { - return () => { - consoleLogCalls += 1; - throw Object.assign(new Error("EPIPE: broken pipe, write"), { code: "EPIPE" }); - }; - } - const value = Reflect.get(target, property, receiver) as unknown; - return typeof value === "function" ? value.bind(target) : value; - }, - }) as Console.Console; + const throwingConsole = consoleWithLog(() => { + consoleLogCalls += 1; + throw Object.assign(new Error("EPIPE: broken pipe, write"), { code: "EPIPE" }); + }); const tracePath = yield* Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; return environment.path.join(environment.logDir, "desktop.trace.ndjson"); @@ -115,7 +118,7 @@ describe("DesktopObservability", () => { ), ); - assert.equal(consoleLogCalls, 0); + assert.equal(consoleLogCalls, 1); const records = (yield* fileSystem.readFileString(tracePath)) .trim() @@ -140,6 +143,40 @@ describe("DesktopObservability", () => { ), ); + it.effect("does not hide unrelated packaged Windows console failures", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-console-error-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir, { + development: false, + platform: "win32", + }); + const unexpectedError = Object.assign(new Error("unexpected console failure"), { + code: "EINVAL", + }); + const throwingConsole = consoleWithLog(() => { + throw unexpectedError; + }); + + const exit = yield* Effect.exit( + Effect.scoped( + Effect.logInfo("desktop console failure").pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + Effect.provideService(Console.Console, throwingConsole), + ), + ), + ); + + assert(Exit.isFailure(exit)); + assert.equal(Cause.squash(exit.cause), unexpectedError); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + ), + ); + it.effect("persists backend child output as structured JSON records in development", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 384d3eaa865..a7f0ad59ebf 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -438,18 +438,37 @@ const backendOutputLogFactoryLayer = Layer.effect( }), ); +const isBrokenPipeError = (error: unknown): boolean => + typeof error === "object" && error !== null && "code" in error && error.code === "EPIPE"; + +const makeBrokenPipeSafeConsoleLogger = (): Logger.Logger => { + const delegate = Logger.consolePretty(); + let pipeBroken = false; + + return Logger.make((options) => { + if (pipeBroken) return; + + try { + delegate.log(options); + } catch (error) { + if (!isBrokenPipeError(error)) throw error; + pipeBroken = true; + } + }); +}; + const desktopLoggerLayer = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; - // Packaged Windows launches can inherit a short-lived stdout pipe whose closed reader makes - // synchronous console writes throw EPIPE. The tracer still persists these logs locally. - const loggers = + // Packaged Windows launches can inherit a short-lived stdout pipe. Keep console logging active + // until that pipe closes, then disable only the unavailable sink while tracing continues. + const consoleLogger = environment.platform === "win32" && !environment.isDevelopment - ? [Logger.tracerLogger] - : [Logger.consolePretty(), Logger.tracerLogger]; + ? makeBrokenPipeSafeConsoleLogger() + : Logger.consolePretty(); return Layer.mergeAll( - Logger.layer(loggers, { mergeWithExisting: false }), + Logger.layer([consoleLogger, Logger.tracerLogger], { mergeWithExisting: false }), Layer.succeed(References.MinimumLogLevel, "Info"), ); }),