# fix\(desktop\): a stuck quit no longer leaves the app unkillable · PR \#23873 · pingdotgg/t3code

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

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

Visibility: public

State: merged

Source repository: Unavailable fork

Source branch: fix/desktop-quit-stalled-shutdown

Target branch: main

Head commit: 91e08f662c7e6eb993238e7ad02528cf0e256e52

[Discussion and reviews](https://git.cafe/pingdotgg/t3code/pull/23873)

[Commits](https://git.cafe/pingdotgg/t3code/pull/23873/commits?format=markdown)

[Files changed](https://git.cafe/pingdotgg/t3code/pull/23873/diffs?format=markdown)

## Description

---

## Problem

After the "This T3 home is unavailable" dialog, clicking OK starts a quit that waits for desktop shutdown. If that shutdown never finishes, the app stays running with no window, and every later Quit and SIGTERM is swallowed: `before-quit` is cancelled again and the signal handler returns early because `quitting` is already set. Only SIGKILL ends it (#23843).

## Change

`DesktopLifecycle` now records, on the monotonic clock, when a quit started waiting on shutdown. A Quit or SIGTERM that arrives after shutdown has been pending for 10 seconds logs `desktop shutdown stalled; exiting without it` and calls `app.exit(0)`.

A repeat inside those 10 seconds is ignored, as before. That window is longer than the backend's own stop budget (SIGTERM, 5 s grace, SIGKILL, 1 s wait), so an impatient double Cmd-Q at the start of a normal quit can't cut the backend stop short and orphan it, which would bring back #22983's "T3 home is unavailable" on the next launch. The 10 s count starts when the quit starts, not when the backend stop starts. So a quit that is slow before it gets to the backend could still be forced out after 10 s, but only if the user asks again. Normal quits are unchanged.

I didn't add a blanket shutdown timeout because #22996 deliberately keeps quit waiting until the backend exits. This change only kicks in when the user asks to quit again.

## Scope and approval

Refs #23843. Not `Closes`: I could not reproduce the reporter's stall itself. Without a forced hang, the app quits right after OK when the home is held by another server. What the reporter's evidence pins down is that their stall left the lifecycle's Quit and SIGTERM listeners installed. That means startup never reached the shutdown wait, or a teardown step registered after those listeners hung. Which step hangs is still unknown. This PR makes that state escapable; it doesn't remove the stall.

## Verification

- `DesktopLifecycle.test.ts`: new test for a quit whose shutdown never completes. An immediate second Quit doesn't exit, and a Quit after 10 s (TestClock) exits with code 0. It fails on main (`expected [] to deeply equal [ 0 ]`) and passes here. A repeat at 9.999 s is still ignored. `vp run check:changed` passed locally; CI's required checks are green.
- Unsigned packaged builds of main (`2c8bdf6177`, this PR's base) and of this PR: an AppImage on Linux (Xvfb) and an arm64 `.app` on macOS 27. In every run a test server already holds a throwaway T3 home, which brings up the dialog. To get the reporter's stall on demand, every build had the same local line (not in this PR): one teardown step that never finishes when `T3CODE_DEBUG_STALL_SHUTDOWN=1`.

| 12 s after clicking OK | main | this PR |
|---|---|---|
| macOS, App > Quit | ignored (so was a Quit at 3 s) | exits |
| macOS, `kill` | ignored | exits, status 0 |
| Linux, `kill` | ignored | exits, status 0 |

The dialog, from the macOS build:

![macOS dialog](https://github.com/user-attachments/assets/a1a333ea-fc19-4d56-a20f-144a281b9193)

**Linux, main:** after OK the app keeps running with no window, and `kill` at 12 s is ignored:

https://github.com/user-attachments/assets/41f41368-f782-4b7c-8cd2-e79d7238e6e7

**Linux, this PR:** same stall; `kill` at 12 s exits the app:

https://github.com/user-attachments/assets/1ed6761d-a88a-41e7-8b53-b2695d254099

**macOS, main:** Quit is sent with `NSRunningApplication.terminate`, the same quit event as Dock > Quit and Cmd-Q:

```
13:56:20  Launched the packaged build; "This T3 home is unavailable" dialog is up
13:56:29  Clicked OK
13:56:32  +3s   Quit: ignored, as intended
13:56:45  +12s  still running, no window. Quit
13:56:49  ignored Quit. SIGTERM
13:56:53  ignored SIGTERM (cleaned up with kill -9)
  log: [13:56:28.767] INFO before-quit received
  log: [13:56:32.308] INFO before-quit received
  log: [13:56:45.620] INFO before-quit received
  log: [13:56:49.643] INFO before-quit received
```

**macOS, this PR:**

```
13:56:53  Launched the packaged build; "This T3 home is unavailable" dialog is up
13:57:01  Clicked OK
13:57:04  +3s   Quit: ignored, as intended
13:57:17  +12s  still running, no window. Quit
13:57:21  exited
  log: [13:57:01.098] INFO before-quit received
  log: [13:57:04.619] INFO quit already in progress
  log: [13:57:17.949] ERROR desktop shutdown stalled; exiting without it
```

A separate macOS run with only `kill` at 12 s: main ignored it; this PR exited with status 0 after logging the same `desktop shutdown stalled` line.

Without the forced stall, this PR's packaged builds exit right after OK: about 1 s on Linux, at once on macOS.

One limit I hit on Linux: Electron turns only the first signal into a quit. If the first signal lands inside the 10 s (I tried SIGINT at 3 s), a SIGTERM at 12 s never reaches the app, on main or here. Sending the same signal a second time kills the process by signal (status 143) on both builds. So on Linux a repeated `kill` already ends a stuck app on main; this PR makes the first `kill` after 10 s work, and makes Quit work on macOS, where every Quit goes through `before-quit`.

Not checked: a signed macOS build with the updater turned on, and the reporter's launchd service setup.

Model and harness: Claude Opus 5.5 through Claude Code in T3 Code.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

