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

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

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

Visibility: public

[fix\(desktop\): a stuck quit no longer leaves the app unkillable](https://git.cafe/pingdotgg/t3code/pull/23873.md)

Version: 15

Head commit: 91e08f662c7e6eb993238e7ad02528cf0e256e52

Base commit: 2c8bdf6177159d8fcd12a897d7c9311f6eb93237

Comparison base: 2c8bdf6177159d8fcd12a897d7c9311f6eb93237

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

## apps/desktop/src/app/DesktopLifecycle.test.ts

Status: modified; +57; −1

```diff
@@ -3,6 +3,7 @@
 import * as Effect from "effect/Effect";
 import * as Layer from "effect/Layer";
 import * as Ref from "effect/Ref";
+import * as TestClock from "effect/testing/TestClock";
 
 import type * as Electron from "electron";
 
@@ -18,6 +19,7 @@
 function layerElectronApp(
   appListeners: Map<string, (...args: readonly unknown[]) => void>,
   quit: Effect.Effect<void> = Effect.void,
+  exit: (code: number) => Effect.Effect<void> = () => Effect.void,
 ) {
   const registerListener = (eventName: string, listener: (...args: readonly unknown[]) => void) =>
     Effect.acquireRelease(
@@ -37,7 +39,7 @@
     whenReady: Effect.void,
     requestSingleInstanceLock: Effect.succeed(true),
     quit,
-    exit: () => Effect.void,
+    exit,
     relaunch: () => Effect.void,
     setPath: () => Effect.void,
     setName: () => Effect.void,
@@ -221,6 +223,60 @@
     }),
   );
 
+  it.effect("exits on a repeated quit only once shutdown has stalled", () =>
+    Effect.gen(function* () {
+      const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
+      const exitCodes: number[] = [];
+      const exit = (code: number) =>
+        Effect.sync(() => {
+          exitCodes.push(code);
+        });
+      const layerEnvironment = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
+        platform: "darwin",
+        isDevelopment: false,
+      } as DesktopEnvironment.DesktopEnvironment["Service"]);
+      const layer = DesktopLifecycle.layer.pipe(
+        Layer.provideMerge(layerElectronApp(appListeners, Effect.void, exit)),
+        Layer.provideMerge(layerElectronTheme),
+        Layer.provideMerge(layerElectronWindow()),
+        Layer.provideMerge(layerDesktopWindow()),
+        Layer.provideMerge(layerEnvironment),
+        // Nothing marks this shutdown complete, so it stays pending.
+        Layer.provideMerge(DesktopShutdown.layer),
+        Layer.provideMerge(DesktopState.layer),
+      );
+
+      yield* Effect.scoped(
+        Effect.gen(function* () {
+          const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
+          yield* lifecycle.register;
+
+          let prevented = 0;
+          const event = {
+            preventDefault: () => {
+              prevented += 1;
+            },
+          } as Electron.Event;
+          appListeners.get("before-quit")?.(event);
+          appListeners.get("before-quit")?.(event);
+          yield* TestClock.adjust("9999 millis");
+          appListeners.get("before-quit")?.(event);
+          assert.deepEqual(
+            exitCodes,
+            [],
+            "a repeat before the threshold must not cut shutdown short",
+          );
+
+          yield* TestClock.adjust("1 millis");
+          appListeners.get("before-quit")?.(event);
+
+          assert.deepEqual(exitCodes, [0]);
+          assert.equal(prevented, 4);
+        }),
+      ).pipe(Effect.provide(layer));
+    }),
+  );
+
   it.effect("ignores app activation while quitting", () =>
     Effect.gen(function* () {
       const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
```

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

Status: modified; +58; −17

```diff
@@ -1,4 +1,6 @@
+import * as Clock from "effect/Clock";
 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 Ref from "effect/Ref";
@@ -93,16 +95,51 @@
     yield* shutdown.awaitComplete;
   },
 );
+
+interface QuitState {
+  phase: "running" | "waiting" | "allowed";
+  waitingSinceNanos: bigint;
+}
+
+// Longer than a backend's stop budget (SIGTERM, 5s grace, SIGKILL, 1s wait),
+// so a double Cmd-Q at the start of a normal quit cannot cut the backend stop
+// short and orphan it.
+const SHUTDOWN_STALL_THRESHOLD = Duration.seconds(10);
+
+const startWaitingForShutdown = Effect.fn("desktop.lifecycle.startWaitingForShutdown")(function* (
+  quit: QuitState,
+) {
+  quit.phase = "waiting";
+  quit.waitingSinceNanos = yield* Clock.monotonicTimeNanos;
+});
 
+// While a quit waits on shutdown, `quitting` makes activation open nothing. If
+// that shutdown stalls, a repeated Quit or SIGTERM is the user's only way out
+// short of SIGKILL.
+const exitIfShutdownStalled = Effect.fn("desktop.lifecycle.exitIfShutdownStalled")(function* (
+  quit: QuitState,
+  source: string,
+) {
+  const pendingMs = Number(
+    ((yield* Clock.monotonicTimeNanos) - quit.waitingSinceNanos) / 1_000_000n,
+  );
+  if (pendingMs < Duration.toMillis(SHUTDOWN_STALL_THRESHOLD)) {
+    yield* logLifecycleInfo("quit already in progress", { source, pendingMs });
+    return;
+  }
+  yield* logLifecycleError("desktop shutdown stalled; exiting without it", { source, pendingMs });
+  const electronApp = yield* ElectronApp.ElectronApp;
+  yield* electronApp.exit(0);
+});
+
 function handleBeforeQuit(
   event: Electron.Event,
   runEffect: <A, E>(
     effect: Effect.Effect<A, E, DesktopLifecycleRegistrationServices>,
   ) => Promise<A>,
-  allowQuit: () => boolean,
+  quit: QuitState,
-  markQuitAllowed: () => void,
 ): void {
-  if (allowQuit()) {
+  if (quit.phase === "allowed") {
     void runEffect(
       Effect.gen(function* () {
         const state = yield* DesktopState.DesktopState;
@@ -114,10 +151,15 @@
   }
 
   event.preventDefault();
+  if (quit.phase === "waiting") {
+    void runEffect(exitIfShutdownStalled(quit, "before-quit"));
+    return;
+  }
   void runEffect(
     Effect.gen(function* () {
       const state = yield* DesktopState.DesktopState;
       const electronWindow = yield* ElectronWindow.ElectronWindow;
+      yield* startWaitingForShutdown(quit);
       yield* Ref.set(state.quitting, true);
       yield* logLifecycleInfo("before-quit received");
       yield* requestDesktopShutdownAndWait(
@@ -129,7 +171,7 @@
       );
     }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")),
   ).finally(() => {
-    markQuitAllowed();
+    quit.phase = "allowed";
     void runEffect(
       Effect.gen(function* () {
         const electronApp = yield* ElectronApp.ElectronApp;
@@ -144,7 +186,12 @@
   runEffect: <A, E>(
     effect: Effect.Effect<A, E, DesktopLifecycleRegistrationServices>,
   ) => Promise<A>,
+  quit: QuitState,
 ): void {
+  if (quit.phase === "waiting") {
+    void runEffect(exitIfShutdownStalled(quit, signal));
+    return;
+  }
   void runEffect(
     Effect.gen(function* () {
       yield* Effect.annotateCurrentSpan({ signal });
@@ -152,8 +199,10 @@
       const state = yield* DesktopState.DesktopState;
       const wasQuitting = yield* Ref.getAndSet(state.quitting, true);
       if (wasQuitting) return;
+      yield* startWaitingForShutdown(quit);
       yield* logLifecycleInfo("process signal received", { signal });
       yield* requestDesktopShutdownAndWait();
+      quit.phase = "allowed";
       yield* electronApp.quit;
     }).pipe(Effect.withSpan("desktop.lifecycle.processSignal")),
   );
@@ -196,8 +245,7 @@
     const environment = yield* DesktopEnvironment.DesktopEnvironment;
     const context = yield* Effect.context<DesktopLifecycleRegistrationServices>();
     const runEffect = Effect.runPromiseWith(context);
-    let quitAllowed = false;
+    const quit: QuitState = { phase: "running", waitingSinceNanos: 0n };
-    let updaterQuitAllowed = false;
     yield* electronTheme.onUpdated(() => {
       void runEffect(
         desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")),
@@ -207,7 +255,7 @@
       // Electron's updater owns the remaining quit/install/relaunch sequence.
       // Cancelling the following app "before-quit" event breaks that sequence,
       // most visibly on macOS where the native updater performs the relaunch.
-      updaterQuitAllowed = true;
+      quit.phase = "allowed";
       // This event is synchronous and the updater's quit proceeds as soon as
       // the listener returns, so a forked destroyAll would race the quit
       // and windows could still be open when the process exits (visible on
@@ -223,14 +271,7 @@
       );
     });
     yield* electronApp.on("before-quit", (event: Electron.Event) => {
-      handleBeforeQuit(
-        event,
-        runEffect,
-        () => quitAllowed || updaterQuitAllowed,
-        () => {
+      handleBeforeQuit(event, runEffect, quit);
-          quitAllowed = true;
-        },
-      );
     });
     yield* electronApp.on("activate", () => {
       void runEffect(
@@ -255,10 +296,10 @@
 
     if (environment.platform !== "win32") {
       yield* addScopedListener(process, "SIGINT", () => {
-        quitFromSignal("SIGINT", runEffect);
+        quitFromSignal("SIGINT", runEffect, quit);
       });
       yield* addScopedListener(process, "SIGTERM", () => {
-        quitFromSignal("SIGTERM", runEffect);
+        quitFromSignal("SIGTERM", runEffect, quit);
       });
     }
   }).pipe(Effect.withSpan("desktop.lifecycle.register")),
```
