From ed48655a777577a8ab3dd4e6f61a6cec64555413 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:18:19 +0200 Subject: [PATCH] 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> --- .../src/utils/directory-suggestions.test.ts | 55 +++++++++++++++++++ .../server/src/utils/directory-suggestions.ts | 23 +++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index c7d4126af1..9d9015aed0 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -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): Promise { + const pending = new Set(); + 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); + }); +}); diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index 35e8f5154b..39baf3a44c 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -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 { + return filesystemCalls(() => fs.readdir(directory, { withFileTypes: true })); +} + +function realpath(target: string): Promise { + return filesystemCalls(() => fs.realpath(target)); +} + +function stat(target: string): Promise { + return filesystemCalls(() => fs.stat(target)); +} + export const WORKSPACE_SEARCH_HIDDEN_DIRECTORIES = [ ".agents", ".claude", @@ -655,7 +674,7 @@ async function readChildren(directory: string): Promise { ) { 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)