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,