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
This commit is contained in:
Cole Miller authored and GitHub committed 2026-07-29 06:41:29 +00:00
1 parent 90727b44e5
commit 6ac1191f6a
5 files changed
+167

No files matched your search

+34
View File
@@ -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<R>(
&self,
timeout: Option<Duration>,
future: impl Future<Output = R> + 'static,
) -> Task<R>
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<Duration> {
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]
+13
View File
@@ -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<Duration>,
) {
let _ = timeout;
self.dispatch_on_main_thread(runnable, Priority::Low);
}
fn idle_time_remaining(&self) -> Option<Duration> {
None
}
fn spawn_realtime(&self, f: Box<dyn FnOnce() + Send>);
fn now(&self) -> Instant {
+2
View File
@@ -51,6 +51,8 @@ web-sys = { version = "0.3", features = [
"HtmlCanvasElement",
"HtmlElement",
"HtmlInputElement",
"IdleDeadline",
"IdleRequestOptions",
"KeyboardEvent",
"MediaQueryList",
"MediaQueryListEvent",
+91
View File
@@ -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<Duration>,
},
Function(Box<dyn FnOnce() + Send>),
// TODO-Wasm: Shouldn't these run on their own dedicated thread?
RealtimeFunction(Box<dyn FnOnce() + Send>),
@@ -293,6 +298,31 @@ impl PlatformDispatcher for WebDispatcher {
}
}
fn dispatch_on_main_thread_when_idle(
&self,
runnable: RunnableVariant,
timeout: Option<Duration>,
) {
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<Duration> {
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<Option<web_sys::IdleDeadline>> = const { RefCell::new(None) };
/// Whether `requestIdleCallback` exists (it is absent on Safari), probed
/// on first use.
static IDLE_CALLBACK_SUPPORTED: Cell<Option<bool>> = 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<Duration>,
) {
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();
+27
View File
@@ -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<F>(
&self,
future: F,
dispatch: impl Fn(Runnable<RunnableMeta>) + Send + Sync + 'static,
) -> Task<F::Output>
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<Fut: Future>(&self, future: Fut) -> Fut::Output {
use std::cell::Cell;