From fcabb9e2b321d548b43bf6ffe69433487c2432c3 Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 1 Sep 2026 12:36:28 -0700 Subject: [PATCH] Fix macOS zombie windows by completing the activation handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- examples/slow_window_start.nim | 9 ++ src/windy/platforms/macos/macdefs.nim | 1 + src/windy/platforms/macos/platform.nim | 114 ++++++++++++++++--------- 3 files changed, 85 insertions(+), 39 deletions(-) diff --git a/examples/slow_window_start.nim b/examples/slow_window_start.nim index dacb130d..5b58c030 100644 --- a/examples/slow_window_start.nim +++ b/examples/slow_window_start.nim @@ -78,10 +78,19 @@ echo "Slow window start repro." echo "The window is visible before the event loop is allowed to run." echo "After the delay, move the mouse and click the square." +echo "Focused right after newWindow: ", window.focused + drawFrame() echo "Delaying for 3,500 ms." sleep(SlowStartMs) echo "Delay done." +pollEvents() +if window.focused: + echo "PASS: window is focused after the delay, no zombie state." +else: + echo "FAIL: window is not focused after the delay (zombie state), unless" + echo "another app was being used while this example started." + while not window.closeRequested: pollEvents() diff --git a/src/windy/platforms/macos/macdefs.nim b/src/windy/platforms/macos/macdefs.nim index 5e66a288..55c87728 100644 --- a/src/windy/platforms/macos/macdefs.nim +++ b/src/windy/platforms/macos/macdefs.nim @@ -248,6 +248,7 @@ objc: x: NSApplicationPresentationOptions ) proc activateIgnoringOtherApps*(self: NSApplication, x: bool) + proc isActive*(self: NSApplication): bool proc setDelegate*(self: NSApplication, x: ID) proc setDelegate*(self: NSWindow, x: ID) proc setMainMenu*(self: NSApplication, x: NSMenu) diff --git a/src/windy/platforms/macos/platform.nim b/src/windy/platforms/macos/platform.nim index 89cd437c..98651f2f 100644 --- a/src/windy/platforms/macos/platform.nim +++ b/src/windy/platforms/macos/platform.nim @@ -40,11 +40,11 @@ type fullscreenState: bool minimizedState: bool cpuImage: NSImage - activationSettlePolls: int const - ActivationSettlePolls = 60 - ActivationSettleSeconds = 0.001 + ActivationTurnSeconds = 0.001 + ActivationPumpSeconds = 0.25 + ActivationRescueSeconds = 2.0 decoratedResizableWindowMask = NSWindowStyleMaskTitled or NSWindowStyleMaskClosable or NSWindowStyleMaskMiniaturizable or NSWindowStyleMaskResizable @@ -57,6 +57,12 @@ const var WindyAppDelegate, WindyWindow, WindyView: Class windows: seq[Window] + # App activation is process-level, not per-window. While this deadline + # is in the future, pollEvents re-requests activation until it succeeds. + activationRescueDeadline: float64 + +proc drainEvents() +proc pumpActivation() objc: proc initWithFrame(self: NSView, x: NSRect): NSView @@ -166,25 +172,28 @@ proc `title=`*(window: Window, title: string) = proc `icon=`*(window: Window, icon: Image) = window.state.icon = icon -proc requestActivation(window: Window) = - ## Shows the window and asks AppKit to activate this app. - window.inner.makeKeyAndOrderFront(0.ID) - NSApp.activateIgnoringOtherApps(true) - -proc settleRunLoop() = - ## Gives AppKit a short turn to deliver activation notifications. +proc runLoopTurn() = + ## Gives AppKit a short turn to exchange messages with the window server. discard NSRunLoop.currentRunLoop.runMode( NSDefaultRunLoopMode, - NSDate.dateWithTimeIntervalSinceNow(ActivationSettleSeconds) + NSDate.dateWithTimeIntervalSinceNow(ActivationTurnSeconds) ) proc `visible=`*(window: Window, visible: bool) = autoreleasepool: if visible: - window.activationSettlePolls = ActivationSettlePolls - window.requestActivation() + window.inner.makeKeyAndOrderFront(0.ID) + NSApp.activateIgnoringOtherApps(true) + # The window server only honors this activation request while it is + # fresh, and completing it is a handshake that needs run loop turns. + # If we return without pumping and the app blocks (loading assets, + # etc.), the grant expires and the process is stuck as a background + # app: window front and clickable, but undecorated and never key. + # So finish the handshake now, before giving control back. + activationRescueDeadline = epochTime() + ActivationRescueSeconds + pumpActivation() else: - window.activationSettlePolls = 0 + activationRescueDeadline = 0 window.inner.orderOut(0.ID) proc `style=`*(window: Window, windowStyle: WindowStyle) = @@ -1070,6 +1079,16 @@ proc init() {.raises: [].} = NSApp.finishLaunching() + # Give the window server a moment to finish promoting the process to a + # regular GUI app before any window is created. A window created while + # the process is still a background process comes up unmanaged: no + # traffic light buttons and it can never become key. + # No windows or user callbacks exist yet, so nothing here can raise. + {.cast(raises: []).}: + for _ in 0 ..< 10: + drainEvents() + runLoopTurn() + platformDoubleClickInterval = NSEvent.doubleClickInterval initialized = true @@ -1114,31 +1133,8 @@ proc processFlagsChanged(event: NSEvent) = else: window.handleButtonPress(button) -proc settleActivationRequests() = - ## Replays activation while AppKit catches up after delayed startup. - var pending = false - for window in windows: - if window.activationSettlePolls == 0: - continue - dec window.activationSettlePolls - pending = true - window.requestActivation() - if pending: - settleRunLoop() - -proc pollEvents*() = - autoreleasepool: - settleActivationRequests() - - # Draw first (in case a message closes a window or similar) - for window in windows: - if window.onFrame != nil: - window.onFrame() - - # Clear all per-frame data - for window in windows: - window.state.perFrame = PerFrame() - +proc drainEvents() = + ## Dequeues and dispatches all pending AppKit events. autoreleasepool: while true: let event = NSApp.nextEventMatchingMask( @@ -1168,6 +1164,46 @@ proc pollEvents*() = # Forward event for app to handle. NSApp.sendEvent(event) +proc pumpActivation() = + ## Services the run loop until the app-activation handshake completes, + ## bounded by ActivationPumpSeconds. Normally finishes in a couple of + ## milliseconds; times out when macOS declines to activate us (e.g. the + ## user is actively working in another app). + let deadline = epochTime() + ActivationPumpSeconds + while not NSApp.isActive and epochTime() < deadline: + drainEvents() + runLoopTurn() + +proc rescueActivation() = + ## Cooperative activation (macOS 14+) drops requests made while the user + ## is interacting with another app; a fresh request right after they stop + ## is granted. So for a short time after showing a window, re-request + ## activation until it succeeds. Once we are active (or the deadline + ## passes) this never fires again, so focus is not stolen back from the + ## user later. + if activationRescueDeadline == 0: + return + if NSApp.isActive or epochTime() > activationRescueDeadline: + activationRescueDeadline = 0 + return + NSApp.activateIgnoringOtherApps(true) + runLoopTurn() + +proc pollEvents*() = + autoreleasepool: + rescueActivation() + + # Draw first (in case a message closes a window or similar) + for window in windows: + if window.onFrame != nil: + window.onFrame() + + # Clear all per-frame data + for window in windows: + window.state.perFrame = PerFrame() + + drainEvents() + pollHttp() proc centerWindow(window: Window) =