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

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

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

Visibility: public

[fix\(desktop\): wait for the local server to exit when quitting](https://git.cafe/pingdotgg/t3code/pull/22999.md)

Version: 19

Head commit: 424fe021a8815e3b8cd80d365c8477c77d1ebfbf

Base commit: 8d3b061121fe65669d5d43d011b48cd3027a4108

Comparison base: 8d3b061121fe65669d5d43d011b48cd3027a4108

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

## apps/desktop/scripts/quit-lock-evidence.sh

Status: added; +200; −0

```diff
@@ -1,0 +1,200 @@
+#!/usr/bin/env bash
+# Runtime evidence for desktop quit releasing the backend's single-writer lock
+# (<T3CODE_HOME>/userdata/server-owner.sqlite) before Electron exits.
+#
+# Usage: apps/desktop/scripts/quit-lock-evidence.sh <checkout> <fixture> <run-dir>
+#   checkout  repo root with `vp run build:desktop` done (apps/server/dist + apps/desktop/dist-electron)
+#   fixture   F1  SIGTERM to the Electron main process, idle backend
+#             F2  UI quit: window.close() over CDP (window-all-closed -> app.quit)
+#             F3  backend SIGSTOPped (ignores SIGTERM), then SIGTERM to Electron
+#             F4  backend SIGSTOPped, SIGTERM to Electron, SIGCONT after $F4_DELAY (3s)
+#             F12 backend SIGKILLed before quit, then SIGTERM to Electron
+#   run-dir   fresh directory for the isolated T3CODE_HOME and XDG dirs (never ~/.t3)
+#
+# Needs headless-desktop (xvfb-run + dbus + keyring), lsof, fuser, node >= 22 (node:sqlite).
+# After Electron exits it records the lock holders, a BEGIN EXCLUSIVE probe, and then
+# relaunches on the same home to check the new backend acquires the lock. It only signals
+# PIDs it spawned or found under its own wrapper whose environ has this run's T3CODE_HOME.
+set -uo pipefail
+
+CHECKOUT=$(cd "${1:?checkout}" && pwd)
+FIXTURE=${2:?fixture}
+RUN=$(mkdir -p "${3:?run-dir}" && cd "$3" && pwd)
+F4_DELAY=${F4_DELAY:-3}
+CDP_PORT=${CDP_PORT:-9333}
+H=$RUN/home
+L=$H/userdata/server-owner.sqlite
+RT=$H/userdata/server-runtime.json
+DESKTOP=$CHECKOUT/apps/desktop
+ELECTRON=$(realpath "$DESKTOP/node_modules/electron/dist/electron")
+SEEN_PIDS=()
+
+mkdir -p "$RUN"
+[ -z "$(ls -A "$RUN" 2>/dev/null)" ] || { echo "run-dir must be empty: $RUN" >&2; exit 2; }
+mkdir -p "$H" "$RUN/xdg/config" "$RUN/xdg/data"
+
+ts() { date +%T.%N; }
+log() { echo "[$(ts)] $*"; }
+now() { date +%s.%N; }
+elapsed() { awk -v a="$1" -v b="$2" 'BEGIN{printf "%.3fs", b-a}'; }
+
+descendants() {
+  ps -e -o pid=,ppid= | awk -v root="$1" '
+    { kids[$2]=kids[$2] " " $1 }
+    END { q=root; while (q != "") { split(q, a, " "); q=""; for (i in a) { n=split(kids[a[i]], k, " "); for (j=1;j<=n;j++) { print k[j]; q=q " " k[j] } } } }'
+}
+
+home_of() { tr '\0' '\n' 2>/dev/null < "/proc/$1/environ" | sed -n 's/^T3CODE_HOME=//p'; }
+ours() { [ "$(home_of "$1")" = "$H" ]; }
+
+# Process table: everything under the wrapper plus every member of the backend group.
+ptable() {
+  local pids
+  pids=$( { descendants "$WRAPPER"; [ -n "${BACKEND_PGID:-}" ] && ps -e -o pid=,pgid= | awk -v g="$BACKEND_PGID" '$2==g{print $1}'; } | sort -un | paste -sd, -)
+  [ -n "$pids" ] || { echo "    (no processes)"; return; }
+  # args reads /proc/<pid>/cmdline, which can stall behind a process in D state on a loaded box.
+  timeout 5 ps -o pid,ppid,pgid,stat,etime,args -p "$pids" | cut -c1-200 | sed 's/^/    /'
+}
+
+lockstate() {
+  log "lsof $L:"; lsof "$L" 2>/dev/null | cut -c1-160 | sed 's/^/    /' || true
+  log "fuser:"; fuser -v "$L" 2>&1 | sed 's/^/    /' || true
+  log "P1 probe: $(node -e 'const {DatabaseSync}=require("node:sqlite");const d=new DatabaseSync(process.argv[1]);
+try{d.exec("PRAGMA busy_timeout=0; BEGIN EXCLUSIVE;");d.exec("ROLLBACK");console.log("LOCK FREE")}
+catch(e){console.log("LOCK HELD: "+e.message)}' "$L")"
+  log "server-runtime.json: $(cat "$RT" 2>/dev/null || echo '(absent)')"
+}
+
+launch() { # $1 = label
+  local extra=()
+  [ "$FIXTURE" = F2 ] && [ "$1" = primary ] && extra=(--remote-debugging-port="$CDP_PORT")
+  (cd "$DESKTOP" && exec env -u VITE_DEV_SERVER_URL -u ELECTRON_RUN_AS_NODE -u T3CODE_PORT \
+    T3CODE_HOME="$H" XDG_CONFIG_HOME="$RUN/xdg/config" XDG_DATA_HOME="$RUN/xdg/data" \
+    T3CODE_DISABLE_AUTO_UPDATE=1 ELECTRON_ENABLE_LOGGING=1 \
+    headless-desktop "$ELECTRON" --no-sandbox --disable-gpu "${extra[@]}" dist-electron/boot.cjs \
+    > "$RUN/$1-stdout.log" 2>&1) &
+  WRAPPER=$!
+  MAIN=""; BACKEND=""; BACKEND_PGID=""
+  log "[$1] spawned wrapper pid=$WRAPPER"
+  local i p
+  for i in $(seq 1 120); do
+    for p in $(descendants "$WRAPPER"); do
+      local args; args=$(tr '\0' ' ' 2>/dev/null < "/proc/$p/cmdline")
+      case "$args" in
+        "$ELECTRON "*boot.cjs*) [[ "$args" == *--type=* ]] || MAIN=$p ;;
+        *apps/server/dist/bin.mjs*) ours "$p" && BACKEND=$p ;;
+      esac
+    done
+    [ -n "$MAIN" ] && [ -n "$BACKEND" ] && lsof -t "$L" 2>/dev/null | grep -qx "$BACKEND" && break
+    sleep 0.5
+  done
+  [ -n "$BACKEND" ] && ! lsof -t "$L" 2>/dev/null | grep -qx "$BACKEND" && { log "[$1] backend $BACKEND never acquired the lock (holders: $(lsof -t "$L" 2>/dev/null | paste -sd, -))"; return 1; }
+  if [ -z "$MAIN" ] || [ -z "$BACKEND" ]; then log "[$1] FAILED to find main=$MAIN backend=$BACKEND"; return 1; fi
+  BACKEND_PGID=$(ps -o pgid= -p "$BACKEND" | tr -d ' ')
+  BACKEND_PGID=$(ps -o pgid= -p "$BACKEND" | tr -d ' ')
+  SEEN_PIDS+=("$BACKEND")
+  log "[$1] electron main pid=$MAIN pgid=$(ps -o pgid= -p "$MAIN" | tr -d ' ') backend pid=$BACKEND pgid=$BACKEND_PGID T3CODE_HOME=$(home_of "$BACKEND")"
+}
+
+wait_main_exit() { # samples the table every 0.5s until the Electron main process is gone
+  local i=0
+  while kill -0 "$MAIN" 2>/dev/null; do
+    if (( i % 5 == 0 )); then log "sample (main alive):"; ptable; fi
+    i=$((i+1)); sleep 0.1
+  done
+}
+
+relaunch_check() {
+  local old_owner; old_owner=$(sed -n 's/.*"ownerId":"\([^"]*\)".*/\1/p' "$RT" 2>/dev/null)
+  log "P2 relaunch on the same home (previous ownerId=${old_owner:-none})"
+  rm -f "$RUN/relaunch.ok"
+  if launch relaunch; then
+    local owner; owner=$(sed -n 's/.*"ownerId":"\([^"]*\)".*/\1/p' "$RT")
+    log "P2 relaunch: backend pid=$BACKEND holds lock: $(lsof -t "$L" 2>/dev/null | paste -sd, -) ownerId=$owner"
+  else
+    log "P2 relaunch: no backend acquired the lock within 60s"
+  fi
+  log "P2 'already running' lines in relaunch logs: $(grep -ci 'already running\|ServerAlreadyRunning' "$RUN/relaunch-stdout.log" "$H/userdata/logs/server.trace.ndjson" 2>/dev/null | paste -sd' ' -)"
+  grep -i 'already running\|ServerAlreadyRunning' "$RUN/relaunch-stdout.log" 2>/dev/null | head -3 | cut -c1-240 | sed 's/^/    /'
+  if [ -n "$MAIN" ]; then
+    log "stopping relaunch: SIGTERM electron main $MAIN"; kill -TERM "$MAIN"
+    wait_main_exit; wait "$WRAPPER" 2>/dev/null
+    log "relaunch exited"; lockstate
+  else
+    kill -TERM "$WRAPPER" 2>/dev/null; wait "$WRAPPER" 2>/dev/null
+  fi
+}
+
+cleanup_orphans() {
+  local p
+  for p in "${SEEN_PIDS[@]}"; do
+    if kill -0 "$p" 2>/dev/null && ours "$p"; then
+      log "CLEANUP: captured backend pid=$p still alive ($(ps -o stat=,ppid= -p "$p")); SIGCONT+SIGKILL"
+      kill -CONT "$p"; kill -KILL "$p"
+    fi
+  done
+  sleep 0.5
+  local leftover
+  # Electron's setproctitle clobbers its own /proc/<pid>/environ, so also list the wrapper's tree.
+  leftover=$( { descendants "$WRAPPER"; for p in /proc/[0-9]*; do [ "$(home_of "${p#/proc/}")" = "$H" ] && echo "${p#/proc/}"; done; } | sort -un | paste -sd, -)
+  log "leftover processes with T3CODE_HOME=$H: ${leftover:-none}"
+}
+
+cdp_close() {
+  node -e '
+const port = process.argv[1];
+(async () => {
+  const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
+  const page = targets.find((t) => t.type === "page" && !t.url.startsWith("devtools"));
+  console.log("CDP page:", page.url);
+  const ws = new WebSocket(page.webSocketDebuggerUrl);
+  ws.onopen = () => ws.send(JSON.stringify({ id: 1, method: "Runtime.evaluate", params: { expression: "window.close()" } }));
+  ws.onmessage = () => { console.log("CDP window.close() sent"); process.exit(0); };
+  ws.onclose = () => { console.log("CDP socket closed"); process.exit(0); };
+  setTimeout(() => process.exit(0), 3000);
+})();' "$CDP_PORT"
+}
+
+echo "fixture=$FIXTURE checkout=$CHECKOUT head=$(git -C "$CHECKOUT" rev-parse HEAD) run=$RUN"
+echo "host=$(hostname) kernel=$(uname -r) node=$(node -v) electron=v$(cat "$(dirname "$ELECTRON")/version") cpus=$(nproc)"
+launch primary || { [ -n "$MAIN" ] && kill -TERM "$MAIN"; sleep 20; cleanup_orphans; exit 1; }
+[ -n "${SCREENSHOT:-}" ] && {
+  sleep 4
+  # Electron's environ is clobbered, so take the display and auth file from Xvfb's argv.
+  xvfb=$(for p in $(descendants "$WRAPPER"); do [ "$(cat /proc/$p/comm 2>/dev/null)" = Xvfb ] && tr '\0' ' ' < /proc/$p/cmdline; done)
+  DISPLAY=$(awk '{print $2}' <<< "$xvfb") XAUTHORITY=$(sed -n 's/.*-auth \([^ ]*\).*/\1/p' <<< "$xvfb") \
+    import -window root "$SCREENSHOT" && log "screenshot: $SCREENSHOT"
+}
+log "running state:"; ptable; lockstate
+
+case "$FIXTURE" in
+  F1) t0=$(now); log "QUIT: SIGTERM electron main $MAIN"; kill -TERM "$MAIN" ;;
+  F2) t0=$(now); log "QUIT: CDP window.close()"; cdp_close 2>&1 | sed 's/^/    /' ;;
+  F3) log "SIGSTOP backend $BACKEND"; kill -STOP "$BACKEND"; sleep 0.3
+      t0=$(now); log "QUIT: SIGTERM electron main $MAIN"; kill -TERM "$MAIN" ;;
+  F4) log "SIGSTOP backend $BACKEND"; kill -STOP "$BACKEND"; sleep 0.3
+      t0=$(now); log "QUIT: SIGTERM electron main $MAIN"; kill -TERM "$MAIN"
+      (sleep "$F4_DELAY"; log "SIGCONT backend $BACKEND (t0+${F4_DELAY}s)"; kill -CONT "$BACKEND") & ;;
+  F12) log "SIGKILL backend $BACKEND before quit"; kill -KILL "$BACKEND"
+       for _ in $(seq 1 40); do kill -0 "$BACKEND" 2>/dev/null || break; sleep 0.1; done
+       sleep 2; log "after backend kill:"; ptable
+       for p in $(descendants "$MAIN"); do
+         [[ "$(tr '\0' ' ' 2>/dev/null < /proc/$p/cmdline)" == *apps/server/dist/bin.mjs* ]] && ours "$p" \
+           && { log "backend respawned as pid=$p"; SEEN_PIDS+=("$p"); BACKEND_PGID=$(ps -o pgid= -p "$p" | tr -d ' '); }
+       done
+       t0=$(now); log "QUIT: SIGTERM electron main $MAIN"; kill -TERM "$MAIN" ;;
+  *) echo "unknown fixture $FIXTURE" >&2; exit 2 ;;
+esac
+
+wait_main_exit
+t1=$(now)
+log "ELECTRON MAIN EXITED after $(elapsed "$t0" "$t1")"
+log "table at electron exit:"; ptable
+lockstate
+wait "$WRAPPER" 2>/dev/null; wait
+log "wrapper exited after $(elapsed "$t0" "$(now)")"
+log "backend $BACKEND alive at wrapper exit: $(kill -0 "$BACKEND" 2>/dev/null && ps -o stat=,ppid= -p "$BACKEND" || echo no)"
+log "desktop log quit lines:"
+grep -iE 'before-quit|shutdown|exceeded|backend.*(exit|stop)|signal' "$RUN/primary-stdout.log" | tail -n 12 | cut -c1-200 | sed 's/^/    /'
+relaunch_check
+cleanup_orphans
```

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

Status: modified; +2; −2

```diff
@@ -1,5 +1,4 @@
 import * as Cause from "effect/Cause";
-import * as Duration from "effect/Duration";
 import * as Effect from "effect/Effect";
 import * as Option from "effect/Option";
 import * as Ref from "effect/Ref";
@@ -19,6 +18,7 @@
 import * as DesktopWindow from "../window/DesktopWindow.ts";
 import * as PreviewPasskeys from "../preview/Passkeys.ts";
 import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
+import { BACKEND_QUIT_TIMEOUT } from "../backend/DesktopBackendManager.ts";
 import * as DesktopEnvironment from "./DesktopEnvironment.ts";
 import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts";
 import * as DesktopLifecycle from "./DesktopLifecycle.ts";
@@ -153,7 +153,7 @@
     const instances = yield* pool.list;
     yield* Effect.forEach(
       instances,
-      (instance) => instance.stop({ timeout: Duration.seconds(5) }),
+      (instance) => instance.stop({ timeout: BACKEND_QUIT_TIMEOUT }),
       { concurrency: "unbounded" },
     );
   },
```

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

Status: modified; +91; −1

```diff
@@ -24,6 +24,7 @@
 import { ChildProcess, ChildProcessSpawner } from "effect/process";
 
 import * as DesktopBackendManager from "./DesktopBackendManager.ts";
+import { makeFakeBackendProcess } from "./testUtils/FakeBackendProcess.ts";
 import * as DesktopObservability from "../app/DesktopObservability.ts";
 import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
 import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts";
@@ -373,7 +374,7 @@
     ),
   );
 
+  it.effect("stops the scoped process with output still pending", () =>
-  it.effect("starts the configured backend and closes the scoped process on stop", () =>
     Effect.scoped(
       Effect.gen(function* () {
         let startCount = 0;
@@ -410,6 +411,7 @@
               yield* Scope.addFinalizer(scope, close);
 
               return makeProcess({
+                stdout: Stream.never,
                 exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
                 kill: () => close,
               });
@@ -480,6 +482,94 @@
     ),
   );
 
+  it.effect(
+    "stop with the five-second quit timeout waits for a SIGTERM-ignoring backend to be gone",
+    () =>
+      Effect.scoped(
+        Effect.gen(function* () {
+          const backend = yield* makeFakeBackendProcess(false);
+          const ready = yield* Deferred.make<void>();
+          const instance = yield* makeTestInstance({
+            spawnerLayer: backend.layer,
+            onReady: Deferred.succeed(ready, undefined).pipe(Effect.asVoid),
+          });
+          yield* instance.start;
+          yield* backend.started;
+          yield* Deferred.await(ready);
+
+          const stopFiber = yield* instance
+            .stop({ timeout: Duration.seconds(5) })
+            .pipe(Effect.forkChild);
+          assert.equal(yield* Queue.take(backend.signals), "SIGTERM");
+          yield* TestClock.adjust(Duration.seconds(5));
+          assert.equal(yield* Queue.take(backend.signals), "SIGKILL");
+          const completedBeforeExit = yield* Effect.sync(() => stopFiber.pollUnsafe());
+          const runningBeforeExit = yield* backend.isRunning;
+          yield* TestClock.adjust(backend.killWait);
+          yield* Fiber.join(stopFiber);
+
+          assert.isTrue(
+            completedBeforeExit === undefined,
+            "stop resolved before the backend exited",
+          );
+          assert.isTrue(runningBeforeExit);
+          assert.isFalse(yield* backend.isRunning);
+          assert.deepEqual(backend.receivedSignals, ["SIGTERM", "SIGKILL"]);
+          assert.isTrue(Option.isNone((yield* instance.snapshot).activePid));
+          yield* instance.stop();
+          assert.deepEqual(backend.receivedSignals, ["SIGTERM", "SIGKILL"]);
+        }),
+      ),
+  );
+
+  it.effect("the shared quit budget is strictly greater than terminate grace plus kill wait", () =>
+    Effect.scoped(
+      Effect.gen(function* () {
+        const backend = yield* makeFakeBackendProcess(true);
+        const instance = yield* makeTestInstance({ spawnerLayer: backend.layer });
+        yield* instance.start;
+        yield* backend.started;
+        const terminateGrace = yield* backend.terminateGrace;
+        yield* instance.stop();
+
+        assert.isAbove(
+          Duration.toMillis(DesktopBackendManager.BACKEND_QUIT_TIMEOUT),
+          Duration.toMillis(terminateGrace) + Duration.toMillis(backend.killWait),
+        );
+      }),
+    ),
+  );
+
+  it.effect("a cooperative backend stops on SIGTERM without SIGKILL", () =>
+    Effect.scoped(
+      Effect.gen(function* () {
+        const backend = yield* makeFakeBackendProcess(true);
+        const ready = yield* Deferred.make<void>();
+        const instance = yield* makeTestInstance({
+          spawnerLayer: backend.layer,
+          onReady: Deferred.succeed(ready, undefined).pipe(Effect.asVoid),
+        });
+        yield* instance.start;
+        yield* backend.started;
+        yield* Deferred.await(ready);
+
+        yield* instance.stop({ timeout: Duration.seconds(5) });
+
+        assert.isFalse(yield* backend.isRunning);
+        assert.deepEqual(backend.receivedSignals, ["SIGTERM"]);
+        assert.deepEqual(yield* instance.snapshot, {
+          desiredRunning: false,
+          ready: false,
+          activePid: Option.none(),
+          restartAttempt: 0,
+          restartScheduled: false,
+        });
+        yield* instance.stop();
+        assert.deepEqual(backend.receivedSignals, ["SIGTERM"]);
+      }),
+    ),
+  );
+
   it.effect("restarts when start is requested during stop teardown", () =>
     Effect.scoped(
       Effect.gen(function* () {
```

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

Status: modified; +23; −8

```diff
@@ -40,6 +40,11 @@
 // Same as the service launcher. A killed backend leaves its agents running,
 // and a busy one needs a few seconds to stop every provider session.
 const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(5);
+// The spawner waits one more second after SIGKILL; leave room for exit bookkeeping.
+export const BACKEND_QUIT_TIMEOUT = Duration.sum(
+  DEFAULT_BACKEND_TERMINATE_GRACE,
+  Duration.seconds(2),
+);
 const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5);
 const BACKEND_READINESS_PATH = "/.well-known/t3/environment";
 const { logWarning: logBackendProcessWarning } =
@@ -224,6 +229,7 @@
   readonly id: BackendInstanceId;
   readonly label: Effect.Effect<string>;
   readonly start: Effect.Effect<void>;
+  /** Timeout controls the warning; stop still waits for process teardown and exit. */
   readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect<void>;
   readonly currentConfig: Effect.Effect<Option.Option<DesktopBackendStartConfig>>;
   readonly snapshot: Effect.Effect<DesktopBackendSnapshot>;
@@ -293,7 +299,7 @@
   run: ActiveBackendRun,
   parentScope: Scope.Scope,
   options?: { readonly timeout?: Duration.Duration },
-): Effect.Effect<boolean> => {
+) => {
   const waitForFiber = Option.match(run.fiber, {
     onNone: () => Effect.void,
     onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid),
@@ -302,12 +308,24 @@
   const timeout = options?.timeout;
 
   if (!timeout) {
-    return close.pipe(Effect.as(true));
+    return close;
   }
 
-  return Effect.forkIn(close, parentScope).pipe(
+  return Effect.forkIn(close, parentScope, { startImmediately: true }).pipe(
     Effect.flatMap((closeFiber) =>
+      Fiber.await(closeFiber).pipe(
+        Effect.timeoutOption(timeout),
+        Effect.tap((completed) =>
+          Option.isNone(completed)
+            ? logBackendProcessWarning("backend shutdown exceeded timeout; waiting for exit", {
+                timeoutMs: Duration.toMillis(timeout),
+              })
+            : Effect.void,
+        ),
+        // Parent-scope interruption still finishes release; do not cancel sibling stops.
+        Effect.andThen(Fiber.await(closeFiber)),
+        Effect.asVoid,
-      Fiber.await(closeFiber).pipe(Effect.timeoutOption(timeout), Effect.map(Option.isSome)),
+      ),
     ),
   );
 };
@@ -1061,10 +1079,7 @@
       onNone: () => Effect.void,
       onSome: (run) =>
         Effect.gen(function* () {
-          const closed = yield* closeRun(run, parentScope, options);
+          yield* closeRun(run, parentScope, options);
-          if (!closed) {
-            return;
-          }
           const cleanup = yield* mutex.withPermits(1)(
             Ref.modify(
               state,
```

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

Status: modified; +202; −28

```diff
@@ -1,11 +1,16 @@
 import { assert, describe, it } from "@effect/vitest";
+import * as Deferred from "effect/Deferred";
+import * as Duration from "effect/Duration";
 import * as Effect from "effect/Effect";
+import * as Fiber from "effect/Fiber";
+import * as Queue from "effect/Queue";
+import * as TestClock from "effect/testing/TestClock";
 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 Stream from "effect/Stream";
-import { HttpClient } from "effect/http";
+import { HttpClient, HttpClientResponse } from "effect/http";
 import { ChildProcessSpawner } from "effect/process";
 
 import * as DesktopObservability from "../app/DesktopObservability.ts";
@@ -18,20 +23,54 @@
 import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
 import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
 import * as DesktopBackendPool from "./DesktopBackendPool.ts";
+import { makeFakeBackendProcess } from "./testUtils/FakeBackendProcess.ts";
 import type { DesktopBackendStartConfig } from "./DesktopBackendManager.ts";
 
+const runningConfig = {
+  executablePath: "/electron",
+  args: [],
+  entryPath: "/server/bin.mjs",
+  cwd: "/server",
+  env: {},
+  bootstrap: {
+    mode: "desktop",
+    noBrowser: true,
+    port: 3774,
+    t3Home: "/tmp/t3",
+    host: "127.0.0.1",
+    desktopBootstrapToken: "token",
-function layerPool(labelRef: Ref.Ref<string>): Layer.Layer<DesktopBackendPool.DesktopBackendPool> {
+    tailscaleServeEnabled: false,
+    tailscaleServePort: 443,
+  },
+  bootstrapDelivery: "fd3",
+  extendEnv: true,
+  httpBaseUrl: new URL("http://127.0.0.1:3774"),
+  captureOutput: false,
+  preflightFailure: Option.none(),
+} satisfies DesktopBackendStartConfig;
+
+function layerPool(
+  labelRef: Ref.Ref<string>,
+  backend?: Effect.Success<ReturnType<typeof makeFakeBackendProcess>>,
+): Layer.Layer<DesktopBackendPool.DesktopBackendPool> {
   return DesktopBackendPool.layer.pipe(
     Layer.provideMerge(
       Layer.mergeAll(
+        FileSystem.layerNoop({ exists: () => Effect.succeed(true) }),
+        backend?.layer ??
+          Layer.succeed(
+            ChildProcessSpawner.ChildProcessSpawner,
+            ChildProcessSpawner.make(() => Effect.die("unexpected child process spawn")),
-        FileSystem.layerNoop({}),
+          ),
         Layer.succeed(
-          ChildProcessSpawner.ChildProcessSpawner,
-          ChildProcessSpawner.make(() => Effect.die("unexpected child process spawn")),
-        ),
-        Layer.succeed(
           HttpClient.HttpClient,
+          HttpClient.make((request) =>
+            backend === undefined
+              ? Effect.die("unexpected HTTP request")
-          HttpClient.make(() => Effect.die("unexpected HTTP request")),
+              : Effect.succeed(
+                  HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })),
+                ),
+          ),
         ),
         Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, {
           forInstance: () =>
@@ -88,6 +127,159 @@
 }
 
 describe("DesktopBackendPool", () => {
+  it.effect(
+    "quitting all pool instances waits for a SIGTERM-ignoring registered backend to exit",
+    () =>
+      Effect.scoped(
+        Effect.gen(function* () {
+          const backend = yield* makeFakeBackendProcess(false);
+          yield* Effect.gen(function* () {
+            const pool = yield* DesktopBackendPool.DesktopBackendPool;
+            const ready = yield* Deferred.make<void>();
+            const instance = yield* pool.register({
+              id: DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"),
+              label: Effect.succeed("WSL (Ubuntu)"),
+              configResolve: Effect.succeed(runningConfig),
+              onReady: () => Deferred.succeed(ready, undefined).pipe(Effect.asVoid),
+            });
+            yield* instance.start;
+            yield* backend.started;
+            yield* Deferred.await(ready);
+            const instances = yield* pool.list;
+            const stopFiber = yield* Effect.forEach(
+              instances,
+              (entry) => entry.stop({ timeout: Duration.seconds(5) }),
+              { concurrency: "unbounded" },
+            ).pipe(Effect.forkChild);
+            assert.equal(yield* Queue.take(backend.signals), "SIGTERM");
+            yield* TestClock.adjust(Duration.seconds(5));
+            assert.equal(yield* Queue.take(backend.signals), "SIGKILL");
+            const completedBeforeExit = yield* Effect.sync(() => stopFiber.pollUnsafe());
+            yield* TestClock.adjust(backend.killWait);
+            yield* Fiber.join(stopFiber);
+
+            assert.isTrue(
+              completedBeforeExit === undefined,
+              "pool quit completed before its registered backend exited",
+            );
+            assert.isFalse(yield* backend.isRunning);
+            assert.deepEqual(backend.receivedSignals, ["SIGTERM", "SIGKILL"]);
+            assert.isTrue(Option.isNone((yield* instance.snapshot).activePid));
+          }).pipe(Effect.provide(layerPool(Ref.makeUnsafe("Windows"), backend)));
+        }),
+      ),
+  );
+
+  it.effect("unregister during pool quit does not interrupt sibling stops", () =>
+    Effect.scoped(
+      Effect.gen(function* () {
+        const backend = yield* makeFakeBackendProcess(false);
+        yield* Effect.gen(function* () {
+          const pool = yield* DesktopBackendPool.DesktopBackendPool;
+          const id = DesktopBackendPool.BackendInstanceId("wsl:Ubuntu");
+          const ready = yield* Deferred.make<void>();
+          const siblingStarted = yield* Deferred.make<void>();
+          const finishSibling = yield* Deferred.make<void>();
+          let siblingCompleted = false;
+          const instance = yield* pool.register({
+            id,
+            label: Effect.succeed("WSL (Ubuntu)"),
+            configResolve: Effect.succeed(runningConfig),
+            onReady: () => Deferred.succeed(ready, undefined).pipe(Effect.asVoid),
+          });
+          yield* instance.start;
+          yield* backend.started;
+          yield* Deferred.await(ready);
+          const quitFiber = yield* Effect.all(
+            [
+              instance.stop({ timeout: Duration.seconds(5) }),
+              Deferred.succeed(siblingStarted, undefined).pipe(
+                Effect.andThen(Deferred.await(finishSibling)),
+                Effect.andThen(
+                  Effect.sync(() => {
+                    siblingCompleted = true;
+                  }),
+                ),
+              ),
+            ],
+            { concurrency: "unbounded" },
+          ).pipe(Effect.forkChild);
+          yield* Deferred.await(siblingStarted);
+          assert.equal(yield* Queue.take(backend.signals), "SIGTERM");
+          const unregisterFiber = yield* pool.unregister(id).pipe(Effect.forkChild);
+          yield* TestClock.adjust(Duration.seconds(5));
+          assert.equal(yield* Queue.take(backend.signals), "SIGKILL");
+          assert.isUndefined(quitFiber.pollUnsafe());
+          assert.isUndefined(unregisterFiber.pollUnsafe());
+          assert.isTrue(yield* backend.isRunning);
+          yield* TestClock.adjust(backend.killWait);
+          yield* Fiber.join(unregisterFiber);
+          yield* Deferred.succeed(finishSibling, undefined);
+          yield* Fiber.join(quitFiber);
+
+          assert.isTrue(siblingCompleted);
+          assert.isFalse(yield* backend.isRunning);
+          assert.isTrue(Option.isNone((yield* instance.snapshot).activePid));
+          assert.isTrue(Option.isNone(yield* pool.get(id)));
+        }).pipe(Effect.provide(layerPool(Ref.makeUnsafe("Windows"), backend)));
+      }),
+    ),
+  );
+
+  it.effect("closing the pool scope stops a registered backend without explicit unregister", () =>
+    Effect.gen(function* () {
+      const backend = yield* makeFakeBackendProcess(true);
+      yield* Effect.scoped(
+        Effect.gen(function* () {
+          const pool = yield* DesktopBackendPool.DesktopBackendPool;
+          const ready = yield* Deferred.make<void>();
+          const instance = yield* pool.register({
+            id: DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"),
+            label: Effect.succeed("WSL (Ubuntu)"),
+            configResolve: Effect.succeed(runningConfig),
+            onReady: () => Deferred.succeed(ready, undefined).pipe(Effect.asVoid),
+          });
+          yield* instance.start;
+          yield* backend.started;
+          yield* Deferred.await(ready);
+          assert.isTrue(yield* backend.isRunning);
+        }).pipe(Effect.provide(layerPool(Ref.makeUnsafe("Windows"), backend))),
+      );
+
+      assert.isFalse(yield* backend.isRunning);
+      assert.deepEqual(backend.receivedSignals, ["SIGTERM"]);
+    }),
+  );
+
+  it.effect("unregister waits for a running registered backend to exit", () =>
+    Effect.scoped(
+      Effect.gen(function* () {
+        const backend = yield* makeFakeBackendProcess(true);
+        yield* Effect.gen(function* () {
+          const pool = yield* DesktopBackendPool.DesktopBackendPool;
+          const id = DesktopBackendPool.BackendInstanceId("wsl:Ubuntu");
+          const ready = yield* Deferred.make<void>();
+          const instance = yield* pool.register({
+            id,
+            label: Effect.succeed("WSL (Ubuntu)"),
+            configResolve: Effect.succeed(runningConfig),
+            onReady: () => Deferred.succeed(ready, undefined).pipe(Effect.asVoid),
+          });
+          yield* instance.start;
+          yield* backend.started;
+          yield* Deferred.await(ready);
+
+          yield* pool.unregister(id);
+
+          assert.isFalse(yield* backend.isRunning);
+          assert.deepEqual(backend.receivedSignals, ["SIGTERM"]);
+          assert.isTrue(Option.isNone(yield* pool.get(id)));
+          assert.isTrue(Option.isNone((yield* instance.snapshot).activePid));
+        }).pipe(Effect.provide(layerPool(Ref.makeUnsafe("Windows"), backend)));
+      }),
+    ),
+  );
+
   it.effect("unregister stops the registered instance", () =>
     Effect.scoped(
       Effect.gen(function* () {
@@ -99,27 +291,9 @@
           // A transient preflight failure leaves a restart fiber scheduled
           // without needing a real child process.
           configResolve: Effect.succeed({
-            executablePath: "/electron",
-            args: [],
-            entryPath: "/server/bin.mjs",
-            cwd: "/server",
-            env: {},
-            bootstrap: {
-              mode: "desktop",
-              noBrowser: true,
-              port: 3774,
-              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:3774"),
+            ...runningConfig,
-            captureOutput: false,
             preflightFailure: Option.some({ reason: "WSL starting", fatal: false }),
+          }),
-          } satisfies DesktopBackendStartConfig),
         });
         yield* instance.start;
         assert.equal((yield* instance.snapshot).restartScheduled, true);
```

## apps/desktop/src/backend/testUtils/FakeBackendProcess.ts

Status: added; +76; −0

```diff
@@ -1,0 +1,76 @@
+import * as Deferred from "effect/Deferred";
+import * as Duration from "effect/Duration";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Queue from "effect/Queue";
+import * as Sink from "effect/Sink";
+import * as Stream from "effect/Stream";
+import { ChildProcessSpawner } from "effect/process";
+
+// Model the spawner's process-group release without native timers or OS signals.
+export const makeFakeBackendProcess = Effect.fn("test.makeFakeBackendProcess")(function* (
+  cooperative: boolean,
+) {
+  const started = yield* Deferred.make<void>();
+  const exited = yield* Deferred.make<ChildProcessSpawner.ExitCode>();
+  const signals = yield* Queue.unbounded<"SIGTERM" | "SIGKILL">();
+  const receivedSignals: Array<"SIGTERM" | "SIGKILL"> = [];
+  let running = false;
+  let terminateGrace = Duration.zero;
+  const killWait = Duration.seconds(1);
+  const signal = (value: "SIGTERM" | "SIGKILL") =>
+    Effect.sync(() => receivedSignals.push(value)).pipe(
+      Effect.andThen(Queue.offer(signals, value)),
+      Effect.asVoid,
+    );
+  const exit = Effect.sync(() => {
+    running = false;
+  }).pipe(Effect.andThen(Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0))), Effect.asVoid);
+  const layer = Layer.succeed(
+    ChildProcessSpawner.ChildProcessSpawner,
+    ChildProcessSpawner.make((command) =>
+      Effect.gen(function* () {
+        if (command._tag !== "StandardCommand") {
+          return yield* Effect.die("Expected a standard backend command");
+        }
+        terminateGrace = Duration.fromInputUnsafe(command.options.forceKillAfter ?? 0);
+        running = true;
+        yield* Effect.addFinalizer(() =>
+          Effect.gen(function* () {
+            yield* signal("SIGTERM");
+            if (!cooperative) {
+              yield* Effect.sleep(terminateGrace);
+              yield* signal("SIGKILL");
+              // Exit acknowledgement can consume the spawner's final one-second wait.
+              yield* Effect.sleep(killWait);
+            }
+            yield* exit;
+          }),
+        );
+        yield* Deferred.succeed(started, undefined);
+        return ChildProcessSpawner.makeHandle({
+          pid: ChildProcessSpawner.ProcessId(123),
+          stdout: Stream.empty,
+          stderr: Stream.empty,
+          all: Stream.empty,
+          stdin: Sink.drain,
+          getInputFd: () => Sink.drain,
+          getOutputFd: () => Stream.empty,
+          exitCode: Deferred.await(exited),
+          isRunning: Effect.sync(() => running),
+          kill: () => exit,
+          unref: Effect.succeed(Effect.void),
+        });
+      }),
+    ),
+  );
+  return {
+    layer,
+    started: Deferred.await(started),
+    signals,
+    receivedSignals,
+    isRunning: Effect.sync(() => running),
+    terminateGrace: Effect.sync(() => terminateGrace),
+    killWait,
+  };
+});
```

## apps/desktop/src/updates/DesktopUpdates.ts

Status: modified; +2; −1

```diff
@@ -25,6 +25,7 @@
 import * as Stream from "effect/Stream";
 
 import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
+import { BACKEND_QUIT_TIMEOUT } from "../backend/DesktopBackendManager.ts";
 import * as DesktopConfig from "../app/DesktopConfig.ts";
 import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
 import * as DesktopObservability from "../app/DesktopObservability.ts";
@@ -755,7 +756,7 @@
           yield* Ref.set(installRunningBackendsRef, runningInstances);
           yield* Effect.forEach(
             instances,
+            (instance) => instance.stop({ timeout: BACKEND_QUIT_TIMEOUT }),
-            (instance) => instance.stop({ timeout: Duration.seconds(5) }),
             { concurrency: "unbounded" },
           );
           yield* electronUpdater.quitAndInstall({
```
