diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index 9438175f602..fa20cd5d10a 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -1,7 +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"; @@ -36,40 +39,65 @@ const TraceRecordLine = Schema.Struct({ const decodeTraceRecordLine = Schema.decodeUnknownSync(Schema.fromJsonString(TraceRecordLine)); -const environmentInput = (baseDir: string) => +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 } = {}, +) => ({ 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("keeps the packaged Windows console logger and tolerates a broken pipe", () => 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 = 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"); @@ -86,9 +114,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, 1); + const records = (yield* fileSystem.readFileString(tracePath)) .trim() .split("\n") @@ -112,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 42451e45ded..a7f0ad59ebf 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -438,9 +438,40 @@ const backendOutputLogFactoryLayer = Layer.effect( }), ); -const desktopLoggerLayer = Layer.mergeAll( - Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { mergeWithExisting: false }), - Layer.succeed(References.MinimumLogLevel, "Info"), +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. Keep console logging active + // until that pipe closes, then disable only the unavailable sink while tracing continues. + const consoleLogger = + environment.platform === "win32" && !environment.isDevelopment + ? makeBrokenPipeSafeConsoleLogger() + : Logger.consolePretty(); + + return Layer.mergeAll( + Logger.layer([consoleLogger, Logger.tracerLogger], { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, "Info"), + ); + }), ); const tracerLayer = Layer.unwrap(