Skip to content

perf(window): re-enable macOS main-window background throttling - #9395

Merged
nwparker merged 6 commits into
mainfrom
nwparker/reenable-bg-throttling
Jul 20, 2026
Merged

nwparker merged 6 commits into
mainfrom
nwparker/reenable-bg-throttling

Conversation

@nwparker

@nwparker nwparker commented Jul 18, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR

Re-enables macOS main-window background throttling — removing the setBackgroundThrottling(false) workaround that has kept every hidden/occluded Orca window rendering at ~60fps since the in-app browser shipped (#430). Browser webviews stay protected by their own per-guest unthrottle, and the black-surface hardening is kept as repaint-on-transition. Adds a Chromium feature opt-out so hidden-window timers keep 1s granularity instead of degrading to 1/min after 5 minutes.

Measured: hidden-window renderer drops from continuous 60fps compositing to 0fps / ~0.0% CPU, with zero timer degradation and no webview black-surface regression through 6.5-minute eviction soaks.

Why the workaround is no longer needed

Deep-dive into Electron/Chromium internals (disable_hidden.patch, frame_eviction_manager.cc, electron#42378):

  • setBackgroundThrottling(false) works by making RenderWidgetHostImpl::WasHidden() a no-op — a per-RenderWidgetHost flag. The browser guests get their own setBackgroundThrottling(false) at webview attach (browser-manager.ts, needed anyway for CDP screenshots while backgrounded), so guest webview surfaces are never enrolled for frame eviction regardless of the main window's setting. The main-window call was redundant for its stated purpose.
  • The one macOS occlusion bug with a real upstream fix (electron#50579, synthetic occlusion events) shipped in Electron ≥42 — already in our ^43.1.0.
  • The open upstream bug (electron#42378, frame evictor desync → blank after ~5-10 min) is triggered specifically by toggling setBackgroundThrottling while a window is hidden. We never toggle: the value is set once at window creation, while visible. The new code comment documents this trap.

Costs of the old workaround (both verified in the July 2026 perf audit): occluded-but-visible windows render at full frame rate (the biggest remaining battery cost), and document.visibilityState is pinned 'visible' forever, suppressing every renderer visibility gate.

What changed

  • createMainWindow.ts: setBackgroundThrottling(true) on darwin. Black-surface hardening kept and extended: repaint on restore/show plus a debounced 250ms late repaint (macOS can finish restoring webview layers after Electron's event), and invalidate()-only on focus to cover occlusion-uncover (which fires neither restore nor show). Focus deliberately does not run the setSize jiggle — that would SIGWINCH every terminal on each Cmd+Tab.
  • configure-process.ts: --disable-features=IntensiveWakeUpThrottling. With throttling back on, Chromium's intensive mode clamps hidden-page timers to 1/min after 5 minutes hidden, which would delay agent-done/bell notifications by up to 60s. This keeps the normal 1s hidden clamp (rAF and rendering still stop — the perf win is untouched) while timers stay usable.
  • Regression tests: main-window setBackgroundThrottling(false) now fails a unit test; focus-repaint asserts no setSize calls; IntensiveWakeUpThrottling opt-out asserted in configure-process tests.

Verification (live dev app on macOS, CDP-driven)

Instrumented the real renderer with rAF/timer/visibilitychange probes, drove hide/occlude/restore cycles via System Events, captured OS-level (screencapture -l) window pixels:

Check Before (main) After (this PR)
Hidden window: renderer rAF 60/s forever 0/s (throttled) ✅
Hidden window: renderer CPU continuous compositing ~0.0% (5-sample avg)
visibilitychange events never fire fire correctly on hide/show ✅
Guest webview rAF while embedder hidden 60/s 60/s (unchanged — per-guest unthrottle intact) ✅
Webview after 6.5 min hidden (past the 5-min FrameEvictor culling delay), OS-level pixel capture n/a renders correctly, no black surface ✅ (2 independent soaks)
Webview after 20s full occlusion by another app's window n/a stays visible+rendering (occlusion didn't even throttle in practice; focus-invalidate covers the uncover path) ✅
1.5s renderer timer chain through 6.5 min hidden n/a before feature flag: 60s gaps in intensive mode; after: 213/213 intervals at ~1.5s, max gap 2s ✅
Terminal output produced while hidden 45s n/a all 60 TICK lines present and ordered on restore ✅

Suites: main 12,826 ✅ · renderer 14,642 ✅ · preload+shared 2,571 ✅ · typecheck ✅ · git diff --check ✅. (Repo-wide pnpm lint has a pre-existing failure on main in skill-freshness-group.tsx, unrelated.)

Accepted behavior changes (deliberate, degrade-not-break)

  • Hidden-terminal output beyond the backlog cap goes lossy (the cap + ack-credit-before-drop design in pane-terminal-output-scheduler.ts was built for exactly this; the shell cannot wedge — credits fire before dropping).
  • Renderer visibility gates now actually engage while hidden (git polls, useNow, spinner clocks stop) — that's the point.
  • Notification quiet-gates (~1.5s renderer setTimeouts) can slip to ~2s while hidden — covered by the intensive-throttling opt-out; verified max 2s.

Adversarial review findings (subagent, addressed or accepted)

  • Fixed — focus jiggle: an early draft ran the full forceRepaint (incl. the ±1px setSize jiggle) on every window focus, which would have twitched the window edge and poked terminal ResizeObservers on each Cmd+Tab. Final code invalidates only on focus; a test asserts no setSize.
  • Fixed — use-after-destroy: forceRepaint/focus-invalidate now also check webContents.isDestroyed() (webContents can die a beat before its BrowserWindow during close, and these run from timers/focus events).
  • Accepted — newly-active visibility handlers: visibilitychange handlers that were dead on macOS now run on real hide/show flips: refreshAllGitHub() on become-visible (has its own throttle; macOS only flips visibility on ~full occlusion or hide, so frequency is bounded) and the heavy wake-recovery path (glyph-atlas clear + refit) on visibility flips. Both are the intended design finally engaging on macOS, same as they already do on Windows/Linux.
  • Residual risk — occlusion-uncover without focus: Electron exposes no occlusion event; dragging a covering window away without clicking Orca produces neither restore/show/focus nor (in my live test, where full occlusion by another app never even flipped visibility) a visibilitychange. The window then relies on Chromium's own occlusion→repaint. Mitigations: full-occlusion flips that do change visibility trigger the in-tree wake-recovery self-heal, and my 20s full-occlusion test showed frames never stopped in practice. This narrow case is the main thing perf-rc soaking should watch.

History

Supersedes stalled PR #5486 (same core shape; this adds the focus/occlusion-uncover path, the no-jiggle-on-focus fix, and the IntensiveWakeUpThrottling opt-out). The original workaround shipped inside #430 with no linked issue; the "floating Claude stuck" concern that motivated keeping renderers unthrottled (#5829) was resolved by #7214's hidden-delivery pipeline, and #5829 was closed as obsolete.

Recommend soaking on the perf-rc channel (#7336) before stable, since the original black-surface reports were never captured as issues.

nwparker and others added 3 commits July 18, 2026 15:04
Browser-guest surfaces stay alive via their own per-guest unthrottle at
webview attach, so the main window no longer needs throttling disabled to
avoid black webview surfaces. Keep the black-surface hardening as repaint
on show/restore/focus plus one debounced 250ms late repaint, and add a
regression test that fails if setBackgroundThrottling(false) returns.

Co-authored-by: Orca <help@stably.ai>
With background throttling re-enabled, Chromium's intensive mode clamps
hidden-page timers to one per minute after 5 minutes, delaying agent-done
and bell notifications by up to 60s. Disable the IntensiveWakeUpThrottling
feature so hidden timers keep the normal 1s clamp while rAF and rendering
still stop. Verified live: 1.5s timer chain fired on schedule through a
6.5-minute hidden soak (was 60s gaps).

Co-authored-by: Orca <help@stably.ai>
webContents can be destroyed a beat before its BrowserWindow during
close, and forceRepaint runs from timers and focus events that can land
in that gap.

Co-authored-by: Orca <help@stably.ai>
@nwparker
nwparker marked this pull request as ready for review July 18, 2026 22:58
… opt-out

Co-authored-by: Orca <help@stably.ai>
@coderabbitai

coderabbitai Bot commented Jul 18, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 29ad25bf-a4e4-451a-9669-7af6e1d6c647

📥 Commits

Reviewing files that changed from the base of the PR and between d91dbd1 and 2d25d7d.

📒 Files selected for processing (1)
  • src/main/window/createMainWindow.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/window/createMainWindow.test.ts

📝 Walkthrough

Walkthrough

The startup GPU configuration now appends IntensiveWakeUpThrottling to Chromium’s disabled features while preserving existing values, with corresponding test coverage. macOS main-window creation now keeps background throttling enabled and installs visibility repaint handlers for restore, show, focus, and close events. Repaint logic also guards against destroyed webContents and manages delayed repaint timers.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: re-enabling macOS main-window background throttling.
Description check ✅ Passed The description covers the summary, testing, review, and risks, but it misses the template's explicit Screenshots and Security Audit sections.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 611bf6d9-8dcb-48f5-8f0a-8d93c2bd2dae

📥 Commits

Reviewing files that changed from the base of the PR and between 927ca9e and d806607.

📒 Files selected for processing (4)
  • src/main/startup/configure-process.test.ts
  • src/main/startup/configure-process.ts
  • src/main/window/createMainWindow.test.ts
  • src/main/window/createMainWindow.ts

Comment thread src/main/window/createMainWindow.ts Resolved
@nwparker

Copy link
Copy Markdown
Contributor Author

Verification: orca-cli browser automation works fully WHILE MINIMIZED — screenshots are pixel-correct, never black

Ran the whole orca-cli browser surface against this branch's dev build with the main window genuinely minimized to the Dock (AXMinimized=true via System Events, re-asserted before every command — not hidden, not occluded), including a soak past Chromium's 5-minute FrameEvictor culling delay.

Protocol

  1. Opened a browser tab in a worktree, navigated via orca goto.
  2. Minimized the window for real (AXMinimized on the AX window attribute; verified true before and after each step).
  3. While minimized: orca screenshot (viewport, CDP Page.captureScreenshot), orca full-screenshot, orca snapshot, orca click, orca eval.
  4. Kept it minimized for 6.5 minutes (past the 5-min frame-eviction window), taking screenshots at 1 min / 3 min / 6.5 min.
  5. Un-minimized and took an OS-level pixel capture (screencapture -l <windowID>, not CDP) to prove the restored window itself isn't black.

Results — every step passed

Step (window minimized throughout) Result
orca screenshot on a magenta/green test page ✅ full-color, correct pixels (image 1)
orca snapshot ✅ returns refs (e1 heading, e2 button)
orca click --element @e2 → orca eval document.title ✅ click landed, title flipped to CLICKED — interaction, not just capture
orca goto https://example.com + inject magenta outline marker ✅
orca screenshot at 1 min minimized ✅ page + injected marker intact
orca screenshot at 3 min minimized ✅ byte-identical content
orca screenshot at 6.5 min minimized (past 5-min eviction) ✅ correct, marker intact (image 2)
orca full-screenshot at 6.5 min minimized ✅ (image 3)
orca snapshot at 6.5 min minimized ✅ DOM refs present
Un-minimize → OS-level screencapture of the real window ✅ whole app renders, browser pane correct, no black surface (image 4)
Renderer CPU while minimized (main + guest, 5-sample avg) 0.00% both — the perf win holds even with a browser tab open

1 — orca screenshot while minimized (magenta/green test page):
minimized viewport screenshot

2 — orca screenshot after 6.5 min minimized (magenta outline = marker injected pre-soak; its survival proves this is the live page, not a cached frame):
after 6.5 min minimized

3 — orca full-screenshot after 6.5 min minimized:
full page after 6.5 min minimized

4 — OS-level pixel capture immediately after un-minimize (screencapture of the real window, not CDP):
os capture after restore

Why this works by construction, not luck

The CLI screenshot path never depends on the main window's compositor: cdp-screenshot.ts routes Page.captureScreenshot through the guest's CDP debugger, which renders server-side in the guest's Blink compositor — and every browser guest keeps its own setBackgroundThrottling(false) (browser-manager.ts, unchanged by this PR) precisely so it keeps producing frames while Orca is backgrounded. This PR only throttles the main window's renderer; the guest renderer, which is what screenshots capture, is untouched. The 0.00% CPU readings confirm macOS additionally suspends paint work while minimized without affecting CDP capture correctness.

One observation for completeness: during initial tab setup (a freshly created webview that hadn't painted its first frame yet, window fully visible), the viewport orca screenshot briefly returned the guest's pre-paint dark frame while full-screenshot/snapshot were already live; it converged after first paint and never recurred — including immediately after fresh navigations. This is a pre-first-paint artifact of a brand-new guest, not a minimized/throttling behavior (the window was visible when it happened), and the capture path + guest throttling state are byte-identical to main in this PR.

@nwparker

Copy link
Copy Markdown
Contributor Author

Latent-risk closure: every open risk from the adversarial review empirically verified

Follow-up pass closing the residual risks flagged in the review section, each with a live test on this branch's dev build (macOS, OS-level screencapture -l <windowID> pixel captures — not CDP, so a wedged compositor cannot fake these).

1. Occlusion-uncover WITHOUT focus (the top residual risk) — ✅ no stale/black surface

The one path with no Electron event coverage: another app's window fully covers Orca, then moves away without Orca ever being clicked — no restore, no show, no focus fires.

Test: positioned Orca at a fixed frame, fully covered it with a focused TextEdit window for 90 seconds, then moved TextEdit away by repositioning TextEdit's own window (Orca never focused), and captured Orca's window at the OS level 2s later:

occlusion uncover without focus

Fully rendered — sidebar, browser pane, file panel, status bar. Chromium's own occlusion→repaint handled the uncover with zero help from our event handlers. (Consistent with the earlier probe finding that macOS occlusion detection is conservative: a 20s full cover never even stopped the renderer's frame production.)

2. Maximized windows get invalidate-only repaint (no setSize jiggle) — ✅ clean restore after eviction-window soak

forceRepaint early-returns to invalidate()-only for maximized/fullscreen windows, and this PR makes that path more load-bearing. Test: zoom-maximized the window (1920×1200 full display frame), hid it for 6.5 minutes (past the 5-min FrameEvictor culling delay), restored, OS-level capture:

maximized hidden 6.5min restore

Correct pixels everywhere, webview included — invalidate() alone is sufficient on the maximized path.

3. refreshAllGitHub() burst on become-visible — ✅ bounded by design (code-verified)

The newly-active visibilitychange handler calls refreshAllGitHub(), but the implementation (github.ts) only re-fetches entries whose cache entry is already past CACHE_TTL (now - fetchedAt >= CACHE_TTL gate per PR/issue) and prunes rather than force-fetches everything. A visibility flip with a warm cache issues zero API calls. This matches the AGENTS.md rate-limit guidance; no change needed.

4. --disable-features=IntensiveWakeUpThrottling now also applies on Windows/Linux — ✅ strictly beneficial there

On Windows/Linux the main window has always throttled (Electron default; intentional per #7214's design). Intensive throttling's 1/min clamp therefore already applies to hidden windows there today — this flag improves those platforms the same way (hidden timers keep 1s granularity, so agent-done/bell notifications stop being up-to-60s late on Win/Linux too), while rendering/rAF stay stopped. It's the same fix the June "floating Claude stuck" report needed, delivered without unthrottling rendering. No behavioral downside identified; the GPU-fallback path that skips the flag is Windows-only and merely preserves today's exact behavior there (noted in a code comment).

Cumulative soak evidence on this branch

Four independent 6.5-minute hidden/minimized soaks (normal-size hidden ×2, minimized ×1, maximized-hidden ×1) plus a 90s no-focus occlusion cycle — zero black or stale surfaces across all five, verified by OS-level pixel capture. Combined with the minimized orca-cli automation evidence in the previous comment, every concrete failure mode raised in review now has a direct empirical check on this branch.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f604f9cd-f3ad-4688-82c6-f80a89ee7ba8

📥 Commits

Reviewing files that changed from the base of the PR and between d806607 and d91dbd1.

📒 Files selected for processing (2)
  • src/main/window/createMainWindow.test.ts
  • src/main/window/createMainWindow.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/window/createMainWindow.ts

@nwparker
nwparker merged commit 74e2cb7 into main Jul 20, 2026
2 checks passed
@nwparker
nwparker deleted the nwparker/reenable-bg-throttling branch July 20, 2026 04:30
nwparker added a commit that referenced this pull request Jul 20, 2026
Per-element infinite CSS spin animations kept Chromium's frame pipeline
awake for the whole agent run — measured live (interleaved A/B/A/B,
renderer+GPU): 86.0/76.0 ms CPU/s with the CSS animation vs 61.0/61.0
with the clock, one working agent, and the CSS cost scales per element
while the clock is one flat timer for N spinners.

The clock ticks 12 steps/s at 30° — frame-for-frame identical to the
retired animation — writes style.transform on registered elements, and
stops on document hidden (native visibilitychange post-#9395), under
prefers-reduced-motion (static ring keeps the filled top border from
#9515), and when the last spinner unmounts. The stale-visibility latch
keeps it spinning when proven user input contradicts the occlusion
tracker.

Co-authored-by: Orca <help@stably.ai>
nwparker added a commit that referenced this pull request Jul 20, 2026
…wake (one shared clock, hidden-window stop) (#9380)

* perf(renderer): drive agent working-spinners from one shared clock

Per-element infinite CSS spin animations kept Chromium's frame pipeline
awake for the whole agent run — measured live (interleaved A/B/A/B,
renderer+GPU): 86.0/76.0 ms CPU/s with the CSS animation vs 61.0/61.0
with the clock, one working agent, and the CSS cost scales per element
while the clock is one flat timer for N spinners.

The clock ticks 12 steps/s at 30° — frame-for-frame identical to the
retired animation — writes style.transform on registered elements, and
stops on document hidden (native visibilitychange post-#9395), under
prefers-reduced-motion (static ring keeps the filled top border from
#9515), and when the last spinner unmounts. The stale-visibility latch
keeps it spinning when proven user input contradicts the occlusion
tracker.

Co-authored-by: Orca <help@stably.ai>

* fix(github): restore explicit space after Filter fallback in PRFilterSections

Pre-existing failure on main: i18n-jsx-spacing-guard requires {' '} after
the 'Filter' translate fallback, but the file had a bare JSX space (which
oxfmt collapses). Wrap the subject in a span so the explicit {' '} survives
formatting, matching the pattern in the other guarded files.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
nwparker added a commit that referenced this pull request Jul 22, 2026
The iOS MJPEG and Android scrcpy device streams are gated only on the pane
being the active tab (isActive, PR #7382). When the emulator tab is frontmost
but the whole Orca window is hidden/minimized/occluded/display-asleep, the
full-fps pipeline keeps running: main-process socket read + JPEG/H.264 decode
+ IPC + renderer decode. Renderer background-throttling (#9395) cannot stop it
because the pipeline is IPC-push driven from main.

Gate showStream additionally on window visibility via a new occlusion-safe
hook that honors the terminal stale-visibility latch (so a display-sleep
occlusion wedge can't freeze the emulator on a black frame) and delays the
visible->hidden park by 500ms so a quick Cmd+Tab round-trip doesn't renegotiate
the device stream.

Co-authored-by: Orca <help@stably.ai>
nwparker added a commit that referenced this pull request Jul 22, 2026
The iOS MJPEG and Android scrcpy device streams are gated only on the pane
being the active tab (isActive, PR #7382). When the emulator tab is frontmost
but the whole Orca window is hidden/minimized/occluded/display-asleep, the
full-fps pipeline keeps running: main-process socket read + JPEG/H.264 decode
+ IPC + renderer decode. Renderer background-throttling (#9395) cannot stop it
because the pipeline is IPC-push driven from main.

Gate showStream additionally on window visibility via a new occlusion-safe
hook that honors the terminal stale-visibility latch (so a display-sleep
occlusion wedge can't freeze the emulator on a black frame) and delays the
visible->hidden park by 500ms so a quick Cmd+Tab round-trip doesn't renegotiate
the device stream.

Co-authored-by: Orca <help@stably.ai>
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…wake (one shared clock, hidden-window stop) (stablyai#9380)

* perf(renderer): drive agent working-spinners from one shared clock

Per-element infinite CSS spin animations kept Chromium's frame pipeline
awake for the whole agent run — measured live (interleaved A/B/A/B,
renderer+GPU): 86.0/76.0 ms CPU/s with the CSS animation vs 61.0/61.0
with the clock, one working agent, and the CSS cost scales per element
while the clock is one flat timer for N spinners.

The clock ticks 12 steps/s at 30° — frame-for-frame identical to the
retired animation — writes style.transform on registered elements, and
stops on document hidden (native visibilitychange post-stablyai#9395), under
prefers-reduced-motion (static ring keeps the filled top border from
stablyai#9515), and when the last spinner unmounts. The stale-visibility latch
keeps it spinning when proven user input contradicts the occlusion
tracker.

Co-authored-by: Orca <help@stably.ai>

* fix(github): restore explicit space after Filter fallback in PRFilterSections

Pre-existing failure on main: i18n-jsx-spacing-guard requires {' '} after
the 'Filter' translate fallback, but the file had a bare JSX space (which
oxfmt collapses). Wrap the subject in a span so the explicit {' '} survives
formatting, matching the pattern in the other guarded files.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…i#9842)

The iOS MJPEG and Android scrcpy device streams are gated only on the pane
being the active tab (isActive, PR stablyai#7382). When the emulator tab is frontmost
but the whole Orca window is hidden/minimized/occluded/display-asleep, the
full-fps pipeline keeps running: main-process socket read + JPEG/H.264 decode
+ IPC + renderer decode. Renderer background-throttling (stablyai#9395) cannot stop it
because the pipeline is IPC-push driven from main.

Gate showStream additionally on window visibility via a new occlusion-safe
hook that honors the terminal stale-visibility latch (so a display-sleep
occlusion wedge can't freeze the emulator on a black frame) and delays the
visible->hidden park by 500ms so a quick Cmd+Tab round-trip doesn't renegotiate
the device stream.

Co-authored-by: Orca <help@stably.ai>
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