# Files changed · PR \#23862 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23862/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(desktop\): show diagnostics when backend startup keeps failing](https://git.cafe/pingdotgg/t3code/pull/23862.md)

Version: 18

Head commit: 0c333664e9f9931a4bf716b492fecc0ff3f785b5

Base commit: 2c8bdf6177159d8fcd12a897d7c9311f6eb93237

Comparison base: ef92441313da327139df75daa8f723c0f42348f6

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/desktop/src/app/DesktopObservability.ts

Status: modified; +3; −0

```diff
@@ -44,6 +44,8 @@
 }
 
 export interface DesktopBackendOutputLogShape {
+  // Absent when the log file could not be opened.
+  readonly filePath?: string;
   readonly beginSession: (input: { readonly details: string }) => Effect.Effect<void>;
   readonly writeOutputChunk: (
     streamName: "stdout" | "stderr",
@@ -499,6 +501,7 @@
           },
         );
         return {
+          filePath: backendLogFilePathForInstance(environment, id),
           beginSession: Effect.fn("desktop.observability.backendOutput.beginSession")(function* ({
             details,
           }) {
```

## apps/desktop/src/backend/DesktopBackendManager.ts

Status: modified; +36; −6

```diff
@@ -34,6 +34,8 @@
 const INITIAL_RESTART_DELAY = Duration.millis(500);
 const MAX_RESTART_DELAY = Duration.seconds(10);
 const MAX_PREFLIGHT_FAILURE_ATTEMPTS = 5;
+// About 3.5s of back-to-back restarts without readiness.
+const REPEATED_START_FAILURE_ATTEMPTS = 3;
 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);
@@ -243,6 +245,12 @@
   readonly onShutdown?: () => Effect.Effect<void>;
   readonly onPreflightFailed?: (failure: PreflightFailure) => Effect.Effect<boolean>;
   readonly onStateDirOwned?: () => Effect.Effect<void>;
+  // Fires once per streak of restarts without readiness (crashes, config
+  // failures, missing entry), detached from the restart loop.
+  readonly onRepeatedStartFailure?: (failure: {
+    readonly reason: string;
+    readonly logFilePath: string | undefined;
+  }) => Effect.Effect<void>;
 }
 
 interface ActiveBackendRun {
@@ -261,6 +269,9 @@
   readonly active: Option.Option<ActiveBackendRun>;
   readonly restartAttempt: number;
   readonly preflightFailureAttempt: number;
+  // Separate from restartAttempt so bounded preflight retries, which surface
+  // their own dialog, cannot step past the notification threshold.
+  readonly startFailureStreak: number;
   readonly restartFiber: Option.Option<Fiber.Fiber<void, never>>;
   readonly nextRunId: number;
 }
@@ -272,6 +283,7 @@
   active: Option.none(),
   restartAttempt: 0,
   preflightFailureAttempt: 0,
+  startFailureStreak: 0,
   restartFiber: Option.none(),
   nextRunId: 1,
 };
@@ -755,7 +767,7 @@
               spec.onPreflightFailed?.(preflightFailure.value) ?? Effect.succeed(false)
             );
             if (shouldRestart) {
+              yield* scheduleRestart(reason, { countsAsStartFailure: false });
-              yield* scheduleRestart(reason);
             } else {
               yield* Ref.update(state, (latest) => ({
                 ...latest,
@@ -765,7 +777,7 @@
             }
             return;
           }
-          yield* scheduleRestart(reason);
+          yield* scheduleRestart(reason, { countsAsStartFailure: false });
           return;
         }
         yield* Ref.update(state, (latest) =>
@@ -912,6 +924,7 @@
                 {
                   ...latest,
                   restartAttempt: 0,
+                  startFailureStreak: 0,
                   ready: true,
                 },
               ] as const;
@@ -964,25 +977,41 @@
 
   const scheduleRestart = Effect.fn("desktop.backendInstance.scheduleRestart")(function* (
     reason: string,
+    options?: { readonly countsAsStartFailure?: boolean },
   ) {
+    const countsAsStartFailure = options?.countsAsStartFailure ?? true;
     const scheduled = yield* Ref.modify(state, (latest) => {
       if (!latest.desiredRunning || Option.isSome(latest.restartFiber)) {
-        return [Option.none<Duration.Duration>(), latest] as const;
+        return [Option.none<{ delay: Duration.Duration; notify: boolean }>(), latest] as const;
       }
 
-      const delay = calculateRestartDelay(latest.restartAttempt);
+      const startFailureStreak = latest.startFailureStreak + (countsAsStartFailure ? 1 : 0);
       return [
+        Option.some({
+          delay: calculateRestartDelay(latest.restartAttempt),
+          notify: countsAsStartFailure && startFailureStreak === REPEATED_START_FAILURE_ATTEMPTS,
+        }),
-        Option.some(delay),
         {
           ...latest,
           restartAttempt: latest.restartAttempt + 1,
+          startFailureStreak,
         },
       ] as const;
     });
 
     yield* Option.match(scheduled, {
       onNone: () => Effect.void,
+      onSome: Effect.fn("desktop.backendInstance.scheduleRestartFiber")(function* ({
+        delay,
+        notify,
+      }) {
+        if (notify && spec.onRepeatedStartFailure) {
+          // Forked so a dialog awaiting the user never holds the mutex or delays the restart.
-      onSome: Effect.fn("desktop.backendInstance.scheduleRestartFiber")(function* (delay) {
+          yield* Effect.forkIn(
+            spec.onRepeatedStartFailure({ reason, logFilePath: backendOutputLog.filePath }),
+            parentScope,
+          );
+        }
         yield* logInstanceError("backend exited unexpectedly; restart scheduled", {
           reason,
           delayMs: Duration.toMillis(delay),
@@ -1041,6 +1070,7 @@
               ...latest,
               desiredRunning: false,
               ready: false,
+              startFailureStreak: 0,
               active,
               restartFiber: Option.none<Fiber.Fiber<void, never>>(),
             },
```

