Skip to content

fix(cua-driver-rs)(windows): stop idle overlay CPU + orphan mcp child (#1808) - #1933

Merged
f-trycua merged 2 commits into
mainfrom
fix/windows-idle-overlay-cpu-1808
Jun 18, 2026
Merged

fix(cua-driver-rs)(windows): stop idle overlay CPU + orphan mcp child (#1808)#1933
f-trycua merged 2 commits into
mainfrom
fix/windows-idle-overlay-cpu-1808

Conversation

@f-trycua

@f-trycua f-trycua commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Fixes #1808.

Root cause

The Windows agent-cursor overlay (crates/platform-windows/src/overlay.rs) drives its render loop with a Win32 SetTimer firing every 8 ms (~125 Hz). The WM_TIMER handler ran the full render pipeline on every tick, unconditionally:

  1. allocate a full virtual-screen tiny_skia::Pixmap,
  2. composite all cursors,
  3. copy the whole buffer RGBA→BGRA one pixel at a time,
  4. UpdateLayeredWindow blit,
  5. periodic z-order SetWindowPos.

This happened even with the cursor static and no client activity, so an idle cua-driver mcp pinned 60–85% of one core (reporter measured --no-overlay → 0%). This is the same class of bug as the macOS render loop fixed in #1865 — actually worse, because Windows ran at 125 Hz with a full-screen pixel swizzle.

Second failure mode: the overlay runs on a detached STA thread with its own GetMessageW loop. The in-process Windows mcp (stdio) path returned from async_main on stdin EOF but never force-exited, so on some disconnects the process lingered with the overlay loop still spinning → an orphan accumulating CPU over a day.

Fix

A — idle CPU (event-driven render):

  • Add RenderState::needs_frame_tick() / render_map_needs_frame_tick() mirroring macOS fix(cua-driver)(macos): stop idle overlay frame ticks #1865: true only while a cursor has an in-flight glide path, a spring-settle, a click pulse, or an unfinished idle-fade.
  • Gate the composite + RGBA→BGRA + UpdateLayeredWindow + z-order behind had_msg || needs_tick || was_active. A fully-quiescent tick does no compositing at all and a final settle frame is still emitted as animations finish (so the layered window is left in its resting/cleared state).
  • Re-arm the timer between an ACTIVE cadence (8 ms, ~125 Hz) while animating and a slow IDLE heartbeat (250 ms) once quiescent. send_command / remove_cursor call wake_overlay() to flip back to ACTIVE within ~8 ms via a cross-thread SetTimer, so the first move after idle isn't delayed.
  • Agent cursor stays default-on; animations/clicks/fades are unchanged when a session is active.

B — orphan on disconnect:

  • The in-process non-macOS mcp path now std::process::exites once server::run returns (stdin EOF / fatal I/O), mirroring the macOS arm. The detached overlay thread dies with the process the moment the transport closes.

Tests

  • Added headless unit tests: quiescent-sentinel (no frame tick), active-animation (requests ticks), click-pulse-then-quiescent transition.
  • cargo test -p platform-windows --lib → 13 passed (incl. the 3 new).
  • cargo check -p platform-windows --target x86_64-pc-windows-msvc → clean (the real Windows compile gate for the cfg-gated code). The full cua-driver binary cross-compile only fails on the ring C dep needing Windows headers on this macOS host — CI's Windows job is the authoritative build gate.

Notes

  • Could not run a live CPU repro on the Windows VM in this pass; the CI Windows build/test job is the required gate and the event-driven structure is unit-tested. --no-overlay remains a full workaround for headless runs.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Fixed idle CPU burn and orphaned process issues on Windows related to the agent cursor overlay.
  • New Features

    • Added --no-overlay option to disable the agent cursor for headless runs.

…#1808)

The Windows agent-cursor overlay render timer ran at ~125 Hz unconditionally:
every WM_TIMER tick allocated a full virtual-screen tiny-skia pixmap, swizzled
it RGBA->BGRA pixel-by-pixel, and blitted it via UpdateLayeredWindow — even
when no cursor was animating and the pointer was static. An idle `cua-driver
mcp` therefore pinned 60-85% of a CPU core (issue #1808), and long-lived
instances accumulated CPU-hours.

Part A (idle CPU): mirror the macOS fix (#1865). Add a `needs_frame_tick`
predicate (in-flight path / spring / click pulse / unfinished idle-fade) and
gate the composite+blit+z-order behind it. The render timer is now re-armed
between an ACTIVE cadence (~125 Hz, smooth animation) and a slow IDLE heartbeat
(250 ms) once every cursor goes quiescent. `send_command` / `remove_cursor`
call `wake_overlay()` to flip back to ACTIVE within ~8 ms via a cross-thread
SetTimer, so the first move after idle is not delayed. A final settle frame is
still emitted as animations finish, so the layered window is left in its
resting/cleared state before the loop parks. No full-screen pixmap allocation,
no RGBA->BGRA copy, no UpdateLayeredWindow while idle.

Part B (orphan on disconnect): the overlay runs on a detached STA thread with
its own Win32 message loop, so returning from `async_main` after the stdio MCP
server loop ended (stdin EOF) was not guaranteed to tear it down promptly. The
in-process Windows/Linux `mcp` path now `std::process::exit`es once
`server::run` returns, mirroring the macOS arm, so the overlay thread dies with
the process the moment the client disconnects.

Adds headless unit tests for the quiescent-sentinel state, the active-animation
state, and the click-pulse-then-quiescent transition. Verified `platform-windows`
cross-compiles cleanly for x86_64-pc-windows-msvc and all platform-windows lib
tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Ignored Ignored Preview Jun 18, 2026 3:12am

Request Review

@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cf30ec3d-7b48-4468-9ce5-6d350c024290

📥 Commits

Reviewing files that changed from the base of the PR and between 14058e6 and da80a8d.

📒 Files selected for processing (3)
  • docs/content/docs/cua-driver/reference/changelog.mdx
  • libs/cua-driver/rust/crates/cua-driver/src/main.rs
  • libs/cua-driver/rust/crates/platform-windows/src/overlay.rs

📝 Walkthrough

Walkthrough

The Win32 cursor overlay render loop is changed from a fixed-cadence timer to an event-driven idle gate: send_command/remove_cursor wake the overlay via wake_overlay(), and WM_TIMER composites only when commands arrived or cursors are animating. The MCP process is updated to force-exit on stdin EOF. A changelog entry documents these changes and the new --no-overlay flag.

Changes

Windows Idle Render Gating and MCP Cleanup

Layer / File(s) Summary
Idle gate constants, quiescence helpers, and startup cadence
libs/cua-driver/rust/crates/platform-windows/src/overlay.rs
Defines TIMER_ID, ACTIVE_PERIOD_MS/IDLE_PERIOD_MS constants, and TIMER_PERIOD_MS atomic. Adds RenderState::needs_frame_tick() and render_map_needs_frame_tick() to aggregate animation/fade state across all cursors. Arms SetTimer in the ACTIVE cadence at startup.
wake_overlay() wiring in send_command and remove_cursor
libs/cua-driver/rust/crates/platform-windows/src/overlay.rs
send_command and remove_cursor call wake_overlay() after enqueuing messages, immediately re-arming SetTimer to the ACTIVE period when the overlay is currently idle.
Gate-aware WM_TIMER render loop and cadence re-arm
libs/cua-driver/rust/crates/platform-windows/src/overlay.rs
WM_TIMER tracks had_msg from command drain and needs_tick from cursor animation state; compositing and UpdateLayeredWindow are skipped unless had_msg || needs_tick || was_active. Timer re-arms to ACTIVE or IDLE only on actual cadence flips. Unit tests validate quiescence for sentinel-only, animating, and click-pulse cursor states.
MCP force-exit on stdin EOF and changelog
libs/cua-driver/rust/crates/cua-driver/src/main.rs, docs/content/docs/cua-driver/reference/changelog.mdx
async_main captures the MCP server result, logs errors, and calls process::exit(0/1) after the server loop ends to terminate the detached overlay thread. Changelog documents these Windows fixes and the new --no-overlay flag.

Sequence Diagram(s)

sequenceDiagram
    participant Client as MCP Client
    participant main as async_main
    participant send_command as send_command / remove_cursor
    participant WM_TIMER as Win32 WM_TIMER
    participant UpdateLayeredWindow

    Client->>main: stdin EOF / pipe closed
    main->>main: capture server::run() result, log error if any
    main->>main: process::exit(0 or 1)

    Note over send_command, WM_TIMER: Normal operation (overlay active)
    send_command->>send_command: enqueue OverlayMsg
    send_command->>WM_TIMER: wake_overlay() → SetTimer(ACTIVE)
    WM_TIMER->>WM_TIMER: drain commands → had_msg=true
    WM_TIMER->>WM_TIMER: tick cursors → needs_tick
    WM_TIMER->>UpdateLayeredWindow: composite pixmap

    Note over WM_TIMER: After animation completes
    WM_TIMER->>WM_TIMER: had_msg=false, needs_tick=false
    WM_TIMER->>WM_TIMER: SetTimer(IDLE) — skip UpdateLayeredWindow
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

  • #1808 (~50% CPU usage when idle): This PR directly addresses that issue — the overlay render loop is changed to be event-driven (compositing only while animating/fading), which eliminates the idle CPU burn reported in #1808, and --no-overlay is documented as the headless workaround.

Possibly related PRs

  • trycua/cua#1692: Modifies the same WM_TIMER-driven render loop, tick completion, z-order enforcer, and arrival signaling logic in platform-windows/src/overlay.rs that this PR extends with idle quiescence gating.
  • trycua/cua#1801: Changes the per-session keyed RenderMap and Win32 render-loop compositing path in platform-windows/src/overlay.rs, directly adjacent to the idle gate logic added here.

Poem

🐇 Hop hop, the overlay rests its paws,
No more spinning wheels without a cause!
When cursors fade and clients say goodbye,
The process exits — no orphans left to cry.
Event-driven dreams in Win32 land,
A heartbeat slow — just as the rabbit planned. 🌙

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/windows-idle-overlay-cpu-1808

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 and usage tips.

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Linux visual regression artifacts

Matrix jobs now run independently. Download visual artifacts from this workflow run.
Each background-GUI job uploads a .gif of the interaction plus two annotated PNGs (<app>.png raw, <app>-atspi.png with AT-SPI element boxes); the cua-driver-linux-som-overlays artifact adds <app>-som.png cua Set-of-Marks overlays:

  • cua-driver-linux-cursor-click-gif
  • cua-driver-linux-background-terminal-gif
  • cua-driver-linux-parallel-drag-xserver
  • cua-driver-linux-background-gui-chromium
  • cua-driver-linux-background-gui-tk
  • cua-driver-linux-background-gui-gtk3-gedit
  • cua-driver-linux-background-gui-gtk3-mousepad
  • cua-driver-linux-background-gui-gtk3-scite
  • cua-driver-linux-background-gui-gtk4-characters
  • cua-driver-linux-background-gui-qt5-manuskript
  • cua-driver-linux-background-gui-qt5-klog
  • cua-driver-linux-background-gui-qt5-openambit
  • cua-driver-linux-background-gui-qt6-kate
  • cua-driver-linux-background-gui-qt6-kcalc
  • cua-driver-linux-background-gui-qt6-okular
  • cua-driver-linux-background-gui-qt6-qownnotes
  • cua-driver-linux-background-gui-electron-zettlr
  • cua-driver-linux-background-gui-electron-joplin
  • cua-driver-linux-background-gui-electron-logseq
  • cua-driver-linux-som-overlays

Open workflow run and download artifacts

…ash churn)

The overlay fix needs no new dependencies; an incidental cargo build had
re-synced the workspace member versions (0.5.3 -> 0.5.6) in Cargo.lock,
which fetchCargoVendor hashes, breaking the Nix cargoHash and turning every
Linux nix job red. Restore Cargo.lock to main's committed state so the hash
stays valid. (The Cargo.toml/Cargo.lock version drift on main is a separate
pre-existing issue, not this PR's concern.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@f-trycua
f-trycua marked this pull request as ready for review June 18, 2026 03:22
@f-trycua
f-trycua merged commit 2fd2b88 into main Jun 18, 2026
63 of 64 checks passed
f-trycua added a commit that referenced this pull request Jun 18, 2026
…n bump (kill version drift) (#1934)

The release bump edits only Cargo.toml's `[workspace.package] version`, leaving
Cargo.lock's 9 workspace-member versions stale. Nix pinned a manual `cargoHash`
that hashes the vendored lockfile, so the committed lock + hash stayed mutually
consistent (and green) only by never touching the lock — a frozen-inconsistent
state that detonated the instant anyone ran `cargo build`, which re-locked the
members and invalidated the hash, turning every nix job red (hit on PR #1933).
The drift had silently accumulated across 0.5.3 -> 0.5.6.

Three changes so this can't recur:
1. package.nix uses `cargoLock.lockFile` instead of `cargoHash`. importCargoLock
   derives each dep's hash from the lockfile itself, so there is NO hash to
   hand-maintain — Cargo.lock can change freely and the build keeps working.
   Verified the old "apple crates unreachable from crates.io" rationale is
   false: apple-cf/apple-metal/objc2 are all registry crates and there are zero
   git deps, so no `outputHashes` are needed.
2. package.nix reads `version` from Cargo.toml's `[workspace.package]` instead of
   a hardcoded literal (which had drifted to 0.5.3).
3. Re-locked Cargo.lock to 0.5.6, and the bump workflow now runs
   `cargo update --workspace` after bump2version and folds the synced lockfile
   into the bump commit (moving the tag), so the manifest and lockfile ship in
   sync every release.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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.

~50% CPU usage when idle

1 participant