Fix hanging during remote project search (#65257)

Closes https://github.com/zed-industries/zed/issues/65099

Before:


https://github.com/user-attachments/assets/36673f06-75b2-4d36-a8d9-4aaaa6bc1768

After:


https://github.com/user-attachments/assets/86a4e129-e1de-465f-9e70-fcec9d421c03

Issues:

- The host checked files that were not yet open for matches, but treated
every already-open buffer as a candidate.
Earlier queries leave thousands of buffers open, so later queries sent
all of them to the client: 3,339 buffers for 51 real matches in one
case.
Open buffers are now checked against their in-memory text first, which
also stops ordinary non-SSH searches from reporting open buffers with no
matches.

- The host queued every candidate buffer's full contents without waiting
for the client, so data from cancelled queries piled up in front of the
new query's results.
A single worker shared by all searches now sends at most 64 buffers or
about 1 MiB before waiting for the client to acknowledge them, and skips
batches from cancelled searches.

- Cancelling a search could stop a buffer transfer after the buffer was
already marked as shared, so later searches waited forever for its
contents.
`BufferStore` now owns transfers, so dropping the caller no longer
cancels them.

Release Notes:

- Fixed hanging during remote project search
This commit is contained in:
Kirill Bulatov authored and GitHub committed 2026-10-07 09:57:41 +00:00
1 parent eba6365025
commit 8323e23277
5 files changed
+392 -59

No files matched your search

+22 -6
View File
@@ -40,6 +40,8 @@ pub struct BufferStore {
path_to_buffer_id: HashMap<ProjectPath, BufferId>,
downstream_client: Option<(AnyProtoClient, u64)>,
shared_buffers: HashMap<proto::PeerId, HashMap<BufferId, SharedBuffer>>,
pending_buffer_shares: HashMap<u64, Task<()>>,
next_buffer_share_id: u64,
non_searchable_buffers: HashSet<BufferId>,
project_search: RemoteProjectSearchState,
}
@@ -850,6 +852,8 @@ impl BufferStore {
opened_buffers: Default::default(),
path_to_buffer_id: Default::default(),
shared_buffers: Default::default(),
pending_buffer_shares: HashMap::default(),
next_buffer_share_id: 0,
loading_buffers: Default::default(),
non_searchable_buffers: Default::default(),
worktree_store,
@@ -877,6 +881,8 @@ impl BufferStore {
path_to_buffer_id: Default::default(),
loading_buffers: Default::default(),
shared_buffers: Default::default(),
pending_buffer_shares: HashMap::default(),
next_buffer_share_id: 0,
non_searchable_buffers: Default::default(),
worktree_store,
project_search: Default::default(),
@@ -1635,12 +1641,11 @@ impl BufferStore {
return Task::ready(Ok(()));
};
cx.spawn(async move |this, cx| {
let Some(buffer) = this.read_with(cx, |this, _| this.get(buffer_id))? else {
return anyhow::Ok(());
};
let operations = buffer.update(cx, |b, cx| b.serialize_ops(None, cx));
let buffer = buffer.clone();
let share_id = util::post_inc(&mut self.next_buffer_share_id);
let (completion_tx, completion_rx) = oneshot::channel();
let transfer = cx.spawn(async move |buffer_store, cx| {
let operations = buffer.update(cx, |buffer, cx| buffer.serialize_ops(None, cx));
let operations = operations.await;
let state = buffer.update(cx, |buffer, cx| buffer.to_proto(cx));
@@ -1673,6 +1678,17 @@ impl BufferStore {
.await
.log_err();
}
completion_tx.send(()).ok();
buffer_store
.update(cx, |store, _| {
store.pending_buffer_shares.remove(&share_id);
})
.ok();
});
self.pending_buffer_shares.insert(share_id, transfer);
cx.background_spawn(async move {
completion_rx.await?;
Ok(())
})
}
+15 -8
View File
@@ -11,7 +11,7 @@ use std::{
use anyhow::Context;
use async_channel::{Receiver, Sender, bounded, unbounded};
use collections::HashSet;
use collections::HashMap;
use fs::{Fs, ReadSeek};
use futures::FutureExt as _;
use futures::{SinkExt, StreamExt, select_biased, stream::FuturesOrdered};
@@ -20,6 +20,7 @@ use language::{Buffer, BufferSnapshot, Point};
use parking_lot::Mutex;
use postage::oneshot;
use rpc::{AnyProtoClient, proto};
use text::Rope;
use encoding_rs::Encoding;
use file_content::{
@@ -167,7 +168,7 @@ impl Search {
/// Prepares a project search run. The resulting [`SearchResultsHandle`] has to be used to specify whether you're interested in matching buffers
/// or full search results.
pub fn into_handle(mut self, query: SearchQuery, cx: &mut App) -> SearchResultsHandle {
let mut open_buffers = HashSet::default();
let mut open_buffers = HashMap::default();
let mut unnamed_buffers = Vec::new();
let mut entryless_file_buffers = Vec::new();
const MAX_CONCURRENT_BUFFER_OPENS: usize = 64;
@@ -183,7 +184,7 @@ impl Search {
{
continue;
} else if let Some(entry_id) = buffer.entry_id(cx) {
open_buffers.insert(entry_id);
open_buffers.insert(entry_id, buffer.as_rope().clone());
} else if searches_all_unnamed_buffers {
match (&self.kind, buffer.file()) {
(SearchKind::Local { .. }, Some(file)) => {
@@ -713,7 +714,7 @@ fn path_key_sort_key(
struct Worker {
query: Arc<SearchQuery>,
open_buffers: Arc<HashSet<ProjectEntryId>>,
open_buffers: Arc<HashMap<ProjectEntryId, Rope>>,
candidates: FindSearchCandidates,
/// Ok, we're back in background: run full scan & find all matches in a given buffer snapshot.
/// Then, when you're done, share them via the channel you were given.
@@ -793,7 +794,7 @@ impl Worker {
struct RequestHandler<'worker> {
query: &'worker SearchQuery,
fs: Option<&'worker dyn Fs>,
open_entries: &'worker HashSet<ProjectEntryId>,
open_entries: &'worker HashMap<ProjectEntryId, Rope>,
confirm_contents_will_match_tx: &'worker Sender<MatchingEntry>,
}
@@ -929,9 +930,15 @@ impl RequestHandler<'_> {
}
}
if self.open_entries.contains(&entry.id) {
// The buffer is already in memory and that's the version we want to scan;
// hence skip the dilly-dally and look for all matches straight away.
if let Some(text) = self.open_entries.get(&entry.id) {
if self
.query
.detect(&mut text.bytes_in_range(0..text.len()))
.await?
.is_none()
{
return Ok(());
}
should_scan_tx
.send((
ProjectPath {
@@ -11988,6 +11988,27 @@ async fn test_search(cx: &mut gpui::TestAppContext) {
(path!("dir/four.rs").to_string(), vec![25..28, 36..39])
])
);
assert_eq!(
search(
&project,
SearchQuery::text(
"THREE",
false,
true,
false,
Default::default(),
Default::default(),
false,
None,
)
.unwrap(),
cx
)
.await
.unwrap(),
HashMap::from_iter([(path!("dir/three.rs").to_string(), vec![6..11])])
);
}
#[gpui::test]
+111 -44
View File
@@ -9,7 +9,8 @@ use lsp::LanguageServerId;
use extension::ExtensionHostProxy;
use extension_host::headless_host::HeadlessExtensionStore;
use fs::Fs;
use gpui::{App, AppContext as _, AsyncApp, Context, Entity, PromptLevel, TaskExt};
use futures::{channel::oneshot, stream::StreamExt as _};
use gpui::{App, AppContext as _, AsyncApp, Context, Entity, PromptLevel, Task, TaskExt};
use http_client::HttpClient;
use language::{Buffer, BufferEvent, LanguageRegistry, proto::serialize_operation};
use node_runtime::NodeRuntime;
@@ -39,6 +40,7 @@ use smol::process::Child;
use settings::{Settings as _, SettingsLocation, initial_server_settings_content};
use std::{
mem,
num::NonZeroU64,
path::{Path, PathBuf},
sync::{
@@ -74,6 +76,8 @@ pub struct HeadlessProject {
// Local variant is used within LSP store, but that's a separate entity.
pub _toolchain_store: Entity<ToolchainStore>,
pub kernels: HashMap<String, Child>,
search_buffer_batches: async_channel::Sender<SearchBufferBatch>,
_search_buffer_sender: Task<()>,
}
pub struct HeadlessAppState {
@@ -86,6 +90,17 @@ pub struct HeadlessAppState {
pub startup_time: Instant,
}
const MAX_SEARCH_CANDIDATES_PER_BATCH: usize = 64;
const MAX_UNACKNOWLEDGED_SEARCH_BYTES: usize = 1024 * 1024;
struct SearchBufferBatch {
buffers: Vec<Entity<Buffer>>,
handle: u64,
peer_id: proto::PeerId,
project_id: u64,
completion: oneshot::Sender<Result<()>>,
}
impl HeadlessProject {
pub fn init(cx: &mut App) {
settings::init(cx);
@@ -343,6 +358,59 @@ impl HeadlessProject {
AgentServerStore::init_headless(&session);
ContextServerStore::init_headless(&session);
let (search_buffer_batches, batches) = async_channel::bounded::<SearchBufferBatch>(1);
let search_buffer_sender = cx.spawn({
let buffer_store = buffer_store.clone();
let client = session.clone();
async move |_, cx| {
while let Ok(batch) = batches.recv().await {
let result = async {
let mut buffer_ids = Vec::new();
let mut unacknowledged_bytes = 0;
for buffer in &batch.buffers {
if batch.completion.is_canceled() {
break;
}
let (buffer_id, transferred_bytes, transfer) =
buffer_store.update(cx, |store, cx| {
let buffer_id = buffer.read(cx).remote_id();
let transferred_bytes = if store.is_shared(buffer_id, cx) {
0
} else {
buffer.read(cx).len()
};
let transfer = store.create_buffer_for_peer(
buffer,
REMOTE_SERVER_PEER_ID,
cx,
);
(buffer_id, transferred_bytes, transfer)
});
transfer.await?;
buffer_ids.push(buffer_id.to_proto());
unacknowledged_bytes += transferred_bytes;
if unacknowledged_bytes >= MAX_UNACKNOWLEDGED_SEARCH_BYTES {
Self::send_search_candidates(
&client,
&batch,
mem::take(&mut buffer_ids),
)
.await?;
unacknowledged_bytes = 0;
}
}
if !buffer_ids.is_empty() {
Self::send_search_candidates(&client, &batch, buffer_ids).await?;
}
anyhow::Ok(())
}
.await;
batch.completion.send(result).ok();
}
}
});
HeadlessProject {
next_entry_id: Default::default(),
session,
@@ -363,6 +431,8 @@ impl HeadlessProject {
profiling_collector: gpui::ProfilingCollector::new(startup_time),
_toolchain_store: toolchain_store,
kernels: Default::default(),
search_buffer_batches,
_search_buffer_sender: search_buffer_sender,
}
}
@@ -1128,8 +1198,6 @@ impl HeadlessProject {
envelope: TypedEnvelope<proto::FindSearchCandidates>,
mut cx: AsyncApp,
) -> Result<proto::Ack> {
use futures::stream::StreamExt as _;
let peer_id = envelope.original_sender_id.unwrap_or(envelope.sender_id);
let message = envelope.payload;
let query = SearchQuery::from_proto(
@@ -1140,8 +1208,9 @@ impl HeadlessProject {
let project_id = message.project_id;
let buffer_store = this.read_with(&cx, |this, _| this.buffer_store.clone());
let handle = message.handle;
let _buffer_store = buffer_store.clone();
let client = this.read_with(&cx, |this, _| this.session.clone());
let client = this.read_with(&cx, |project, _| project.session.clone());
let search_buffer_batches =
this.read_with(&cx, |project, _| project.search_buffer_batches.clone());
let task = cx.spawn(async move |cx| {
let results = this.update(cx, |this, cx| {
project::Search::local(
@@ -1154,44 +1223,25 @@ impl HeadlessProject {
.into_handle(query, cx)
.matching_buffers(cx)
});
let (batcher, batches) =
project::project_search::AdaptiveBatcher::new(cx.background_executor());
let mut new_matches = Box::pin(results.rx);
let sender_task = cx.background_executor().spawn({
let client = client.clone();
async move {
let mut batches = std::pin::pin!(batches);
while let Some(buffer_ids) = batches.next().await {
client
.request(proto::FindSearchCandidatesChunk {
handle,
peer_id: Some(peer_id),
project_id,
variant: Some(
proto::find_search_candidates_chunk::Variant::Matches(
proto::FindSearchCandidatesMatches { buffer_ids },
),
),
})
.await?;
}
anyhow::Ok(())
}
});
while let Some((buffer, _)) = new_matches.next().await {
let _ = buffer_store
.update(cx, |this, cx| {
this.create_buffer_for_peer(&buffer, REMOTE_SERVER_PEER_ID, cx)
let mut batches = std::pin::pin!(
results
.rx
.map(|(buffer, _)| buffer)
.ready_chunks(MAX_SEARCH_CANDIDATES_PER_BATCH)
);
while let Some(buffers) = batches.next().await {
let (completion, completed) = oneshot::channel();
search_buffer_batches
.send(SearchBufferBatch {
buffers,
handle,
peer_id,
project_id,
completion,
})
.await;
let buffer_id = buffer.read_with(cx, |this, _| this.remote_id().to_proto());
batcher.push(buffer_id).await;
.await?;
completed.await??;
}
batcher.flush().await;
sender_task.await?;
client
.request(proto::FindSearchCandidatesChunk {
@@ -1205,13 +1255,31 @@ impl HeadlessProject {
.await?;
anyhow::Ok(())
});
_buffer_store.update(&mut cx, |this, _| {
this.register_ongoing_project_search((peer_id, handle), task);
buffer_store.update(&mut cx, |store, _| {
store.register_ongoing_project_search((peer_id, handle), task);
});
Ok(proto::Ack {})
}
async fn send_search_candidates(
client: &AnyProtoClient,
batch: &SearchBufferBatch,
buffer_ids: Vec<u64>,
) -> Result<()> {
client
.request(proto::FindSearchCandidatesChunk {
handle: batch.handle,
peer_id: Some(batch.peer_id),
project_id: batch.project_id,
variant: Some(proto::find_search_candidates_chunk::Variant::Matches(
proto::FindSearchCandidatesMatches { buffer_ids },
)),
})
.await?;
Ok(())
}
// Goes from client to host.
async fn handle_find_search_candidates_cancel(
this: Entity<Self>,
@@ -1227,7 +1295,6 @@ impl HeadlessProject {
envelope: TypedEnvelope<proto::ListRemoteDirectory>,
cx: AsyncApp,
) -> Result<proto::ListRemoteDirectoryResponse> {
use smol::stream::StreamExt;
let fs = cx.read_entity(&this, |this, _| this.fs.clone());
let expanded = PathBuf::from(shellexpand::tilde(&envelope.payload.path).to_string());
let check_info = envelope
@@ -20,6 +20,7 @@ use editor::{
};
use extension::ExtensionHostProxy;
use fs::{FakeFs, Fs};
use futures::FutureExt as _;
use git::{
Oid,
repository::{CommitData, GitCommitTemplate, RepoPath, Worktree as GitWorktree},
@@ -42,6 +43,7 @@ use node_runtime::NodeRuntime;
use project::{
CompletionSource, LanguageServerLogType, ProgressToken, Project, ProjectPath,
agent_server_store::AgentServerCommand,
buffer_store::BufferStoreEvent,
image_store,
lsp_store::log_store::{
GlobalLogStore, LanguageServerKind, LanguageServerLogKey, LogKind, LogStore,
@@ -66,7 +68,12 @@ use std::{
},
};
use unindent::Unindent as _;
use util::{path, path_list::PathList, paths::PathMatcher, rel_path::rel_path};
use util::{
path,
path_list::PathList,
paths::{PathMatcher, PathStyle},
rel_path::rel_path,
};
#[gpui::test]
async fn test_basic_remote_editing(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
@@ -598,6 +605,221 @@ async fn test_remote_project_search(cx: &mut TestAppContext, server_cx: &mut Tes
.await;
}
#[gpui::test]
async fn test_remote_project_search_backpressure_survives_cancellation(
cx: &mut TestAppContext,
server_cx: &mut TestAppContext,
) {
let fs = FakeFs::new(server_cx.executor());
let files = (0..4)
.map(|directory| {
let files = (0..128)
.map(|index| (format!("{index:03}.txt"), json!("testing rust-analyzer")))
.collect::<serde_json::Map<_, _>>();
(directory.to_string(), serde_json::Value::Object(files))
})
.collect::<serde_json::Map<_, _>>();
fs.insert_tree(path!("/code/project"), serde_json::Value::Object(files))
.await;
let (project, headless) = init_test(&fs, cx, server_cx).await;
project
.update(cx, |project, cx| {
project.find_or_create_worktree(path!("/code/project"), true, cx)
})
.await
.expect("remote worktree should open");
cx.run_until_parked();
let client = project.read_with(cx, |project, cx| {
project.remote_client().unwrap().read(cx).proto_client()
});
let pause_incoming = client.request(proto::Ping {});
cx.run_until_parked();
let received_buffers = Rc::new(RefCell::new(Vec::new()));
let _subscription = cx.update(|cx| {
let received_buffers = received_buffers.clone();
let buffer_store = project.read(cx).buffer_store().clone();
cx.subscribe(&buffer_store, move |_, event, cx| {
if let BufferStoreEvent::BufferAdded(buffer) = event {
received_buffers
.borrow_mut()
.push(buffer.read(cx).remote_id());
}
})
});
let shared_buffers = |cx: &mut TestAppContext| {
headless.read_with(cx, |headless, cx| {
let store = headless.buffer_store.read(cx);
let mut ids = store
.buffers()
.filter_map(|buffer| {
let id = buffer.read(cx).remote_id();
store.is_shared(id, cx).then_some(id)
})
.collect::<Vec<_>>();
ids.sort();
ids
})
};
let mut first_batch = None;
let mut searches = Vec::new();
for directory in 0..4 {
let query = SearchQuery::text(
"testing".to_string(),
false,
true,
false,
PathMatcher::new([format!("{directory}/**")], PathStyle::local()).unwrap(),
PathMatcher::default(),
false,
None,
)
.unwrap();
searches.push(project.update(cx, |project, cx| project.search(query, cx)));
cx.run_until_parked();
let shared = shared_buffers(server_cx);
if let Some(first_batch) = &first_batch {
assert_eq!(&shared, first_batch);
} else {
assert!(!shared.is_empty());
assert!(
shared.len() <= 64,
"queued {} buffers without an acknowledgement",
shared.len()
);
first_batch = Some(shared);
}
if directory >= 2 {
searches.clear();
cx.run_until_parked();
}
}
let mut replacement = std::pin::pin!(do_search_and_assert(
&project,
"rust-analyzer",
PathMatcher::new(["3/127.txt"], PathStyle::local()).unwrap(),
false,
&[path!("project/3/127.txt")],
cx.clone(),
));
assert!(replacement.as_mut().now_or_never().is_none());
cx.run_until_parked();
let mut expected_buffers = first_batch.unwrap();
assert_eq!(shared_buffers(server_cx), expected_buffers);
drop(pause_incoming);
let buffers = replacement.await;
expected_buffers.extend(
buffers
.iter()
.map(|buffer| buffer.read_with(cx, |buffer, _| buffer.remote_id())),
);
expected_buffers.sort();
cx.run_until_parked();
received_buffers.borrow_mut().sort();
assert_eq!(*received_buffers.borrow(), expected_buffers);
}
#[gpui::test]
async fn test_remote_project_search_limits_unacknowledged_bytes(
cx: &mut TestAppContext,
server_cx: &mut TestAppContext,
) {
let fs = FakeFs::new(server_cx.executor());
let text = format!("needle{}", "a".repeat(64 * 1024));
let files = (0..64)
.map(|index| (format!("{index:03}.txt"), json!(text)))
.collect::<serde_json::Map<_, _>>();
fs.insert_tree(path!("/code/project"), serde_json::Value::Object(files))
.await;
let (project, headless) = init_test(&fs, cx, server_cx).await;
project
.update(cx, |project, cx| {
project.find_or_create_worktree(path!("/code/project"), true, cx)
})
.await
.expect("remote worktree should open");
cx.run_until_parked();
let client = project.read_with(cx, |project, cx| {
project.remote_client().unwrap().read(cx).proto_client()
});
let pause_incoming = client.request(proto::Ping {});
cx.run_until_parked();
let expected_paths = (0..64)
.map(|index| format!("project{}{index:03}.txt", std::path::MAIN_SEPARATOR))
.collect::<Vec<_>>();
let expected_paths = expected_paths
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
let mut search = std::pin::pin!(do_search_and_assert(
&project,
"needle",
PathMatcher::default(),
false,
&expected_paths,
cx.clone(),
));
let shared_bytes = |cx: &mut TestAppContext| {
headless.read_with(cx, |headless, cx| {
let store = headless.buffer_store.read(cx);
store
.buffers()
.filter(|buffer| store.is_shared(buffer.read(cx).remote_id(), cx))
.map(|buffer| buffer.read(cx).len())
.sum::<usize>()
})
};
assert!(search.as_mut().now_or_never().is_none());
cx.run_until_parked();
let first_batch_bytes = shared_bytes(server_cx);
let pause_second_batch = client.request(proto::Ping {});
cx.run_until_parked();
drop(pause_incoming);
cx.run_until_parked();
let second_batch_bytes = shared_bytes(server_cx) - first_batch_bytes;
assert!(
(text.len()..1024 * 1024 + text.len()).contains(&second_batch_bytes),
"shared {second_batch_bytes} bytes without an acknowledgement"
);
drop(pause_second_batch);
search.await;
}
#[gpui::test]
async fn test_remote_buffer_transfer_owned_by_buffer_store(
cx: &mut TestAppContext,
server_cx: &mut TestAppContext,
) {
let fs = FakeFs::new(server_cx.executor());
let (project, headless) = init_test(&fs, cx, server_cx).await;
let buffer_store = headless.read_with(server_cx, |headless, _| headless.buffer_store.clone());
let buffer = buffer_store.update(server_cx, |store, cx| {
store.create_local_buffer("needle", None, false, cx)
});
let buffer_id = buffer.read_with(server_cx, |buffer, _| buffer.remote_id());
buffer.update(server_cx, |buffer, cx| buffer.edit([(6..6, "!")], None, cx));
let remote_buffer = project.update(cx, |project, cx| {
project
.buffer_store()
.update(cx, |store, cx| store.wait_for_remote_buffer(buffer_id, cx))
});
let transfer = buffer_store.update(server_cx, |store, cx| {
store.create_buffer_for_peer(&buffer, proto::REMOTE_SERVER_PEER_ID, cx)
});
drop(transfer);
server_cx.update(|_| drop(buffer));
cx.run_until_parked();
let remote_buffer = remote_buffer
.now_or_never()
.expect("the transfer should survive dropping its caller")
.expect("the transferred buffer should load");
remote_buffer.read_with(cx, |buffer, _| assert_eq!(buffer.text(), "needle!"));
}
#[gpui::test]
async fn test_remote_project_search_single_cpu(
cx: &mut TestAppContext,