fix(server): stop directory search from taking every filesystem thread (#6029)

Each pending fs call holds one of libuv's four shared threadpool threads,
and a read on a hung mount, a protected macOS folder, or a huge network
directory does not return. Add Project starts a new search per keystroke
and nothing cancels the old ones, so four stuck searches took every
thread: logging stopped and `paseo project create` timed out until the
read returned or the daemon was killed.

Directory search now runs its filesystem calls through one shared limit
of two, leaving the rest of the pool to the daemon.

Refs #4879

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Leandro Ardissone <59826+lardissone@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-04 10:18:19 +02:00
committed by GitHub
co-authored by Leandro Ardissone
parent cc8fe41e28
commit ed48655a77
2 changed files with 76 additions and 2 deletions
@@ -1,3 +1,4 @@
import { createHook } from "node:async_hooks";
import { execFileSync } from "node:child_process";
import {
mkdtempSync,
@@ -1024,3 +1025,57 @@ describe("home-tree scan cost", () => {
},
);
});
// Every pending fs call holds one of libuv's shared threadpool threads (4 by default). A read that
// never returns, such as one on a hung network mount, keeps its thread, so searches that pile up
// while the user types must not be able to take every thread from the rest of the daemon.
describe("filesystem threads held by concurrent searches", () => {
const SEARCHES = 8;
let scanRoot: string;
beforeEach(() => {
scanRoot = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-scan-threads-")));
for (let branch = 0; branch < 20; branch += 1) {
mkdirSync(path.join(scanRoot, `b${branch}`, "inner", "leaf"), { recursive: true });
}
});
afterEach(() => {
rmSync(scanRoot, { recursive: true, force: true });
});
async function peakPendingFilesystemRequests(run: () => Promise<unknown>): Promise<number> {
const pending = new Set<number>();
let peak = 0;
const hook = createHook({
init(asyncId, type) {
if (type !== "FSREQPROMISE" && type !== "FSREQCALLBACK") return;
pending.add(asyncId);
peak = Math.max(peak, pending.size);
},
before(asyncId) {
pending.delete(asyncId);
},
});
hook.enable();
try {
await run();
} finally {
hook.disable();
}
return peak;
}
function searchWhileTyping() {
const queries = Array.from({ length: SEARCHES }, (_, index) => `nomatch${index}`);
return Promise.all(
queries.map((query) => searchAbsoluteDirectoryPaths({ homeDir: scanRoot, query })),
);
}
it("leaves threadpool threads free for the rest of the daemon", async () => {
const peak = await peakPendingFilesystemRequests(searchWhileTyping);
expect(peak).toBeLessThanOrEqual(2);
});
});
@@ -1,6 +1,7 @@
import type { Dirent, Stats } from "node:fs";
import { readdir, realpath, stat } from "node:fs/promises";
import * as fs from "node:fs/promises";
import path from "node:path";
import pLimit from "p-limit";
import { scorePathMatch, type MatchScore } from "@getpaseo/protocol/search/text-match";
import { isPathInsideRoot } from "./path.js";
import { runGitCommand } from "./run-git-command.js";
@@ -102,6 +103,24 @@ const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER;
const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER;
const NO_FUZZY_SCORE = Number.MAX_SAFE_INTEGER;
const NO_MATCH_TIER = 5;
// Each pending fs call holds one of libuv's shared threadpool threads (4 by default), and a read on
// a hung mount or a protected folder never returns. Searches pile up while the user types, so they
// share two threads and leave the rest of the pool to the daemon.
const filesystemCalls = pLimit(2);
function readdir(directory: string): Promise<Dirent[]> {
return filesystemCalls(() => fs.readdir(directory, { withFileTypes: true }));
}
function realpath(target: string): Promise<string> {
return filesystemCalls(() => fs.realpath(target));
}
function stat(target: string): Promise<Stats> {
return filesystemCalls(() => fs.stat(target));
}
export const WORKSPACE_SEARCH_HIDDEN_DIRECTORIES = [
".agents",
".claude",
@@ -655,7 +674,7 @@ async function readChildren(directory: string): Promise<ChildEntry[]> {
) {
rawEntries = cached.entries;
} else {
const dirents = await readdir(directory, { withFileTypes: true }).catch(() => [] as Dirent[]);
const dirents = await readdir(directory).catch(() => [] as Dirent[]);
rawEntries = dirents
.map(toRawChildEntry)
.filter((entry): entry is RawChildEntry => entry !== null)