Extract a scheduler crate from GPUI to enable unified integration testing of client and server code (#37326)

Extracts and cleans up GPUI's scheduler code into a new `scheduler`
crate, making it pluggable by external runtimes. This will enable
deterministic integration testing with cloud components by providing a
unified test scheduler across Zed and backend code. In Zed, it will
replace the existing GPUI scheduler for consistent async task management
across platforms.

## Changes

- **Core Implementation**: `TestScheduler` with seed-based
randomization, session tracking (`SessionId`), and foreground/background
task separation for reproducible testing.
- **Executors**: `ForegroundExecutor` (!Send, thread-local) and
`BackgroundExecutor` (Send, with blocking/timeout support) as
GPUI-compatible wrappers.
- **Clock and Timer**: Controllable `TestClock` and future-based `Timer`
for time-sensitive tests.
- **Testing APIs**: `once()`, `with_seed()`, and `many()` methods for
configurable test runs.
- **Dependencies**: Added `async-task`, `chrono`, `futures`, etc., with
updates to `Cargo.toml` and lock file.

## Benefits

- **Integration Testing**: Facilitates reliable async tests involving
cloud sessions, reducing flakiness via deterministic execution.
- **Pluggability**: Trait-based design (`Scheduler`) allows easy
integration into non-GPUI runtimes while maintaining GPUI compatibility.
- **Cleanup**: Refactors GPUI scheduler logic for clarity, correctness
(no `unwrap()`, proper error handling), and extensibility.

Follows Rust guidelines; run `./script/clippy` for verification.

- [x] Define and test a core scheduler that we think can power our cloud
code and GPUI
- [ ] Replace GPUI's scheduler


Release Notes:

- N/A

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
This commit is contained in:
Nathan Sobo
2025-09-04 17:14:53 +02:00
committed by GitHub
co-authored by Antonio Scandurra
parent a05f86f97b
commit 1ae326432e
64 changed files with 1569 additions and 473 deletions
+20 -19
View File
@@ -3580,7 +3580,7 @@ impl MultiBuffer {
pub fn build_random(rng: &mut impl rand::Rng, cx: &mut gpui::App) -> Entity<Self> {
cx.new(|cx| {
let mut multibuffer = MultiBuffer::new(Capability::ReadWrite);
let mutation_count = rng.gen_range(1..=5);
let mutation_count = rng.random_range(1..=5);
multibuffer.randomly_edit_excerpts(rng, mutation_count, cx);
multibuffer
})
@@ -3603,16 +3603,17 @@ impl MultiBuffer {
}
let new_start = last_end.map_or(0, |last_end| last_end + 1);
let end = snapshot.clip_offset(rng.gen_range(new_start..=snapshot.len()), Bias::Right);
let start = snapshot.clip_offset(rng.gen_range(new_start..=end), Bias::Right);
let end =
snapshot.clip_offset(rng.random_range(new_start..=snapshot.len()), Bias::Right);
let start = snapshot.clip_offset(rng.random_range(new_start..=end), Bias::Right);
last_end = Some(end);
let mut range = start..end;
if rng.gen_bool(0.2) {
if rng.random_bool(0.2) {
mem::swap(&mut range.start, &mut range.end);
}
let new_text_len = rng.gen_range(0..10);
let new_text_len = rng.random_range(0..10);
let new_text: String = RandomCharIter::new(&mut *rng).take(new_text_len).collect();
edits.push((range, new_text.into()));
@@ -3639,18 +3640,18 @@ impl MultiBuffer {
let mut buffers = Vec::new();
for _ in 0..mutation_count {
if rng.gen_bool(0.05) {
if rng.random_bool(0.05) {
log::info!("Clearing multi-buffer");
self.clear(cx);
continue;
} else if rng.gen_bool(0.1) && !self.excerpt_ids().is_empty() {
} else if rng.random_bool(0.1) && !self.excerpt_ids().is_empty() {
let ids = self.excerpt_ids();
let mut excerpts = HashSet::default();
for _ in 0..rng.gen_range(0..ids.len()) {
for _ in 0..rng.random_range(0..ids.len()) {
excerpts.extend(ids.choose(rng).copied());
}
let line_count = rng.gen_range(0..5);
let line_count = rng.random_range(0..5);
log::info!("Expanding excerpts {excerpts:?} by {line_count} lines");
@@ -3664,8 +3665,8 @@ impl MultiBuffer {
}
let excerpt_ids = self.excerpt_ids();
if excerpt_ids.is_empty() || (rng.r#gen() && excerpt_ids.len() < max_excerpts) {
let buffer_handle = if rng.r#gen() || self.buffers.borrow().is_empty() {
if excerpt_ids.is_empty() || (rng.random() && excerpt_ids.len() < max_excerpts) {
let buffer_handle = if rng.random() || self.buffers.borrow().is_empty() {
let text = RandomCharIter::new(&mut *rng).take(10).collect::<String>();
buffers.push(cx.new(|cx| Buffer::local(text, cx)));
let buffer = buffers.last().unwrap().read(cx);
@@ -3687,11 +3688,11 @@ impl MultiBuffer {
let buffer = buffer_handle.read(cx);
let buffer_text = buffer.text();
let ranges = (0..rng.gen_range(0..5))
let ranges = (0..rng.random_range(0..5))
.map(|_| {
let end_ix =
buffer.clip_offset(rng.gen_range(0..=buffer.len()), Bias::Right);
let start_ix = buffer.clip_offset(rng.gen_range(0..=end_ix), Bias::Left);
buffer.clip_offset(rng.random_range(0..=buffer.len()), Bias::Right);
let start_ix = buffer.clip_offset(rng.random_range(0..=end_ix), Bias::Left);
ExcerptRange::new(start_ix..end_ix)
})
.collect::<Vec<_>>();
@@ -3708,7 +3709,7 @@ impl MultiBuffer {
let excerpt_id = self.push_excerpts(buffer_handle.clone(), ranges, cx);
log::info!("Inserted with ids: {:?}", excerpt_id);
} else {
let remove_count = rng.gen_range(1..=excerpt_ids.len());
let remove_count = rng.random_range(1..=excerpt_ids.len());
let mut excerpts_to_remove = excerpt_ids
.choose_multiple(rng, remove_count)
.cloned()
@@ -3730,7 +3731,7 @@ impl MultiBuffer {
) {
use rand::prelude::*;
if rng.gen_bool(0.7) || self.singleton {
if rng.random_bool(0.7) || self.singleton {
let buffer = self
.buffers
.borrow()
@@ -3740,7 +3741,7 @@ impl MultiBuffer {
if let Some(buffer) = buffer {
buffer.update(cx, |buffer, cx| {
if rng.r#gen() {
if rng.random() {
buffer.randomly_edit(rng, mutation_count, cx);
} else {
buffer.randomly_undo_redo(rng, cx);
@@ -6388,8 +6389,8 @@ impl MultiBufferSnapshot {
#[cfg(any(test, feature = "test-support"))]
impl MultiBufferSnapshot {
pub fn random_byte_range(&self, start_offset: usize, rng: &mut impl rand::Rng) -> Range<usize> {
let end = self.clip_offset(rng.gen_range(start_offset..=self.len()), Bias::Right);
let start = self.clip_offset(rng.gen_range(start_offset..=end), Bias::Right);
let end = self.clip_offset(rng.random_range(start_offset..=self.len()), Bias::Right);
let start = self.clip_offset(rng.random_range(start_offset..=end), Bias::Right);
start..end
}