diff --git a/apps/desktop/src/app/DesktopCrashReport.ts b/apps/desktop/src/app/DesktopCrashReport.ts index 1e52decf..abf5215c 100644 --- a/apps/desktop/src/app/DesktopCrashReport.ts +++ b/apps/desktop/src/app/DesktopCrashReport.ts @@ -39,7 +39,7 @@ const bundledPosthogHost = : "https://us.i.posthog.com"; const ANONYMOUS_ID_FILE_NAME = "anonymous-id"; -const STDERR_TAIL_MAX_CHARS = 2_000; +const OUTPUT_TAIL_MAX_CHARS = 2_000; // Values following credential-shaped labels are cut before anything leaves // the machine. Boot-phase stderr should never contain these, but a config @@ -56,7 +56,8 @@ export interface DesktopStartupFailureReport { readonly attempts: number; readonly lastExitCode: Option.Option; readonly lastReason: string; - readonly stderrTail: string; + /** Interleaved stdout+stderr tail; the server logs fatal causes on stdout. */ + readonly outputTail: string; } export interface DesktopCrashReportShape { @@ -92,7 +93,7 @@ export function scrubUserPaths(text: string, homeDirectory: string): string { } /** Keeps the end of the captured stderr, where the fatal error lands. */ -export function truncateTail(text: string, maxChars: number = STDERR_TAIL_MAX_CHARS): string { +export function truncateTail(text: string, maxChars: number = OUTPUT_TAIL_MAX_CHARS): string { return text.length <= maxChars ? text : text.slice(text.length - maxChars); } @@ -187,7 +188,7 @@ const makeDesktopCrashReport = Effect.gen(function* () { attempts: report.attempts, exitCode: Option.getOrNull(report.lastExitCode), reason: scrub(report.lastReason), - stderrTail: scrub(truncateTail(report.stderrTail)), + outputTail: scrub(truncateTail(report.outputTail)), }, timestamp: new Date().toISOString(), }, diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index e4f41400..5ae7388f 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -587,7 +587,13 @@ describe("DesktopBackendManager", () => { const close = Deferred.succeed(closed, void 0).pipe(Effect.asVoid); yield* Scope.addFinalizer(scope, close); return makeProcess({ - stderr: Stream.make(new TextEncoder().encode("EADDRINUSE: port already bound\n")), + // The fatal cause lands on stdout, like the server's Effect + // logger; stderr carries a secondary line. Both must reach + // the crash-report tail. + stdout: Stream.make( + new TextEncoder().encode("[FATAL] EADDRINUSE: port already bound\n"), + ), + stderr: Stream.make(new TextEncoder().encode("node exited\n")), exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(143))), kill: () => close, }); @@ -615,7 +621,8 @@ describe("DesktopBackendManager", () => { assert.equal(report.failureKind, "readiness-timeout"); assert.equal(report.attempts, 1); assert.include(report.lastReason, "Timed out"); - assert.include(report.stderrTail, "EADDRINUSE"); + assert.include(report.outputTail, "EADDRINUSE"); + assert.include(report.outputTail, "node exited"); // The unresponsive process was killed and nothing respawns. yield* TestClock.adjust(Duration.seconds(30)); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 01dece25..fb932690 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -40,8 +40,8 @@ const MAX_RESTART_DELAY = Duration.seconds(10); // session, restarts stay unbounded: the window exists, so a crash there is // not an invisible wedge. const MAX_STARTUP_ATTEMPTS = 3; -// Cap on the retained stderr needed for a useful crash report. -const STDERR_TAIL_MAX_CHARS = 8_192; +// Cap on the retained stdout+stderr needed for a useful crash report. +const OUTPUT_TAIL_MAX_CHARS = 8_192; // How long a finished process's output drains may lag its exit. const DRAIN_FLUSH_TIMEOUT = Duration.seconds(1); const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); @@ -419,7 +419,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio attempts: latest.restartAttempt + 1, lastExitCode: Option.none(), lastReason: reason, - stderrTail: "", + outputTail: "", }); return; } @@ -428,10 +428,14 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio } const runScope = yield* Scope.make("sequential"); - // Per-run crash-report inputs: the stderr tail carries the fatal + // Per-run crash-report inputs: the output tail carries the fatal // error when the process dies, the timeout marker reroutes a - // killed-for-unresponsiveness exit away from the restart path. - const stderrTailRef = yield* Ref.make(""); + // killed-for-unresponsiveness exit away from the restart path. Both + // streams feed the tail — the server's Effect logger reports fatal + // causes on stdout, and field crash reports came back empty when + // only stderr was kept. + const outputTailRef = yield* Ref.make(""); + const stdoutDecoder = new TextDecoder(); const stderrDecoder = new TextDecoder(); const readinessTimeoutRef = yield* Ref.make(Option.none()); const runId = yield* Ref.modify(state, (latest) => [ @@ -526,7 +530,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio })); // Let buffered output land in the tail before reading it. yield* flushOutput; - const stderrTail = yield* Ref.get(stderrTailRef); + const outputTail = yield* Ref.get(outputTailRef); yield* triggerStartupFailure({ failureKind: Option.isSome(readinessTimeout) ? "readiness-timeout" : "process-exit", attempts: nextState.restartAttempt + 1, @@ -535,7 +539,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio onNone: () => reason, onSome: (timeout) => timeout.message, }), - stderrTail, + outputTail, }); }), ); @@ -607,18 +611,17 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio } }), onOutput: (streamName, chunk) => - streamName === "stderr" - ? backendOutputLog.writeOutputChunk(streamName, chunk).pipe( - Effect.andThen( - Ref.update(stderrTailRef, (tail) => { - const appended = tail + stderrDecoder.decode(chunk, { stream: true }); - return appended.length <= STDERR_TAIL_MAX_CHARS - ? appended - : appended.slice(appended.length - STDERR_TAIL_MAX_CHARS); - }), - ), - ) - : backendOutputLog.writeOutputChunk(streamName, chunk), + backendOutputLog.writeOutputChunk(streamName, chunk).pipe( + Effect.andThen( + Ref.update(outputTailRef, (tail) => { + const decoder = streamName === "stderr" ? stderrDecoder : stdoutDecoder; + const appended = tail + decoder.decode(chunk, { stream: true }); + return appended.length <= OUTPUT_TAIL_MAX_CHARS + ? appended + : appended.slice(appended.length - OUTPUT_TAIL_MAX_CHARS); + }), + ), + ), }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(HttpClient.HttpClient, httpClient),