## apps/desktop/src/backend/DesktopBackendPool.test.ts

Status: modified; +280; −6

```diff
@@ -1,10 +1,13 @@
 import { assert, describe, it } from "@effect/vitest";
+import * as Duration from "effect/Duration";
 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 Ref from "effect/Ref";
+import * as Sink from "effect/Sink";
 import * as Stream from "effect/Stream";
+import * as TestClock from "effect/testing/TestClock";
 import { HttpClient } from "effect/http";
 import { ChildProcessSpawner } from "effect/process";
 
@@ -14,28 +17,104 @@
 import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts";
 import * as ElectronDialog from "../electron/ElectronDialog.ts";
 import * as ElectronApp from "../electron/ElectronApp.ts";
+import * as ElectronShell from "../electron/ElectronShell.ts";
 import * as DesktopWindow from "../window/DesktopWindow.ts";
 import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
 import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
 import * as DesktopBackendPool from "./DesktopBackendPool.ts";
 import type { DesktopBackendStartConfig } from "./DesktopBackendManager.ts";
 
+const primaryConfig: DesktopBackendStartConfig = {
+  executablePath: "/electron",
+  args: [],
+  entryPath: "/server/bin.mjs",
+  cwd: "/server",
+  env: {},
+  bootstrap: {
+    mode: "desktop",
+    noBrowser: true,
+    port: 3773,
+    t3Home: "/tmp/t3",
+    host: "127.0.0.1",
+    desktopBootstrapToken: "token",
+    tailscaleServeEnabled: false,
+    tailscaleServePort: 443,
+  },
+  bootstrapDelivery: "fd3",
+  extendEnv: true,
+  httpBaseUrl: new URL("http://127.0.0.1:3773"),
+  captureOutput: false,
+  preflightFailure: Option.none(),
+};
+
+interface LayerPoolOptions {
+  readonly resolvePrimary?: DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]["resolvePrimary"];
+  readonly spawner?: ChildProcessSpawner.ChildProcessSpawner["Service"];
+  readonly httpClient?: HttpClient.HttpClient;
+  readonly entryExists?: boolean;
+  readonly native?: NativeSpy;
+}
+
+interface NativeSpy {
+  readonly errorBoxes: Array<{ readonly title: string; readonly content: string }>;
+  readonly messageBoxes: Array<Electron.MessageBoxOptions>;
+  readonly copied: Array<string>;
+  // Resolves to the clicked button index; defaults to "Copy Diagnostics".
+  readonly messageBoxResponse: Effect.Effect<number>;
+}
+
+const makeNativeSpy = (messageBoxResponse = Effect.succeed(0)): NativeSpy => ({
+  errorBoxes: [],
+  messageBoxes: [],
+  copied: [],
+  messageBoxResponse,
+});
+
+const BACKEND_LOG_PATH = "/logs/server-primary.log";
+
+const exitingProcess = (onSpawn: () => void) =>
+  ChildProcessSpawner.make(() =>
+    Effect.sync(() => {
+      onSpawn();
+      return ChildProcessSpawner.makeHandle({
+        pid: ChildProcessSpawner.ProcessId(123),
+        stdout: Stream.empty,
+        stderr: Stream.empty,
+        all: Stream.empty,
+        exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)),
+        isRunning: Effect.succeed(false),
+        kill: () => Effect.void,
+        stdin: Sink.drain,
+        getInputFd: () => Sink.drain,
+        getOutputFd: () => Stream.empty,
+        unref: Effect.succeed(Effect.void),
+      });
+    }),
+  );
+
+function layerPool(
+  labelRef: Ref.Ref<string>,
+  options: LayerPoolOptions = {},
+): Layer.Layer<DesktopBackendPool.DesktopBackendPool> {
+  const native = options.native;
-function layerPool(labelRef: Ref.Ref<string>): Layer.Layer<DesktopBackendPool.DesktopBackendPool> {
+  const entryExists = options.entryExists ?? true;
   return DesktopBackendPool.layer.pipe(
     Layer.provideMerge(
       Layer.mergeAll(
-        FileSystem.layerNoop({}),
+        FileSystem.layerNoop({ exists: () => Effect.succeed(entryExists) }),
         Layer.succeed(
           ChildProcessSpawner.ChildProcessSpawner,
+          options.spawner ??
+            ChildProcessSpawner.make(() => Effect.die("unexpected child process spawn")),
-          ChildProcessSpawner.make(() => Effect.die("unexpected child process spawn")),
         ),
         Layer.succeed(
           HttpClient.HttpClient,
-          HttpClient.make(() => Effect.die("unexpected HTTP request")),
+          options.httpClient ?? HttpClient.make(() => Effect.die("unexpected HTTP request")),
         ),
         Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, {
           forInstance: () =>
             Effect.succeed({
+              filePath: BACKEND_LOG_PATH,
               beginSession: () => Effect.void,
               writeOutputChunk: () => Effect.void,
               persistFailureSnapshot: () => Effect.void,
@@ -56,14 +135,38 @@
         }),
         DesktopBrowserHost.layer,
         Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, {
-          resolvePrimary: Effect.die("unexpected primary config resolve"),
+          resolvePrimary: options.resolvePrimary ?? Effect.die("unexpected primary config resolve"),
           resolvePrimaryLabel: Ref.get(labelRef),
           resolveWsl: () => Effect.die("unexpected WSL config resolve"),
           currentBootstrapToken: Effect.die("unexpected bootstrap token read"),
         } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]),
         DesktopAppSettings.layerTest(),
         DesktopWslEnvironment.layerTest(),
+        native === undefined
+          ? Layer.merge(ElectronDialog.layer, ElectronShell.layer)
+          : Layer.merge(
+              Layer.succeed(ElectronDialog.ElectronDialog, {
+                ...ElectronDialog.make,
+                showErrorBox: (title, content) =>
+                  Effect.sync(() => {
+                    native.errorBoxes.push({ title, content });
+                  }),
+                showMessageBox: (options) =>
+                  Effect.sync(() => {
+                    native.messageBoxes.push(options);
+                  }).pipe(
+                    Effect.andThen(native.messageBoxResponse),
+                    Effect.map((response) => ({ response, checkboxChecked: false })),
+                  ),
+              }),
+              Layer.succeed(ElectronShell.ElectronShell, {
+                ...ElectronShell.make,
+                copyText: (text) =>
+                  Effect.sync(() => {
+                    native.copied.push(text);
+                  }),
+              }),
-        ElectronDialog.layer,
+            ),
         ElectronApp.layer,
         Layer.succeed(DesktopWindow.DesktopWindow, {
           createMain: Effect.die("unexpected window create"),
@@ -133,4 +236,175 @@
       }).pipe(Effect.provide(layerPool(Ref.makeUnsafe("Windows")))),
     ),
   );
+
+  it.effect("shows a visible error when the primary backend crashes before it is ready", () => {
+    const native = makeNativeSpy();
+    let spawnCount = 0;
+    return Effect.scoped(
+      Effect.gen(function* () {
+        const pool = yield* DesktopBackendPool.DesktopBackendPool;
+        const primary = yield* pool.primary;
+        yield* primary.start;
+        yield* TestClock.adjust(Duration.minutes(1));
+
+        assert.isAbove(spawnCount, 1);
+        assert.isFalse((yield* primary.snapshot).ready);
+        // The renderer is served by this backend, so only a native dialog can
+        // tell the user why the window stays blank.
+        assert.equal(native.messageBoxes.length, 1);
+        assert.include(native.messageBoxes[0]!.detail, "code=1");
+        assert.equal(native.copied.length, 1);
+        assert.include(native.copied[0], "code=1");
+        assert.include(native.copied[0], BACKEND_LOG_PATH);
+      }).pipe(
+        Effect.provide(
+          Layer.merge(
+            layerPool(Ref.makeUnsafe("Windows"), {
+              resolvePrimary: Effect.succeed(primaryConfig),
+              spawner: exitingProcess(() => {
+                spawnCount += 1;
+              }),
+              httpClient: HttpClient.make(() => Effect.never),
+              native,
+            }),
+            TestClock.layer(),
+          ),
+        ),
+      ),
+    );
+  });
+
+  it.effect("keeps restarting while the failure dialog is open", () => {
+    const native = makeNativeSpy(Effect.never);
+    let spawnCount = 0;
+    return Effect.scoped(
+      Effect.gen(function* () {
+        const pool = yield* DesktopBackendPool.DesktopBackendPool;
+        const primary = yield* pool.primary;
+        yield* primary.start;
+        yield* TestClock.adjust(Duration.minutes(1));
+
+        assert.equal(native.messageBoxes.length, 1);
+        assert.isAbove(spawnCount, 4);
+      }).pipe(
+        Effect.provide(
+          Layer.merge(
+            layerPool(Ref.makeUnsafe("Windows"), {
+              resolvePrimary: Effect.succeed(primaryConfig),
+              spawner: exitingProcess(() => {
+                spawnCount += 1;
+              }),
+              httpClient: HttpClient.make(() => Effect.never),
+              native,
+            }),
+            TestClock.layer(),
+          ),
+        ),
+      ),
+    );
+  });
+
+  it.effect("surfaces a new crash loop after an explicit stop and start", () => {
+    const native = makeNativeSpy();
+    return Effect.scoped(
+      Effect.gen(function* () {
+        const pool = yield* DesktopBackendPool.DesktopBackendPool;
+        const primary = yield* pool.primary;
+        yield* primary.start;
+        yield* TestClock.adjust(Duration.minutes(1));
+        assert.equal(native.messageBoxes.length, 1);
+
+        yield* primary.stop();
+        yield* primary.start;
+        yield* TestClock.adjust(Duration.minutes(1));
+
+        assert.equal(native.messageBoxes.length, 2);
+      }).pipe(
+        Effect.provide(
+          Layer.merge(
+            layerPool(Ref.makeUnsafe("Windows"), {
+              resolvePrimary: Effect.succeed(primaryConfig),
+              spawner: exitingProcess(() => {}),
+              httpClient: HttpClient.make(() => Effect.never),
+              native,
+            }),
+            TestClock.layer(),
+          ),
+        ),
+      ),
+    );
+  });
+
+  it.effect("still surfaces crashes that follow a bounded preflight fallback", () => {
+    const native = makeNativeSpy();
+    let resolveCount = 0;
+    let spawnCount = 0;
+    return Effect.scoped(
+      Effect.gen(function* () {
+        const pool = yield* DesktopBackendPool.DesktopBackendPool;
+        const primary = yield* pool.primary;
+        yield* primary.start;
+        yield* TestClock.adjust(Duration.minutes(2));
+
+        assert.isAbove(spawnCount, 1);
+        // The preflight fallback reports itself; the crash loop after it needs its own report.
+        assert.equal(native.errorBoxes.length, 1);
+        assert.equal(native.messageBoxes.length, 1);
+        assert.include(native.messageBoxes[0]!.detail, "code=1");
+      }).pipe(
+        Effect.provide(
+          Layer.merge(
+            layerPool(Ref.makeUnsafe("Windows"), {
+              resolvePrimary: Effect.sync(() => {
+                resolveCount += 1;
+                return resolveCount <= 4
+                  ? {
+                      ...primaryConfig,
+                      preflightFailure: Option.some({
+                        reason: "WSL unreachable",
+                        fatal: false,
+                        retryLimit: 4,
+                      }),
+                    }
+                  : primaryConfig;
+              }),
+              spawner: exitingProcess(() => {
+                spawnCount += 1;
+              }),
+              httpClient: HttpClient.make(() => Effect.never),
+              native,
+            }),
+            TestClock.layer(),
+          ),
+        ),
+      ),
+    );
+  });
+
+  it.effect("surfaces a missing server entry once", () => {
+    const native = makeNativeSpy();
+    return Effect.scoped(
+      Effect.gen(function* () {
+        const pool = yield* DesktopBackendPool.DesktopBackendPool;
+        const primary = yield* pool.primary;
+        yield* primary.start;
+        yield* TestClock.adjust(Duration.minutes(2));
+
+        assert.isAbove((yield* primary.snapshot).restartAttempt, 3);
+        assert.equal(native.messageBoxes.length, 1);
+        assert.include(native.copied[0], "missing server entry at /server/bin.mjs");
+      }).pipe(
+        Effect.provide(
+          Layer.merge(
+            layerPool(Ref.makeUnsafe("Windows"), {
+              resolvePrimary: Effect.succeed(primaryConfig),
+              entryExists: false,
+              native,
+            }),
+            TestClock.layer(),
+          ),
+        ),
+      ),
+    );
+  });
 });
```

