feat(app): add MainThreadPoster for posting to the main thread#7
Conversation
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.
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 40 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Motivation
Apps that embed gpui need to re-enter the
Appfrom callbacks that fire off the main thread — tray-icon menu handlers, global hotkeys, filesystem watchers, and similar OS callbacks. gpui-component-app'sAppProxyis the concrete case driving this: it wants to hand background threads a way to postFnOnce(&mut App) + Sendwork onto the main thread.Today there is no dependency-free way to do that:
ForegroundExecutor::spawnis!Sendand must be called on the main thread, so it can't be reached from a background thread.PlatformDispatcher::dispatch_on_main_threadwakes the main loop, but it wants anasync_task::Runnablethat 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 + SyncMainThreadPoster:MainThreadPosterisClone+Send+Sync, so it can be moved to and shared across background threads.post(impl FnOnce(&mut App) + Send + 'static) -> boolenqueues a closure to run on the main thread with exclusive&mut Appaccess.Mechanism
The poster is backed by a
futures::channel::mpsc::unboundedchannel drained by a single persistent foreground task (the "pump"), spawned lazily on the first call and cached on theApp:Sending on the channel wakes the pump task's waker. Because the foreground executor's schedule callback is
Send + Syncand routes throughPlatformDispatcher::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 theyawaitbackground work.Semantics
App::update, so effects are flushed like any other update.Apphandle and never panics. Once theAppis dropped the pump stops andpostreturnsfalse; closures that were enqueued but not drained before shutdown are dropped without running (documented onpost).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 usesPlatformDispatcher::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 inapp.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— twomain_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✅