Skip to content

fix(macos): recover when WebKit abandons its GPU process at launch - #3342

Merged
matt2e merged 4 commits into
mainfrom
app-stuck-at-transparent
Nov 13, 2026
Merged

matt2e merged 4 commits into
mainfrom
app-stuck-at-transparent

Conversation

@matt2e

@matt2e matt2e commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

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:

  1. On the first page connection, WebKit's GPU process makes a blocking audio-codec lookup. Right after login the macOS audio services it calls are still starting, so the lookup is slow.
  2. WebKit gives the GPU process 3 s to answer. When it misses that, WebKit declares it unresponsive and asks launchd to kill it.
  3. macOS refuses the kill with Could not signal service com.apple.WebKit.GPU: 1: Operation not permitted. WebKit stops tracking the process anyway and never starts a replacement.
  4. The page stays connected to the abandoned process, so nothing is painted again.

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

  • Solid native window outside the welcome intro. The main NSWindow gets 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.
  • Kill the GPU process WebKit abandoned. A new macOS-only module, 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 gets SIGKILL. WebKit then starts a new GPU process and the page paints without a reload.
  • Skip the check while the webview has no content process. WebKit also reports no GPU process in that state, while other webviews keep using the healthy one. This came out of review of the previous commit.
  • Write the watcher's messages to the unified log. An app opened from Finder, the Dock or at login has no stderr, so the watcher's own lines were lost and a kill that macOS refused left nothing to find. The four messages now also go to the unified log at notice level.

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:

  • WebKit bug 324797: "GPU process termination fails with EPERM after unresponsiveness, leaving WKWebView blank". Open and unassigned. The log lines match ours. The reporter induced the stall on purpose and has not established a natural trigger.
  • WebKit bug 331017 and its unmerged fix WebKit/WebKit#80415: the GPU process is killed as unresponsive while it is only slow to start on a loaded machine. That fix moves the start of the checks to the end of GPU process initialisation. Our stall happens later, on the first page connection, so it may not cover this case.
  • WebKit commit 5e174d0 for bug 300013: adds the Opus and Vorbis capability check that runs when a GPU connection is created. This is the lookup our GPU process was blocked in.

Other apps with the same failure:

  • Saqoosha/Canopy#299, merged: a WKWebView pane stayed blank after the GPU process hung at launch inside CoreAudio. The app now finds the abandoned process through _gpuProcessIdentifier and kills it, after which "WebKit launch[es] a new GPU process, and the page paints at once". This PR takes the same approach.
  • DigitalN/pythonflow#5, merged: "After a reboot the dashboard opened at login and stayed blank." The GPU process hung setting up audio, the kill failed with the same message, and the app now ends that process itself.
  • bambulab/BambuStudio#12575, open: on macOS 27.0.1 every web view is blank because the GPU process hangs during CoreAudio initialisation. It does not mention the failed kill.
  • yetone/magpie#3272, open: the same three log lines on macOS 27.0.1, with the GPU process still alive afterwards. It happened mid-session, not at launch.

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:

  • Eight unit tests for the watcher pass, including one that kills a child process the test starts. Removing either safety check makes tests fail.
  • clippy --all-targets -D warnings is clean for the crate.
  • In a standalone WKWebView test app, WebKit's own kill was refused every time. Killing the abandoned GPU process from the app brought up a new one and both web views painted in under half a second. Reloading the page did not recover it.
  • Hardened-runtime, ad-hoc signed release builds of Buzz were launched with their own GPU process stopped. In five runs before the logging commit, the watcher killed it 1.1 to 2.0 s after WebKit gave up, WebKit started a new GPU process within half a second, and the page painted without a reload. Two of those runs were launched hidden through LaunchServices and never activated.
  • A build with the logging commit was run once the same way, launched in the background through LaunchServices. The watcher's Killed WebKit GPU process line 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.
  • The timer keeps its rate while the window is hidden. In a hidden, never-activated test app, App Nap began 31 s after launch and a 1 s timer still fired every 0.6 to 1.4 s for the next 270 s. The stall happens within the first 5 s.
  • The solid background was checked on screen in light and dark mode, and the welcome intro still shows the desktop.
  • An independent review found no way for the watcher to kill another app's process or a reused pid. Its one warning is fixed by the third commit.

Not done:

  • The kill has not run from a Developer ID signed, notarized build. Signed builds come only from main, so the restart test below runs on the first signed build after merge.
  • The watcher's Could not kill line 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.
  • Timer behaviour on battery power and with the screen locked has not been measured. A missed process only leaves the window blank, as it is today.

Known limits, left for a follow-up:

  • The native colour follows the macOS appearance. A user whose Buzz theme differs can see the other colour until the page paints.
  • The session that shows the welcome intro keeps a see-through native window until the next launch.

Test plan

  • Normal launch in light and dark mode: solid --bg-app before first paint, usual look afterwards.
  • Fresh welcome intro: the desktop still shows through.
  • Release build, window visible and hidden: kill -STOP the build's own com.apple.WebKit.GPU process and wait. The log shows GPUProcessProxy::didBecomeUnresponsive, then the watcher's kill, then a new GPU process, and the page paints again.
  • Signed build: restart with Buzz open and confirm it reopens to a painted window. Then check the log:
    • 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

@matt2e
matt2e marked this pull request as ready for review November 12, 2026 02:20
@matt2e
matt2e requested a review from a team as a code owner November 12, 2026 02:20
@matt2e
matt2e marked this pull request as draft November 12, 2026 02:20
@matt2e
matt2e marked this pull request as ready for review November 12, 2026 03:52

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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. _gpuProcessIdentifier reads 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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src-tauri/src/gpu_reaper.rs Resolved
#[cfg(target_os = "macos")]
const INTERVAL: f64 = 1.0;
/// Checks a recorded process must go unreported before it is killed.
const GRACE: u8 = 2;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 kazemek21 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

matt2e and others added 4 commits November 13, 2026 14:14
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]>
@matt2e
matt2e force-pushed the app-stuck-at-transparent branch from fd9f59b to 7bbf930 Compare November 13, 2026 03:25
@matt2e
matt2e merged commit b598c32 into main Nov 13, 2026
46 checks passed
@matt2e
matt2e deleted the app-stuck-at-transparent branch November 13, 2026 03:45
ljknight added a commit to ljknight/buzz-app that referenced this pull request Nov 13, 2026
* 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
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.

3 participants