## apps/desktop/src/backend/DesktopBackendPool.ts

Status: modified; +24; −0

```diff
@@ -22,6 +22,7 @@
 import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
 import * as ElectronDialog from "../electron/ElectronDialog.ts";
 import * as ElectronApp from "../electron/ElectronApp.ts";
+import * as ElectronShell from "../electron/ElectronShell.ts";
 
 const { logWarning: logBackendPoolWarning } =
   DesktopObservability.makeComponentLogger("desktop-backend-pool");
@@ -105,6 +106,7 @@
     const desktopWindow = yield* DesktopWindow.DesktopWindow;
     const electronDialog = yield* ElectronDialog.ElectronDialog;
     const electronApp = yield* ElectronApp.ElectronApp;
+    const electronShell = yield* ElectronShell.ElectronShell;
     const appSettings = yield* DesktopAppSettings.DesktopAppSettings;
     // Without this, instance scopes are orphaned: they only close via explicit unregister() calls,
     // so on app shutdown the WSL backend child process gets hard-killed by the OS instead of
@@ -176,6 +178,28 @@
         ),
       onShutdown: () => desktopWindow.handleBackendNotReady,
       onPreflightFailed: handlePrimaryPreflightFailure,
+      onRepeatedStartFailure: Effect.fn("desktop.backendPool.primaryRepeatedStartFailure")(
+        function* ({ reason, logFilePath }) {
+          const diagnostics = `Reason: ${reason}\nBackend log: ${logFilePath ?? "unavailable"}`;
+          const { response } = yield* electronDialog.showMessageBox({
+            type: "error",
+            title: "T3 Code's backend couldn't start",
+            message: "The local backend keeps stopping before it is ready.",
+            detail: `${diagnostics}\n\nT3 Code keeps retrying in the background. If the window stays blank, copy the diagnostics, check the log, then quit and reopen T3 Code.`,
+            buttons: ["Copy Diagnostics", "Close"],
+            defaultId: 0,
+            cancelId: 1,
+          });
+          if (response === 0) {
+            yield* electronShell.copyText(diagnostics);
+          }
+        },
+        Effect.catch((error) =>
+          logBackendPoolWarning("failed to show repeated backend start failure", {
+            error: error.message,
+          }),
+        ),
+      ),
       onStateDirOwned: () =>
         electronDialog
           .showErrorBox(
```

