Repository navigation
fix(macos): recover when WebKit abandons its GPU process at launch - #3342
Conversation
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Carl, an automated reviewer, commenting via Wes's GitHub account.
Reviewed at a47d91b against 6fc0e1f. No confirmed defects and no new side effects found. The reaper hasn't been shown to work in the case it exists for.
Checked, no problem:
- No wrong kills found.
_gpuProcessIdentifierreads the shared GPU process for the page's process pool, not per-page use. A standalone two-webview probe on macOS 26.7.1 kept the same GPU pid through reload, hiding, cross-origin navigation and a reload of the other view. - Huddles look recoverable. They use getUserMedia and Web Audio. WebKit recreates capture and restarts audio after GPU process loss, so even a wrong kill should interrupt a call briefly rather than end it. Live recovery wasn't run.
- The solid background is safe. Intro ordering, setup ordering, the other windows and the dynamic colour's memory handling are all fine.
Before merging the reaper: run the PR's own last test-plan item on a Developer ID signed build. Restart with Buzz open, confirm the watcher logs a successful kill, and confirm the window paints. Nothing so far shows the kill is allowed from the hardened signed app, or that the timer catches the GPU pid during login. If that evidence isn't ready, consider merging the background on its own first.
The inline notes are all optional.
The PR description is out of date: it still says "draft" and "no Buzz build has been launched", but the PR isn't a draft and three test-plan boxes are checked.
| // SAFETY: the block only runs on this thread, the main thread, | ||
| // whose run loop keeps the timer for the whole app. | ||
| unsafe { | ||
| NSTimer::scheduledTimerWithTimeInterval_repeats_block(INTERVAL, true, &check) |
There was a problem hiding this comment.
Optional, UNVERIFIED: Apple documents that App Nap lowers NSTimer frequency for apps that aren't visible. If Buzz is hidden at login (for example a login item set to "Hide"), the timer may never see the GPU pid during the roughly 3 s WebKit still reports it. The process is then never recorded, and the blank window stays. That fails safe, since nothing is wrongly killed, but the fix quietly does nothing. A NSProcessInfo activity held for the first minute of launch, or a first sample taken right after the main page's first navigation, would close the gap.
| #[cfg(target_os = "macos")] | ||
| const INTERVAL: f64 = 1.0; | ||
| /// Checks a recorded process must go unreported before it is killed. | ||
| const GRACE: u8 = 2; |
There was a problem hiding this comment.
Optional hardening: GRACE = 2 at 1 s intervals means the kill can come 1 to 2 s after the pid disappears, not a guaranteed 2 s. Combined with sampling only main (line 146), this is tighter than Canopy, which waits 10 s and kills only when no webview reports the pid. No false orphan was reproduced, so this isn't a blocker. Since WebKit has already waited 3 s by that point, a longer grace adds little delay to recovery.
| }; | ||
| // Resolved by the system appearance: Buzz's own light/dark choice is page | ||
| // state, so it can differ until the page paints. | ||
| let (light, dark) = (srgb(241.0, 241.0, 242.0), srgb(16.0, 16.0, 16.0)); |
There was a problem hiding this comment.
Optional, UNVERIFIED in a real launch: the colour follows the macOS appearance, while Buzz's theme lives only in localStorage and is never synced to the native window. With macOS in light mode and Buzz in dark, you get a light #F1F1F2 frame before first paint and along the edges during a live resize. Today that moment shows the desktop through the window instead, so it swaps one flash for another. A fix is getCurrentWindow().setTheme(mode) on theme changes, with null for system mode.
| let native: &objc2_app_kit::NSWindow = unsafe { &*native.cast() }; | ||
| native.setAnimationBehavior(objc2_app_kit::NSWindowAnimationBehavior::None); | ||
| // Undo `opaque_background` while still hidden: the intro shows the desktop. | ||
| native.setBackgroundColor(Some(&objc2_app_kit::NSColor::clearColor())); |
There was a problem hiding this comment.
Optional: after the welcome intro, nothing sets the opaque background again. finish() in useWelcomeIntro.ts leaves the window clear, so a stall later in that first session still shows the see-through window. That's today's behaviour, so it's low priority.
kazemek21
left a comment
There was a problem hiding this comment.
Kyle reviewed this PR and the Nexus review-tracker notes and approved with no additional comments.
Posted by Codex (gpt-6.1-sol) on behalf of kylez.
Since the onboarding update (#2183) the macOS main window is cleared at both the NSWindow and the WKWebView layer so the one-time welcome intro can show the desktop. On every other launch the only opaque pixels come from page CSS, which WebKit paints through its GPU process. When that process hangs at login, the page still shows the window before first paint and it stays fully see-through, with no sign that Buzz is open. Setup now gives the main NSWindow a dynamic background that resolves to --bg-app for the system appearance (#f1f1f2 light, #101010 dark), so a launch that never paints shows a solid blank window instead of nothing. The intro clears it again in onboarding_preserve_window, which already runs on the still-hidden window before the intro shows it, so the intro keeps its current look. isOpaque, the webview's drawsBackground and tauri.macos.conf.json are unchanged, and no command or capability is added. Buzz's own light/dark choice lives in page storage, so when it differs from the system appearance the window shows the other mode's --bg-app until the page paints. Signed-off-by: Matt Toohey <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Matt Toohey <[email protected]>
…nning WebKit gives its GPU process 3 s to answer a page. When the process misses that, WebKit stops tracking it and asks launchd to kill it, and macOS refuses the kill (WebKit bug 324797). The page stays connected to the dropped process and never paints. At login, when the GPU process is slow to start, Buzz opens to a window that stays empty until it is quit. A new macOS-only module, installed from setup beside reshow, watches for this. A one-second main-thread timer reads the main webview's private _gpuProcessIdentifier and records each reported process with its start time. A recorded process that goes unreported for two checks, and is still the same process running the WebKit GPU binary, gets SIGKILL. A process that WebKit shut down exits within that time and is forgotten instead. Each kill, and each failed kill, is logged. If the webview lacks the selector, no timer is installed. A process the timer never saw reported is never killed, and a failed kill is not retried. The decision function is compiled for tests on every platform, so the Linux and Windows CI suites run its tests. The timer, the selector call and the kill are macOS-only. objc2-foundation gains the NSDate and NSTimer features; Cargo.lock is unchanged. Verified on macOS 27.2: - The seven unit tests pass: six for the decision function, and one that reads the identity of a `sleep` child the test starts, kills it through the module's own kill, and checks the identity is then gone. - Setting the grace to one check fails four of them, and skipping the still-running check fails two. - clippy --all-targets -D warnings is clean for the crate. The file also compiles warning-free and passes with the macOS parts compiled out, using rustc directly, not the Linux or Windows toolchain. - Read-only: WKWebView has _gpuProcessIdentifier and it returns an int. The identity read returns a start time for this user's running WebKit GPU processes, including a suspended one. None of them was signalled. Not verified at runtime: - No Buzz build was launched. The install path, the timer, the selector call on a live webview and a real kill have not run inside Buzz. - That WebKit starts a new GPU process and the page paints after the kill was measured earlier in a standalone test app, not in Buzz. - Whether the timer keeps its one-second rate while the launch window is still hidden. A throttled timer could miss the roughly four seconds in which WebKit reports the process, and then nothing is killed. - Killing from the hardened, Developer ID signed build. Signed-off-by: Matt Toohey <[email protected]> Co-Authored-By: Claude Fable 5.2 <[email protected]> Signed-off-by: Matt Toohey <[email protected]>
The watcher took a main webview that reports no GPU process as proof that WebKit had dropped it. WebKit also reports none whenever the webview has no running content process, while it keeps using the same GPU process for the app's other webviews. A crash of the main webview's content process that is not reloaded at once would have looked like a dropped GPU process, and after two checks Buzz would have killed the healthy one. Only Tauri's default handler, which reloads inside the termination callback, kept the timer from seeing that state. Each check now also reads the webview's private _webProcessIdentifier. While it is 0 the check is skipped: nothing is recorded, counted towards the grace, or killed. The decision is a new `check` function, compiled for tests on every platform like the one it calls. The timer is not installed unless the webview has both selectors. A check is also skipped while the content process is still launching, because its pid is 0 until then. WebKit's 3 s wait is run by a launched content process, so the hang is still seen. Verified on macOS 27.2: - The eight unit tests pass. The new one keeps a recorded GPU process running through five checks without a content process and expects no kill, then expects the kill after two checks with one. It fails with the skip removed. - clippy --all-targets -D warnings is clean for the crate. The file also compiles warning-free and its seven portable tests pass with the macOS parts compiled out, using rustc directly, not the Linux or Windows toolchain. - In a standalone two-webview app, one webview's content process was killed and not reloaded. That webview reported 0 for both processes on five consecutive checks, while the other kept reporting the same running GPU process. After a reload it reported both again. - In a standalone app whose own GPU process was stopped at launch, over three runs, the content process pid was 0 when the GPU process first appeared and nonzero 50 ms later. It stayed nonzero for the 3 s that WebKit reported the stopped process and after WebKit dropped it. With the GPU process stopped 4 s after launch instead, the first webview's content process pid was nonzero at every reading. Not verified at runtime: - No Buzz build was launched and the installed Buzz was not touched. The new selector call has not run on Buzz's live webview. - A content process crash inside Buzz, with or without Tauri's reload. - Everything the previous commit lists as unverified still is. Signed-off-by: Matt Toohey <[email protected]> Co-Authored-By: Claude Fable 5.2 <[email protected]> Signed-off-by: Matt Toohey <[email protected]>
The watcher reported a kill, a refused kill and its two install failures
with eprintln! only. An app opened from Finder, the Dock or at login has
standard error on /dev/null, and Buzz's native code has no other log. A
kill that macOS refused left nothing to find, so it could not be told
apart from a watcher that never saw the process.
A new `log` helper in the macOS half of gpu_reaper.rs keeps the eprintln!
and also calls libc::syslog at notice level, which the unified log keeps
at its default level. The four messages go through it with their text
unchanged. Each carries at most a pid and an OS error. No dependency is
added, and the decision functions and their tests are untouched.
Read the messages back with:
log show --last 30m --predicate \
'process == "Buzz" AND eventMessage CONTAINS "GPU process"'
Verified on macOS 27.2:
- The eight unit tests pass. The one that kills a child it starts now
goes through the helper. Its "Killed WebKit GPU process" line was read
back from the unified log at default level, under the test binary's
process name.
- clippy --all-targets -D warnings is clean for the crate, and rustfmt
--check passes for the file.
- One run of a release build of this change, ad-hoc signed with the
hardened runtime and the app's entitlements. It was launched in the
background through LaunchServices with `open -g`, under a throwaway
home, keychain and bundle identifier, and its own GPU process was
stopped 0.2 s after it appeared. WebKit logged
GPUProcessProxy::didBecomeUnresponsive and its own kill was refused.
1.77 s later the unified log held the watcher's "Killed WebKit GPU
process" line for that pid, under process "Buzz" at default level.
launchd's "exited due to SIGKILL | sent by Buzz" line followed 6 ms
later, and a new GPU process was running. The window never came to the
front, and the installed Buzz was not signalled.
Not verified:
- The "Could not kill" line from Buzz. No kill was refused in any run.
The same syslog call with an "Operation not permitted" text was read
back earlier from a standalone hardened-runtime binary, not from Buzz.
- The two install-failure messages. Neither condition occurred.
- A launch with standard error discarded. The test launch captured
stderr to a file, and the syslog call does not depend on it.
- A bundled, Developer ID signed, notarized build. The test app was a
wrapper bundle whose executable starts the unbundled release binary.
- How long the unified log keeps the lines. They were read back within a
minute.
- The Linux and Windows builds were not re-run. The change is confined
to the macOS-only module.
Signed-off-by: Matt Toohey <[email protected]>
Co-Authored-By: Claude Fable 5.2 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
fd9f59b to
7bbf930
Compare
* origin/main: feat(agents): let a plugin give one agent standing instructions in one conversation (block#3432) Keep Solo draft roles in source-owned JSON (block#3463) fix(media): save Download Image from the native menu on macOS (block#3352) fix(bestie): show the Bestie's profile picture in navigation (block#3423) Expose local agent lifecycle evidence (block#3430) fix(macos): recover when WebKit abandons its GPU process at launch (block#3342) Preserve JSON context in Solo drafts (block#3429) fix(messages): qualify namesake mentions so each renders as a chip (block#3206) perf(app): hand a page its request without rendering the shell again (block#3007) perf(solo): subscribe to Solo navigation only for a chat that can be filed (block#3335) perf(messages): skip markdown preparation for link-free preview scans (block#3334) feat(messages): open message actions from a right-click on the row (block#3316) fix(agents): read hosted agent records on foreground capacity (block#3431) Signed-off-by: Laura Knight <[email protected]> # Conflicts: # docs/plugin-authoring-guide.md
Problem
On macOS, Buzz sometimes opens to a window that never paints. It has been seen when macOS reopens Buzz at login after a restart. The app is otherwise alive: the page loads and its JavaScript and IPC keep running. Only quitting and reopening Buzz clears it.
The cause is in WebKit, not in Buzz:
Could not signal service com.apple.WebKit.GPU: 1: Operation not permitted. WebKit stops tracking the process anyway and never starts a replacement.Since #2183 the main window is clear at the native level, so the failure shows as a fully see-through window with only the traffic lights.
What this changes
NSWindowgets a background that matches--bg-app, so a launch that never paints shows a blank window instead of nothing. The intro clears it again and keeps its current look.src-tauri/src/gpu_reaper.rs, checks once a second which GPU process the main webview reports. A process that was reported earlier, is no longer reported for two checks, and is still the same running WebKit GPU binary getsSIGKILL. WebKit then starts a new GPU process and the page paints without a reload.The watcher never kills a process it did not first see reported by Buzz's own webview. It does nothing if the private selectors are missing.
Research
Upstream WebKit:
Other apps with the same failure:
_gpuProcessIdentifierand kills it, after which "WebKit launch[es] a new GPU process, and the page paints at once". This PR takes the same approach.We found no report from Tauri, wry or tao, and no public report that names the Opus lookup as the trigger.
Verification
Done, on macOS 27.2:
clippy --all-targets -D warningsis clean for the crate.Killed WebKit GPU processline was in the unified log at default level 1.77 s after WebKit gave up. launchd's kill record followed and a new GPU process was running.Not done:
main, so the restart test below runs on the first signed build after merge.Could not killline has not been produced by Buzz, because no kill was refused in any run. The same log call with a refused-kill text was read back from a standalone hardened-runtime binary.Known limits, left for a follow-up:
Test plan
--bg-appbefore first paint, usual look afterwards.kill -STOPthe build's owncom.apple.WebKit.GPUprocess and wait. The log showsGPUProcessProxy::didBecomeUnresponsive, then the watcher's kill, then a new GPU process, and the page paints again.log show --last 30m --predicate 'process == "Buzz" AND eventMessage CONTAINS "GPU process"'log show --last 30m --predicate 'process == "launchd" AND eventMessage CONTAINS "exited due to SIGKILL | sent by Buzz"'🤖 Generated with Claude Code