From e87c2f45fd89c4bb6b6ce6d38471495eea68a876 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 06:52:57 -0700 Subject: [PATCH] Preserve desktop backend log failures Co-authored-by: codex --- .../src/app/DesktopBackendOutputLog.test.ts | 122 +++++++++++ .../src/app/DesktopBackendOutputLog.ts | 197 ++++++++++++++---- 2 files changed, 273 insertions(+), 46 deletions(-) create mode 100644 apps/desktop/src/app/DesktopBackendOutputLog.test.ts diff --git a/apps/desktop/src/app/DesktopBackendOutputLog.test.ts b/apps/desktop/src/app/DesktopBackendOutputLog.test.ts new file mode 100644 index 00000000000..18bba9486cb --- /dev/null +++ b/apps/desktop/src/app/DesktopBackendOutputLog.test.ts @@ -0,0 +1,122 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; + +import * as DesktopBackendOutputLog from "./DesktopBackendOutputLog.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const LOG_FILE_PATH = "/Users/alice/.t3/userdata/logs/server-child.log"; + +const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: "/Users/alice", + platform: "darwin", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + isPackaged: true, + resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + runningUnderArm64Translation: false, +}).pipe(Layer.provide(Layer.merge(Path.layer, DesktopConfig.layerTest({})))); + +const withOutputLog = ( + effect: Effect.Effect, + fileSystemLayer: Layer.Layer, + messages: Array>, +) => { + const logger = Logger.make(({ message }) => { + messages.push(Array.isArray(message) ? message : [message]); + }); + const outputLogLayer = DesktopBackendOutputLog.layer.pipe( + Layer.provide(Layer.mergeAll(fileSystemLayer, Path.layer, environmentLayer)), + Layer.provideMerge(Logger.layer([logger], { mergeWithExisting: false })), + ); + return effect.pipe(Effect.provide(outputLogLayer)); +}; + +const loggedError = (messages: ReadonlyArray>): unknown => + messages.flat().find((value) => typeof value === "object" && value !== null && "error" in value) + ?.error; + +describe("DesktopBackendOutputLog", () => { + it.effect("logs setup failures with the log path and exact cause", () => { + const messages: Array> = []; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "makeDirectory", + pathOrDescriptor: "/Users/alice/.t3/userdata/logs", + description: "private setup diagnostic", + }); + const fileSystemLayer = FileSystem.layerNoop({ + makeDirectory: () => Effect.fail(cause), + }); + + return withOutputLog( + Effect.gen(function* () { + const outputLog = yield* DesktopBackendOutputLog.DesktopBackendOutputLog; + yield* outputLog.writeSessionBoundary({ phase: "START", details: "test" }); + + const error = loggedError(messages); + assert.instanceOf(error, DesktopBackendOutputLog.DesktopBackendOutputLogSetupError); + assert.equal(error.logFilePath, LOG_FILE_PATH); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + `Failed to initialize the desktop backend output log at ${LOG_FILE_PATH}.`, + ); + assert.notInclude(error.message, "private setup diagnostic"); + }), + fileSystemLayer, + messages, + ); + }); + + it.effect("logs record write failures with the operation and exact cause", () => { + const messages: Array> = []; + const missingCause = PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "stat", + pathOrDescriptor: LOG_FILE_PATH, + }); + const writeCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "writeFile", + pathOrDescriptor: LOG_FILE_PATH, + description: "private write diagnostic", + }); + const fileSystemLayer = FileSystem.layerNoop({ + makeDirectory: () => Effect.void, + stat: () => Effect.fail(missingCause), + readDirectory: () => Effect.succeed([]), + writeFile: () => Effect.fail(writeCause), + }); + + return withOutputLog( + Effect.gen(function* () { + const outputLog = yield* DesktopBackendOutputLog.DesktopBackendOutputLog; + yield* outputLog.writeSessionBoundary({ phase: "START", details: "test" }); + + const error = loggedError(messages); + assert.instanceOf(error, DesktopBackendOutputLog.DesktopBackendOutputLogWriteError); + assert.equal(error.operation, "write-record"); + assert.equal(error.logFilePath, LOG_FILE_PATH); + assert.strictEqual(error.cause, writeCause); + assert.equal( + error.message, + `Desktop backend output log operation "write-record" failed at ${LOG_FILE_PATH}.`, + ); + assert.notInclude(error.message, "private write diagnostic"); + }), + fileSystemLayer, + messages, + ); + }); +}); diff --git a/apps/desktop/src/app/DesktopBackendOutputLog.ts b/apps/desktop/src/app/DesktopBackendOutputLog.ts index ec29d54f44a..cad83229deb 100644 --- a/apps/desktop/src/app/DesktopBackendOutputLog.ts +++ b/apps/desktop/src/app/DesktopBackendOutputLog.ts @@ -19,8 +19,75 @@ export const DESKTOP_LOG_FILE_MAX_FILES = 10; const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child"; interface RotatingLogFileWriter { - readonly writeBytes: (chunk: Uint8Array) => Effect.Effect; - readonly writeText: (chunk: string) => Effect.Effect; + readonly filePath: string; + readonly writeBytes: ( + chunk: Uint8Array, + ) => Effect.Effect; + readonly writeText: ( + chunk: string, + ) => Effect.Effect; +} + +class DesktopLogFileWriterConfigurationError extends Schema.TaggedErrorClass()( + "DesktopLogFileWriterConfigurationError", + { + option: Schema.Literals(["maxBytes", "maxFiles"]), + value: Schema.Number, + }, +) { + override get message(): string { + return `${this.option} must be >= 1 (received ${this.value})`; + } +} + +class DesktopLogFileWriterRecoveryError extends Schema.TaggedErrorClass()( + "DesktopLogFileWriterRecoveryError", + { + logFilePath: Schema.String, + cause: Schema.Defect(), + recoveryCause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to refresh desktop backend output log size after a write failure at ${this.logFilePath}.`; + } +} + +export class DesktopBackendOutputLogSetupError extends Schema.TaggedErrorClass()( + "DesktopBackendOutputLogSetupError", + { + logFilePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to initialize the desktop backend output log at ${this.logFilePath}.`; + } +} + +export class DesktopBackendOutputLogWriteError extends Schema.TaggedErrorClass()( + "DesktopBackendOutputLogWriteError", + { + operation: Schema.Literals(["encode-record", "write-record"]), + logFilePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop backend output log operation "${this.operation}" failed at ${this.logFilePath}.`; + } +} + +export class DesktopBackendConsoleWriteError extends Schema.TaggedErrorClass()( + "DesktopBackendConsoleWriteError", + { + streamName: Schema.Literals(["stdout", "stderr"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to mirror desktop backend output to ${this.streamName}.`; + } } export class DesktopBackendOutputLog extends Context.Service< @@ -37,18 +104,6 @@ export class DesktopBackendOutputLog extends Context.Service< } >()("@t3tools/desktop/app/DesktopBackendOutputLog") {} -class DesktopLogFileWriterConfigurationError extends Schema.TaggedErrorClass()( - "DesktopLogFileWriterConfigurationError", - { - option: Schema.Literals(["maxBytes", "maxFiles"]), - value: Schema.Number, - }, -) { - override get message(): string { - return `${this.option} must be >= 1 (received ${this.value})`; - } -} - type DesktopLogFileWriterError = | DesktopLogFileWriterConfigurationError | PlatformError.PlatformError; @@ -85,10 +140,13 @@ const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").t const refreshFileSize = ( fileSystem: FileSystem.FileSystem, filePath: string, -): Effect.Effect => +): Effect.Effect => fileSystem.stat(filePath).pipe( Effect.map((stat) => Number(stat.size)), - Effect.orElseSucceed(() => 0), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed(0) : Effect.fail(error), + }), ); const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { @@ -126,41 +184,52 @@ const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(functio const currentSize = yield* Ref.make(yield* refreshFileSize(fileSystem, input.filePath)); const mutex = yield* Semaphore.make(1); + const recoverCurrentSize = ( + cause: PlatformError.PlatformError, + ): Effect.Effect => + refreshFileSize(fileSystem, input.filePath).pipe( + Effect.matchEffect({ + onFailure: (recoveryCause) => + Effect.fail( + new DesktopLogFileWriterRecoveryError({ + logFilePath: input.filePath, + cause, + recoveryCause, + }), + ), + onSuccess: (size) => Ref.set(currentSize, size).pipe(Effect.andThen(Effect.fail(cause))), + }), + ); + const pruneOverflowBackups = Effect.gen(function* () { - const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); + const entries = yield* fileSystem.readDirectory(directory); for (const entry of entries) { if (!entry.startsWith(`${baseName}.`)) continue; const suffix = Number(entry.slice(baseName.length + 1)); if (!Number.isInteger(suffix) || suffix <= maxFiles) continue; - yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore); + yield* fileSystem.remove(path.join(directory, entry), { force: true }); } }); const rotate = Effect.gen(function* () { - yield* fileSystem.remove(withSuffix(maxFiles), { force: true }).pipe(Effect.ignore); + yield* fileSystem.remove(withSuffix(maxFiles), { force: true }); for (let index = maxFiles - 1; index >= 1; index -= 1) { const source = withSuffix(index); - const sourceExists = yield* fileSystem.exists(source).pipe(Effect.orElseSucceed(() => false)); + const sourceExists = yield* fileSystem.exists(source); if (sourceExists) { yield* fileSystem.rename(source, withSuffix(index + 1)); } } - const currentExists = yield* fileSystem - .exists(input.filePath) - .pipe(Effect.orElseSucceed(() => false)); + const currentExists = yield* fileSystem.exists(input.filePath); if (currentExists) { yield* fileSystem.rename(input.filePath, withSuffix(1)); } yield* Ref.set(currentSize, 0); - }).pipe( - Effect.catch(() => - refreshFileSize(fileSystem, input.filePath).pipe( - Effect.flatMap((size) => Ref.set(currentSize, size)), - ), - ), - ); + }); - const writeBytes = (chunk: Uint8Array): Effect.Effect => { + const writeBytes = ( + chunk: Uint8Array, + ): Effect.Effect => { if (chunk.byteLength === 0) return Effect.void; return mutex.withPermits(1)( @@ -178,11 +247,9 @@ const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(functio yield* rotate; } }).pipe( - Effect.catch(() => - refreshFileSize(fileSystem, input.filePath).pipe( - Effect.flatMap((size) => Ref.set(currentSize, size)), - ), - ), + Effect.catchTags({ + PlatformError: recoverCurrentSize, + }), ), ); }; @@ -190,6 +257,7 @@ const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(functio yield* pruneOverflowBackups; return { + filePath: input.filePath, writeBytes, writeText: (chunk) => writeBytes(textEncoder.encode(chunk)), } satisfies RotatingLogFileWriter; @@ -199,10 +267,17 @@ const writeDevelopmentConsoleOutput = ( streamName: "stdout" | "stderr", chunk: Uint8Array, ): Effect.Effect => - Effect.sync(() => { - const output = streamName === "stderr" ? process.stderr : process.stdout; - output.write(chunk); - }).pipe(Effect.ignore); + Effect.try({ + try: () => { + const output = streamName === "stderr" ? process.stderr : process.stdout; + output.write(chunk); + }, + catch: (cause) => new DesktopBackendConsoleWriteError({ streamName, cause }), + }).pipe( + Effect.catchTags({ + DesktopBackendConsoleWriteError: (error) => Effect.logError(error.message, { error }), + }), + ); const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackendChildLogRecord")( function* ( @@ -222,17 +297,47 @@ const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackend annotations: input.annotations, spans: {}, fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID, - }); - yield* logFile.writeText(`${encoded}\n`); - }).pipe(Effect.ignore({ log: true })); + }).pipe( + Effect.mapError( + (cause) => + new DesktopBackendOutputLogWriteError({ + operation: "encode-record", + logFilePath: logFile.filePath, + cause, + }), + ), + ); + yield* logFile.writeText(`${encoded}\n`).pipe( + Effect.mapError( + (cause) => + new DesktopBackendOutputLogWriteError({ + operation: "write-record", + logFilePath: logFile.filePath, + cause, + }), + ), + ); + }).pipe( + Effect.catchTags({ + DesktopBackendOutputLogWriteError: (error) => Effect.logError(error.message, { error }), + }), + ); }, ); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; + const logFilePath = environment.path.join(environment.logDir, "server-child.log"); const writer = yield* makeRotatingLogFileWriter({ - filePath: environment.path.join(environment.logDir, "server-child.log"), - }).pipe(Effect.option); + filePath: logFilePath, + }).pipe( + Effect.mapError((cause) => new DesktopBackendOutputLogSetupError({ logFilePath, cause })), + Effect.map(Option.some), + Effect.catchTags({ + DesktopBackendOutputLogSetupError: (error) => + Effect.logError(error.message, { error }).pipe(Effect.as(Option.none())), + }), + ); const service = Option.match(writer, { onNone: () => DesktopBackendOutputLogNoop,