mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
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:
co-authored by
Leandro Ardissone
parent
cc8fe41e28
commit
ed48655a77
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user