Files
cmux/cmux-Bridging-Header.h
39df334577 Bound app termination with a force-exit watchdog (#6758) (#6837)
* test: red regression for termination watchdog (#6758)

cmux can hang the main thread for ~30s on Cmd+Q when a clipboard-history
manager (Paste, Raycast, Maccy, …) is mid-read of cmux's promised
pasteboard data: AppKit's will-terminate gauntlet runs
CFPasteboardResolveAllPromisedData, which blocks on a stuck mach
round-trip to the pasteboard server until the OS force-kills the app.

This is the third "an observer blocks the main thread during quit" report
(cf. #6415 PostHog flush, #6381 ghostty lock); the structural gap is that
quit has no global "return within N seconds no matter what" guard.

Add TerminationWatchdog plus its tests, with the watchdog deliberately
inert (it never starts the firing thread) so the tests go red. The end-to-
end pasteboard deadlock is not unit-testable — reproducing it requires the
real pasteboard server and would wedge the test process — so the tests
cover the watchdog mechanism that bounds it. The fix commit starts the
thread and arms the watchdog from the terminate path.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Bound app termination with a force-exit watchdog (#6758)

Implement TerminationWatchdog.arm and arm it from the terminate path so a
committed quit always returns within a bounded time, even when AppKit's
will-terminate gauntlet wedges on an Apple-owned observer we don't control
(CFPasteboardResolveAllPromisedData blocking on a stuck pasteboard-server
round-trip while a clipboard-history manager reads cmux's promised data).

The watchdog runs on a dedicated background thread with no run-loop, GCD,
or main-actor dependency, so it fires even while the main thread is parked
in mach_msg. It is armed in prepareForConfirmedAppTermination() — after the
critical session/state save and before AppKit posts will-terminate — and,
as a backstop, at the start of applicationWillTerminate(). Arming is
idempotent, so the two sites and repeated quit attempts never stack
threads. If the process has not exited within the deadline it force-exits
cleanly, turning a ~30s hang into a bounded quit.

This closes the structural gap shared with #6415 and #6381: quit now has a
global "return within N seconds no matter what" guard.

Fixes #6758

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Address review: lock-free watchdog exit, drop singleton (#6758)

- Codex/autoreview P1 (correctness): the watchdog's onFire logged a
  StartupBreadcrumbLog entry (flock + Foundation/file I/O) before _exit. If
  that logging stalled or contended during an already-wedged termination, the
  watchdog thread could block before reaching _exit and the quit hang would
  stay unbounded — defeating the guarantee. Drop the breadcrumb: the firing
  path is now an unconditional, lock-free _exit (the default onFire), which
  does zero Foundation/filesystem work before exiting.

- Greptile P1 (no-ambient-global-state): replace the
  TerminationWatchdog.shared singleton with an AppDelegate-owned instance,
  next to the existing terminate-control state (terminateKillWatchdogTask).
  The type was already injectable, so this is a small wiring change.

- Greptile P2: document why the deadline uses a raw Thread + Thread.sleep
  rather than a GCD timer (the wedged termination can sit on GCD/run-loop
  infrastructure, so the firing path must not depend on it).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Document lock-not-actor choice in TerminationWatchdog (#6758)

cmux-policy (Aziz concurrency) prefers actor isolation over locks for new
runtime state. Rejected here with rationale recorded in-code: an actor would
force `arm()` async, but it is called synchronously from the terminate delegate
methods and the deadline fires on a raw Thread — and the watchdog must not
depend on the Swift concurrency runtime, which may itself be wedged during the
termination it guards against. This is the same sanctioned NSLock +
nonisolated(unsafe) shape TerminalPasteboardService uses for synchronous-
callback state. Comment-only change.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Determinize TerminationWatchdog test via injected scheduler (#6758)

CI's test-determinism gate (scripts/check-test-determinism.py --strict) flagged
the prior tests for real sleeps / wall-clock timeouts (sleep-then-assert and
assert-on-duration). Invert the time dependency per the gate's contract instead
of allowlisting: extract the deadline scheduler as an injectable
`DeadlineScheduler`. Production keeps the raw background Thread
(`TerminationWatchdog.threadScheduler`); the tests inject a synchronous
capturing scheduler and advance the deadline by hand.

The tests now assert idempotency (three arms schedule the deadline exactly once)
and exactly-once firing with zero real sleeps, timeouts, or wall-clock reads, so
they are deterministic by construction. arm() is now a thin idempotent latch
over scheduleDeadline(deadline, onFire); behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Use atomic termination watchdog latch

* Use non-deprecated termination watchdog latch

* Use C11 atomic termination watchdog latch

* Save termination state before watchdog fallback

* Avoid growing AppDelegate termination path

---------

Co-authored-by: cmux <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-29 08:09:42 -07:00

3 lines
66 B
Objective-C

@import GhosttyKit;
#import "Sources/TerminationWatchdogAtomic.h"