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
+26 -22
View File
@@ -2491,12 +2491,12 @@ async fn test_random_set_ranges(cx: &mut TestAppContext, mut rng: StdRng) {
for _ in 0..operations {
let snapshot = buf.update(cx, |buf, _| buf.snapshot());
let num_ranges = rng.gen_range(0..=10);
let num_ranges = rng.random_range(0..=10);
let max_row = snapshot.max_point().row;
let mut ranges = (0..num_ranges)
.map(|_| {
let start = rng.gen_range(0..max_row);
let end = rng.gen_range(start + 1..max_row + 1);
let start = rng.random_range(0..max_row);
let end = rng.random_range(start + 1..max_row + 1);
Point::row_range(start..end)
})
.collect::<Vec<_>>();
@@ -2562,11 +2562,11 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
let mut needs_diff_calculation = false;
for _ in 0..operations {
match rng.gen_range(0..100) {
match rng.random_range(0..100) {
0..=14 if !buffers.is_empty() => {
let buffer = buffers.choose(&mut rng).unwrap();
buffer.update(cx, |buf, cx| {
let edit_count = rng.gen_range(1..5);
let edit_count = rng.random_range(1..5);
buf.randomly_edit(&mut rng, edit_count, cx);
log::info!("buffer text:\n{}", buf.text());
needs_diff_calculation = true;
@@ -2577,11 +2577,11 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
multibuffer.update(cx, |multibuffer, cx| {
let ids = multibuffer.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(&mut rng).copied());
}
let line_count = rng.gen_range(0..5);
let line_count = rng.random_range(0..5);
let excerpt_ixs = excerpts
.iter()
@@ -2600,7 +2600,7 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
}
20..=29 if !reference.excerpts.is_empty() => {
let mut ids_to_remove = vec![];
for _ in 0..rng.gen_range(1..=3) {
for _ in 0..rng.random_range(1..=3) {
let Some(excerpt) = reference.excerpts.choose(&mut rng) else {
break;
};
@@ -2620,8 +2620,12 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
let multibuffer =
multibuffer.read_with(cx, |multibuffer, cx| multibuffer.snapshot(cx));
let offset =
multibuffer.clip_offset(rng.gen_range(0..=multibuffer.len()), Bias::Left);
let bias = if rng.r#gen() { Bias::Left } else { Bias::Right };
multibuffer.clip_offset(rng.random_range(0..=multibuffer.len()), Bias::Left);
let bias = if rng.random() {
Bias::Left
} else {
Bias::Right
};
log::info!("Creating anchor at {} with bias {:?}", offset, bias);
anchors.push(multibuffer.anchor_at(offset, bias));
anchors.sort_by(|a, b| a.cmp(b, &multibuffer));
@@ -2654,7 +2658,7 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
45..=55 if !reference.excerpts.is_empty() => {
multibuffer.update(cx, |multibuffer, cx| {
let snapshot = multibuffer.snapshot(cx);
let excerpt_ix = rng.gen_range(0..reference.excerpts.len());
let excerpt_ix = rng.random_range(0..reference.excerpts.len());
let excerpt = &reference.excerpts[excerpt_ix];
let start = excerpt.range.start;
let end = excerpt.range.end;
@@ -2691,7 +2695,7 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
});
}
_ => {
let buffer_handle = if buffers.is_empty() || rng.gen_bool(0.4) {
let buffer_handle = if buffers.is_empty() || rng.random_bool(0.4) {
let mut base_text = util::RandomCharIter::new(&mut rng)
.take(256)
.collect::<String>();
@@ -2708,7 +2712,7 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
buffers.choose(&mut rng).unwrap()
};
let prev_excerpt_ix = rng.gen_range(0..=reference.excerpts.len());
let prev_excerpt_ix = rng.random_range(0..=reference.excerpts.len());
let prev_excerpt_id = reference
.excerpts
.get(prev_excerpt_ix)
@@ -2716,8 +2720,8 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
let excerpt_ix = (prev_excerpt_ix + 1).min(reference.excerpts.len());
let (range, anchor_range) = buffer_handle.read_with(cx, |buffer, _| {
let end_row = rng.gen_range(0..=buffer.max_point().row);
let start_row = rng.gen_range(0..=end_row);
let end_row = rng.random_range(0..=buffer.max_point().row);
let start_row = rng.random_range(0..=end_row);
let end_ix = buffer.point_to_offset(Point::new(end_row, 0));
let start_ix = buffer.point_to_offset(Point::new(start_row, 0));
let anchor_range = buffer.anchor_before(start_ix)..buffer.anchor_after(end_ix);
@@ -2766,7 +2770,7 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
}
}
if rng.gen_bool(0.3) {
if rng.random_bool(0.3) {
multibuffer.update(cx, |multibuffer, cx| {
old_versions.push((multibuffer.snapshot(cx), multibuffer.subscribe()));
})
@@ -2815,7 +2819,7 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
pretty_assertions::assert_eq!(actual_row_infos, expected_row_infos);
for _ in 0..5 {
let start_row = rng.gen_range(0..=expected_row_infos.len());
let start_row = rng.random_range(0..=expected_row_infos.len());
assert_eq!(
snapshot
.row_infos(MultiBufferRow(start_row as u32))
@@ -2872,8 +2876,8 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
let text_rope = Rope::from(expected_text.as_str());
for _ in 0..10 {
let end_ix = text_rope.clip_offset(rng.gen_range(0..=text_rope.len()), Bias::Right);
let start_ix = text_rope.clip_offset(rng.gen_range(0..=end_ix), Bias::Left);
let end_ix = text_rope.clip_offset(rng.random_range(0..=text_rope.len()), Bias::Right);
let start_ix = text_rope.clip_offset(rng.random_range(0..=end_ix), Bias::Left);
let text_for_range = snapshot
.text_for_range(start_ix..end_ix)
@@ -2908,7 +2912,7 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
}
for _ in 0..10 {
let end_ix = text_rope.clip_offset(rng.gen_range(0..=text_rope.len()), Bias::Right);
let end_ix = text_rope.clip_offset(rng.random_range(0..=text_rope.len()), Bias::Right);
assert_eq!(
snapshot.reversed_chars_at(end_ix).collect::<String>(),
expected_text[..end_ix].chars().rev().collect::<String>(),
@@ -2916,8 +2920,8 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
}
for _ in 0..10 {
let end_ix = rng.gen_range(0..=text_rope.len());
let start_ix = rng.gen_range(0..=end_ix);
let end_ix = rng.random_range(0..=text_rope.len());
let start_ix = rng.random_range(0..=end_ix);
assert_eq!(
snapshot
.bytes_in_range(start_ix..end_ix)