Skip to content

feat(app): add MainThreadPoster for posting to the main thread#7

Merged
BumpyClock merged 1 commit into
mainfrom
feat/spawn-on-main
Jul 17, 2026
Merged

feat(app): add MainThreadPoster for posting to the main thread#7
BumpyClock merged 1 commit into
mainfrom
feat/spawn-on-main

Conversation

@BumpyClock

Copy link
Copy Markdown
Owner

Motivation

Apps that embed gpui need to re-enter the App from callbacks that fire off the main thread — tray-icon menu handlers, global hotkeys, filesystem watchers, and similar OS callbacks. gpui-component-app's AppProxy is the concrete case driving this: it wants to hand background threads a way to post FnOnce(&mut App) + Send work onto the main thread.

Today there is no dependency-free way to do that:

  • ForegroundExecutor::spawn is !Send and must be called on the main thread, so it can't be reached from a background thread.
  • PlatformDispatcher::dispatch_on_main_thread wakes the main loop, but it wants an async_task::Runnable that a consumer can't construct (the constructor lives inside the scheduler crate).

The only available workaround was a foreground timer that polls a queue every ~16ms, which costs battery on idle tray apps that mostly do nothing.

What this adds

App::main_thread_poster() returns a cloneable, Send + Sync MainThreadPoster:

let poster = cx.main_thread_poster();
std::thread::spawn(move || {
    // later, from a background thread / OS callback:
    poster.post(|cx: &mut App| {
        cx.refresh_windows();
    });
});
  • MainThreadPoster is Clone + Send + Sync, so it can be moved to and shared across background threads.
  • post(impl FnOnce(&mut App) + Send + 'static) -> bool enqueues a closure to run on the main thread with exclusive &mut App access.

Mechanism

The poster is backed by a futures::channel::mpsc::unbounded channel drained by a single persistent foreground task (the "pump"), spawned lazily on the first call and cached on the App:

while let Some(task) = rx.next().await {
    let Some(app) = weak_app.upgrade() else { break };
    app.borrow_mut().update(|cx| task(cx));
}

Sending on the channel wakes the pump task's waker. Because the foreground executor's schedule callback is Send + Sync and routes through PlatformDispatcher::dispatch_on_main_thread (GCD main queue on macOS, and the equivalent on every other backend), waking from a background thread reschedules the pump onto the main thread and wakes the run loop. No polling — an idle app stays parked until there is work. This reuses the exact cross-thread wake path that existing foreground tasks already use when they await background work.

Semantics

  • Ordering: closures run on the main thread in the order they were posted.
  • App access: each closure runs via App::update, so effects are flushed like any other update.
  • Shutdown: the pump guards on a weak App handle and never panics. Once the App is dropped the pump stops and post returns false; closures that were enqueued but not drained before shutdown are dropped without running (documented on post).
  • Caching: repeated main_thread_poster() calls return clones backed by the same channel and pump.

Platform parity

Pure gpui-core change (crates/gpui/src/app.rs). The wake path uses PlatformDispatcher::dispatch_on_main_thread, implemented by every backend (macOS, Linux, Windows, test), so parity holds with no per-platform code.

Tests

Three #[gpui::test] cases in app.rs:

  • main_thread_poster_runs_posts_in_order — posts from a background task and asserts FIFO delivery.
  • main_thread_poster_grants_app_access — a posted closure mutates a global through &mut App.
  • main_thread_poster_is_cached_across_calls — two main_thread_poster() calls both deliver.

Gates

  • cargo check -p gpui
  • cargo clippy -p gpui --all-features --all-targets -- --deny warnings
  • cargo test -p gpui --lib main_thread_poster ✅ (3 passed)
  • cargo test -p gpui --doc main_thread_poster
  • cargo fmt -p gpui --check

Add `App::main_thread_poster`, returning a cloneable, Send + Sync
`MainThreadPoster` whose `post(impl FnOnce(&mut App) + Send)` schedules a
closure to run on the main thread with exclusive `App` access.

Consumers embedding gpui (e.g. gpui-component-app's AppProxy) need to
re-enter the app from OS callbacks that fire off the main thread: tray
menu handlers, global hotkeys, filesystem watchers. Until now there was
no dependency-free way to do this. `ForegroundExecutor::spawn` is !Send
and must be called on the main thread, and `dispatch_on_main_thread`
wants an `async_task::Runnable` a consumer can't construct. The only
workaround was a foreground poll loop, which burns power on idle apps.

The poster is backed by an unbounded channel drained by a single
persistent foreground task. Sending wakes that task's waker, which
schedules it onto the main thread through the platform dispatcher, so an
idle app stays parked until there is work — no polling. Closures run in
the order posted. Once the app is dropped the pump stops and further
posts are dropped harmlessly.

The channel is created lazily and cached on first call, so repeated
calls return clones backed by the same pump.
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 40 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 99dddee1-ae36-4882-9421-6b128748e12b

📥 Commits

Reviewing files that changed from the base of the PR and between 4332ea7 and 2ee4fa6.

📒 Files selected for processing (1)
  • crates/gpui/src/app.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/spawn-on-main

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

@BumpyClock
BumpyClock merged commit 2a03ae6 into main Jul 17, 2026
7 checks passed
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