## scripts/fixtures/issue-10517/README.md

Status: added; +26; −0

````diff
@@ -1,0 +1,26 @@
+# Native Linux startup fixture
+
+`capture.py` runs the built desktop, records the entire X display with timestamped action captions, saves native root screenshots, records artifact hashes and executable `process.versions`, and reads the clipboard with xclip on the same X session. A hidden Electron helper records executable versions. The helper and video captions are capture instrumentation; they do not replace application dialogs. Output contains runtime databases and must remain outside version control. Review logs for private data before uploading. A `.stop-captures` file in the output parent prevents subsequent captures from launching.
+
+Prerequisites: built desktop/server/web assets, Python sqlite3, Electron, ffmpeg with drawtext, ImageMagick import, xdotool, xclip, Xvfb, D-Bus, GNOME keyring. No browser is used. Build each revision and save its main bundle at `.t3/evidence-head/main.cjs` and `.t3/evidence-base/main.cjs`, then pass the commits those builds came from as `--head-source` and `--base-source`. Both are required and must resolve to commits in the worktree, and both bundles must exist; the script exits before touching the checkout otherwise. The manifest records each ref, its resolved commit, and each saved bundle's hash. The script cannot prove a bundle was built from its ref, so name the refs you actually built. The script temporarily swaps only `dist-electron/main.cjs` and restores it in `finally`, including on SIGTERM, SIGHUP, or SIGINT, which also stop the processes it started. SIGKILL cannot be handled: after one, restore `main.cjs` and rename `bin.mjs.evidence-save` back by hand, and stop leftover processes listed in `pids.json`. An advisory lock serializes bundle swaps. Do not run other desktop instances from this checkout during capture or run captures concurrently. `missing-entry` temporarily renames the server entry and restores it: use only an isolated artifact checkout with no other app/server running.
+
+To create a nonprivate seed, launch the built desktop once with a new empty task-local `T3CODE_HOME`, allow the backend to reach ready, then quit that captured process. Do not add projects or pair accounts. Check `projection_projects` and `projection_threads` both contain zero rows. Never use the live developer home.
+
+Use that clean, stopped, isolated T3 home's `userdata/statev2.sqlite` as `--seed`. The SQLite backup API includes committed WAL state. The missing-column fixture drops `projection_threads.branch_pull_request_json` but retains the migration ledger. Invalid/unavailable use literal non-SQLite bytes, a different failure. `unavailable` makes `userdata/logs/server-child.log` a directory; this probes later write failure, but sink creation is lazy and still advertises a path. `unavailable-parent` makes `userdata/logs` a file so sink initialization cannot create its parent; it probes the no-op sink branch if application bootstrap can continue. Record actual behavior rather than assuming either fixture reaches the intended branch. Healthy uses an unchanged SQLite snapshot. No credentials, private data, or database fixtures belong in this directory.
+
+Example, from the worktree, after saving/building both revisions and exporting `HEAD_REF` and `BASE_REF` as the commits they were built from:
+
+```sh
+worktree="$PWD"
+export XDG_DATA_HOME="$worktree/.t3/keyring-data"
+xvfb-run -a -s '-screen 0 1440x900x24' dbus-run-session -- bash -c '
+  printf %s t3-fleet | gnome-keyring-daemon --unlock --components=secrets >/dev/null
+  GTK_THEME=Adwaita:light python3 scripts/fixtures/issue-10517/capture.py \
+    --worktree "$PWD" --output /tmp/issue10517-base \
+    --seed .t3/healthy-home/userdata/statev2.sqlite \
+    --variant missing-column --revision base --seconds 33 \
+    --head-source "$HEAD_REF" --base-source "$BASE_REF"
+'
+```
+
+Repeat with fresh output and `--revision head`, identical seed/display/theme/steps. Use `--action escape`, `close`, or `none` for cancellation/control. Probe 390 with a real 390-wide X screen, not a cropped image. GTK theme requests may not affect Electron's native toolkit; report observed appearance and actual dialog geometry rather than claiming theme coverage. No window manager is started. Captures are Linux X11 evidence only, not macOS, Windows, WSL, web, or mobile proof.
````

