authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 15:12:40-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 15:12:40-07:00
logfaccdd4ca9511204144f3ec8d696a8993d878946
tree3cc6326002baf2cc4cf25c48eace72c26e31c007
parentdd48bfd98c440029c1ecaf946916105850cfd0ab
signature Signed by PGP key B5690EEEBB952194

[codex] Preserve desktop backend log failures (#3375)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 273 insertions(+), 46 deletions(-)

apps/desktop/src/app/DesktopBackendOutputLog.test.ts created+122
......@@ -0,0 +1,122 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import * as Logger from "effect/Logger";
6import * as Path from "effect/Path";
7import * as PlatformError from "effect/PlatformError";
8
9import * as DesktopBackendOutputLog from "./DesktopBackendOutputLog.ts";
10import * as DesktopConfig from "./DesktopConfig.ts";
11import * as DesktopEnvironment from "./DesktopEnvironment.ts";
12
13const LOG_FILE_PATH = "/Users/alice/.t3/userdata/logs/server-child.log";
14
15const environmentLayer = DesktopEnvironment.layer({
16 dirname: "/repo/apps/desktop/dist-electron",
17 homeDirectory: "/Users/alice",
18 platform: "darwin",
19 processArch: "arm64",
20 appVersion: "1.2.3",
21 appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar",
22 isPackaged: true,
23 resourcesPath: "/Applications/T3 Code.app/Contents/Resources",
24 runningUnderArm64Translation: false,
25}).pipe(Layer.provide(Layer.merge(Path.layer, DesktopConfig.layerTest({}))));
26
27const withOutputLog = <A, E, R>(
28 effect: Effect.Effect<A, E, R | DesktopBackendOutputLog.DesktopBackendOutputLog>,
29 fileSystemLayer: Layer.Layer<FileSystem.FileSystem>,
30 messages: Array<ReadonlyArray<unknown>>,
31) => {
32 const logger = Logger.make(({ message }) => {
33 messages.push(Array.isArray(message) ? message : [message]);
34 });
35 const outputLogLayer = DesktopBackendOutputLog.layer.pipe(
36 Layer.provide(Layer.mergeAll(fileSystemLayer, Path.layer, environmentLayer)),
37 Layer.provideMerge(Logger.layer([logger], { mergeWithExisting: false })),
38 );
39 return effect.pipe(Effect.provide(outputLogLayer));
40};
41
42const loggedError = (messages: ReadonlyArray<ReadonlyArray<unknown>>): unknown =>
43 messages.flat().find((value) => typeof value === "object" && value !== null && "error" in value)
44 ?.error;
45
46describe("DesktopBackendOutputLog", () => {
47 it.effect("logs setup failures with the log path and exact cause", () => {
48 const messages: Array<ReadonlyArray<unknown>> = [];
49 const cause = PlatformError.systemError({
50 _tag: "PermissionDenied",
51 module: "FileSystem",
52 method: "makeDirectory",
53 pathOrDescriptor: "/Users/alice/.t3/userdata/logs",
54 description: "private setup diagnostic",
55 });
56 const fileSystemLayer = FileSystem.layerNoop({
57 makeDirectory: () => Effect.fail(cause),
58 });
59
60 return withOutputLog(
61 Effect.gen(function* () {
62 const outputLog = yield* DesktopBackendOutputLog.DesktopBackendOutputLog;
63 yield* outputLog.writeSessionBoundary({ phase: "START", details: "test" });
64
65 const error = loggedError(messages);
66 assert.instanceOf(error, DesktopBackendOutputLog.DesktopBackendOutputLogSetupError);
67 assert.equal(error.logFilePath, LOG_FILE_PATH);
68 assert.strictEqual(error.cause, cause);
69 assert.equal(
70 error.message,
71 `Failed to initialize the desktop backend output log at ${LOG_FILE_PATH}.`,
72 );
73 assert.notInclude(error.message, "private setup diagnostic");
74 }),
75 fileSystemLayer,
76 messages,
77 );
78 });
79
80 it.effect("logs record write failures with the operation and exact cause", () => {
81 const messages: Array<ReadonlyArray<unknown>> = [];
82 const missingCause = PlatformError.systemError({
83 _tag: "NotFound",
84 module: "FileSystem",
85 method: "stat",
86 pathOrDescriptor: LOG_FILE_PATH,
87 });
88 const writeCause = PlatformError.systemError({
89 _tag: "PermissionDenied",
90 module: "FileSystem",
91 method: "writeFile",
92 pathOrDescriptor: LOG_FILE_PATH,
93 description: "private write diagnostic",
94 });
95 const fileSystemLayer = FileSystem.layerNoop({
96 makeDirectory: () => Effect.void,
97 stat: () => Effect.fail(missingCause),
98 readDirectory: () => Effect.succeed([]),
99 writeFile: () => Effect.fail(writeCause),
100 });
101
102 return withOutputLog(
103 Effect.gen(function* () {
104 const outputLog = yield* DesktopBackendOutputLog.DesktopBackendOutputLog;
105 yield* outputLog.writeSessionBoundary({ phase: "START", details: "test" });
106
107 const error = loggedError(messages);
108 assert.instanceOf(error, DesktopBackendOutputLog.DesktopBackendOutputLogWriteError);
109 assert.equal(error.operation, "write-record");
110 assert.equal(error.logFilePath, LOG_FILE_PATH);
111 assert.strictEqual(error.cause, writeCause);
112 assert.equal(
113 error.message,
114 `Desktop backend output log operation "write-record" failed at ${LOG_FILE_PATH}.`,
115 );
116 assert.notInclude(error.message, "private write diagnostic");
117 }),
118 fileSystemLayer,
119 messages,
120 );
121 });
122});
apps/desktop/src/app/DesktopBackendOutputLog.ts+151-46
......@@ -19,8 +19,75 @@ export const DESKTOP_LOG_FILE_MAX_FILES = 10;
1919const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child";
2020
2121interface RotatingLogFileWriter {
22 readonly writeBytes: (chunk: Uint8Array) => Effect.Effect<void>;
23 readonly writeText: (chunk: string) => Effect.Effect<void>;
22 readonly filePath: string;
23 readonly writeBytes: (
24 chunk: Uint8Array,
25 ) => Effect.Effect<void, PlatformError.PlatformError | DesktopLogFileWriterRecoveryError>;
26 readonly writeText: (
27 chunk: string,
28 ) => Effect.Effect<void, PlatformError.PlatformError | DesktopLogFileWriterRecoveryError>;
29}
30
31class DesktopLogFileWriterConfigurationError extends Schema.TaggedErrorClass<DesktopLogFileWriterConfigurationError>()(
32 "DesktopLogFileWriterConfigurationError",
33 {
34 option: Schema.Literals(["maxBytes", "maxFiles"]),
35 value: Schema.Number,
36 },
37) {
38 override get message(): string {
39 return `${this.option} must be >= 1 (received ${this.value})`;
40 }
41}
42
43class DesktopLogFileWriterRecoveryError extends Schema.TaggedErrorClass<DesktopLogFileWriterRecoveryError>()(
44 "DesktopLogFileWriterRecoveryError",
45 {
46 logFilePath: Schema.String,
47 cause: Schema.Defect(),
48 recoveryCause: Schema.Defect(),
49 },
50) {
51 override get message(): string {
52 return `Failed to refresh desktop backend output log size after a write failure at ${this.logFilePath}.`;
53 }
54}
55
56export class DesktopBackendOutputLogSetupError extends Schema.TaggedErrorClass<DesktopBackendOutputLogSetupError>()(
57 "DesktopBackendOutputLogSetupError",
58 {
59 logFilePath: Schema.String,
60 cause: Schema.Defect(),
61 },
62) {
63 override get message(): string {
64 return `Failed to initialize the desktop backend output log at ${this.logFilePath}.`;
65 }
66}
67
68export class DesktopBackendOutputLogWriteError extends Schema.TaggedErrorClass<DesktopBackendOutputLogWriteError>()(
69 "DesktopBackendOutputLogWriteError",
70 {
71 operation: Schema.Literals(["encode-record", "write-record"]),
72 logFilePath: Schema.String,
73 cause: Schema.Defect(),
74 },
75) {
76 override get message(): string {
77 return `Desktop backend output log operation "${this.operation}" failed at ${this.logFilePath}.`;
78 }
79}
80
81export class DesktopBackendConsoleWriteError extends Schema.TaggedErrorClass<DesktopBackendConsoleWriteError>()(
82 "DesktopBackendConsoleWriteError",
83 {
84 streamName: Schema.Literals(["stdout", "stderr"]),
85 cause: Schema.Defect(),
86 },
87) {
88 override get message(): string {
89 return `Failed to mirror desktop backend output to ${this.streamName}.`;
90 }
2491}
2592
2693export class DesktopBackendOutputLog extends Context.Service<
......@@ -37,18 +104,6 @@ export class DesktopBackendOutputLog extends Context.Service<
37104 }
38105>()("@t3tools/desktop/app/DesktopBackendOutputLog") {}
39106
40class DesktopLogFileWriterConfigurationError extends Schema.TaggedErrorClass<DesktopLogFileWriterConfigurationError>()(
41 "DesktopLogFileWriterConfigurationError",
42 {
43 option: Schema.Literals(["maxBytes", "maxFiles"]),
44 value: Schema.Number,
45 },
46) {
47 override get message(): string {
48 return `${this.option} must be >= 1 (received ${this.value})`;
49 }
50}
51
52107type DesktopLogFileWriterError =
53108 | DesktopLogFileWriterConfigurationError
54109 | PlatformError.PlatformError;
......@@ -85,10 +140,13 @@ const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").t
85140const refreshFileSize = (
86141 fileSystem: FileSystem.FileSystem,
87142 filePath: string,
88): Effect.Effect<number, never> =>
143): Effect.Effect<number, PlatformError.PlatformError> =>
89144 fileSystem.stat(filePath).pipe(
90145 Effect.map((stat) => Number(stat.size)),
91 Effect.orElseSucceed(() => 0),
146 Effect.catchTags({
147 PlatformError: (error) =>
148 error.reason._tag === "NotFound" ? Effect.succeed(0) : Effect.fail(error),
149 }),
92150 );
93151
94152const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: {
......@@ -126,41 +184,52 @@ const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(functio
126184 const currentSize = yield* Ref.make(yield* refreshFileSize(fileSystem, input.filePath));
127185 const mutex = yield* Semaphore.make(1);
128186
187 const recoverCurrentSize = (
188 cause: PlatformError.PlatformError,
189 ): Effect.Effect<never, PlatformError.PlatformError | DesktopLogFileWriterRecoveryError> =>
190 refreshFileSize(fileSystem, input.filePath).pipe(
191 Effect.matchEffect({
192 onFailure: (recoveryCause) =>
193 Effect.fail(
194 new DesktopLogFileWriterRecoveryError({
195 logFilePath: input.filePath,
196 cause,
197 recoveryCause,
198 }),
199 ),
200 onSuccess: (size) => Ref.set(currentSize, size).pipe(Effect.andThen(Effect.fail(cause))),
201 }),
202 );
203
129204 const pruneOverflowBackups = Effect.gen(function* () {
130 const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
205 const entries = yield* fileSystem.readDirectory(directory);
131206 for (const entry of entries) {
132207 if (!entry.startsWith(`${baseName}.`)) continue;
133208 const suffix = Number(entry.slice(baseName.length + 1));
134209 if (!Number.isInteger(suffix) || suffix <= maxFiles) continue;
135 yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore);
210 yield* fileSystem.remove(path.join(directory, entry), { force: true });
136211 }
137212 });
138213
139214 const rotate = Effect.gen(function* () {
140 yield* fileSystem.remove(withSuffix(maxFiles), { force: true }).pipe(Effect.ignore);
215 yield* fileSystem.remove(withSuffix(maxFiles), { force: true });
141216 for (let index = maxFiles - 1; index >= 1; index -= 1) {
142217 const source = withSuffix(index);
143 const sourceExists = yield* fileSystem.exists(source).pipe(Effect.orElseSucceed(() => false));
218 const sourceExists = yield* fileSystem.exists(source);
144219 if (sourceExists) {
145220 yield* fileSystem.rename(source, withSuffix(index + 1));
146221 }
147222 }
148 const currentExists = yield* fileSystem
149 .exists(input.filePath)
150 .pipe(Effect.orElseSucceed(() => false));
223 const currentExists = yield* fileSystem.exists(input.filePath);
151224 if (currentExists) {
152225 yield* fileSystem.rename(input.filePath, withSuffix(1));
153226 }
154227 yield* Ref.set(currentSize, 0);
155 }).pipe(
156 Effect.catch(() =>
157 refreshFileSize(fileSystem, input.filePath).pipe(
158 Effect.flatMap((size) => Ref.set(currentSize, size)),
159 ),
160 ),
161 );
228 });
162229
163 const writeBytes = (chunk: Uint8Array): Effect.Effect<void> => {
230 const writeBytes = (
231 chunk: Uint8Array,
232 ): Effect.Effect<void, PlatformError.PlatformError | DesktopLogFileWriterRecoveryError> => {
164233 if (chunk.byteLength === 0) return Effect.void;
165234
166235 return mutex.withPermits(1)(
......@@ -178,11 +247,9 @@ const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(functio
178247 yield* rotate;
179248 }
180249 }).pipe(
181 Effect.catch(() =>
182 refreshFileSize(fileSystem, input.filePath).pipe(
183 Effect.flatMap((size) => Ref.set(currentSize, size)),
184 ),
185 ),
250 Effect.catchTags({
251 PlatformError: recoverCurrentSize,
252 }),
186253 ),
187254 );
188255 };
......@@ -190,6 +257,7 @@ const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(functio
190257 yield* pruneOverflowBackups;
191258
192259 return {
260 filePath: input.filePath,
193261 writeBytes,
194262 writeText: (chunk) => writeBytes(textEncoder.encode(chunk)),
195263 } satisfies RotatingLogFileWriter;
......@@ -199,10 +267,17 @@ const writeDevelopmentConsoleOutput = (
199267 streamName: "stdout" | "stderr",
200268 chunk: Uint8Array,
201269): Effect.Effect<void> =>
202 Effect.sync(() => {
203 const output = streamName === "stderr" ? process.stderr : process.stdout;
204 output.write(chunk);
205 }).pipe(Effect.ignore);
270 Effect.try({
271 try: () => {
272 const output = streamName === "stderr" ? process.stderr : process.stdout;
273 output.write(chunk);
274 },
275 catch: (cause) => new DesktopBackendConsoleWriteError({ streamName, cause }),
276 }).pipe(
277 Effect.catchTags({
278 DesktopBackendConsoleWriteError: (error) => Effect.logError(error.message, { error }),
279 }),
280 );
206281
207282const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackendChildLogRecord")(
208283 function* (
......@@ -222,17 +297,47 @@ const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackend
222297 annotations: input.annotations,
223298 spans: {},
224299 fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID,
225 });
226 yield* logFile.writeText(`${encoded}\n`);
227 }).pipe(Effect.ignore({ log: true }));
300 }).pipe(
301 Effect.mapError(
302 (cause) =>
303 new DesktopBackendOutputLogWriteError({
304 operation: "encode-record",
305 logFilePath: logFile.filePath,
306 cause,
307 }),
308 ),
309 );
310 yield* logFile.writeText(`${encoded}\n`).pipe(
311 Effect.mapError(
312 (cause) =>
313 new DesktopBackendOutputLogWriteError({
314 operation: "write-record",
315 logFilePath: logFile.filePath,
316 cause,
317 }),
318 ),
319 );
320 }).pipe(
321 Effect.catchTags({
322 DesktopBackendOutputLogWriteError: (error) => Effect.logError(error.message, { error }),
323 }),
324 );
228325 },
229326);
230327
231const make = Effect.gen(function* () {
328export const make = Effect.gen(function* () {
232329 const environment = yield* DesktopEnvironment.DesktopEnvironment;
330 const logFilePath = environment.path.join(environment.logDir, "server-child.log");
233331 const writer = yield* makeRotatingLogFileWriter({
234 filePath: environment.path.join(environment.logDir, "server-child.log"),
235 }).pipe(Effect.option);
332 filePath: logFilePath,
333 }).pipe(
334 Effect.mapError((cause) => new DesktopBackendOutputLogSetupError({ logFilePath, cause })),
335 Effect.map(Option.some),
336 Effect.catchTags({
337 DesktopBackendOutputLogSetupError: (error) =>
338 Effect.logError(error.message, { error }).pipe(Effect.as(Option.none())),
339 }),
340 );
236341
237342 const service = Option.match(writer, {
238343 onNone: () => DesktopBackendOutputLogNoop,