Skip to content

Fix macOS zombie windows by completing the activation handshake - #189

Merged
treeform merged 1 commit into
masterfrom
mac-activation-root-fix
Sep 1, 2026
Merged

Fix macOS zombie windows by completing the activation handshake#189
treeform merged 1 commit into
masterfrom
mac-activation-root-fix

Conversation

@treeform

@treeform treeform commented Sep 1, 2026

Copy link
Copy Markdown
Owner

The problem

Sometimes a windy window on macOS comes up in a zombie state: frontmost and clickable, but keyboard input is dead and the traffic light buttons are missing. Previous attempts (#174, #176) made it better but it still happened, and the #176 workaround made windows force themselves to the front even when unwanted.

Root cause

App activation on macOS is an async handshake with the window server that only completes while the main thread pumps the run loop, and the request is only honored while it is fresh. Windy requested activation in visible= and returned immediately. If the app then blocked (e.g. loading assets), the grant expired — and under macOS 14+ cooperative activation, later re-requests are silently dropped whenever the user is interacting with another app. The window got ordered front (that part works regardless), but the process never became an active registered GUI app: no key focus, and since it was still a background process when the NSWindow was created, no traffic lights.

The #176 workaround replayed makeKeyAndOrderFront + activateIgnoringOtherApps 60 times in pollEvents with no success check. Measured on macOS 15.1 with an interleaved probe sweep: the replays only rescue activation when they happen to land in a moment where the user isn't typing/clicking elsewhere — and they keep forcing the window to the front even after activation already succeeded. That was both the residual zombie and the focus-stealing annoyance.

The fix

  • pumpActivation() in visible=: after makeKeyAndOrderFront + activation request, drain events and give the run loop 1 ms turns until NSApp.isActive or 250 ms. The handshake completes before newWindow returns, so the app can block afterwards without losing the grant.
  • rescueActivation() in pollEvents: for 2 s of wall clock after showing a window, if still not active, re-request activation only (no makeKeyAndOrderFront spam). Stops the instant activation succeeds or the deadline passes — it can never steal focus back from the user later.
  • init(): pump a few run loop turns after finishLaunching so the background→Regular promotion lands before the first NSWindow exists (the missing-traffic-lights half of the zombie).
  • Removed activationSettlePolls/settleActivationRequests, added the NSApplication.isActive binding, extracted drainEvents so pollEvents, the pump, and init share one event loop.

If the user launches a slow app and keeps working in another app past the rescue window, macOS keeps focus with them (by design); the window now comes up as a normal decorated background window instead of a zombie.

Verification (macOS 15.1)

  • Probe that blocks 3.5 s between newWindow and the first pollEvents: previously zombied intermittently (reliably, if the user was typing elsewhere); now leaves newWindow with isActive=true, focused=true and wakes from the block still focused — 5/5 runs, including runs while the user was actively working in other apps.
  • Fast start: focused in the first poll, 5/5.
  • Hide/reshow and second-window smoke test pass; examples/slow_window_start.nim is now self-verifying and prints PASS/FAIL.
  • All macOS examples compile, including -d:useCpu.

🤖 Generated with Claude Code

App activation on macOS is an async handshake with the window server
that only completes while the main thread pumps the run loop, and the
window server only honors the request while it is fresh. Windy asked
for activation in `visible=` and returned immediately; if the app then
blocked (loading assets), the grant expired and — under macOS 14+
cooperative activation — later requests are dropped whenever the user
is interacting with another app. The result was the zombie window:
ordered front and clickable, but the process never became an active
GUI app, so no key focus and no traffic light buttons.

The previous workaround (60 blind replays of makeKeyAndOrderFront +
activateIgnoringOtherApps in pollEvents) had no success check, so it
kept forcing the window to the front even when activation had already
succeeded or the user had deliberately switched away — and it still
failed when the user kept typing in another app.

The fix:

- pumpActivation() in `visible=`: drain events and give the run loop
  1 ms turns until NSApp.isActive or 250 ms. The handshake completes
  before newWindow returns, so the app can block afterwards without
  losing the grant. A 3.5 s block right after newWindow now wakes up
  still focused.
- rescueActivation() in pollEvents: for 2 s of wall clock after
  showing a window, re-request activation only (no repeated
  makeKeyAndOrderFront), and stop the moment it succeeds or the
  deadline passes. This catches "user was typing elsewhere during a
  slow launch and stopped a moment later" without ever stealing focus
  back afterwards.
- init(): pump a few run loop turns after finishLaunching so the
  background-to-Regular promotion lands before the first NSWindow is
  created. Windows created by a still-background process come up
  unmanaged (no traffic lights).
- Removed activationSettlePolls and settleActivationRequests, added
  the NSApplication.isActive binding, and extracted drainEvents from
  pollEvents so all three call sites share the event loop.

examples/slow_window_start.nim now prints focus state after newWindow
and a PASS/FAIL line after the delay, so the repro is self-verifying.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@treeform
treeform merged commit ccda23d into master Sep 1, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant