From b992ff36bc8aa5978bbe602478a0b9bd0a8e5582 Mon Sep 17 00:00:00 2001 From: Guillermo Casanova Date: Mon, 7 Sep 2026 11:19:41 -0300 Subject: [PATCH 1/3] fix(desktop): show startup failures instead of a blank window --- apps/desktop/src/app/DesktopLifecycle.test.ts | 1 + .../src/backend/DesktopBackendManager.test.ts | 94 +++++++++++++++++++ .../src/backend/DesktopBackendManager.ts | 32 ++++++- .../src/backend/DesktopBackendPool.test.ts | 1 + .../desktop/src/backend/DesktopBackendPool.ts | 4 +- apps/desktop/src/electron/ElectronDialog.ts | 8 +- .../src/window/DesktopApplicationMenu.test.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 88 +++++++++++++++-- apps/desktop/src/window/DesktopWindow.ts | 49 ++++++++++ 9 files changed, 265 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 33c74f5a8b9b..0cdb093701b3 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -91,6 +91,7 @@ function makeDesktopWindowLayer( showConnectingSplash: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, + handleBackendFailed: () => Effect.void, flushMainWindowBounds: input.flushMainWindowBounds ?? Effect.void, prepareCaptureReveal: Effect.void, dispatchMenuAction: () => Effect.void, diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 901d9f4a2708..3e4c15a15700 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -123,6 +123,7 @@ interface MakeInstanceInput { readonly backendOutputLog?: Partial; readonly onReady?: Effect.Effect; readonly onShutdown?: Effect.Effect; + readonly onFailed?: (reason: string) => Effect.Effect; readonly onPreflightFailed?: ( failure: DesktopBackendManager.PreflightFailure, ) => Effect.Effect; @@ -184,6 +185,7 @@ function makeTestInstance(input: MakeInstanceInput) { configResolve: input.configResolve ?? Effect.succeed(input.config ?? baseConfig), ...(input.onReady ? { onReady: () => input.onReady! } : {}), ...(input.onShutdown ? { onShutdown: () => input.onShutdown! } : {}), + ...(input.onFailed ? { onFailed: input.onFailed } : {}), ...(input.onPreflightFailed ? { onPreflightFailed: input.onPreflightFailed } : {}), }); @@ -191,6 +193,98 @@ function makeTestInstance(input: MakeInstanceInput) { } describe("DesktopBackendManager", () => { + for (const becomesReady of [false, true]) { + it.effect(`stops a primary crash loop (reaches readiness: ${becomesReady})`, () => + Effect.scoped( + Effect.gen(function* () { + const persisted = yield* Queue.unbounded(); + const ready = yield* Queue.unbounded(); + const failures: string[] = []; + let starts = 0; + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + starts += 1; + return makeProcess({ + exitCode: (becomesReady ? Queue.take(ready) : Effect.void).pipe( + Effect.as(ChildProcessSpawner.ExitCode(1)), + ), + }); + }), + ), + ), + httpClientLayer: becomesReady + ? healthyHttpClientLayer + : httpClientLayer(() => Effect.never), + onReady: Queue.offer(ready, undefined).pipe(Effect.asVoid), + backendOutputLog: { + persistFailure: ({ details }) => Queue.offer(persisted, details).pipe(Effect.asVoid), + }, + onFailed: (reason) => + Effect.sync(() => { + failures.push(reason); + }), + }); + yield* instance.start; + yield* Queue.take(persisted); + for (const delay of [500, 1_000, 2_000, 4_000]) { + yield* TestClock.adjust(delay); + yield* Queue.take(persisted); + } + yield* TestClock.adjust(10_000); + assert.deepEqual(failures, ["code=1"]); + assert.equal(starts, 5); + assert.equal((yield* instance.snapshot).desiredRunning, false); + assert.equal((yield* instance.snapshot).restartScheduled, false); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + } + + it.effect("resets the primary crash allowance after a minute of readiness", () => + Effect.scoped( + Effect.gen(function* () { + const ready = yield* Queue.unbounded(); + const exits = yield* Queue.unbounded(); + const persisted = yield* Queue.unbounded(); + const failures: string[] = []; + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + exitCode: Queue.take(exits).pipe(Effect.as(ChildProcessSpawner.ExitCode(1))), + }), + ), + ), + ), + onReady: Queue.offer(ready, undefined).pipe(Effect.asVoid), + onFailed: (reason) => + Effect.sync(() => { + failures.push(reason); + }), + backendOutputLog: { + persistFailure: () => Queue.offer(persisted, undefined).pipe(Effect.asVoid), + }, + }); + yield* instance.start; + for (const [index, delay] of [500, 1_000, 500, 1_000, 2_000].entries()) { + yield* Queue.take(ready); + if (index === 2) yield* TestClock.adjust(60_000); + yield* Queue.offer(exits, undefined); + yield* Queue.take(persisted); + yield* TestClock.adjust(delay); + } + yield* Queue.take(ready); + assert.deepEqual(failures, []); + assert.equal((yield* instance.snapshot).ready, true); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + it.effect("spawns the backend with fd3 bootstrap and fd4 telemetry", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 6f1ea139bdca..23373a3d3ad4 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -25,6 +25,7 @@ import * as Brand from "effect/Brand"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -56,6 +57,8 @@ import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; const INITIAL_RESTART_DELAY = Duration.millis(500); const MAX_RESTART_DELAY = Duration.seconds(10); +const MAX_CRASH_ATTEMPTS = 5; +const STABLE_BACKEND_UPTIME_MS = 60_000; // After this many consecutive fatal preflight failures, stop the silent // restart loop and surface the reason via onPreflightFailed. Transient // failures may instead provide their own larger retryLimit when they should @@ -295,6 +298,8 @@ export interface BackendInstanceSpec { // between "fired onReady" and "currentConfig already advanced". readonly onReady?: (httpBaseUrl: URL) => Effect.Effect; readonly onShutdown?: () => Effect.Effect; + // Opts this instance into bounded crash recovery and reports the terminal failure. + readonly onFailed?: (reason: string) => Effect.Effect; // Fired once when a fatal or bounded preflight failure has exhausted its // retries. Returns true when the callback changed configuration and the // manager should resolve once more; false stops the failed instance. @@ -735,6 +740,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ready: false, config: Option.some(config.value), preflightFailureAttempt: resetFatalPreflightCounter ? 0 : latest.preflightFailureAttempt, + restartAttempt: current.desiredRunning ? latest.restartAttempt : 0, })); const preflightFailure = config.value.preflightFailure; @@ -805,7 +811,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ); if (!entryExists) { - yield* scheduleRestart(`missing server entry at ${config.value.entryPath}`); + yield* scheduleRestart(`missing server entry at ${config.value.entryPath}`, true); return; } @@ -826,11 +832,13 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }, ]); + let readyAt: number | undefined; const finalizeRun = Effect.fn("desktop.backendInstance.finalizeRun")(function* ( reason: string, ) { yield* mutex.withPermits(1)( Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; const { isCurrentRun, nextState, pid, exitObserved, stopRequested, wasReady } = yield* Ref.modify( state, @@ -864,6 +872,10 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const next = { ...latest, + restartAttempt: + readyAt !== undefined && now - readyAt >= STABLE_BACKEND_UPTIME_MS + ? 0 + : latest.restartAttempt, active: Option.none(), ready: false, }; @@ -898,7 +910,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( } if (isCurrentRun && nextState.desiredRunning) { - yield* scheduleRestart(reason); + yield* scheduleRestart(reason, true); } }), ); @@ -924,6 +936,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( exitObserved: true, })), onReady: Effect.fn("desktop.backendInstance.onReady")(function* () { + readyAt = yield* Clock.currentTimeMillis; const isCurrentRun = yield* Ref.modify(state, (latest) => { const activeRun = Option.getOrUndefined(latest.active); if (activeRun?.id !== runId) { @@ -934,7 +947,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( true, { ...latest, - restartAttempt: 0, + restartAttempt: spec.onFailed ? latest.restartAttempt : 0, ready: true, }, ] as const; @@ -987,7 +1000,20 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const scheduleRestart = Effect.fn("desktop.backendInstance.scheduleRestart")(function* ( reason: string, + terminalFailure = false, ) { + const current = yield* Ref.get(state); + if ( + terminalFailure && + current.desiredRunning && + spec.onFailed && + current.restartAttempt >= MAX_CRASH_ATTEMPTS - 1 + ) { + yield* Ref.update(state, (latest) => ({ ...latest, desiredRunning: false, ready: false })); + // The dialog can quit the app, whose shutdown acquires this instance's mutex. + yield* Effect.forkIn(spec.onFailed(reason), parentScope); + return; + } const scheduled = yield* Ref.modify(state, (latest) => { if (!latest.desiredRunning || Option.isSome(latest.restartFiber)) { return [Option.none(), latest] as const; diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 8373437ae312..d30661e6419b 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -94,6 +94,7 @@ function makePoolLayer( showConnectingSplash: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, + handleBackendFailed: () => Effect.void, flushMainWindowBounds: Effect.void, prepareCaptureReveal: Effect.void, dispatchMenuAction: () => Effect.die("unexpected menu action"), diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index 2930780d4102..2a3f0229db99 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -286,8 +286,7 @@ export const layer = Layer.effect( label: configuration.resolvePrimaryLabel, configResolve: configuration.resolvePrimary, // Window creation errors propagating out of handleBackendReady must - // not block the readiness callback (that would prevent restartAttempt - // from being reset), so we absorb them here. The window service only + // not block the readiness callback, so we absorb them here. The window service only // logs on success, so log the failure here before swallowing it — // otherwise a post-readiness window-open failure vanishes silently and // is near-impossible to diagnose in production. @@ -300,6 +299,7 @@ export const layer = Layer.effect( ), ), onShutdown: () => desktopWindow.handleBackendNotReady, + onFailed: desktopWindow.handleBackendFailed, onPreflightFailed: handlePrimaryPreflightFailure, }); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 18280333754c..f1b1089ba3df 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -97,6 +97,7 @@ export class ElectronDialog extends Context.Service< ) => Effect.Effect; readonly showMessageBox: ( options: Electron.MessageBoxOptions, + owner?: Electron.BrowserWindow, ) => Effect.Effect; readonly showErrorBox: (title: string, content: string) => Effect.Effect; } @@ -164,9 +165,12 @@ export const make = ElectronDialog.of({ }); return result.canceled ? [] : result.filePaths; }), - showMessageBox: (options) => + showMessageBox: (options, owner) => Effect.tryPromise({ - try: () => Electron.dialog.showMessageBox(options), + try: () => + owner && !owner.isDestroyed() + ? Electron.dialog.showMessageBox(owner, options) + : Electron.dialog.showMessageBox(options), catch: (cause) => new ElectronDialogShowMessageBoxError({ type: options.type ?? null, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index d24ffdcba48f..9cd15588f94e 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -81,6 +81,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => showConnectingSplash: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, + handleBackendFailed: () => Effect.void, flushMainWindowBounds: Effect.void, prepareCaptureReveal: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 5df400b90c44..d43839d2ffa5 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -4,6 +4,7 @@ import * as Deferred from "effect/Deferred"; import { DesktopSnapShotId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; @@ -41,6 +42,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; @@ -226,6 +228,12 @@ function makeTestLayer(input: { readonly onPopupTemplate?: (input: ElectronMenu.ElectronMenuTemplateInput) => Effect.Effect; readonly previewZoomReapplies?: number[]; readonly onReveal?: (window: Electron.BrowserWindow) => void; + readonly production?: boolean; + readonly showMessageBox?: ElectronDialog.ElectronDialog["Service"]["showMessageBox"]; + readonly copyText?: ElectronShell.ElectronShell["Service"]["copyText"]; + readonly quit?: Effect.Effect; + readonly relaunch?: ElectronApp.ElectronApp["Service"]["relaunch"]; + readonly readLog?: Effect.Effect; }) { let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { @@ -286,12 +294,25 @@ function makeTestLayer(input: { Layer.provide( Layer.mergeAll( desktopAssetsLayer, - desktopEnvironmentLayer, + Layer.effect( + DesktopEnvironment.DesktopEnvironment, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return { ...environment, isDevelopment: !input.production }; + }), + ).pipe(Layer.provide(desktopEnvironmentLayer)), desktopAppSettingsLayer, desktopClientSettingsLayer, desktopServerExposureLayer, DesktopState.layer, - electronAppLayer, + Layer.mock(ElectronApp.ElectronApp)({ + quit: input.quit ?? Effect.void, + ...(input.relaunch ? { relaunch: input.relaunch } : {}), + }), + FileSystem.layerNoop({ readFileString: () => input.readLog ?? Effect.succeed("") }), + Layer.mock(ElectronDialog.ElectronDialog)( + input.showMessageBox ? { showMessageBox: input.showMessageBox } : {}, + ), Layer.succeed(ElectronMenu.ElectronMenu, { setApplicationMenu: () => Effect.void, showContextMenu: () => Effect.succeedNone, @@ -304,10 +325,12 @@ function makeTestLayer(input: { return true; }), openSystemSettings: () => Effect.succeed(true), - copyText: (text) => - Effect.sync(() => { - input.copiedTexts?.push(text); - }), + copyText: + input.copyText ?? + ((text) => + Effect.sync(() => { + input.copiedTexts?.push(text); + })), } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, electronWindowLayer, @@ -405,6 +428,8 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n desktopClientSettingsLayer, desktopServerExposureLayer, electronAppLayer, + NodeServices.layer, + Layer.mock(ElectronDialog.ElectronDialog)({}), electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), @@ -544,6 +569,57 @@ describe("DesktopWindow", () => { }), ); + it.effect("surfaces production load failures once and supports copying logs and retrying", () => + Effect.gen(function* () { + const fake = makeFakeBrowserWindow(); + const dialogs = yield* Queue.unbounded(); + const responses = yield* Queue.unbounded(); + const copied = yield* Queue.unbounded(); + const quit = yield* Deferred.make(); + let relaunched = false; + const log = "Error: no such column: branch_pull_request_json"; + const layer = makeTestLayer({ + window: fake.window, + createCount: yield* Ref.make(0), + mainWindow: yield* Ref.make(Option.none()), + production: true, + readLog: Effect.succeed(log), + showMessageBox: (options) => + Queue.offer(dialogs, options).pipe( + Effect.andThen(Queue.take(responses)), + Effect.map((response) => ({ response, checkboxChecked: false })), + ), + copyText: (text) => Queue.offer(copied, text).pipe(Effect.asVoid), + relaunch: () => + Effect.sync(() => { + relaunched = true; + }), + quit: Deferred.succeed(quit, undefined).pipe(Effect.asVoid), + }); + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.createMain; + const fail = fake.webContentsListeners.get("did-fail-load")!; + fail({}, -3, "ERR_ABORTED", "t3code://app/", true); + fail({}, -9, "ERR_UNEXPECTED", "t3code://app/", false); + assert.equal(yield* Queue.size(dialogs), 0); + fail({}, -9, "ERR_UNEXPECTED", "t3code://app/", true); + const dialog = yield* Queue.take(dialogs); + assert.include(dialog.detail, log); + assert.include(dialog.detail, "ERR_UNEXPECTED (-9)"); + assert.deepEqual(dialog.buttons, ["Retry", "Copy Logs", "Quit"]); + yield* desktopWindow.handleBackendFailed("code=1"); + assert.equal(yield* Queue.size(dialogs), 0); + yield* Queue.offer(responses, 1); + assert.include(yield* Queue.take(copied), log); + yield* Queue.take(dialogs); + yield* Queue.offer(responses, 0); + yield* Deferred.await(quit); + assert.equal(relaunched, true); + }).pipe(Effect.provide(layer)); + }), + ); + it("leaves fullscreen before concealing a pending quit", () => { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 754de3caa727..b8dd47b7ba97 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -2,6 +2,7 @@ import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -14,6 +15,7 @@ import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; @@ -116,6 +118,7 @@ export class DesktopWindow extends Context.Service< // window so a "macOS dock click" while the backend is down doesn't // produce a stranded window pointing at nothing. readonly handleBackendNotReady: Effect.Effect; + readonly handleBackendFailed: (reason: string) => Effect.Effect; readonly flushMainWindowBounds: Effect.Effect; readonly prepareCaptureReveal: Effect.Effect; readonly dispatchMenuAction: ( @@ -322,6 +325,9 @@ export const make = Effect.gen(function* () { const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; const electronApp = yield* ElectronApp.ElectronApp; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const fileSystem = yield* FileSystem.FileSystem; + const failureVisible = yield* Ref.make(false); // Window-side latch for the primary backend's readiness. Set by // handleBackendReady (driven by the pool's onReady callback), cleared // by handleBackendNotReady (driven by onShutdown). Only consumed by @@ -343,6 +349,45 @@ export const make = Effect.gen(function* () { } }); + const handleBackendFailed = Effect.fn("desktop.window.handleBackendFailed")(function* ( + reason: string, + ) { + if (yield* Ref.getAndSet(failureVisible, true)) return; + const logPath = environment.path.join(environment.logDir, "server-child.log"); + const logs = yield* fileSystem.readFileString(logPath).pipe(Effect.orElseSucceed(() => "")); + const detail = `${reason}\n\n${logs.slice(-4_000)}\n\nLogs: ${logPath}`; + while (true) { + const { response } = yield* electronDialog + .showMessageBox( + { + type: "error", + title: environment.displayName, + message: "T3 Code couldn't start", + detail, + buttons: ["Retry", "Copy Logs", "Quit"], + defaultId: 0, + cancelId: 2, + noLink: true, + }, + Option.getOrUndefined(yield* electronWindow.currentMainOrFirst), + ) + .pipe( + Effect.catch(() => + electronDialog + .showErrorBox("T3 Code couldn't start", detail) + .pipe(Effect.as({ response: 2 })), + ), + ); + if (response === 1) { + yield* electronShell.copyText(`${reason}\n\n${logs}\n\nLogs: ${logPath}`); + continue; + } + if (response === 0) yield* electronApp.relaunch({}); + yield* electronApp.quit; + return; + } + }); + // currentMainOrFirst / focusedMainOrFirst fall back to "any first window", // which during WSL-only boot is the connecting splash. The splash is never // registered via setMain, so it must be treated as "no real main window" -- @@ -752,6 +797,9 @@ export const make = Effect.gen(function* () { if (!isMainFrame) { return; } + if (!environment.isDevelopment && errorCode !== -3 && !window.isDestroyed()) { + runFork(handleBackendFailed(`${errorDescription} (${errorCode})\n${validatedURL}`)); + } const retryInMs = environment.isDevelopment && isRetryableDevelopmentRendererLoadFailure({ @@ -948,6 +996,7 @@ export const make = Effect.gen(function* () { }); return DesktopWindow.of({ + handleBackendFailed, createMain, ensureMain, revealOrCreateMain, From 7da56dc3ede8546edd7867336c503012a6025681 Mon Sep 17 00:00:00 2001 From: Guillermo Casanova Date: Mon, 7 Sep 2026 11:31:47 -0300 Subject: [PATCH 2/3] fix(desktop): keep preflight retries out of the crash allowance --- .../src/backend/DesktopBackendManager.test.ts | 108 +++++++++++------- .../src/backend/DesktopBackendManager.ts | 10 +- 2 files changed, 73 insertions(+), 45 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 3e4c15a15700..b244b0c336d0 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -193,53 +193,73 @@ function makeTestInstance(input: MakeInstanceInput) { } describe("DesktopBackendManager", () => { - for (const becomesReady of [false, true]) { - it.effect(`stops a primary crash loop (reaches readiness: ${becomesReady})`, () => - Effect.scoped( - Effect.gen(function* () { - const persisted = yield* Queue.unbounded(); - const ready = yield* Queue.unbounded(); - const failures: string[] = []; - let starts = 0; - const instance = yield* makeTestInstance({ - spawnerLayer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => + for (const [becomesReady, preflightRetries] of [ + [false, 0], + [true, 0], + [false, 4], + [true, 4], + ] as const) { + it.effect( + `stops after five crashes (ready: ${becomesReady}, preflight retries: ${preflightRetries})`, + () => + Effect.scoped( + Effect.gen(function* () { + const persisted = yield* Queue.unbounded(); + const ready = yield* Queue.unbounded(); + const failures: string[] = []; + let starts = 0; + let resolves = 0; + const instance = yield* makeTestInstance({ + configResolve: Effect.sync(() => + resolves++ < preflightRetries + ? { + ...baseConfig, + preflightFailure: Option.some({ reason: "WSL is starting", fatal: false }), + } + : baseConfig, + ), + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + starts += 1; + return makeProcess({ + exitCode: (becomesReady ? Queue.take(ready) : Effect.void).pipe( + Effect.as(ChildProcessSpawner.ExitCode(1)), + ), + }); + }), + ), + ), + httpClientLayer: becomesReady + ? healthyHttpClientLayer + : httpClientLayer(() => Effect.never), + onReady: Queue.offer(ready, undefined).pipe(Effect.asVoid), + backendOutputLog: { + persistFailure: ({ details }) => + Queue.offer(persisted, details).pipe(Effect.asVoid), + }, + onFailed: (reason) => Effect.sync(() => { - starts += 1; - return makeProcess({ - exitCode: (becomesReady ? Queue.take(ready) : Effect.void).pipe( - Effect.as(ChildProcessSpawner.ExitCode(1)), - ), - }); + failures.push(reason); }), - ), - ), - httpClientLayer: becomesReady - ? healthyHttpClientLayer - : httpClientLayer(() => Effect.never), - onReady: Queue.offer(ready, undefined).pipe(Effect.asVoid), - backendOutputLog: { - persistFailure: ({ details }) => Queue.offer(persisted, details).pipe(Effect.asVoid), - }, - onFailed: (reason) => - Effect.sync(() => { - failures.push(reason); - }), - }); - yield* instance.start; - yield* Queue.take(persisted); - for (const delay of [500, 1_000, 2_000, 4_000]) { - yield* TestClock.adjust(delay); + }); + yield* instance.start; + if (preflightRetries > 0) yield* TestClock.adjust(7_500); yield* Queue.take(persisted); - } - yield* TestClock.adjust(10_000); - assert.deepEqual(failures, ["code=1"]); - assert.equal(starts, 5); - assert.equal((yield* instance.snapshot).desiredRunning, false); - assert.equal((yield* instance.snapshot).restartScheduled, false); - }).pipe(Effect.provide(TestClock.layer())), - ), + yield* TestClock.adjust(0); + assert.deepEqual(failures, []); + for (const delay of [500, 1_000, 2_000, 4_000]) { + yield* TestClock.adjust(preflightRetries > 0 ? 10_000 : delay); + yield* Queue.take(persisted); + } + yield* TestClock.adjust(10_000); + assert.deepEqual(failures, ["code=1"]); + assert.equal(starts, 5); + assert.equal((yield* instance.snapshot).desiredRunning, false); + assert.equal((yield* instance.snapshot).restartScheduled, false); + }).pipe(Effect.provide(TestClock.layer())), + ), ); } diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 23373a3d3ad4..e224652617be 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -321,6 +321,7 @@ interface BackendManagerState { readonly config: Option.Option; readonly active: Option.Option; readonly restartAttempt: number; + readonly crashAttempt: number; // Consecutive bounded/fatal preflight failures, reset on a clean or // unbounded-transient preflight. restartAttempt counts all restarts. readonly preflightFailureAttempt: number; @@ -334,6 +335,7 @@ const initialState: BackendManagerState = { config: Option.none(), active: Option.none(), restartAttempt: 0, + crashAttempt: 0, preflightFailureAttempt: 0, restartFiber: Option.none(), nextRunId: 1, @@ -741,6 +743,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( config: Option.some(config.value), preflightFailureAttempt: resetFatalPreflightCounter ? 0 : latest.preflightFailureAttempt, restartAttempt: current.desiredRunning ? latest.restartAttempt : 0, + crashAttempt: current.desiredRunning ? latest.crashAttempt : 0, })); const preflightFailure = config.value.preflightFailure; @@ -876,6 +879,10 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( readyAt !== undefined && now - readyAt >= STABLE_BACKEND_UPTIME_MS ? 0 : latest.restartAttempt, + crashAttempt: + readyAt !== undefined && now - readyAt >= STABLE_BACKEND_UPTIME_MS + ? 0 + : latest.crashAttempt, active: Option.none(), ready: false, }; @@ -1007,7 +1014,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( terminalFailure && current.desiredRunning && spec.onFailed && - current.restartAttempt >= MAX_CRASH_ATTEMPTS - 1 + current.crashAttempt >= MAX_CRASH_ATTEMPTS - 1 ) { yield* Ref.update(state, (latest) => ({ ...latest, desiredRunning: false, ready: false })); // The dialog can quit the app, whose shutdown acquires this instance's mutex. @@ -1025,6 +1032,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( { ...latest, restartAttempt: latest.restartAttempt + 1, + crashAttempt: latest.crashAttempt + (terminalFailure ? 1 : 0), }, ] as const; }); From 8932575769e7042eaf3f89df76f00d998dcc47f3 Mon Sep 17 00:00:00 2001 From: Guillermo Casanova Date: Thu, 1 Oct 2026 10:18:34 -0300 Subject: [PATCH 3/3] fix(desktop): keep the startup failure dialog on screen server-child.log is NDJSON, so the dialog showed raw JSON lines with escaped newlines and grew past the screen, hiding its buttons. Show the last run's output without stack frames; Copy Logs still copies the full file. --- apps/desktop/src/window/DesktopWindow.test.ts | 21 +++++++++++++++ apps/desktop/src/window/DesktopWindow.ts | 26 ++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index d43839d2ffa5..693b72ad5e41 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -620,6 +620,27 @@ describe("DesktopWindow", () => { }), ); + it("summarizes the last backend run from the NDJSON child log", () => { + const entry = (message: string, text?: string) => + JSON.stringify({ message, annotations: text === undefined ? {} : { text } }); + const log = [ + entry("backend child process failure output start"), + entry("backend child process output", "old run failure"), + entry("backend child process failure output end"), + entry("backend child process failure output start"), + entry( + "backend child process output", + "ERROR: SqlError: Failed to prepare statement\n at catch (bin.mjs:1:1)\n [cause]: Error: file is not a database\n", + ), + entry("backend child process failure output end"), + ].join("\n"); + + assert.equal( + DesktopWindow.summarizeBackendChildLog(log), + "ERROR: SqlError: Failed to prepare statement\n [cause]: Error: file is not a database", + ); + }); + it("leaves fullscreen before concealing a pending quit", () => { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index b8dd47b7ba97..0a0f68cebf8f 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -212,6 +212,30 @@ function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean): string { return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; } +// server-child.log is NDJSON. Show only the last run's output without stack +// frames so the failure dialog fits on screen; Copy Logs keeps the full file. +export function summarizeBackendChildLog(logs: string): string { + let output: Array = []; + for (const line of logs.split("\n")) { + if (line.trim() === "") continue; + let entry: { message?: unknown; annotations?: { text?: unknown } }; + try { + entry = JSON.parse(line); + } catch { + output.push(line); + continue; + } + if (entry.message === "backend child process failure output start") output = []; + if (typeof entry.annotations?.text === "string") output.push(entry.annotations.text); + } + return output + .join("\n") + .split("\n") + .filter((line) => line.trim() !== "" && !/^\s+at /.test(line)) + .join("\n") + .slice(-1_500); +} + export function isSameOriginRendererNavigation(input: { readonly applicationUrl: string; readonly navigationUrl: string; @@ -355,7 +379,7 @@ export const make = Effect.gen(function* () { if (yield* Ref.getAndSet(failureVisible, true)) return; const logPath = environment.path.join(environment.logDir, "server-child.log"); const logs = yield* fileSystem.readFileString(logPath).pipe(Effect.orElseSucceed(() => "")); - const detail = `${reason}\n\n${logs.slice(-4_000)}\n\nLogs: ${logPath}`; + const detail = `${reason}\n\n${summarizeBackendChildLog(logs)}\n\nLogs: ${logPath}`; while (true) { const { response } = yield* electronDialog .showMessageBox(