From 6ac1191f6a41fdcaed3ced371ea40a51e97ef7c2 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Wed, 29 Jul 2026 02:41:29 -0400 Subject: [PATCH] gpui: Add an abstraction for scheduling foreground work for idle periods, and implement it for the web (#61827) - Add `ForegroundExecutor::spawn_when_idle(timeout, future)`, which schedules `future` to be polled on the main thread when the main thread is idle, but tries to do so no later than `timeout` after the current instant (with the same `timeout` being applied to subsequent polls of the same future) - Add `Platform::dispatch_on_main_thread_when_idle(runnable, timeout)` as the basis for `spawn_when_idle`, with a default implementation that ignores the timeout and delegates to `dispatch_on_main_thread(runnable, Priority::Low)` - Implement `dispatch_on_main_thread_when_idle` for the web platform using [`requestIdleCallback`](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback) Release Notes: - N/A --- crates/gpui/src/executor.rs | 34 ++++++++++++ crates/gpui/src/platform.rs | 13 +++++ crates/gpui_web/Cargo.toml | 2 + crates/gpui_web/src/dispatcher.rs | 91 +++++++++++++++++++++++++++++++ crates/scheduler/src/executor.rs | 27 +++++++++ 5 files changed, 167 insertions(+) diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index 7f8d7da0d4..92afc696d4 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -332,6 +332,40 @@ impl ForegroundExecutor { self.inner.spawn(future) } + /// On platforms with dedicated support, enqueues the given future to run + /// on the main thread during platform idle time. Without a `timeout`, + /// polls may be deferred indefinitely while the platform stays busy; + /// with one, a poll still waiting after that long runs as ordinary main-thread work. + /// Each poll occupies part of one idle slice, so long synchronous stretches + /// should bound themselves against [`Self::idle_time_remaining`] and yield. + /// + /// On platforms without dedicated support, schedules the given future to run + /// with a low priority, ignoring `timeout`. + #[track_caller] + pub fn spawn_when_idle( + &self, + timeout: Option, + future: impl Future + 'static, + ) -> Task + where + R: 'static, + { + let dispatcher = self.dispatcher.clone(); + self.inner + .spawn_with_dispatch(future.boxed_local(), move |runnable| { + dispatcher.dispatch_on_main_thread_when_idle(runnable, timeout); + }) + } + + /// The time remaining in the current idle slice, when called from a task + /// spawned via [`Self::spawn_when_idle`] on a platform that meters idle + /// time. `None` when idle time is unmetered (or the caller is not inside + /// an idle slice); work that must bound itself should then fall back to a + /// budget of its own. + pub fn idle_time_remaining(&self) -> Option { + self.dispatcher.idle_time_remaining() + } + /// Used by the test harness to run an async test in a synchronous fashion. #[cfg(any(test, feature = "test-support"))] #[track_caller] diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index d6b2c1acdf..bc73a56060 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -1007,6 +1007,19 @@ pub trait PlatformDispatcher: Send + Sync { fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority); fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant); + fn dispatch_on_main_thread_when_idle( + &self, + runnable: RunnableVariant, + timeout: Option, + ) { + let _ = timeout; + self.dispatch_on_main_thread(runnable, Priority::Low); + } + + fn idle_time_remaining(&self) -> Option { + None + } + fn spawn_realtime(&self, f: Box); fn now(&self) -> Instant { diff --git a/crates/gpui_web/Cargo.toml b/crates/gpui_web/Cargo.toml index eee71bafec..2ec64e6968 100644 --- a/crates/gpui_web/Cargo.toml +++ b/crates/gpui_web/Cargo.toml @@ -51,6 +51,8 @@ web-sys = { version = "0.3", features = [ "HtmlCanvasElement", "HtmlElement", "HtmlInputElement", + "IdleDeadline", + "IdleRequestOptions", "KeyboardEvent", "MediaQueryList", "MediaQueryListEvent", diff --git a/crates/gpui_web/src/dispatcher.rs b/crates/gpui_web/src/dispatcher.rs index a00c81bf5f..37ba891f70 100644 --- a/crates/gpui_web/src/dispatcher.rs +++ b/crates/gpui_web/src/dispatcher.rs @@ -1,6 +1,7 @@ use gpui::{ PlatformDispatcher, Priority, PriorityQueueReceiver, PriorityQueueSender, RunnableVariant, }; +use std::cell::{Cell, RefCell}; use std::sync::Arc; use std::sync::atomic::AtomicI32; use std::time::Duration; @@ -39,6 +40,10 @@ enum MainThreadItem { runnable: RunnableVariant, millis: i32, }, + Idle { + runnable: RunnableVariant, + timeout: Option, + }, Function(Box), // TODO-Wasm: Shouldn't these run on their own dedicated thread? RealtimeFunction(Box), @@ -293,6 +298,31 @@ impl PlatformDispatcher for WebDispatcher { } } + fn dispatch_on_main_thread_when_idle( + &self, + runnable: RunnableVariant, + timeout: Option, + ) { + if self.on_main_thread() { + schedule_idle_runnable(&browser_window(), runnable, timeout); + } else { + self.main_thread_mailbox + .post(Priority::Low, MainThreadItem::Idle { runnable, timeout }); + } + } + + fn idle_time_remaining(&self) -> Option { + if !self.on_main_thread() { + return None; + } + IDLE_DEADLINE.with(|deadline| { + deadline + .borrow() + .as_ref() + .map(|deadline| Duration::from_secs_f64(deadline.time_remaining() / 1000.0)) + }) + } + fn now(&self) -> Instant { Instant::now() } @@ -307,6 +337,9 @@ fn execute_on_main_thread(window: &web_sys::Window, item: MainThreadItem) { MainThreadItem::Runnable(runnable) => { runnable.run(); } + MainThreadItem::Idle { runnable, timeout } => { + schedule_idle_runnable(window, runnable, timeout); + } MainThreadItem::Delayed { runnable, millis } => { let callback = Closure::once_into_js(move || { runnable.run(); @@ -324,6 +357,64 @@ fn execute_on_main_thread(window: &web_sys::Window, item: MainThreadItem) { } } +thread_local! { + /// The deadline of the idle callback currently running; read by + /// [`PlatformDispatcher::idle_time_remaining`] from inside the runnable. + static IDLE_DEADLINE: RefCell> = const { RefCell::new(None) }; + /// Whether `requestIdleCallback` exists (it is absent on Safari), probed + /// on first use. + static IDLE_CALLBACK_SUPPORTED: Cell> = const { Cell::new(None) }; +} + +/// Registers one `requestIdleCallback` per runnable, mirroring how +/// `dispatch_after` maps each timer to its own platform alarm. The browser +/// already provides the queue semantics a central pump would rebuild: idle +/// callbacks run in registration order, an idle period drains as many as its +/// deadline allows, and a callback whose `timeout` expires is posted as an +/// ordinary task instead. +fn schedule_idle_runnable( + window: &web_sys::Window, + runnable: RunnableVariant, + timeout: Option, +) { + if !idle_callback_supported(window) { + // Safari: run idle work as ordinary macrotasks. With no metered + // deadline, `idle_time_remaining` stays `None` and idle tasks bound + // their own slices. + schedule_runnable(window, runnable, Priority::Low); + return; + } + let callback = Closure::once_into_js(move |deadline: web_sys::IdleDeadline| { + IDLE_DEADLINE.with(|current| *current.borrow_mut() = Some(deadline)); + runnable.run(); + IDLE_DEADLINE.with(|current| *current.borrow_mut() = None); + }); + let result = match timeout { + Some(timeout) => { + let options = web_sys::IdleRequestOptions::new(); + options.set_timeout(timeout.as_millis().min(u32::MAX as u128) as u32); + window.request_idle_callback_with_options(callback.unchecked_ref(), &options) + } + None => window.request_idle_callback(callback.unchecked_ref()), + }; + if let Err(error) = result { + log::error!("requestIdleCallback failed: {error:?}"); + } +} + +fn idle_callback_supported(window: &web_sys::Window) -> bool { + IDLE_CALLBACK_SUPPORTED.with(|supported| { + if let Some(supported) = supported.get() { + return supported; + } + let probed = + js_sys::Reflect::has(window.as_ref(), &JsValue::from_str("requestIdleCallback")) + .unwrap_or(false); + supported.set(Some(probed)); + probed + }) +} + fn schedule_runnable(window: &web_sys::Window, runnable: RunnableVariant, priority: Priority) { let callback = Closure::once_into_js(move || { runnable.run(); diff --git a/crates/scheduler/src/executor.rs b/crates/scheduler/src/executor.rs index 959c2e3207..7791abfc17 100644 --- a/crates/scheduler/src/executor.rs +++ b/crates/scheduler/src/executor.rs @@ -79,6 +79,33 @@ impl LocalExecutor { Task(TaskState::Spawned(task)) } + /// Like [`Self::spawn`], but routes the task's runnables through the + /// given dispatch destination instead of this executor's own. The + /// destination must deliver runnables to this executor's thread: the + /// future is polled and dropped on the spawning thread. + #[track_caller] + pub fn spawn_with_dispatch( + &self, + future: F, + dispatch: impl Fn(Runnable) + Send + Sync + 'static, + ) -> Task + where + F: Future + 'static, + F::Output: 'static, + { + let location = Location::caller(); + let (runnable, task) = spawn_local_with_source_location( + future, + dispatch, + RunnableMeta { + location, + spawned: crate::SpawnTime(Instant::now()), + }, + ); + runnable.schedule(); + Task(TaskState::Spawned(task)) + } + pub fn block_on(&self, future: Fut) -> Fut::Output { use std::cell::Cell;