diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index 6826566e71..0000000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,9 +0,0 @@ -## Description of feature or change - -## Link to related issues from zed or community - -## Before Merging - -- [ ] Does this have tests or have existing tests been updated to cover this change? -- [ ] Have you added the necessary settings to configure this feature? -- [ ] Has documentation been created or updated (including above changes to settings)? diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a32f25fbe9..2b7cb97efa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,7 @@ jobs: - name: Install Node uses: actions/setup-node@v2 with: - node-version: '16' + node-version: '18' - name: Checkout repo uses: actions/checkout@v2 @@ -102,7 +102,7 @@ jobs: - name: Install Node uses: actions/setup-node@v2 with: - node-version: '16' + node-version: '18' - name: Checkout repo uses: actions/checkout@v2 diff --git a/.github/workflows/randomized_tests.yml b/.github/workflows/randomized_tests.yml new file mode 100644 index 0000000000..d963ac46e0 --- /dev/null +++ b/.github/workflows/randomized_tests.yml @@ -0,0 +1,43 @@ +name: Randomized Tests + +concurrency: randomized-tests + +on: + push: + branches: + - randomized-tests-runner + schedule: + - cron: '0 * * * *' + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + RUST_BACKTRACE: 1 + ZED_SERVER_URL: https://zed.dev + ZED_CLIENT_SECRET_TOKEN: ${{ secrets.ZED_CLIENT_SECRET_TOKEN }} + +jobs: + tests: + name: Run randomized tests + runs-on: + - self-hosted + - randomized-tests + steps: + - name: Install Rust + run: | + rustup set profile minimal + rustup update stable + + - name: Install Node + uses: actions/setup-node@v2 + with: + node-version: '18' + + - name: Checkout repo + uses: actions/checkout@v2 + with: + clean: false + submodules: 'recursive' + + - name: Run randomized tests + run: script/randomized-test-ci diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index 5d7eb4d2b4..4c177f0f00 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/crates/collab_ui/src/collab_titlebar_item.rs @@ -459,9 +459,7 @@ impl CollabTitlebarItem { .with_child( MouseEventHandler::::new(0, cx, |state, _| { //TODO: Ensure this button has consistant width for both text variations - let style = titlebar - .share_button - .style_for(state, self.contacts_popover.is_some()); + let style = titlebar.share_button.style_for(state, false); Label::new(label, style.text.clone()) .contained() .with_style(style.container) @@ -710,11 +708,9 @@ impl CollabTitlebarItem { } })?; - let location = remote_participant.map(|p| p.location); - Some(Self::render_face( avatar.clone(), - Self::location_style(workspace, location, follower_style, cx), + follower_style, background_color, )) })) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index dd4e466b9a..d70ed96725 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -2352,53 +2352,66 @@ impl Editor { let id = post_inc(&mut self.next_completion_id); let task = cx.spawn_weak(|this, mut cx| { async move { - let completions = completions.await?; - if completions.is_empty() { - return Ok(()); - } - - let mut menu = CompletionsMenu { - id, - initial_position: position, - match_candidates: completions - .iter() - .enumerate() - .map(|(id, completion)| { - StringMatchCandidate::new( - id, - completion.label.text[completion.label.filter_range.clone()].into(), - ) - }) - .collect(), - buffer, - completions: completions.into(), - matches: Vec::new().into(), - selected_item: 0, - list: Default::default(), + let menu = if let Some(completions) = completions.await.log_err() { + let mut menu = CompletionsMenu { + id, + initial_position: position, + match_candidates: completions + .iter() + .enumerate() + .map(|(id, completion)| { + StringMatchCandidate::new( + id, + completion.label.text[completion.label.filter_range.clone()] + .into(), + ) + }) + .collect(), + buffer, + completions: completions.into(), + matches: Vec::new().into(), + selected_item: 0, + list: Default::default(), + }; + menu.filter(query.as_deref(), cx.background()).await; + if menu.matches.is_empty() { + None + } else { + Some(menu) + } + } else { + None }; - menu.filter(query.as_deref(), cx.background()).await; + let this = this + .upgrade(&cx) + .ok_or_else(|| anyhow!("editor was dropped"))?; + this.update(&mut cx, |this, cx| { + this.completion_tasks.retain(|(task_id, _)| *task_id > id); - if let Some(this) = this.upgrade(&cx) { - this.update(&mut cx, |this, cx| { - match this.context_menu.as_ref() { - None => {} - Some(ContextMenu::Completions(prev_menu)) => { - if prev_menu.id > menu.id { - return; - } + match this.context_menu.as_ref() { + None => {} + Some(ContextMenu::Completions(prev_menu)) => { + if prev_menu.id > id { + return; } - _ => return, } + _ => return, + } - this.completion_tasks.retain(|(id, _)| *id > menu.id); - if this.focused && !menu.matches.is_empty() { - this.show_context_menu(ContextMenu::Completions(menu), cx); - } else if this.hide_context_menu(cx).is_none() { + if this.focused && menu.is_some() { + let menu = menu.unwrap(); + this.show_context_menu(ContextMenu::Completions(menu), cx); + } else if this.completion_tasks.is_empty() { + // If there are no more completion tasks and the last menu was + // empty, we should hide it. If it was already hidden, we should + // also show the copilot suggestion when available. + if this.hide_context_menu(cx).is_none() { this.update_visible_copilot_suggestion(cx); } - })?; - } + } + })?; + Ok::<_, anyhow::Error>(()) } .log_err() diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index b86f6af8b5..81bcfbc7a1 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -5932,13 +5932,12 @@ async fn test_copilot(deterministic: Arc, cx: &mut gpui::TestAppC ) .await; + // When inserting, ensure autocompletion is favored over Copilot suggestions. cx.set_state(indoc! {" oneˇ two three "}); - - // When inserting, ensure autocompletion is favored over Copilot suggestions. cx.simulate_keystroke("."); let _ = handle_completion_request( &mut cx, @@ -5952,8 +5951,8 @@ async fn test_copilot(deterministic: Arc, cx: &mut gpui::TestAppC handle_copilot_completion_request( &copilot_lsp, vec![copilot::request::Completion { - text: "copilot1".into(), - range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 5)), + text: "one.copilot1".into(), + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), ..Default::default() }], vec![], @@ -5975,13 +5974,45 @@ async fn test_copilot(deterministic: Arc, cx: &mut gpui::TestAppC assert_eq!(editor.display_text(cx), "one.completion_a\ntwo\nthree\n"); }); + // Ensure Copilot suggestions are shown right away if no autocompletion is available. cx.set_state(indoc! {" oneˇ two three "}); + cx.simulate_keystroke("."); + let _ = handle_completion_request( + &mut cx, + indoc! {" + one.|<> + two + three + "}, + vec![], + ); + handle_copilot_completion_request( + &copilot_lsp, + vec![copilot::request::Completion { + text: "one.copilot1".into(), + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), + ..Default::default() + }], + vec![], + ); + deterministic.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + cx.update_editor(|editor, cx| { + assert!(!editor.context_menu_visible()); + assert!(editor.has_active_copilot_suggestion(cx)); + assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.\ntwo\nthree\n"); + }); - // When inserting, ensure autocompletion is favored over Copilot suggestions. + // Reset editor, and ensure autocompletion is still favored over Copilot suggestions. + cx.set_state(indoc! {" + oneˇ + two + three + "}); cx.simulate_keystroke("."); let _ = handle_completion_request( &mut cx, diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index c53c20c774..d856b71e39 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -523,31 +523,7 @@ impl FakeFs { } pub async fn insert_file(&self, path: impl AsRef, content: String) { - let mut state = self.state.lock(); - let path = path.as_ref(); - let inode = state.next_inode; - let mtime = state.next_mtime; - state.next_inode += 1; - state.next_mtime += Duration::from_nanos(1); - let file = Arc::new(Mutex::new(FakeFsEntry::File { - inode, - mtime, - content, - })); - state - .write_path(path, move |entry| { - match entry { - btree_map::Entry::Vacant(e) => { - e.insert(file); - } - btree_map::Entry::Occupied(mut e) => { - *e.get_mut() = file; - } - } - Ok(()) - }) - .unwrap(); - state.emit_event(&[path]); + self.write_file_internal(path, content).unwrap() } pub async fn insert_symlink(&self, path: impl AsRef, target: PathBuf) { @@ -569,6 +545,33 @@ impl FakeFs { state.emit_event(&[path]); } + fn write_file_internal(&self, path: impl AsRef, content: String) -> Result<()> { + let mut state = self.state.lock(); + let path = path.as_ref(); + let inode = state.next_inode; + let mtime = state.next_mtime; + state.next_inode += 1; + state.next_mtime += Duration::from_nanos(1); + let file = Arc::new(Mutex::new(FakeFsEntry::File { + inode, + mtime, + content, + })); + state.write_path(path, move |entry| { + match entry { + btree_map::Entry::Vacant(e) => { + e.insert(file); + } + btree_map::Entry::Occupied(mut e) => { + *e.get_mut() = file; + } + } + Ok(()) + })?; + state.emit_event(&[path]); + Ok(()) + } + pub async fn pause_events(&self) { self.state.lock().events_paused = true; } @@ -952,7 +955,7 @@ impl Fs for FakeFs { async fn atomic_write(&self, path: PathBuf, data: String) -> Result<()> { self.simulate_random_delay().await; let path = normalize_path(path.as_path()); - self.insert_file(path, data.to_string()).await; + self.write_file_internal(path, data.to_string())?; Ok(()) } @@ -961,7 +964,7 @@ impl Fs for FakeFs { self.simulate_random_delay().await; let path = normalize_path(path); let content = chunks(text, line_ending).collect(); - self.insert_file(path, content).await; + self.write_file_internal(path, content)?; Ok(()) } diff --git a/crates/gpui/src/test.rs b/crates/gpui/src/test.rs index d2ae646ed4..37e5299231 100644 --- a/crates/gpui/src/test.rs +++ b/crates/gpui/src/test.rs @@ -76,7 +76,7 @@ pub fn run_test( let seed = atomic_seed.load(SeqCst); if is_randomized { - dbg!(seed); + eprintln!("seed = {seed}"); } let deterministic = executor::Deterministic::new(seed); diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 9192c7a411..2deada6a5c 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -1733,13 +1733,19 @@ impl Project { async fn send_buffer_messages( this: WeakModelHandle, - mut rx: UnboundedReceiver, + rx: UnboundedReceiver, mut cx: AsyncAppContext, - ) { + ) -> Option<()> { + const MAX_BATCH_SIZE: usize = 128; + let mut needs_resync_with_host = false; - while let Some(change) = rx.next().await { - if let Some(this) = this.upgrade(&mut cx) { - let is_local = this.read_with(&cx, |this, _| this.is_local()); + let mut operations_by_buffer_id = HashMap::default(); + let mut changes = rx.ready_chunks(MAX_BATCH_SIZE); + while let Some(changes) = changes.next().await { + let this = this.upgrade(&mut cx)?; + let is_local = this.read_with(&cx, |this, _| this.is_local()); + + for change in changes { match change { BufferMessage::Operation { buffer_id, @@ -1748,21 +1754,14 @@ impl Project { if needs_resync_with_host { continue; } - let request = this.read_with(&cx, |this, _| { - let project_id = this.remote_id()?; - Some(this.client.request(proto::UpdateBuffer { - buffer_id, - project_id, - operations: vec![operation], - })) - }); - if let Some(request) = request { - if request.await.is_err() && !is_local { - needs_resync_with_host = true; - } - } + + operations_by_buffer_id + .entry(buffer_id) + .or_insert(Vec::new()) + .push(operation); } BufferMessage::Resync => { + operations_by_buffer_id.clear(); if this .update(&mut cx, |this, cx| this.synchronize_remote_buffers(cx)) .await @@ -1772,10 +1771,27 @@ impl Project { } } } - } else { - break; + } + + for (buffer_id, operations) in operations_by_buffer_id.drain() { + let request = this.read_with(&cx, |this, _| { + let project_id = this.remote_id()?; + Some(this.client.request(proto::UpdateBuffer { + buffer_id, + project_id, + operations, + })) + }); + if let Some(request) = request { + if request.await.is_err() && !is_local { + needs_resync_with_host = true; + break; + } + } } } + + None } fn on_buffer_event( diff --git a/crates/project/src/worktree.rs b/crates/project/src/worktree.rs index 3459bd7e5d..d0cf2faa7e 100644 --- a/crates/project/src/worktree.rs +++ b/crates/project/src/worktree.rs @@ -95,7 +95,17 @@ pub struct Snapshot { root_char_bag: CharBag, entries_by_path: SumTree, entries_by_id: SumTree, + + /// A number that increases every time the worktree begins scanning + /// a set of paths from the filesystem. This scanning could be caused + /// by some operation performed on the worktree, such as reading or + /// writing a file, or by an event reported by the filesystem. scan_id: usize, + + /// The latest scan id that has completed, and whose preceding scans + /// have all completed. The current `scan_id` could be more than one + /// greater than the `completed_scan_id` if operations are performed + /// on the worktree while it is processing a file-system event. completed_scan_id: usize, } @@ -1481,7 +1491,12 @@ impl LocalSnapshot { } let scan_id = self.scan_id; - self.entries_by_path.insert_or_replace(entry.clone(), &()); + let removed = self.entries_by_path.insert_or_replace(entry.clone(), &()); + if let Some(removed) = removed { + if removed.id != entry.id { + self.entries_by_id.remove(&removed.id, &()); + } + } self.entries_by_id.insert_or_replace( PathEntry { id: entry.id, @@ -2168,6 +2183,7 @@ impl BackgroundScanner { } { let mut snapshot = self.snapshot.lock(); + snapshot.scan_id += 1; ignore_stack = snapshot.ignore_stack_for_abs_path(&root_abs_path, true); if ignore_stack.is_all() { if let Some(mut root_entry) = snapshot.root_entry().cloned() { @@ -2189,6 +2205,10 @@ impl BackgroundScanner { .unwrap(); drop(scan_job_tx); self.scan_dirs(true, scan_job_rx).await; + { + let mut snapshot = self.snapshot.lock(); + snapshot.completed_scan_id = snapshot.scan_id; + } self.send_status_update(false, None); // Process any any FS events that occurred while performing the initial scan. @@ -2200,7 +2220,6 @@ impl BackgroundScanner { paths.extend(more_events.into_iter().map(|e| e.path)); } self.process_events(paths).await; - self.send_status_update(false, None); } self.finished_initial_scan = true; @@ -2212,9 +2231,8 @@ impl BackgroundScanner { // these before handling changes reported by the filesystem. request = self.refresh_requests_rx.recv().fuse() => { let Ok((paths, barrier)) = request else { break }; - self.reload_entries_for_paths(paths, None).await; - if !self.send_status_update(false, Some(barrier)) { - break; + if !self.process_refresh_request(paths, barrier).await { + return; } } @@ -2225,15 +2243,17 @@ impl BackgroundScanner { paths.extend(more_events.into_iter().map(|e| e.path)); } self.process_events(paths).await; - self.send_status_update(false, None); } } } } - async fn process_events(&mut self, paths: Vec) { - use futures::FutureExt as _; + async fn process_refresh_request(&self, paths: Vec, barrier: barrier::Sender) -> bool { + self.reload_entries_for_paths(paths, None).await; + self.send_status_update(false, Some(barrier)) + } + async fn process_events(&mut self, paths: Vec) { let (scan_job_tx, scan_job_rx) = channel::unbounded(); if let Some(mut paths) = self .reload_entries_for_paths(paths, Some(scan_job_tx.clone())) @@ -2245,35 +2265,7 @@ impl BackgroundScanner { drop(scan_job_tx); self.scan_dirs(false, scan_job_rx).await; - let (ignore_queue_tx, ignore_queue_rx) = channel::unbounded(); - let snapshot = self.update_ignore_statuses(ignore_queue_tx); - self.executor - .scoped(|scope| { - for _ in 0..self.executor.num_cpus() { - scope.spawn(async { - loop { - select_biased! { - // Process any path refresh requests before moving on to process - // the queue of ignore statuses. - request = self.refresh_requests_rx.recv().fuse() => { - let Ok((paths, barrier)) = request else { break }; - self.reload_entries_for_paths(paths, None).await; - if !self.send_status_update(false, Some(barrier)) { - return; - } - } - - // Recursively process directories whose ignores have changed. - job = ignore_queue_rx.recv().fuse() => { - let Ok(job) = job else { break }; - self.update_ignore_status(job, &snapshot).await; - } - } - } - }); - } - }) - .await; + self.update_ignore_statuses().await; let mut snapshot = self.snapshot.lock(); let mut git_repositories = mem::take(&mut snapshot.git_repositories); @@ -2281,6 +2273,9 @@ impl BackgroundScanner { snapshot.git_repositories = git_repositories; snapshot.removed_entry_ids.clear(); snapshot.completed_scan_id = snapshot.scan_id; + drop(snapshot); + + self.send_status_update(false, None); } async fn scan_dirs( @@ -2313,8 +2308,7 @@ impl BackgroundScanner { // the scan queue, so that user operations are prioritized. request = self.refresh_requests_rx.recv().fuse() => { let Ok((paths, barrier)) = request else { break }; - self.reload_entries_for_paths(paths, None).await; - if !self.send_status_update(false, Some(barrier)) { + if !self.process_refresh_request(paths, barrier).await { return; } } @@ -2521,12 +2515,10 @@ impl BackgroundScanner { .await; let mut snapshot = self.snapshot.lock(); - - if snapshot.completed_scan_id == snapshot.scan_id { - snapshot.scan_id += 1; - if !doing_recursive_update { - snapshot.completed_scan_id = snapshot.scan_id; - } + let is_idle = snapshot.completed_scan_id == snapshot.scan_id; + snapshot.scan_id += 1; + if is_idle && !doing_recursive_update { + snapshot.completed_scan_id = snapshot.scan_id; } // Remove any entries for paths that no longer exist or are being recursively @@ -2596,16 +2588,17 @@ impl BackgroundScanner { Some(event_paths) } - fn update_ignore_statuses( - &self, - ignore_queue_tx: Sender, - ) -> LocalSnapshot { + async fn update_ignore_statuses(&self) { + use futures::FutureExt as _; + let mut snapshot = self.snapshot.lock().clone(); let mut ignores_to_update = Vec::new(); let mut ignores_to_delete = Vec::new(); for (parent_abs_path, (_, scan_id)) in &snapshot.ignores_by_parent_abs_path { if let Ok(parent_path) = parent_abs_path.strip_prefix(&snapshot.abs_path) { - if *scan_id == snapshot.scan_id && snapshot.entry_for_path(parent_path).is_some() { + if *scan_id > snapshot.completed_scan_id + && snapshot.entry_for_path(parent_path).is_some() + { ignores_to_update.push(parent_abs_path.clone()); } @@ -2624,6 +2617,7 @@ impl BackgroundScanner { .remove(&parent_abs_path); } + let (ignore_queue_tx, ignore_queue_rx) = channel::unbounded(); ignores_to_update.sort_unstable(); let mut ignores_to_update = ignores_to_update.into_iter().peekable(); while let Some(parent_abs_path) = ignores_to_update.next() { @@ -2642,8 +2636,34 @@ impl BackgroundScanner { })) .unwrap(); } + drop(ignore_queue_tx); - snapshot + self.executor + .scoped(|scope| { + for _ in 0..self.executor.num_cpus() { + scope.spawn(async { + loop { + select_biased! { + // Process any path refresh requests before moving on to process + // the queue of ignore statuses. + request = self.refresh_requests_rx.recv().fuse() => { + let Ok((paths, barrier)) = request else { break }; + if !self.process_refresh_request(paths, barrier).await { + return; + } + } + + // Recursively process directories whose ignores have changed. + job = ignore_queue_rx.recv().fuse() => { + let Ok(job) = job else { break }; + self.update_ignore_status(job, &snapshot).await; + } + } + } + }); + } + }) + .await; } async fn update_ignore_status(&self, job: UpdateIgnoreStatusJob, snapshot: &LocalSnapshot) { @@ -3054,12 +3074,11 @@ mod tests { use fs::repository::FakeGitRepository; use fs::{FakeFs, RealFs}; use gpui::{executor::Deterministic, TestAppContext}; + use pretty_assertions::assert_eq; use rand::prelude::*; use serde_json::json; use std::{env, fmt::Write}; - use util::http::FakeHttpClient; - - use util::test::temp_tree; + use util::{http::FakeHttpClient, test::temp_tree}; #[gpui::test] async fn test_traversal(cx: &mut TestAppContext) { @@ -3461,7 +3480,7 @@ mod tests { } #[gpui::test(iterations = 30)] - async fn test_create_directory(cx: &mut TestAppContext) { + async fn test_create_directory_during_initial_scan(cx: &mut TestAppContext) { let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); let fs = FakeFs::new(cx.background()); @@ -3486,6 +3505,8 @@ mod tests { .await .unwrap(); + let mut snapshot1 = tree.update(cx, |tree, _| tree.as_local().unwrap().snapshot()); + let entry = tree .update(cx, |tree, cx| { tree.as_local_mut() @@ -3497,10 +3518,91 @@ mod tests { assert!(entry.is_dir()); cx.foreground().run_until_parked(); - tree.read_with(cx, |tree, _| { assert_eq!(tree.entry_for_path("a/e").unwrap().kind, EntryKind::Dir); }); + + let snapshot2 = tree.update(cx, |tree, _| tree.as_local().unwrap().snapshot()); + let update = snapshot2.build_update(&snapshot1, 0, 0, true); + snapshot1.apply_remote_update(update).unwrap(); + assert_eq!(snapshot1.to_vec(true), snapshot2.to_vec(true),); + } + + #[gpui::test(iterations = 100)] + async fn test_random_worktree_operations_during_initial_scan( + cx: &mut TestAppContext, + mut rng: StdRng, + ) { + let operations = env::var("OPERATIONS") + .map(|o| o.parse().unwrap()) + .unwrap_or(5); + let initial_entries = env::var("INITIAL_ENTRIES") + .map(|o| o.parse().unwrap()) + .unwrap_or(20); + + let root_dir = Path::new("/test"); + let fs = FakeFs::new(cx.background()) as Arc; + fs.as_fake().insert_tree(root_dir, json!({})).await; + for _ in 0..initial_entries { + randomly_mutate_fs(&fs, root_dir, 1.0, &mut rng).await; + } + log::info!("generated initial tree"); + + let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); + let worktree = Worktree::local( + client.clone(), + root_dir, + true, + fs.clone(), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + + let mut snapshot = worktree.update(cx, |tree, _| tree.as_local().unwrap().snapshot()); + + for _ in 0..operations { + worktree + .update(cx, |worktree, cx| { + randomly_mutate_worktree(worktree, &mut rng, cx) + }) + .await + .log_err(); + worktree.read_with(cx, |tree, _| { + tree.as_local().unwrap().snapshot.check_invariants() + }); + + if rng.gen_bool(0.6) { + let new_snapshot = + worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot()); + let update = new_snapshot.build_update(&snapshot, 0, 0, true); + snapshot.apply_remote_update(update.clone()).unwrap(); + assert_eq!( + snapshot.to_vec(true), + new_snapshot.to_vec(true), + "incorrect snapshot after update {:?}", + update + ); + } + } + + worktree + .update(cx, |tree, _| tree.as_local_mut().unwrap().scan_complete()) + .await; + worktree.read_with(cx, |tree, _| { + tree.as_local().unwrap().snapshot.check_invariants() + }); + + let new_snapshot = worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot()); + let update = new_snapshot.build_update(&snapshot, 0, 0, true); + snapshot.apply_remote_update(update.clone()).unwrap(); + assert_eq!( + snapshot.to_vec(true), + new_snapshot.to_vec(true), + "incorrect snapshot after update {:?}", + update + ); } #[gpui::test(iterations = 100)] @@ -3516,18 +3618,17 @@ mod tests { let fs = FakeFs::new(cx.background()) as Arc; fs.as_fake().insert_tree(root_dir, json!({})).await; for _ in 0..initial_entries { - randomly_mutate_tree(&fs, root_dir, 1.0, &mut rng).await; + randomly_mutate_fs(&fs, root_dir, 1.0, &mut rng).await; } log::info!("generated initial tree"); - let next_entry_id = Arc::new(AtomicUsize::default()); let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); let worktree = Worktree::local( client.clone(), root_dir, true, fs.clone(), - next_entry_id.clone(), + Default::default(), &mut cx.to_async(), ) .await @@ -3583,14 +3684,14 @@ mod tests { let mut snapshots = Vec::new(); let mut mutations_len = operations; while mutations_len > 1 { - randomly_mutate_tree(&fs, root_dir, 1.0, &mut rng).await; + randomly_mutate_fs(&fs, root_dir, 1.0, &mut rng).await; let buffered_event_count = fs.as_fake().buffered_event_count().await; if buffered_event_count > 0 && rng.gen_bool(0.3) { let len = rng.gen_range(0..=buffered_event_count); log::info!("flushing {} events", len); fs.as_fake().flush_events(len).await; } else { - randomly_mutate_tree(&fs, root_dir, 0.6, &mut rng).await; + randomly_mutate_fs(&fs, root_dir, 0.6, &mut rng).await; mutations_len -= 1; } @@ -3615,7 +3716,7 @@ mod tests { root_dir, true, fs.clone(), - next_entry_id, + Default::default(), &mut cx.to_async(), ) .await @@ -3659,7 +3760,67 @@ mod tests { } } - async fn randomly_mutate_tree( + fn randomly_mutate_worktree( + worktree: &mut Worktree, + rng: &mut impl Rng, + cx: &mut ModelContext, + ) -> Task> { + let worktree = worktree.as_local_mut().unwrap(); + let snapshot = worktree.snapshot(); + let entry = snapshot.entries(false).choose(rng).unwrap(); + + match rng.gen_range(0_u32..100) { + 0..=33 if entry.path.as_ref() != Path::new("") => { + log::info!("deleting entry {:?} ({})", entry.path, entry.id.0); + worktree.delete_entry(entry.id, cx).unwrap() + } + ..=66 if entry.path.as_ref() != Path::new("") => { + let other_entry = snapshot.entries(false).choose(rng).unwrap(); + let new_parent_path = if other_entry.is_dir() { + other_entry.path.clone() + } else { + other_entry.path.parent().unwrap().into() + }; + let mut new_path = new_parent_path.join(gen_name(rng)); + if new_path.starts_with(&entry.path) { + new_path = gen_name(rng).into(); + } + + log::info!( + "renaming entry {:?} ({}) to {:?}", + entry.path, + entry.id.0, + new_path + ); + let task = worktree.rename_entry(entry.id, new_path, cx).unwrap(); + cx.foreground().spawn(async move { + task.await?; + Ok(()) + }) + } + _ => { + let task = if entry.is_dir() { + let child_path = entry.path.join(gen_name(rng)); + let is_dir = rng.gen_bool(0.3); + log::info!( + "creating {} at {:?}", + if is_dir { "dir" } else { "file" }, + child_path, + ); + worktree.create_entry(child_path, is_dir, cx) + } else { + log::info!("overwriting file {:?} ({})", entry.path, entry.id.0); + worktree.write_file(entry.path.clone(), "".into(), Default::default(), cx) + }; + cx.foreground().spawn(async move { + task.await?; + Ok(()) + }) + } + } + } + + async fn randomly_mutate_fs( fs: &Arc, root_path: &Path, insertion_probability: f64, @@ -3827,6 +3988,20 @@ mod tests { impl LocalSnapshot { fn check_invariants(&self) { + assert_eq!( + self.entries_by_path + .cursor::<()>() + .map(|e| (&e.path, e.id)) + .collect::>(), + self.entries_by_id + .cursor::<()>() + .map(|e| (&e.path, e.id)) + .collect::>() + .into_iter() + .collect::>(), + "entries_by_path and entries_by_id are inconsistent" + ); + let mut files = self.files(true, 0); let mut visible_files = self.files(false, 0); for entry in self.entries_by_path.cursor::<()>() { @@ -3837,6 +4012,7 @@ mod tests { } } } + assert!(files.next().is_none()); assert!(visible_files.next().is_none()); diff --git a/script/randomized-test-ci b/script/randomized-test-ci new file mode 100755 index 0000000000..7816ebfbbf --- /dev/null +++ b/script/randomized-test-ci @@ -0,0 +1,65 @@ +#!/usr/bin/env node --redirect-warnings=/dev/null + +const fs = require('fs') +const {randomBytes} = require('crypto') +const {execFileSync} = require('child_process') +const {minimizeTestPlan, buildTests, runTests} = require('./randomized-test-minimize'); + +const {ZED_SERVER_URL, ZED_CLIENT_SECRET_TOKEN} = process.env +if (!ZED_SERVER_URL) throw new Error('Missing env var `ZED_SERVER_URL`') +if (!ZED_CLIENT_SECRET_TOKEN) throw new Error('Missing env var `ZED_CLIENT_SECRET_TOKEN`') + +main() + +async function main() { + buildTests() + + const seed = randomU64(); + const commit = execFileSync( + 'git', + ['rev-parse', 'HEAD'], + {encoding: 'utf8'} + ).trim() + + console.log("commit:", commit) + console.log("starting seed:", seed) + + const planPath = 'target/test-plan.json' + const minPlanPath = 'target/test-plan.min.json' + const failingSeed = runTests({ + SEED: seed, + SAVE_PLAN: planPath, + ITERATIONS: 50000, + OPERATIONS: 200, + }) + + if (!failingSeed) { + console.log("tests passed") + return + } + + console.log("found failure at seed", failingSeed) + const minimizedSeed = minimizeTestPlan(planPath, minPlanPath) + const minimizedPlan = fs.readFileSync(minPlanPath, 'utf8') + + console.log("minimized plan:\n", minimizedPlan) + + const url = `${ZED_SERVER_URL}/api/randomized_test_failure` + const body = { + seed: minimizedSeed, + token: ZED_CLIENT_SECRET_TOKEN, + plan: JSON.parse(minimizedPlan), + commit: commit, + } + await fetch(url, { + method: 'POST', + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(body) + }) +} + +function randomU64() { + const bytes = randomBytes(8) + const hexString = bytes.reduce(((string, byte) => string + byte.toString(16)), '') + return BigInt('0x' + hexString).toString(10) +} diff --git a/script/randomized-test-minimize b/script/randomized-test-minimize new file mode 100755 index 0000000000..ce0b7203b4 --- /dev/null +++ b/script/randomized-test-minimize @@ -0,0 +1,132 @@ +#!/usr/bin/env node --redirect-warnings=/dev/null + +const fs = require('fs') +const path = require('path') +const {spawnSync} = require('child_process') + +const FAILING_SEED_REGEX = /failing seed: (\d+)/ig +const CARGO_TEST_ARGS = [ + '--release', + '--lib', + '--package', 'collab', + 'random_collaboration', +] + +if (require.main === module) { + if (process.argv.length < 4) { + process.stderr.write("usage: script/randomized-test-minimize [start-index]\n") + process.exit(1) + } + + minimizeTestPlan( + process.argv[2], + process.argv[3], + parseInt(process.argv[4]) || 0 + ); +} + +function minimizeTestPlan( + inputPlanPath, + outputPlanPath, + startIndex = 0 +) { + const tempPlanPath = inputPlanPath + '.try' + + fs.copyFileSync(inputPlanPath, outputPlanPath) + let testPlan = JSON.parse(fs.readFileSync(outputPlanPath, 'utf8')) + + process.stderr.write("minimizing failing test plan...\n") + for (let ix = startIndex; ix < testPlan.length; ix++) { + // Skip 'MutateClients' entries, since they themselves are not single operations. + if (testPlan[ix].MutateClients) { + continue + } + + // Remove a row from the test plan + const newTestPlan = testPlan.slice() + newTestPlan.splice(ix, 1) + fs.writeFileSync(tempPlanPath, serializeTestPlan(newTestPlan), 'utf8'); + + process.stderr.write(`${ix}/${testPlan.length}: ${JSON.stringify(testPlan[ix])}`) + const failingSeed = runTests({ + SEED: '0', + LOAD_PLAN: tempPlanPath, + SAVE_PLAN: tempPlanPath, + ITERATIONS: '500' + }) + + // If the test failed, keep the test plan with the removed row. Reload the test + // plan from the JSON file, since the test itself will remove any operations + // which are no longer valid before saving the test plan. + if (failingSeed != null) { + process.stderr.write(` - remove. failing seed: ${failingSeed}.\n`) + fs.copyFileSync(tempPlanPath, outputPlanPath) + testPlan = JSON.parse(fs.readFileSync(outputPlanPath, 'utf8')) + ix-- + } else { + process.stderr.write(` - keep.\n`) + } + } + + fs.unlinkSync(tempPlanPath) + + // Re-run the final minimized plan to get the correct failing seed. + // This is a workaround for the fact that the execution order can + // slightly change when replaying a test plan after it has been + // saved and loaded. + const failingSeed = runTests({ + SEED: '0', + ITERATIONS: '5000', + LOAD_PLAN: outputPlanPath, + }) + + process.stderr.write(`final test plan: ${outputPlanPath}\n`) + process.stderr.write(`final seed: ${failingSeed}\n`) + return failingSeed +} + +function buildTests() { + const {status} = spawnSync('cargo', ['test', '--no-run', ...CARGO_TEST_ARGS], { + stdio: 'inherit', + encoding: 'utf8', + env: { + ...process.env, + } + }); + if (status !== 0) { + throw new Error('build failed') + } +} + +function runTests(env) { + const {status, stdout} = spawnSync('cargo', ['test', ...CARGO_TEST_ARGS], { + stdio: 'pipe', + encoding: 'utf8', + env: { + ...process.env, + ...env, + } + }); + + if (status !== 0) { + FAILING_SEED_REGEX.lastIndex = 0 + const match = FAILING_SEED_REGEX.exec(stdout) + if (!match) { + process.stderr.write("test failed, but no failing seed found:\n") + process.stderr.write(stdout) + process.stderr.write('\n') + process.exit(1) + } + return match[1] + } else { + return null + } +} + +function serializeTestPlan(plan) { + return "[\n" + plan.map(row => JSON.stringify(row)).join(",\n") + "\n]\n" +} + +exports.buildTests = buildTests +exports.runTests = runTests +exports.minimizeTestPlan = minimizeTestPlan