## scripts/fixtures/issue-10517/capture.py

Status: added; +437; −0

```diff
@@ -1,0 +1,437 @@
+#!/usr/bin/env python3
+"""Capture real Linux Electron startup. Run inside an isolated X/DBus session.
+See README.md for prerequisites and fixture provenance. No production hooks.
+"""
+
+import fcntl
+import textwrap
+import argparse, hashlib, json, os, pathlib, signal, sqlite3, subprocess, time
+
+p = argparse.ArgumentParser()
+p.add_argument("--worktree", required=True)
+p.add_argument("--output", required=True)
+p.add_argument("--seed", required=True)
+p.add_argument(
+    "--variant",
+    choices=[
+        "missing-column",
+        "invalid",
+        "unavailable",
+        "unavailable-parent",
+        "healthy",
+        "missing-entry",
+    ],
+    required=True,
+)
+p.add_argument("--revision", choices=["base", "head"], required=True)
+# Git refs the saved bundles were built from; the script cannot verify this.
+p.add_argument("--head-source", required=True)
+p.add_argument("--base-source", required=True)
+p.add_argument(
+    "--action", choices=["copy", "click", "escape", "close", "none"], default="copy"
+)
+p.add_argument("--seconds", type=int, default=33)
+p.add_argument("--xclip", default="xclip")
+a = p.parse_args()
+w = pathlib.Path(a.worktree).resolve()
+# Bounds every synchronous helper call; a timeout raises into the finally that
+# restores the checkout.
+TIMEOUT = 30
+out = pathlib.Path(a.output).resolve()
+
+
+def on_signal(signum, frame):
+    if spawning:
+        pending.append(signum)
+    else:
+        raise SystemExit(128 + signum)
+
+
+# Turn SIGTERM/SIGHUP/SIGINT into SystemExit so finally restores the checkout.
+# Signals arriving mid-spawn are deferred until the child is tracked.
+spawning = False
+pending = []
+SIGNALS = [signal.SIGTERM, signal.SIGHUP, signal.SIGINT]
+for sig in SIGNALS:
+    signal.signal(sig, on_signal)
+
+
+def source(ref):
+    r = subprocess.run(
+        ["git", "-C", str(w), "rev-parse", "--verify", "--end-of-options"]
+        + [ref + "^{commit}"],
+        capture_output=True,
+        text=True,
+        timeout=TIMEOUT,
+    )
+    if r.returncode != 0:
+        raise SystemExit(f"source ref {ref!r} is not a commit in {w}")
+    return {"ref": ref, "commit": r.stdout.strip()}
+
+
+sources = {"head": source(a.head_source), "base": source(a.base_source)}
+bundles = {
+    rev: (w / f".t3/evidence-{rev}/main.cjs").read_bytes() for rev in ["head", "base"]
+}
+entry = w / "apps/server/dist/bin.mjs"
+saved_entry = entry.with_suffix(".mjs.evidence-save")
+if saved_entry.exists():
+    raise SystemExit(f"{saved_entry} exists; restore it before capturing")
+if a.variant == "missing-entry" and not entry.exists():
+    raise SystemExit(f"{entry} is missing")
+if (out.parent / ".stop-captures").exists():
+    raise SystemExit("capture stop marker present")
+out.mkdir(parents=True, exist_ok=False)
+home = out / "home"
+(home / "userdata").mkdir(parents=True)
+if a.variant in ["invalid", "unavailable", "unavailable-parent"]:
+    (home / "userdata/statev2.sqlite").write_bytes(b"not a SQLite database\n")
+else:
+    src = sqlite3.connect(
+        "file:" + str(pathlib.Path(a.seed).resolve()) + "?mode=ro", uri=True
+    )
+    dst = sqlite3.connect(home / "userdata/statev2.sqlite")
+    src.backup(dst)
+    src.close()
+    if a.variant == "missing-column":
+        cols = [r[1] for r in dst.execute("pragma table_info(projection_threads)")]
+        if "branch_pull_request_json" in cols:
+            dst.execute(
+                "alter table projection_threads drop column branch_pull_request_json"
+            )
+        assert "branch_pull_request_json" not in [
+            r[1] for r in dst.execute("pragma table_info(projection_threads)")
+        ]
+    dst.commit()
+    dst.close()
+if a.variant == "unavailable-parent":
+    (home / "userdata/logs").write_text(
+        "capture fixture blocks log directory creation\n"
+    )
+if a.variant == "unavailable":
+    (home / "userdata/logs/server-child.log").mkdir(parents=True)
+lock = open(w / ".t3/evidence-capture.lock", "w")
+fcntl.flock(lock, fcntl.LOCK_EX)
+main = w / "apps/desktop/dist-electron/main.cjs"
+original = main.read_bytes()
+electron = w / "apps/desktop/node_modules/electron/dist/electron"
+helper = out / "clipboard.cjs"
+helper.write_text(
+    "const {app}=require('electron');const fs=require('fs');app.whenReady().then(()=>{fs.writeFileSync(process.env.CLIP_VERSION,JSON.stringify(process.versions));app.quit();});"
+)
+env = os.environ.copy()
+env.pop("ELECTRON_RUN_AS_NODE", None)
+env["T3CODE_HOME"] = str(home)
+env["CLIP_VERSION"] = str(out / "versions.json")
+env["XDG_DATA_HOME"] = str(w / ".t3/keyring-data")
+for key in [
+    "VITE_HTTP_URL",
+    "VITE_WS_URL",
+    "VITE_DEV_SERVER_URL",
+    "ELECTRON_RENDERER_URL",
+]:
+    env.pop(key, None)
+procs = []
+start = time.monotonic()
+events = []
+caption_columns = max(
+    24,
+    int(
+        subprocess.check_output(
+            ["xdotool", "getdisplaygeometry"], text=True, timeout=TIMEOUT
+        ).split()[0]
+    )
+    // 9,
+)
+
+
+def event(s):
+    events.append({"seconds": round(time.monotonic() - start, 3), "event": s})
+    (out / "timeline.json").write_text(json.dumps(events, indent=2))
+    (out / "caption.txt").write_text(
+        textwrap.fill(
+            f'{a.revision} {a.variant} | {events[-1]["seconds"]}s {s}',
+            width=caption_columns,
+        )
+    )
+
+
+def stop(proc):
+    if proc.poll() is None:
+        proc.terminate()
+        try:
+            proc.wait(timeout=6)
+        except subprocess.TimeoutExpired:
+            proc.kill()
+            proc.wait(timeout=3)
+
+
+def spawn(cmd, **kw):
+    global spawning
+    spawning = True
+    try:
+        proc = subprocess.Popen(cmd, **kw)
+        procs.append(proc)
+    finally:
+        spawning = False
+    if pending:
+        raise SystemExit(128 + pending[0])
+    return proc
+
+
+def recorder_alive(rec):
+    if rec.poll() is not None:
+        event(f"recorder exited early with code {rec.returncode}")
+        raise SystemExit(f"ffmpeg exited early ({rec.returncode}); see ffmpeg.log")
+
+
+def validate_recording(path, minimum):
+    # A missing moov atom (truncated mp4) makes ffprobe fail outright.
+    r = subprocess.run(
+        ["ffprobe", "-v", "error", "-show_entries", "format=duration"]
+        + ["-of", "default=noprint_wrappers=1:nokey=1", str(path)],
+        capture_output=True,
+        text=True,
+        timeout=TIMEOUT,
+    )
+    try:
+        duration = float(r.stdout.strip())
+    except ValueError:
+        duration = None
+    if r.returncode != 0 or duration is None or duration < minimum:
+        event(f"recording invalid: duration={duration}")
+        raise SystemExit(
+            f"{path} is missing or truncated (duration={duration}, "
+            f"expected >= {minimum}s): {r.stderr.strip()}"
+        )
+    return duration
+
+
+def windows():
+    r = subprocess.run(
+        ["xdotool", "search", "--name", "T3 Code's backend"],
+        capture_output=True,
+        text=True,
+        timeout=TIMEOUT,
+    )
+    return r.stdout.split()
+
+
+try:
+    main.write_bytes(bundles[a.revision])
+    if a.variant == "missing-entry":
+        entry.rename(saved_entry)
+    manifest = {
+        "revision": a.revision,
+        "source": sources[a.revision],
+        "headSource": sources["head"],
+        "baseSource": sources["base"],
+        "bundleSha256": {
+            rev: hashlib.sha256(b).hexdigest() for rev, b in bundles.items()
+        },
+        "variant": a.variant,
+        "display": os.environ.get("DISPLAY"),
+        "gtkTheme": os.environ.get("GTK_THEME"),
+        "mainSha256": hashlib.sha256(main.read_bytes()).hexdigest(),
+        "artifacts": {
+            str(f.relative_to(w)): hashlib.sha256(f.read_bytes()).hexdigest()
+            for f in (w / "apps/desktop/dist-electron").glob("*.cjs")
+        },
+        "command": [
+            str(electron),
+            str(w / "apps/desktop/dist-electron/boot.cjs"),
+            "--no-sandbox",
+            "--disable-gpu",
+        ],
+    }
+    (out / "manifest.json").write_text(json.dumps(manifest, indent=2))
+    event("recording starts; whole X display; captions are capture instrumentation")
+    geo = (
+        subprocess.check_output(
+            ["xdotool", "getdisplaygeometry"], text=True, timeout=TIMEOUT
+        )
+        .strip()
+        .split()
+    )
+    size = "x".join(geo)
+    manifest["screen"] = size
+    (out / "manifest.json").write_text(json.dumps(manifest, indent=2))
+    log = open(out / "app.log", "w")
+    hl = open(out / "helper.log", "w")
+    ff = open(out / "ffmpeg.log", "w")
+    clip = spawn(
+        [str(electron), str(helper), "--no-sandbox", "--disable-gpu"],
+        env=env,
+        stdout=hl,
+        stderr=hl,
+    )
+    owner = spawn(
+        [a.xclip, "-selection", "clipboard", "-in", "-quiet"],
+        stdin=subprocess.PIPE,
+        stdout=hl,
+        stderr=hl,
+    )
+    owner.stdin.write(b"ISSUE10517-SENTINEL")
+    owner.stdin.close()
+    rec = spawn(
+        [
+            "ffmpeg",
+            "-y",
+            "-f",
+            "x11grab",
+            "-framerate",
+            "10",
+            "-video_size",
+            size,
+            "-i",
+            os.environ["DISPLAY"],
+            "-vf",
+            f"drawtext=fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf:textfile={out}/caption.txt:reload=1:fontcolor=white:fontsize=16:box=1:boxcolor=black:x=8:y=h-text_h-10",
+            "-c:v",
+            "libx264",
+            "-preset",
+            "ultrafast",
+            "-crf",
+            "24",
+            "-pix_fmt",
+            "yuv420p",
+            str(out / "recording.mp4"),
+        ],
+        stdout=ff,
+        stderr=ff,
+    )
+    app = spawn(manifest["command"], env=env, stdout=log, stderr=log)
+    (out / "pids.json").write_text(
+        json.dumps(
+            {
+                "app": app.pid,
+                "versionHelper": clip.pid,
+                "clipboardOwner": owner.pid,
+                "recorder": rec.pid,
+            }
+        )
+    )
+    event("native application launched")
+    deadline = time.monotonic() + a.seconds
+    seen_retries = 0
+    seen_windows = []
+    while time.monotonic() < deadline:
+        recorder_alive(rec)
+        content = (out / "app.log").read_text(errors="replace")
+        count = content.count("restart scheduled")
+        ids_now = windows()
+        if count != seen_retries:
+            event("restart schedule count=" + str(count))
+            seen_retries = count
+        if ids_now != seen_windows:
+            event("native dialog window change=" + str(ids_now))
+            seen_windows = ids_now
+        time.sleep(0.2)
+    ids = windows()
+    event("dialog windows=" + str(ids))
+    (out / "windows.txt").write_text(
+        subprocess.run(
+            ["xdotool", "search", "--name", "."],
+            capture_output=True,
+            text=True,
+            timeout=TIMEOUT,
+        ).stdout
+    )
+    if ids:
+        (out / "geometry.txt").write_text(
+            subprocess.check_output(
+                ["xdotool", "getwindowgeometry", "--shell", ids[-1]],
+                text=True,
+                timeout=TIMEOUT,
+            )
+        )
+    subprocess.run(
+        ["import", "-window", "root", str(out / "before-action.png")],
+        check=True,
+        timeout=TIMEOUT,
+    )
+    if ids and a.action != "none":
+        wid = ids[-1]
+        subprocess.run(["xdotool", "windowfocus", wid], timeout=TIMEOUT)
+        event("action " + a.action)
+        if a.action == "click":
+            geometry = dict(
+                line.split("=", 1)
+                for line in subprocess.check_output(
+                    ["xdotool", "getwindowgeometry", "--shell", wid],
+                    text=True,
+                    timeout=TIMEOUT,
+                ).splitlines()
+            )
+            subprocess.run(
+                [
+                    "xdotool",
+                    "mousemove",
+                    "--window",
+                    wid,
+                    str(int(geometry["WIDTH"]) // 4),
+                    str(int(geometry["HEIGHT"]) - 18),
+                    "click",
+                    "1",
+                ],
+                timeout=TIMEOUT,
+            )
+        elif a.action == "close":
+            subprocess.run(["xdotool", "key", "Tab", "Return"], timeout=TIMEOUT)
+        else:
+            subprocess.run(
+                ["xdotool", "key", "Return" if a.action == "copy" else "Escape"],
+                timeout=TIMEOUT,
+            )
+        time.sleep(2)
+        text = subprocess.check_output(
+            [a.xclip, "-selection", "clipboard", "-out"], text=True, timeout=4
+        )
+        (out / "clipboard.txt").write_text(text)
+        event("same-X-session clipboard readback: " + text.replace("\n", " | "))
+        time.sleep(3)
+        event("post-action windows=" + str(windows()))
+        time.sleep(8)
+    else:
+        event("no dialog action target; matched observation")
+        time.sleep(5)
+    subprocess.run(
+        ["import", "-window", "root", str(out / "after-action.png")],
+        check=True,
+        timeout=TIMEOUT,
+    )
+    event(
+        "final restart schedules="
+        + str((out / "app.log").read_text(errors="replace").count("restart scheduled"))
+        + "; dialog windows="
+        + str(windows())
+    )
+    time.sleep(2)
+    recorder_alive(rec)
+    event("recording stops")
+    rec.send_signal(signal.SIGINT)
+    # ffmpeg exits 255 after a clean SIGINT shutdown.
+    if rec.wait(timeout=15) not in (0, 255):
+        event(f"recorder exited with code {rec.returncode}")
+        raise SystemExit(f"ffmpeg exited with {rec.returncode}; see ffmpeg.log")
+    duration = validate_recording(out / "recording.mp4", a.seconds)
+    manifest["recordingSeconds"] = duration
+    (out / "manifest.json").write_text(json.dumps(manifest, indent=2))
+    event("capture completed")
+finally:
+    for sig in SIGNALS:
+        signal.signal(sig, signal.SIG_IGN)
+    try:
+        for proc in reversed(procs):
+            stop(proc)
+    finally:
+        try:
+            main.write_bytes(original)
+        finally:
+            try:
+                if saved_entry.exists():
+                    saved_entry.rename(entry)
+            finally:
+                (out / "cleanup.json").write_text(
+                    json.dumps({str(proc.pid): proc.poll() for proc in procs})
+                )
```
