devcontainer: Fix project search returning no results on single-CPU containers (#48798)

Oliver Azevedo Barnes created

Closes #47489

The search worker pool was sized as `num_cpus - 1`, which spawned zero
workers when a devcontainer exposed only 1 CPU. All search channels
closed immediately and the search yielded zero results, while file
finder and LSP symbols worked fine.

The fix ensures at least 1 worker is always spawned: `(num_cpus -
1).max(1)`. A `num_cpus` override on `TestDispatcher` and a new test
reproduce the bug with `server_cx.executor().set_num_cpus(1)`.

## Manual testing

Add a `.devcontainer/` directory to a new project with these files:

```
// docker-compose.yml
services:
  dev:
    image: debian:bookworm-slim
    cpuset: "0"
    volumes:
      - ..:/workspace:cached
    command: sleep infinity
```

```
// devcontainer.json
{
  "name": "zed-sandbox (1 CPU)",
  "dockerComposeFile": "docker-compose.yml",
  "service": "dev",
  "workspaceFolder": "/workspace"
}
```

Build zed and point it at the new project:

```
cargo run -p zed -- ~/Repos/zed-sandbox-project
```

Open the built-in terminal, confirm `nproc` prints `1`.

Finally, run a project search (`Cmd+Shift+F`) and search for contents
that exist in it.

Results should appear 🎉 

Release Notes:

- Fixed project search returning no results in devcontainers with a
single visible CPU.

Change summary

crates/gpui/src/executor.rs                      | 14 ++++
crates/gpui/src/platform/test/dispatcher.rs      | 22 ++++++++
crates/project/src/project_search.rs             |  3 
crates/remote_server/src/remote_editing_tests.rs | 47 ++++++++++++++++++
4 files changed, 82 insertions(+), 4 deletions(-)

Detailed changes

crates/gpui/src/executor.rs 🔗

@@ -349,12 +349,22 @@ impl BackgroundExecutor {
     /// How many CPUs are available to the dispatcher.
     pub fn num_cpus(&self) -> usize {
         #[cfg(any(test, feature = "test-support"))]
-        if self.dispatcher.as_test().is_some() {
-            return 4;
+        if let Some(test) = self.dispatcher.as_test() {
+            return test.num_cpus_override().unwrap_or(4);
         }
         num_cpus::get()
     }
 
+    /// Override the number of CPUs reported by this executor in tests.
+    /// Panics if not called on a test executor.
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn set_num_cpus(&self, count: usize) {
+        self.dispatcher
+            .as_test()
+            .expect("set_num_cpus can only be called on a test executor")
+            .set_num_cpus(count);
+    }
+
     /// Whether we're on the main thread.
     pub fn is_main_thread(&self) -> bool {
         self.dispatcher.is_main_thread()

crates/gpui/src/platform/test/dispatcher.rs 🔗

@@ -1,7 +1,10 @@
 use crate::{PlatformDispatcher, Priority, RunnableVariant};
 use scheduler::{Clock, Scheduler, SessionId, TestScheduler, TestSchedulerConfig, Yield};
 use std::{
-    sync::Arc,
+    sync::{
+        Arc,
+        atomic::{AtomicUsize, Ordering},
+    },
     time::{Duration, Instant},
 };
 
@@ -13,6 +16,7 @@ use std::{
 pub struct TestDispatcher {
     session_id: SessionId,
     scheduler: Arc<TestScheduler>,
+    num_cpus_override: Arc<AtomicUsize>,
 }
 
 impl TestDispatcher {
@@ -31,6 +35,7 @@ impl TestDispatcher {
         TestDispatcher {
             session_id,
             scheduler,
+            num_cpus_override: Arc::new(AtomicUsize::new(0)),
         }
     }
 
@@ -65,6 +70,20 @@ impl TestDispatcher {
     pub fn run_until_parked(&self) {
         while self.tick(false) {}
     }
+
+    /// Override the value returned by `BackgroundExecutor::num_cpus()` in tests.
+    /// A value of 0 means no override (the default of 4 is used).
+    pub fn set_num_cpus(&self, count: usize) {
+        self.num_cpus_override.store(count, Ordering::SeqCst);
+    }
+
+    /// Returns the overridden CPU count, or `None` if no override is set.
+    pub fn num_cpus_override(&self) -> Option<usize> {
+        match self.num_cpus_override.load(Ordering::SeqCst) {
+            0 => None,
+            n => Some(n),
+        }
+    }
 }
 
 impl Clone for TestDispatcher {
@@ -73,6 +92,7 @@ impl Clone for TestDispatcher {
         Self {
             session_id,
             scheduler: self.scheduler.clone(),
+            num_cpus_override: self.num_cpus_override.clone(),
         }
     }
 }

crates/project/src/project_search.rs 🔗

@@ -335,7 +335,8 @@ impl Search {
                     assert!(num_cpus > 0);
                     _executor
                         .scoped(|scope| {
-                            for _ in 0..num_cpus - 1 {
+                            let worker_count = (num_cpus - 1).max(1);
+                            for _ in 0..worker_count {
                                 let worker = Worker {
                                     query: query.clone(),
                                     open_buffers: open_buffers.clone(),

crates/remote_server/src/remote_editing_tests.rs 🔗

@@ -290,6 +290,53 @@ async fn test_remote_project_search(cx: &mut TestAppContext, server_cx: &mut Tes
     .await;
 }
 
+#[gpui::test]
+async fn test_remote_project_search_single_cpu(
+    cx: &mut TestAppContext,
+    server_cx: &mut TestAppContext,
+) {
+    let fs = FakeFs::new(server_cx.executor());
+    fs.insert_tree(
+        path!("/code"),
+        json!({
+            "project1": {
+                ".git": {},
+                "README.md": "# project 1",
+                "src": {
+                    "lib.rs": "fn one() -> usize { 1 }"
+                }
+            },
+        }),
+    )
+    .await;
+
+    // Simulate a single-CPU environment (e.g. a devcontainer with 1 visible CPU).
+    // This causes the worker pool in project search to spawn num_cpus - 1 = 0 workers,
+    // which silently drops all search channels and produces zero results.
+    server_cx.executor().set_num_cpus(1);
+
+    let (project, _) = init_test(&fs, cx, server_cx).await;
+
+    project
+        .update(cx, |project, cx| {
+            project.find_or_create_worktree(path!("/code/project1"), true, cx)
+        })
+        .await
+        .unwrap();
+
+    cx.run_until_parked();
+
+    do_search_and_assert(
+        &project,
+        "project",
+        Default::default(),
+        false,
+        &[path!("project1/README.md")],
+        cx.clone(),
+    )
+    .await;
+}
+
 #[gpui::test]
 async fn test_remote_project_search_inclusion(
     cx: &mut TestAppContext,