Fix macOS zombie windows by completing the activation handshake - #189
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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+activateIgnoringOtherApps60 times inpollEventswith 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()invisible=: aftermakeKeyAndOrderFront+ activation request, drain events and give the run loop 1 ms turns untilNSApp.isActiveor 250 ms. The handshake completes beforenewWindowreturns, so the app can block afterwards without losing the grant.rescueActivation()inpollEvents: for 2 s of wall clock after showing a window, if still not active, re-request activation only (nomakeKeyAndOrderFrontspam). 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 afterfinishLaunchingso the background→Regular promotion lands before the firstNSWindowexists (the missing-traffic-lights half of the zombie).activationSettlePolls/settleActivationRequests, added theNSApplication.isActivebinding, extracteddrainEventssopollEvents, 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)
newWindowand the firstpollEvents: previously zombied intermittently (reliably, if the user was typing elsewhere); now leavesnewWindowwithisActive=true, focused=trueand wakes from the block still focused — 5/5 runs, including runs while the user was actively working in other apps.examples/slow_window_start.nimis now self-verifying and prints PASS/FAIL.-d:useCpu.🤖 Generated with Claude Code