From 3c7f97936f725877505a81b7acf0255ced077d85 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:06:37 -0400 Subject: [PATCH 1/4] fix(desktop): surface backend startup failures instead of looping invisibly Before the first successful readiness, a backend that crashed on start was restarted forever with no window, tray icon, or dialog, and a backend that started but never answered was abandoned after a logged warning while the process sat invisible. Both wedges now cap: three failed spawns (or one readiness timeout) kill the run and show a native dialog with Try Again, Open Logs Folder, and Quit. Restarts after the backend has been ready once keep the old unbounded behavior. The failure also sends one anonymous crash report to PostHog (same consent and install id as server telemetry) carrying the exit code and a stderr tail with the user's home directory scrubbed, so startup failures in the field become visible without asking users for log files. --- .../src/app/DesktopCrashReport.test.ts | 72 +++++++ apps/desktop/src/app/DesktopCrashReport.ts | 193 +++++++++++++++++ .../src/backend/DesktopBackendManager.test.ts | 194 ++++++++++++++++-- .../src/backend/DesktopBackendManager.ts | 113 +++++++++- apps/desktop/src/electron/ElectronShell.ts | 9 + apps/desktop/src/main.ts | 4 + .../DesktopScreenCapture.test.ts | 1 + .../src/window/DesktopApplicationMenu.test.ts | 1 + .../src/window/DesktopStartupFailurePrompt.ts | 135 ++++++++++++ apps/desktop/src/window/DesktopWindow.test.ts | 5 + apps/desktop/vite.config.ts | 22 ++ 11 files changed, 729 insertions(+), 20 deletions(-) create mode 100644 apps/desktop/src/app/DesktopCrashReport.test.ts create mode 100644 apps/desktop/src/app/DesktopCrashReport.ts create mode 100644 apps/desktop/src/window/DesktopStartupFailurePrompt.ts diff --git a/apps/desktop/src/app/DesktopCrashReport.test.ts b/apps/desktop/src/app/DesktopCrashReport.test.ts new file mode 100644 index 000000000..7bf8ee317 --- /dev/null +++ b/apps/desktop/src/app/DesktopCrashReport.test.ts @@ -0,0 +1,72 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { resolveTelemetryConsent, scrubUserPaths, truncateTail } from "./DesktopCrashReport.ts"; + +describe("scrubUserPaths", () => { + it("replaces the home directory in both separator styles and any casing", () => { + const text = + "Error: EACCES at C:\\Users\\wilfredo\\.threadlines\\userdata\\state.sqlite " + + "(also seen as c:/users/wilfredo/.threadlines/logs)"; + const scrubbed = scrubUserPaths(text, "C:\\Users\\wilfredo"); + assert.equal( + scrubbed, + "Error: EACCES at ~\\.threadlines\\userdata\\state.sqlite (also seen as ~/.threadlines/logs)", + ); + }); + + it("handles posix home directories and leaves unrelated text alone", () => { + const scrubbed = scrubUserPaths("failed at /Users/will/x; port 3773 busy", "/Users/will"); + assert.equal(scrubbed, "failed at ~/x; port 3773 busy"); + }); + + it("is a no-op for an empty home directory", () => { + assert.equal(scrubUserPaths("text", ""), "text"); + }); +}); + +describe("truncateTail", () => { + it("keeps the end of oversized output, where the fatal error lands", () => { + assert.equal(truncateTail("abcdef", 4), "cdef"); + assert.equal(truncateTail("abc", 4), "abc"); + }); +}); + +describe("resolveTelemetryConsent", () => { + it("defaults to enabled without settings", () => { + assert.isTrue(resolveTelemetryConsent({ envOverride: undefined, rawSettingsJson: undefined })); + }); + + it("honors usageAnalyticsEnabled from settings", () => { + assert.isFalse( + resolveTelemetryConsent({ + envOverride: undefined, + rawSettingsJson: JSON.stringify({ usageAnalyticsEnabled: false }), + }), + ); + assert.isTrue( + resolveTelemetryConsent({ + envOverride: undefined, + rawSettingsJson: JSON.stringify({ usageAnalyticsEnabled: true }), + }), + ); + }); + + it("lets the env override win in both directions", () => { + assert.isFalse( + resolveTelemetryConsent({ + envOverride: "false", + rawSettingsJson: JSON.stringify({ usageAnalyticsEnabled: true }), + }), + ); + assert.isTrue( + resolveTelemetryConsent({ + envOverride: "true", + rawSettingsJson: JSON.stringify({ usageAnalyticsEnabled: false }), + }), + ); + }); + + it("keeps the default when settings are unreadable", () => { + assert.isTrue(resolveTelemetryConsent({ envOverride: undefined, rawSettingsJson: "not json" })); + }); +}); diff --git a/apps/desktop/src/app/DesktopCrashReport.ts b/apps/desktop/src/app/DesktopCrashReport.ts new file mode 100644 index 000000000..df6f8ea67 --- /dev/null +++ b/apps/desktop/src/app/DesktopCrashReport.ts @@ -0,0 +1,193 @@ +/** + * DesktopCrashReport - anonymous startup-failure telemetry for the desktop + * shell. + * + * The server owns regular usage analytics, but when the backend never boots + * there is no server to report anything — so the shell sends one sanitized + * event itself. It honors the same consent as the server (`settings.json` + * `usageAnalyticsEnabled`, `THREADLINES_TELEMETRY_ENABLED` override), reuses + * the same anonymous install id file, and never sends raw paths: everything + * under the user's home directory is scrubbed to `~` before leaving the + * machine. + */ + +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import * as Crypto from "node:crypto"; + +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +declare const __THREADLINES_BUNDLED_POSTHOG_KEY__: string | undefined; +declare const __THREADLINES_BUNDLED_POSTHOG_HOST__: string | undefined; + +const bundledPosthogKey = + typeof __THREADLINES_BUNDLED_POSTHOG_KEY__ === "string" + ? __THREADLINES_BUNDLED_POSTHOG_KEY__ + : ""; + +const bundledPosthogHost = + typeof __THREADLINES_BUNDLED_POSTHOG_HOST__ === "string" + ? __THREADLINES_BUNDLED_POSTHOG_HOST__ + : "https://us.i.posthog.com"; + +const ANONYMOUS_ID_FILE_NAME = "anonymous-id"; +const STDERR_TAIL_MAX_CHARS = 4_000; + +export interface DesktopStartupFailureReport { + readonly failureKind: "process-exit" | "readiness-timeout"; + readonly attempts: number; + readonly lastExitCode: Option.Option; + readonly lastReason: string; + readonly stderrTail: string; +} + +export interface DesktopCrashReportShape { + /** Best-effort: resolves void on success and on any failure alike. */ + readonly reportStartupFailure: (report: DesktopStartupFailureReport) => Effect.Effect; +} + +export class DesktopCrashReport extends Context.Service< + DesktopCrashReport, + DesktopCrashReportShape +>()("threadlines/desktop/CrashReport") {} + +/** + * Replaces every occurrence of the user's home directory (either separator + * style, any casing) with `~` so crash reports carry no usernames or absolute + * personal paths. + */ +export function scrubUserPaths(text: string, homeDirectory: string): string { + if (homeDirectory.length === 0) { + return text; + } + const variants = new Set([ + homeDirectory, + homeDirectory.replaceAll("\\", "/"), + homeDirectory.replaceAll("/", "\\"), + ]); + let scrubbed = text; + for (const variant of variants) { + const escaped = variant.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + scrubbed = scrubbed.replace(new RegExp(escaped, "gi"), "~"); + } + return scrubbed; +} + +/** Keeps the end of the captured stderr, where the fatal error lands. */ +export function truncateTail(text: string, maxChars: number = STDERR_TAIL_MAX_CHARS): string { + return text.length <= maxChars ? text : text.slice(text.length - maxChars); +} + +/** + * Mirrors the server's telemetry consent: `THREADLINES_TELEMETRY_ENABLED` + * wins when set, otherwise `usageAnalyticsEnabled` from settings.json, + * defaulting to enabled when the file is absent or unreadable. + */ +export function resolveTelemetryConsent(input: { + readonly envOverride: string | undefined; + readonly rawSettingsJson: string | undefined; +}): boolean { + const override = input.envOverride?.trim().toLowerCase(); + if (override === "false") return false; + if (override === "true") return true; + + if (input.rawSettingsJson === undefined) { + return true; + } + try { + const parsed: unknown = JSON.parse(input.rawSettingsJson); + if (typeof parsed === "object" && parsed !== null && "usageAnalyticsEnabled" in parsed) { + return (parsed as { usageAnalyticsEnabled?: unknown }).usageAnalyticsEnabled !== false; + } + } catch { + // Unreadable settings keep the default. + } + return true; +} + +const hashIdentifier = (value: string): string => + Crypto.createHash("sha256").update(value).digest("hex"); + +const makeDesktopCrashReport = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const httpClient = yield* HttpClient.HttpClient; + const anonymousIdPath = environment.path.join(environment.stateDir, ANONYMOUS_ID_FILE_NAME); + + // Same file the server uses, so the crash report and later usage telemetry + // count as one install. Created here when the backend never got far enough + // to create it itself. + const getIdentifier = Effect.gen(function* () { + const existing = yield* fileSystem + .readFileString(anonymousIdPath) + .pipe(Effect.map(Option.some), Effect.orElseSucceed(Option.none)); + if (Option.isSome(existing)) { + return hashIdentifier(existing.value); + } + const generated = Crypto.randomUUID(); + yield* fileSystem.writeFileString(anonymousIdPath, generated).pipe(Effect.ignore); + return hashIdentifier(generated); + }); + + const reportStartupFailure: DesktopCrashReportShape["reportStartupFailure"] = (report) => + Effect.gen(function* () { + // Same env overrides the server's AnalyticsService honors. + const posthogKey = process.env.THREADLINES_POSTHOG_KEY?.trim() || bundledPosthogKey.trim(); + const posthogHost = process.env.THREADLINES_POSTHOG_HOST?.trim() || bundledPosthogHost; + if (!posthogKey) return; + + const rawSettingsJson = yield* fileSystem + .readFileString(environment.serverSettingsPath) + .pipe(Effect.map(Option.some), Effect.orElseSucceed(Option.none)); + const consented = resolveTelemetryConsent({ + envOverride: process.env.THREADLINES_TELEMETRY_ENABLED, + rawSettingsJson: Option.getOrUndefined(rawSettingsJson), + }); + if (!consented) return; + + const identifier = yield* getIdentifier; + const scrub = (text: string) => scrubUserPaths(text, environment.homeDirectory); + const payload = { + api_key: posthogKey, + batch: [ + { + event: "desktop.backend.startup_failed", + distinct_id: identifier, + properties: { + $process_person_profile: false, + platform: environment.platform, + arch: environment.processArch, + threadlinesVersion: environment.appVersion, + clientType: "desktop-app", + failureKind: report.failureKind, + attempts: report.attempts, + exitCode: Option.getOrNull(report.lastExitCode), + reason: scrub(report.lastReason), + stderrTail: scrub(truncateTail(report.stderrTail)), + }, + timestamp: new Date().toISOString(), + }, + ], + }; + + yield* HttpClientRequest.post(`${posthogHost}/batch/`).pipe( + HttpClientRequest.bodyJson(payload), + Effect.flatMap(httpClient.execute), + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.asVoid, + ); + }).pipe( + Effect.catch((error) => + Effect.logDebug("startup failure crash report not sent", { cause: error }), + ), + Effect.withSpan("desktop.crashReport.reportStartupFailure"), + ); + + return DesktopCrashReport.of({ reportStartupFailure }); +}); + +export const layer = Layer.effect(DesktopCrashReport, makeDesktopCrashReport); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index b1094780c..1ca11a6bc 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -24,6 +24,7 @@ import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopStartupFailurePrompt from "../window/DesktopStartupFailurePrompt.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; const decodeDesktopBackendBootstrap = Schema.decodeEffect( @@ -109,6 +110,7 @@ function makeManagerLayer(input: { readonly desktopState?: DesktopState.DesktopStateShape; readonly desktopWindow?: Partial; readonly config?: DesktopBackendManager.DesktopBackendStartConfig; + readonly startupFailurePrompt?: DesktopStartupFailurePrompt.DesktopStartupFailurePromptShape; }) { return DesktopBackendManager.layer.pipe( Layer.provide( @@ -129,6 +131,12 @@ function makeManagerLayer(input: { writeOutputChunk: () => Effect.void, ...input.backendOutputLog, } satisfies DesktopObservability.DesktopBackendOutputLogShape), + Layer.succeed( + DesktopStartupFailurePrompt.DesktopStartupFailurePrompt, + input.startupFailurePrompt ?? { + handle: () => Effect.die("unexpected startup failure prompt"), + }, + ), Layer.succeed(DesktopWindow.DesktopWindow, { createMain: Effect.die("unexpected createMain"), ensureMain: Effect.die("unexpected ensureMain"), @@ -441,45 +449,203 @@ describe("DesktopBackendManager", () => { }), ); - it.effect("restarts an unexpectedly exited backend with the Effect clock", () => + it.effect( + "restarts a backend that dies before readiness, then caps at three attempts with a prompt", + () => + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + const promptReports = + yield* Queue.unbounded(); + const promptAction = + yield* Ref.make("retry"); + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + startCount += 1; + return makeProcess({ + exitCode: Queue.offer(starts, startCount).pipe( + Effect.as(ChildProcessSpawner.ExitCode(1)), + ), + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + startupFailurePrompt: { + handle: (report) => + Queue.offer(promptReports, report).pipe(Effect.andThen(Ref.get(promptAction))), + }, + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + + assert.equal(yield* Queue.take(starts), 1); + + yield* TestClock.adjust(Duration.millis(499)); + assert.equal(yield* Queue.size(starts), 0); + yield* TestClock.adjust(Duration.millis(1)); + assert.equal(yield* Queue.take(starts), 2); + + yield* TestClock.adjust(Duration.millis(999)); + assert.equal(yield* Queue.size(starts), 0); + yield* TestClock.adjust(Duration.millis(1)); + assert.equal(yield* Queue.take(starts), 3); + + // The third failed attempt surfaces the prompt instead of a fourth + // silent restart. + const firstReport = yield* Queue.take(promptReports); + assert.equal(firstReport.failureKind, "process-exit"); + assert.equal(firstReport.attempts, 3); + assert.deepEqual(firstReport.lastExitCode, Option.some(1)); + + // "Try Again" resets the failure budget: a full new round of three. + assert.equal(yield* Queue.take(starts), 4); + yield* Ref.set(promptAction, "quit"); + yield* TestClock.adjust(Duration.millis(500)); + assert.equal(yield* Queue.take(starts), 5); + yield* TestClock.adjust(Duration.seconds(1)); + assert.equal(yield* Queue.take(starts), 6); + + const secondReport = yield* Queue.take(promptReports); + assert.equal(secondReport.attempts, 3); + + // "Quit" leaves the manager idle: no further spawns on any delay. + yield* TestClock.adjust(Duration.seconds(30)); + assert.equal(yield* Queue.size(starts), 0); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); + }), + ); + + it.effect( + "kills and reports a backend that never answers readiness before the first window", + () => + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + const promptReports = + yield* Queue.unbounded(); + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + startCount += 1; + yield* Queue.offer(starts, startCount); + const scope = yield* Scope.Scope; + const closed = yield* Deferred.make(); + 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")), + exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(143))), + kill: () => close, + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + startupFailurePrompt: { + handle: (report) => Queue.offer(promptReports, report).pipe(Effect.as("quit" as const)), + }, + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + assert.equal(yield* Queue.take(starts), 1); + + // The 60s readiness budget elapses without a single healthy answer. + yield* TestClock.adjust(Duration.minutes(1)); + + const report = yield* Queue.take(promptReports); + assert.equal(report.failureKind, "readiness-timeout"); + assert.equal(report.attempts, 1); + assert.include(report.lastReason, "Timed out"); + assert.include(report.stderrTail, "EADDRINUSE"); + + // The unresponsive process was killed and nothing respawns. + yield* TestClock.adjust(Duration.seconds(30)); + assert.equal(yield* Queue.size(starts), 0); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); + }), + ); + + it.effect("keeps restarting without a prompt once the backend has been ready", () => Effect.gen(function* () { const starts = yield* Queue.unbounded(); + const exitFirstRun = yield* Deferred.make(); let startCount = 0; + let requestCount = 0; const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => - Effect.sync(() => { + Effect.gen(function* () { startCount += 1; + yield* Queue.offer(starts, startCount); + if (startCount === 1) { + return makeProcess({ + exitCode: Deferred.await(exitFirstRun).pipe( + Effect.as(ChildProcessSpawner.ExitCode(1)), + ), + }); + } + const scope = yield* Scope.Scope; + const closed = yield* Deferred.make(); + yield* Scope.addFinalizer(scope, Deferred.succeed(closed, void 0).pipe(Effect.asVoid)); return makeProcess({ - exitCode: Queue.offer(starts, startCount).pipe( - Effect.as(ChildProcessSpawner.ExitCode(1)), - ), + exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), }); }), ), ); + // First run answers healthy; every later run never answers, which after + // readiness must stay a logged warning, not a kill or a prompt. const managerLayer = makeManagerLayer({ spawnerLayer, - httpClientLayer: httpClientLayer(() => Effect.never), + httpClientLayer: httpClientLayer((request) => + Effect.suspend(() => { + requestCount += 1; + return requestCount === 1 + ? Effect.succeed(responseForRequest(request, 200)) + : Effect.never; + }), + ), }); yield* Effect.gen(function* () { const manager = yield* DesktopBackendManager.DesktopBackendManager; yield* manager.start; - assert.equal(yield* Queue.take(starts), 1); - yield* TestClock.adjust(Duration.millis(499)); - assert.equal(yield* Queue.size(starts), 0); - yield* TestClock.adjust(Duration.millis(1)); + // Wait for readiness, then crash the first run. + yield* Effect.gen(function* () { + while (!(yield* manager.snapshot).ready) { + yield* Effect.yieldNow; + } + }); + yield* Deferred.succeed(exitFirstRun, void 0); + + yield* TestClock.adjust(Duration.millis(500)); assert.equal(yield* Queue.take(starts), 2); - yield* TestClock.adjust(Duration.millis(999)); - assert.equal(yield* Queue.size(starts), 0); - yield* TestClock.adjust(Duration.millis(1)); - assert.equal(yield* Queue.take(starts), 3); + // The second run never becomes ready; the 60s readiness timeout must + // leave it running instead of surfacing a startup failure. + yield* TestClock.adjust(Duration.minutes(2)); + const snapshot = yield* manager.snapshot; + assert.equal(Option.isSome(snapshot.activePid), true); }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); }), ); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index de5d8c873..32743d319 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -27,12 +27,21 @@ import { } from "@threadlines/contracts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; +import * as DesktopCrashReport from "../app/DesktopCrashReport.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopStartupFailurePrompt from "../window/DesktopStartupFailurePrompt.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; const INITIAL_RESTART_DELAY = Duration.millis(500); const MAX_RESTART_DELAY = Duration.seconds(10); +// Failed spawns before the first successful readiness stop looping and become +// a visible startup-failure prompt. Once the backend has been ready this app +// 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; const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); @@ -136,6 +145,8 @@ interface ActiveBackendRun { interface BackendManagerState { readonly desiredRunning: boolean; readonly ready: boolean; + /** True once any run reached readiness in this app session. */ + readonly everReady: boolean; readonly config: Option.Option; readonly active: Option.Option; readonly restartAttempt: number; @@ -146,6 +157,7 @@ interface BackendManagerState { const initialState: BackendManagerState = { desiredRunning: false, ready: false, + everReady: false, config: Option.none(), active: Option.none(), restartAttempt: 0, @@ -314,6 +326,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio const backendOutputLog = yield* DesktopObservability.DesktopBackendOutputLog; const desktopState = yield* DesktopState.DesktopState; const desktopWindow = yield* DesktopWindow.DesktopWindow; + const startupFailurePrompt = yield* DesktopStartupFailurePrompt.DesktopStartupFailurePrompt; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const state = yield* Ref.make(initialState); @@ -378,6 +391,12 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio } const runScope = yield* Scope.make("sequential"); + // Per-run crash-report inputs: the stderr 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(""); + const stderrDecoder = new TextDecoder(); + const readinessTimeoutRef = yield* Ref.make(Option.none()); const runId = yield* Ref.modify(state, (latest) => [ latest.nextRunId, { @@ -394,6 +413,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio const finalizeRun = Effect.fn("desktop.backendManager.finalizeRun")(function* ( reason: string, + exitCode: Option.Option, ) { yield* mutex.withPermits(1)( Effect.gen(function* () { @@ -447,9 +467,36 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio yield* Ref.set(desktopState.backendReady, false); } - if (isCurrentRun && nextState.desiredRunning) { + if (!isCurrentRun || !nextState.desiredRunning) { + return; + } + + const readinessTimeout = yield* Ref.get(readinessTimeoutRef); + const startupFailed = + !nextState.everReady && + (Option.isSome(readinessTimeout) || + nextState.restartAttempt >= MAX_STARTUP_ATTEMPTS - 1); + if (!startupFailed) { yield* scheduleRestart(reason); + return; } + + // Stop trying; the prompt fiber owns what happens next. + yield* Ref.update(state, (latest) => ({ + ...latest, + desiredRunning: false, + })); + const stderrTail = yield* Ref.get(stderrTailRef); + yield* triggerStartupFailure({ + failureKind: Option.isSome(readinessTimeout) ? "readiness-timeout" : "process-exit", + attempts: nextState.restartAttempt + 1, + lastExitCode: exitCode, + lastReason: Option.match(readinessTimeout, { + onNone: () => reason, + onSome: (timeout) => timeout.message, + }), + stderrTail, + }); }), ); }); @@ -479,6 +526,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio ...latest, restartAttempt: 0, ready: true, + everReady: true, }, ] as const; }); @@ -496,17 +544,44 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio ); }), onReadinessFailure: (error) => - logBackendManagerWarning("backend readiness check failed during bootstrap", { - error: error.message, + Effect.gen(function* () { + yield* logBackendManagerWarning("backend readiness check failed during bootstrap", { + error: error.message, + }); + const latest = yield* Ref.get(state); + if (latest.everReady) { + return; + } + // Before the first readiness there is no window: a process that + // is alive but unresponsive would sit invisible forever. Mark + // the run and kill it so finalizeRun surfaces the failure + // instead of scheduling a restart. + yield* Ref.set(readinessTimeoutRef, Option.some(error)); + const run = Option.getOrUndefined(latest.active); + if (run?.id === runId) { + yield* Effect.forkIn(closeRun(run), parentScope); + } }), - onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), + 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), }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(HttpClient.HttpClient, httpClient), Scope.provide(runScope), Effect.matchEffect({ - onFailure: (error) => finalizeRun(error.message), - onSuccess: (exit) => finalizeRun(exit.reason), + onFailure: (error) => finalizeRun(error.message, Option.none()), + onSuccess: (exit) => finalizeRun(exit.reason, exit.code), }), Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)), ); @@ -580,6 +655,32 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio }); }); + // Forked so the (possibly minutes-long) dialog never blocks the manager + // mutex. "quit" is handled inside the prompt; "retry" resets the failure + // budget and starts over. + const triggerStartupFailure = (report: DesktopCrashReport.DesktopStartupFailureReport) => + Effect.forkIn( + Effect.gen(function* () { + yield* logBackendManagerError("backend failed to start; showing startup failure prompt", { + failureKind: report.failureKind, + attempts: report.attempts, + reason: report.lastReason, + }); + const action = yield* startupFailurePrompt.handle(report); + if (action === "retry") { + yield* Ref.update(state, (latest) => ({ ...latest, restartAttempt: 0 })); + yield* start; + } + }).pipe( + Effect.catchCause((cause) => + logBackendManagerError("startup failure prompt failed", { + cause: Cause.pretty(cause), + }), + ), + ), + parentScope, + ).pipe(Effect.asVoid); + const stop = Effect.fn("desktop.backendManager.stop")(function* (options?: { readonly timeout?: Duration.Duration; }) { diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 934b6b736..c1fe39320 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -23,6 +23,7 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { export interface ElectronShellShape { readonly openExternal: (rawUrl: unknown) => Effect.Effect; + readonly openPath: (fileSystemPath: string) => Effect.Effect; readonly openScreenClip: () => Effect.Effect; readonly copyText: (text: string) => Effect.Effect; } @@ -43,6 +44,14 @@ const make = ElectronShell.of({ ), ), }), + // Electron resolves openPath with an error string on failure, "" on success. + openPath: (fileSystemPath) => + Effect.promise(() => + Electron.shell.openPath(fileSystemPath).then( + (openError) => openError === "", + () => false, + ), + ), openScreenClip: () => Effect.promise(() => Electron.shell.openExternal(WINDOWS_SCREEN_CLIP_URI).then( diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 41213884d..612e10122 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -31,6 +31,7 @@ import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; import * as ElectronWindow from "./electron/ElectronWindow.ts"; import * as DesktopApp from "./app/DesktopApp.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; +import * as DesktopCrashReport from "./app/DesktopCrashReport.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; @@ -55,6 +56,7 @@ import * as DesktopRelay from "./relay/DesktopRelay.ts"; import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; +import * as DesktopStartupFailurePrompt from "./window/DesktopStartupFailurePrompt.ts"; import * as DesktopStatusIndicator from "./window/DesktopStatusIndicator.ts"; import { readDesktopUserDataConfigFromEnv, @@ -253,6 +255,8 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( const desktopWindowLayer = DesktopWindow.layer.pipe(Layer.provideMerge(desktopServerExposureLayer)); const desktopBackendLayer = DesktopBackendManager.layer.pipe( + Layer.provideMerge(DesktopStartupFailurePrompt.layer), + Layer.provideMerge(DesktopCrashReport.layer), Layer.provideMerge(DesktopAppIdentity.layer), Layer.provideMerge(DesktopBackendConfiguration.layer), Layer.provideMerge(desktopWindowLayer), diff --git a/apps/desktop/src/screenCapture/DesktopScreenCapture.test.ts b/apps/desktop/src/screenCapture/DesktopScreenCapture.test.ts index 0844727f1..f96652858 100644 --- a/apps/desktop/src/screenCapture/DesktopScreenCapture.test.ts +++ b/apps/desktop/src/screenCapture/DesktopScreenCapture.test.ts @@ -117,6 +117,7 @@ function screenCaptureLayer(input: { }) { const shellLayer = Layer.succeed(ElectronShell.ElectronShell, { openExternal: input.openExternal ?? (() => Effect.succeed(true)), + openPath: () => Effect.succeed(true), openScreenClip: input.openScreenClip ?? (() => Effect.succeed(true)), copyText: () => Effect.void, } satisfies ElectronShell.ElectronShellShape); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index ea28c8edb..8f623aa1a 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -98,6 +98,7 @@ const makeElectronShellLayer = (openedExternalUrl: Deferred.Deferred) => typeof rawUrl === "string" ? Deferred.succeed(openedExternalUrl, rawUrl).pipe(Effect.as(true)) : Effect.succeed(false), + openPath: () => Effect.die("unexpected openPath"), openScreenClip: () => Effect.die("unexpected openScreenClip"), copyText: () => Effect.die("unexpected copyText"), } satisfies ElectronShell.ElectronShellShape); diff --git a/apps/desktop/src/window/DesktopStartupFailurePrompt.ts b/apps/desktop/src/window/DesktopStartupFailurePrompt.ts new file mode 100644 index 000000000..af3e4263d --- /dev/null +++ b/apps/desktop/src/window/DesktopStartupFailurePrompt.ts @@ -0,0 +1,135 @@ +/** + * DesktopStartupFailurePrompt - the visible end of a failed startup. + * + * When the backend cannot start, the desktop shell has no window to show, so + * without this dialog the app sits in Task Manager doing nothing the user can + * see. The prompt first fires the anonymous crash report (bounded, best + * effort), then offers the three exits a stuck user needs: try again, look at + * the logs, or quit cleanly so the single-instance lock is released. + */ + +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; +import * as DesktopCrashReport from "../app/DesktopCrashReport.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; + +const CRASH_REPORT_TIMEOUT = Duration.seconds(3); +const DETAIL_REASON_MAX_CHARS = 300; + +const TRY_AGAIN_BUTTON_INDEX = 0; +const OPEN_LOGS_BUTTON_INDEX = 1; +const QUIT_BUTTON_INDEX = 2; + +export type DesktopStartupFailureAction = "retry" | "quit"; + +export interface DesktopStartupFailurePromptShape { + /** + * Reports the failure, shows the dialog until the user picks a way out, and + * quits the app itself when they choose to. "retry" asks the caller to + * reset its failure budget and start the backend again. + */ + readonly handle: ( + report: DesktopCrashReport.DesktopStartupFailureReport, + ) => Effect.Effect; +} + +export class DesktopStartupFailurePrompt extends Context.Service< + DesktopStartupFailurePrompt, + DesktopStartupFailurePromptShape +>()("threadlines/desktop/StartupFailurePrompt") {} + +export function describeStartupFailure(input: { + readonly displayName: string; + readonly report: DesktopCrashReport.DesktopStartupFailureReport; + readonly logDir: string; +}): { readonly message: string; readonly detail: string } { + const { displayName, report, logDir } = input; + const attemptsText = report.attempts === 1 ? "1 attempt" : `${report.attempts} attempts`; + const kindText = + report.failureKind === "readiness-timeout" + ? `The ${displayName} background service started but never responded.` + : `The ${displayName} background service stopped unexpectedly while starting (${attemptsText}).`; + const reason = report.lastReason.trim(); + const reasonText = + reason.length === 0 + ? "" + : `\n\nLast error: ${ + reason.length <= DETAIL_REASON_MAX_CHARS + ? reason + : `${reason.slice(0, DETAIL_REASON_MAX_CHARS)}…` + }`; + return { + message: `${displayName} couldn't start`, + detail: `${kindText} You can try again, or open the logs folder to see what happened.${reasonText}\n\nLogs: ${logDir}`, + }; +} + +const makeDesktopStartupFailurePrompt = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const electronApp = yield* ElectronApp.ElectronApp; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const electronShell = yield* ElectronShell.ElectronShell; + const crashReport = yield* DesktopCrashReport.DesktopCrashReport; + + const handle: DesktopStartupFailurePromptShape["handle"] = Effect.fn( + "desktop.startupFailurePrompt.handle", + )(function* (report) { + yield* crashReport + .reportStartupFailure(report) + .pipe(Effect.timeoutOption(CRASH_REPORT_TIMEOUT), Effect.asVoid); + + const { message, detail } = describeStartupFailure({ + displayName: environment.displayName, + report, + logDir: environment.logDir, + }); + + while (true) { + const result = yield* electronDialog.showMessageBox({ + type: "error", + title: message, + message, + detail, + buttons: ["Try Again", "Open Logs Folder", "Quit"], + defaultId: TRY_AGAIN_BUTTON_INDEX, + cancelId: QUIT_BUTTON_INDEX, + noLink: true, + }); + + if (result.response === OPEN_LOGS_BUTTON_INDEX) { + yield* electronShell.openPath(environment.logDir); + continue; + } + if (result.response === TRY_AGAIN_BUTTON_INDEX) { + return "retry" as const; + } + yield* electronApp.quit; + return "quit" as const; + } + }); + + return DesktopStartupFailurePrompt.of({ handle }); +}); + +export const layer = Layer.effect(DesktopStartupFailurePrompt, makeDesktopStartupFailurePrompt); + +// Kept for tests and stub wiring. +export const layerTest = ( + action: DesktopStartupFailureAction, + onHandle?: (report: DesktopCrashReport.DesktopStartupFailureReport) => void, +) => + Layer.succeed(DesktopStartupFailurePrompt, { + handle: (report) => + Effect.sync(() => { + onHandle?.(report); + return action; + }), + }); + +export type { DesktopStartupFailureReport } from "../app/DesktopCrashReport.ts"; diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 40cedf62c..16ebbe522 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -164,6 +164,7 @@ const electronGlobalShortcutLayer = Layer.succeed(ElectronGlobalShortcut.Electro const electronShellLayer = Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), + openPath: () => Effect.succeed(true), openScreenClip: () => Effect.succeed(true), copyText: () => Effect.void, } satisfies ElectronShell.ElectronShellShape); @@ -834,6 +835,7 @@ describe("DesktopWindow", () => { platform: "win32", electronShell: { openExternal: () => Effect.succeed(true), + openPath: () => Effect.succeed(true), openScreenClip, copyText: () => Effect.void, }, @@ -884,6 +886,7 @@ describe("DesktopWindow", () => { }, electronShell: { openExternal: () => Effect.succeed(true), + openPath: () => Effect.succeed(true), openScreenClip, copyText: () => Effect.void, }, @@ -924,6 +927,7 @@ describe("DesktopWindow", () => { platform: "win32", electronShell: { openExternal: () => Effect.succeed(true), + openPath: () => Effect.succeed(true), openScreenClip, copyText: () => Effect.void, }, @@ -952,6 +956,7 @@ describe("DesktopWindow", () => { platform: "win32", electronShell: { openExternal: () => Effect.succeed(true), + openPath: () => Effect.succeed(true), openScreenClip, copyText: () => Effect.void, }, diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 1e4eb82d9..5e245f55a 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,5 +1,23 @@ import { defineConfig } from "vite-plus"; +// The desktop shell sends its own startup-failure telemetry (the backend is +// not running to send anything), so it bakes in the same PostHog key the +// server build does. Keep in sync with resolveBundledTelemetryConfig in +// apps/server/vite.config.ts; importing it here would breach the tsconfig +// project boundary. +function resolveBundledTelemetryConfig(env: NodeJS.ProcessEnv = process.env): { + readonly posthogKey: string; + readonly posthogHost: string; +} { + const telemetryEnabled = env.THREADLINES_TELEMETRY_ENABLED?.trim().toLowerCase() !== "false"; + return { + posthogKey: telemetryEnabled ? (env.THREADLINES_POSTHOG_KEY?.trim() ?? "") : "", + posthogHost: env.THREADLINES_POSTHOG_HOST?.trim() || "https://us.i.posthog.com", + }; +} + +const bundledTelemetryConfig = resolveBundledTelemetryConfig(); + const shared = { format: "cjs" as const, outDir: "dist-electron", @@ -14,6 +32,10 @@ export default defineConfig({ ...shared, entry: ["src/main.ts"], clean: true, + define: { + __THREADLINES_BUNDLED_POSTHOG_KEY__: JSON.stringify(bundledTelemetryConfig.posthogKey), + __THREADLINES_BUNDLED_POSTHOG_HOST__: JSON.stringify(bundledTelemetryConfig.posthogHost), + }, deps: { alwaysBundle: (id) => id.startsWith("@threadlines/"), }, From 794b9252a7b3c962f9b5702a804e922f777faa18 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:08:37 -0400 Subject: [PATCH 2/4] fix(desktop): cap missing-server-entry retries like other startup failures A broken install (missing dist/bin.mjs) retried on the restart schedule without ever creating a run, so the new failure cap never fired and the loop stayed invisible. The missing-entry branch now applies the same three-attempt budget and surfaces the same prompt. --- .../src/backend/DesktopBackendManager.test.ts | 45 ++++++++++++++++++- .../src/backend/DesktopBackendManager.ts | 20 ++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 1ca11a6bc..e4f41400c 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -111,12 +111,13 @@ function makeManagerLayer(input: { readonly desktopWindow?: Partial; readonly config?: DesktopBackendManager.DesktopBackendStartConfig; readonly startupFailurePrompt?: DesktopStartupFailurePrompt.DesktopStartupFailurePromptShape; + readonly entryExists?: boolean; }) { return DesktopBackendManager.layer.pipe( Layer.provide( Layer.mergeAll( FileSystem.layerNoop({ - exists: () => Effect.succeed(true), + exists: () => Effect.succeed(input.entryExists ?? true), }), Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { resolve: Effect.succeed(input.config ?? baseConfig), @@ -524,6 +525,48 @@ describe("DesktopBackendManager", () => { }), ); + it.effect("caps missing-entry retries with the same startup failure prompt", () => + Effect.gen(function* () { + const promptReports = + yield* Queue.unbounded(); + let spawnCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + spawnCount += 1; + return makeProcess(); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + entryExists: false, + startupFailurePrompt: { + handle: (report) => Queue.offer(promptReports, report).pipe(Effect.as("quit" as const)), + }, + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + + yield* TestClock.adjust(Duration.millis(500)); + yield* TestClock.adjust(Duration.seconds(1)); + + const report = yield* Queue.take(promptReports); + assert.equal(report.attempts, 3); + assert.include(report.lastReason, "missing server entry"); + assert.equal(spawnCount, 0); + + yield* TestClock.adjust(Duration.seconds(30)); + assert.equal(yield* Queue.size(promptReports), 0); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); + }), + ); + it.effect( "kills and reports a backend that never answers readiness before the first window", () => diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 32743d319..41db2fc04 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -386,7 +386,25 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio })); if (!entryExists) { - yield* scheduleRestart(`missing server entry at ${config.entryPath}`); + const reason = `missing server entry at ${config.entryPath}`; + const latest = yield* Ref.get(state); + // Same cap as a crashing spawn: a broken install must not retry + // invisibly forever either. + if (!latest.everReady && latest.restartAttempt >= MAX_STARTUP_ATTEMPTS - 1) { + yield* Ref.update(state, (current) => ({ + ...current, + desiredRunning: false, + })); + yield* triggerStartupFailure({ + failureKind: "process-exit", + attempts: latest.restartAttempt + 1, + lastExitCode: Option.none(), + lastReason: reason, + stderrTail: "", + }); + return; + } + yield* scheduleRestart(reason); return; } From 4e310d69a23101e4e70c681fe74d2d4869e798ad Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:20:33 -0400 Subject: [PATCH 3/4] fix(desktop): address review findings on startup failure handling - Consent now decodes settings.json with the same lenient JSONC parser the server uses; strict JSON.parse treated commented files as unreadable and defaulted a disabled opt-out back to enabled. - The crash report is skipped when the anonymous install id cannot be persisted, matching server telemetry, instead of sending throwaway ids. - stop() interrupts a pending startup-failure prompt fiber, and a retry answered after someone else started the backend no longer fights them. - Output drains are flushed (bounded) before the failure path reads the stderr tail, without delaying normal run finalization. --- .../src/app/DesktopCrashReport.test.ts | 12 ++ apps/desktop/src/app/DesktopCrashReport.ts | 43 ++++--- .../src/backend/DesktopBackendManager.ts | 111 +++++++++++++----- 3 files changed, 120 insertions(+), 46 deletions(-) diff --git a/apps/desktop/src/app/DesktopCrashReport.test.ts b/apps/desktop/src/app/DesktopCrashReport.test.ts index 7bf8ee317..ddea3bdfe 100644 --- a/apps/desktop/src/app/DesktopCrashReport.test.ts +++ b/apps/desktop/src/app/DesktopCrashReport.test.ts @@ -66,6 +66,18 @@ describe("resolveTelemetryConsent", () => { ); }); + it("reads JSONC settings the way the server does", () => { + assert.isFalse( + resolveTelemetryConsent({ + envOverride: undefined, + rawSettingsJson: `{ + // telemetry disabled by hand + "usageAnalyticsEnabled": false, + }`, + }), + ); + }); + it("keeps the default when settings are unreadable", () => { assert.isTrue(resolveTelemetryConsent({ envOverride: undefined, rawSettingsJson: "not json" })); }); diff --git a/apps/desktop/src/app/DesktopCrashReport.ts b/apps/desktop/src/app/DesktopCrashReport.ts index df6f8ea67..f17bdfba3 100644 --- a/apps/desktop/src/app/DesktopCrashReport.ts +++ b/apps/desktop/src/app/DesktopCrashReport.ts @@ -16,9 +16,13 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as Crypto from "node:crypto"; +import { DEFAULT_SERVER_SETTINGS, ServerSettings } from "@threadlines/contracts"; +import { fromLenientJson } from "@threadlines/shared/schemaJson"; + import * as DesktopEnvironment from "./DesktopEnvironment.ts"; declare const __THREADLINES_BUNDLED_POSTHOG_KEY__: string | undefined; @@ -82,10 +86,14 @@ export function truncateTail(text: string, maxChars: number = STDERR_TAIL_MAX_CH return text.length <= maxChars ? text : text.slice(text.length - maxChars); } +const ServerSettingsJson = fromLenientJson(ServerSettings); +const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson); + /** * Mirrors the server's telemetry consent: `THREADLINES_TELEMETRY_ENABLED` - * wins when set, otherwise `usageAnalyticsEnabled` from settings.json, - * defaulting to enabled when the file is absent or unreadable. + * wins when set, otherwise `usageAnalyticsEnabled` from settings.json decoded + * with the same lenient JSONC parser the server uses, defaulting to enabled + * when the file is absent or undecodable. */ export function resolveTelemetryConsent(input: { readonly envOverride: string | undefined; @@ -96,17 +104,12 @@ export function resolveTelemetryConsent(input: { if (override === "true") return true; if (input.rawSettingsJson === undefined) { - return true; - } - try { - const parsed: unknown = JSON.parse(input.rawSettingsJson); - if (typeof parsed === "object" && parsed !== null && "usageAnalyticsEnabled" in parsed) { - return (parsed as { usageAnalyticsEnabled?: unknown }).usageAnalyticsEnabled !== false; - } - } catch { - // Unreadable settings keep the default. + return DEFAULT_SERVER_SETTINGS.usageAnalyticsEnabled; } - return true; + return Option.match(decodeServerSettingsJson(input.rawSettingsJson), { + onNone: () => DEFAULT_SERVER_SETTINGS.usageAnalyticsEnabled, + onSome: (settings) => settings.usageAnalyticsEnabled, + }); } const hashIdentifier = (value: string): string => @@ -120,17 +123,22 @@ const makeDesktopCrashReport = Effect.gen(function* () { // Same file the server uses, so the crash report and later usage telemetry // count as one install. Created here when the backend never got far enough - // to create it itself. + // to create it itself. Like the server, sending is skipped when the id + // cannot be persisted: an unpersisted id would make every report look like + // a fresh installation. const getIdentifier = Effect.gen(function* () { const existing = yield* fileSystem .readFileString(anonymousIdPath) .pipe(Effect.map(Option.some), Effect.orElseSucceed(Option.none)); if (Option.isSome(existing)) { - return hashIdentifier(existing.value); + return Option.some(hashIdentifier(existing.value)); } const generated = Crypto.randomUUID(); - yield* fileSystem.writeFileString(anonymousIdPath, generated).pipe(Effect.ignore); - return hashIdentifier(generated); + const persisted = yield* fileSystem.writeFileString(anonymousIdPath, generated).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + return persisted ? Option.some(hashIdentifier(generated)) : Option.none(); }); const reportStartupFailure: DesktopCrashReportShape["reportStartupFailure"] = (report) => @@ -150,13 +158,14 @@ const makeDesktopCrashReport = Effect.gen(function* () { if (!consented) return; const identifier = yield* getIdentifier; + if (Option.isNone(identifier)) return; const scrub = (text: string) => scrubUserPaths(text, environment.homeDirectory); const payload = { api_key: posthogKey, batch: [ { event: "desktop.backend.startup_failed", - distinct_id: identifier, + distinct_id: identifier.value, properties: { $process_person_profile: false, platform: environment.platform, diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 41db2fc04..8127616cf 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -42,6 +42,8 @@ const MAX_RESTART_DELAY = Duration.seconds(10); const MAX_STARTUP_ATTEMPTS = 3; // Cap on the retained stderr needed for a useful crash report. const STDERR_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); const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); @@ -151,6 +153,8 @@ interface BackendManagerState { readonly active: Option.Option; readonly restartAttempt: number; readonly restartFiber: Option.Option>; + /** Startup-failure dialog in flight; interrupted by stop(). */ + readonly promptFiber: Option.Option>; readonly nextRunId: number; } @@ -162,6 +166,7 @@ const initialState: BackendManagerState = { active: Option.none(), restartAttempt: 0, restartFiber: Option.none(), + promptFiber: Option.none(), nextRunId: 1, }; @@ -268,7 +273,11 @@ const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBac const runBackendProcess = Effect.fn("runBackendProcess")(function* ( options: RunBackendProcessOptions, -): Effect.fn.Return { +): Effect.fn.Return< + { readonly exit: BackendProcessExit; readonly flushOutput: Effect.Effect }, + BackendProcessError, + BackendProcessRunRequirements +> { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe( Effect.mapError((cause) => new BackendProcessBootstrapEncodeError({ cause })), @@ -302,9 +311,12 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( .pipe(Effect.mapError((cause) => new BackendProcessSpawnError({ cause }))); yield* options.onStarted?.(handle.pid) ?? Effect.void; + const drainFibers: Array> = []; if (options.captureOutput) { - yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); - yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); + drainFibers.push( + yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped), + yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped), + ); } yield* waitForHttpReady( options.httpBaseUrl, @@ -316,7 +328,14 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( Effect.forkScoped, ); - return describeProcessExit(yield* Effect.result(handle.exitCode)); + const exit = describeProcessExit(yield* Effect.result(handle.exitCode)); + // Joining the drains here would delay finalization (and make a dead run + // look active to start()), so the caller decides when to flush: bounded, + // and only where the buffered tail is actually read. + const flushOutput = Effect.forEach(drainFibers, (fiber) => + Fiber.await(fiber).pipe(Effect.timeoutOption(DRAIN_FLUSH_TIMEOUT), Effect.asVoid), + ).pipe(Effect.asVoid); + return { exit, flushOutput }; }); const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(function* () { @@ -432,6 +451,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio const finalizeRun = Effect.fn("desktop.backendManager.finalizeRun")(function* ( reason: string, exitCode: Option.Option, + flushOutput: Effect.Effect, ) { yield* mutex.withPermits(1)( Effect.gen(function* () { @@ -504,6 +524,8 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio ...latest, desiredRunning: false, })); + // Let buffered output land in the tail before reading it. + yield* flushOutput; const stderrTail = yield* Ref.get(stderrTailRef); yield* triggerStartupFailure({ failureKind: Option.isSome(readinessTimeout) ? "readiness-timeout" : "process-exit", @@ -598,8 +620,8 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio Effect.provideService(HttpClient.HttpClient, httpClient), Scope.provide(runScope), Effect.matchEffect({ - onFailure: (error) => finalizeRun(error.message, Option.none()), - onSuccess: (exit) => finalizeRun(exit.reason, exit.code), + onFailure: (error) => finalizeRun(error.message, Option.none(), Effect.void), + onSuccess: ({ exit, flushOutput }) => finalizeRun(exit.reason, exit.code, flushOutput), }), Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)), ); @@ -675,39 +697,63 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio // Forked so the (possibly minutes-long) dialog never blocks the manager // mutex. "quit" is handled inside the prompt; "retry" resets the failure - // budget and starts over. - const triggerStartupFailure = (report: DesktopCrashReport.DesktopStartupFailureReport) => - Effect.forkIn( - Effect.gen(function* () { - yield* logBackendManagerError("backend failed to start; showing startup failure prompt", { - failureKind: report.failureKind, - attempts: report.attempts, - reason: report.lastReason, - }); - const action = yield* startupFailurePrompt.handle(report); - if (action === "retry") { - yield* Ref.update(state, (latest) => ({ ...latest, restartAttempt: 0 })); - yield* start; - } - }).pipe( - Effect.catchCause((cause) => - logBackendManagerError("startup failure prompt failed", { - cause: Cause.pretty(cause), - }), + // budget and starts over. The fiber is tracked in state so stop() can + // cancel a pending dialog's consequences. + const triggerStartupFailure = Effect.fn("desktop.backendManager.triggerStartupFailure")( + function* (report: DesktopCrashReport.DesktopStartupFailureReport) { + const promptFiber = yield* Effect.forkIn( + Effect.gen(function* () { + yield* logBackendManagerError("backend failed to start; showing startup failure prompt", { + failureKind: report.failureKind, + attempts: report.attempts, + reason: report.lastReason, + }); + const action = yield* startupFailurePrompt.handle(report); + if (action !== "retry") { + return; + } + // Someone may have started the backend while the dialog was open; + // retry must not fight them. + const shouldStart = yield* Ref.modify(state, (latest) => + latest.desiredRunning + ? ([false, latest] as const) + : ([true, { ...latest, restartAttempt: 0 }] as const), + ); + if (shouldStart) { + yield* start; + } + }).pipe( + Effect.catchCause((cause) => + logBackendManagerError("startup failure prompt failed", { + cause: Cause.pretty(cause), + }), + ), + Effect.ensuring( + Ref.update(state, (latest) => ({ + ...latest, + promptFiber: Option.none>(), + })), + ), ), - ), - parentScope, - ).pipe(Effect.asVoid); + parentScope, + ); + yield* Ref.update(state, (latest) => ({ + ...latest, + promptFiber: Option.some(promptFiber), + })); + }, + ); const stop = Effect.fn("desktop.backendManager.stop")(function* (options?: { readonly timeout?: Duration.Duration; }) { - const { active, restartFiber } = yield* mutex.withPermits(1)( + const { active, restartFiber, promptFiber } = yield* mutex.withPermits(1)( Effect.gen(function* () { const result = yield* Ref.modify(state, (latest) => [ { active: latest.active, restartFiber: latest.restartFiber, + promptFiber: latest.promptFiber, }, { ...latest, @@ -715,6 +761,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio ready: false, active: Option.none(), restartFiber: Option.none>(), + promptFiber: Option.none>(), }, ]); yield* Ref.set(desktopState.backendReady, false); @@ -726,6 +773,12 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio onNone: () => Effect.void, onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid), }); + // A stale startup-failure dialog must not resurrect the backend after a + // stop; the OS dialog itself stays visible but its choice goes nowhere. + yield* Option.match(promptFiber, { + onNone: () => Effect.void, + onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid), + }); yield* Option.match(active, { onNone: () => Effect.void, onSome: (run) => closeRun(run, options), From 8e57d8472a01ed6dd4fd9c6c1ae010936365d5ea Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:34:47 -0400 Subject: [PATCH 4/4] fix(desktop): harden the startup failure path after second review - The recovery prompt can no longer fail back into invisibility: a defect in the dialog falls back to a plain error box and a clean quit, and the crash report is sent concurrently instead of delaying the dialog. - A window-open failure after backend readiness re-arms the startup failure cap instead of unlocking unbounded silent restarts. - Backend port discovery gets a 30s deadline routed to the existing fatal startup error box; a hanging bind probe wedged before the manager ran. - Crash-report stderr tail shrinks to 2000 chars and passes a credential redaction pass on top of home-directory scrubbing. - The second-instance dialog now points at the recovery dialog and the Task Manager Details tab. --- apps/desktop/src/app/DesktopApp.ts | 22 +++- .../src/app/DesktopCrashReport.test.ts | 21 +++- apps/desktop/src/app/DesktopCrashReport.ts | 15 ++- .../src/backend/DesktopBackendManager.ts | 6 +- apps/desktop/src/main.ts | 2 +- .../src/window/DesktopStartupFailurePrompt.ts | 105 ++++++++++-------- 6 files changed, 118 insertions(+), 53 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 7e77495e9..e6fb82333 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -1,5 +1,6 @@ import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; import { randomUUIDv4 } from "@threadlines/shared/uuid"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -39,6 +40,20 @@ class DesktopDevelopmentBackendPortRequiredError extends Data.TaggedError( } } +// Port discovery runs before the backend manager exists, so a probe that a +// security product leaves hanging would wedge startup with no window and no +// recovery prompt. The deadline turns that into the visible fatal-startup +// error box instead. +const BACKEND_PORT_DISCOVERY_TIMEOUT = Duration.seconds(30); + +class DesktopBackendPortDiscoveryTimeoutError extends Data.TaggedError( + "DesktopBackendPortDiscoveryTimeoutError", +)<{}> { + override get message() { + return `Timed out selecting a local port for the Threadlines background service after ${Duration.toSeconds(BACKEND_PORT_DISCOVERY_TIMEOUT)}s. A firewall or security tool may be blocking local network probes.`; + } +} + const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } = DesktopObservability.makeComponentLogger("desktop-bootstrap"); @@ -99,7 +114,12 @@ const bootstrap = Effect.gen(function* () { const backendPortSelection = yield* resolveDesktopBackendPort({ configuredPort: environment.configuredBackendPort, probeHosts, - }); + }).pipe( + Effect.timeoutOrElse({ + duration: BACKEND_PORT_DISCOVERY_TIMEOUT, + orElse: () => new DesktopBackendPortDiscoveryTimeoutError(), + }), + ); const backendPort = backendPortSelection.port; yield* logBootstrapInfo( backendPortSelection.selectedByScan diff --git a/apps/desktop/src/app/DesktopCrashReport.test.ts b/apps/desktop/src/app/DesktopCrashReport.test.ts index ddea3bdfe..ea27e2760 100644 --- a/apps/desktop/src/app/DesktopCrashReport.test.ts +++ b/apps/desktop/src/app/DesktopCrashReport.test.ts @@ -1,6 +1,11 @@ import { assert, describe, it } from "@effect/vitest"; -import { resolveTelemetryConsent, scrubUserPaths, truncateTail } from "./DesktopCrashReport.ts"; +import { + redactSecrets, + resolveTelemetryConsent, + scrubUserPaths, + truncateTail, +} from "./DesktopCrashReport.ts"; describe("scrubUserPaths", () => { it("replaces the home directory in both separator styles and any casing", () => { @@ -24,6 +29,20 @@ describe("scrubUserPaths", () => { }); }); +describe("redactSecrets", () => { + it("cuts values after credential-shaped labels", () => { + assert.equal( + redactSecrets("Error: connect failed api_key=sk-live-123 token: abc.def password=hunter2"), + "Error: connect failed api_key=[redacted] token: [redacted] password=[redacted]", + ); + }); + + it("leaves ordinary error text alone", () => { + const text = "EADDRINUSE: address already in use 127.0.0.1:3773"; + assert.equal(redactSecrets(text), text); + }); +}); + describe("truncateTail", () => { it("keeps the end of oversized output, where the fatal error lands", () => { assert.equal(truncateTail("abcdef", 4), "cdef"); diff --git a/apps/desktop/src/app/DesktopCrashReport.ts b/apps/desktop/src/app/DesktopCrashReport.ts index f17bdfba3..1e52decf2 100644 --- a/apps/desktop/src/app/DesktopCrashReport.ts +++ b/apps/desktop/src/app/DesktopCrashReport.ts @@ -39,7 +39,17 @@ const bundledPosthogHost = : "https://us.i.posthog.com"; const ANONYMOUS_ID_FILE_NAME = "anonymous-id"; -const STDERR_TAIL_MAX_CHARS = 4_000; +const STDERR_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 +// echo or connection string in a crash must not end up in telemetry. +const SECRET_PATTERN = + /((?:key|token|secret|password|passwd|credential|bearer|authorization)[\w-]*\s*[=:]\s*)[^\s"']+/gi; + +export function redactSecrets(text: string): string { + return text.replace(SECRET_PATTERN, "$1[redacted]"); +} export interface DesktopStartupFailureReport { readonly failureKind: "process-exit" | "readiness-timeout"; @@ -159,7 +169,8 @@ const makeDesktopCrashReport = Effect.gen(function* () { const identifier = yield* getIdentifier; if (Option.isNone(identifier)) return; - const scrub = (text: string) => scrubUserPaths(text, environment.homeDirectory); + const scrub = (text: string) => + redactSecrets(scrubUserPaths(text, environment.homeDirectory)); const payload = { api_key: posthogKey, batch: [ diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 8127616cf..01dece253 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -579,7 +579,11 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio Effect.catch((error) => logBackendManagerError("failed to open main window after backend readiness", { message: error.message, - }), + }).pipe( + // No window appeared, so a later backend failure is still an + // invisible wedge: keep the startup failure cap armed. + Effect.andThen(Ref.update(state, (latest) => ({ ...latest, everReady: false }))), + ), ), ); }), diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 612e10122..f9f645bea 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -149,7 +149,7 @@ async function exitAfterSecondaryInstanceHandoff(): Promise { // Safe before "ready": showErrorBox is the one dialog Electron allows early. Electron.dialog.showErrorBox( "Threadlines is already running", - "Another Threadlines process is already running on this computer but is not responding. Quit Threadlines from Task Manager (Windows) or Activity Monitor (Mac) and try again.", + "Another Threadlines process is already running on this computer but is not responding. It may be showing a startup error dialog. Check the taskbar and Alt+Tab first. Otherwise quit Threadlines from Task Manager (use the Details tab on Windows) or Activity Monitor (Mac) and try again.", ); Electron.app.exit(1); } diff --git a/apps/desktop/src/window/DesktopStartupFailurePrompt.ts b/apps/desktop/src/window/DesktopStartupFailurePrompt.ts index af3e4263d..2f3960e4b 100644 --- a/apps/desktop/src/window/DesktopStartupFailurePrompt.ts +++ b/apps/desktop/src/window/DesktopStartupFailurePrompt.ts @@ -3,14 +3,17 @@ * * When the backend cannot start, the desktop shell has no window to show, so * without this dialog the app sits in Task Manager doing nothing the user can - * see. The prompt first fires the anonymous crash report (bounded, best - * effort), then offers the three exits a stuck user needs: try again, look at - * the logs, or quit cleanly so the single-instance lock is released. + * see. The prompt offers the three exits a stuck user needs: try again, look + * at the logs, or quit cleanly so the single-instance lock is released. The + * anonymous crash report is sent concurrently (bounded, best effort), and any + * defect in the dialog itself falls back to a plain error box plus quit — + * this path must never fail back into invisibility. */ import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -32,7 +35,7 @@ export interface DesktopStartupFailurePromptShape { /** * Reports the failure, shows the dialog until the user picks a way out, and * quits the app itself when they choose to. "retry" asks the caller to - * reset its failure budget and start the backend again. + * reset its failure budget and start the backend again. Never fails. */ readonly handle: ( report: DesktopCrashReport.DesktopStartupFailureReport, @@ -77,41 +80,62 @@ const makeDesktopStartupFailurePrompt = Effect.gen(function* () { const electronShell = yield* ElectronShell.ElectronShell; const crashReport = yield* DesktopCrashReport.DesktopCrashReport; - const handle: DesktopStartupFailurePromptShape["handle"] = Effect.fn( - "desktop.startupFailurePrompt.handle", - )(function* (report) { - yield* crashReport - .reportStartupFailure(report) - .pipe(Effect.timeoutOption(CRASH_REPORT_TIMEOUT), Effect.asVoid); - - const { message, detail } = describeStartupFailure({ - displayName: environment.displayName, - report, - logDir: environment.logDir, - }); - - while (true) { - const result = yield* electronDialog.showMessageBox({ - type: "error", - title: message, - message, - detail, - buttons: ["Try Again", "Open Logs Folder", "Quit"], - defaultId: TRY_AGAIN_BUTTON_INDEX, - cancelId: QUIT_BUTTON_INDEX, - noLink: true, + const askUser = (report: DesktopCrashReport.DesktopStartupFailureReport) => + Effect.gen(function* () { + const { message, detail } = describeStartupFailure({ + displayName: environment.displayName, + report, + logDir: environment.logDir, }); - if (result.response === OPEN_LOGS_BUTTON_INDEX) { - yield* electronShell.openPath(environment.logDir); - continue; - } - if (result.response === TRY_AGAIN_BUTTON_INDEX) { - return "retry" as const; + while (true) { + const result = yield* electronDialog.showMessageBox({ + type: "error", + title: message, + message, + detail, + buttons: ["Try Again", "Open Logs Folder", "Quit"], + defaultId: TRY_AGAIN_BUTTON_INDEX, + cancelId: QUIT_BUTTON_INDEX, + noLink: true, + }); + + if (result.response === OPEN_LOGS_BUTTON_INDEX) { + yield* electronShell.openPath(environment.logDir); + continue; + } + return result.response === TRY_AGAIN_BUTTON_INDEX ? ("retry" as const) : ("quit" as const); } + }); + + const handle: DesktopStartupFailurePromptShape["handle"] = Effect.fn( + "desktop.startupFailurePrompt.handle", + )(function* (report) { + // Concurrent with the dialog: the user should never wait on telemetry. + const reportFiber = yield* Effect.forkChild( + crashReport + .reportStartupFailure(report) + .pipe(Effect.timeoutOption(CRASH_REPORT_TIMEOUT), Effect.asVoid), + ); + + const action = yield* askUser(report).pipe( + Effect.catchCause(() => + // The rich dialog itself failed; a plain error box and a clean quit + // beat an invisible process holding the single-instance lock. + electronDialog + .showErrorBox( + `${environment.displayName} couldn't start`, + `The background service failed to start and the recovery dialog could not be shown. See logs: ${environment.logDir}`, + ) + .pipe(Effect.as("quit" as const)), + ), + ); + + yield* Fiber.await(reportFiber); + if (action === "quit") { yield* electronApp.quit; - return "quit" as const; } + return action; }); return DesktopStartupFailurePrompt.of({ handle }); @@ -119,17 +143,4 @@ const makeDesktopStartupFailurePrompt = Effect.gen(function* () { export const layer = Layer.effect(DesktopStartupFailurePrompt, makeDesktopStartupFailurePrompt); -// Kept for tests and stub wiring. -export const layerTest = ( - action: DesktopStartupFailureAction, - onHandle?: (report: DesktopCrashReport.DesktopStartupFailureReport) => void, -) => - Layer.succeed(DesktopStartupFailurePrompt, { - handle: (report) => - Effect.sync(() => { - onHandle?.(report); - return action; - }), - }); - export type { DesktopStartupFailureReport } from "../app/DesktopCrashReport.ts";