mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
test(server): count containment derivations instead of timing the scan
The previous budget compared wall-clock scan time against a readdir walk of the same tree. packages/server/AGENTS.md requires tests to be deterministic, and a ratio between an I/O-bound walk and a CPU-bound scan moves with filesystem caching and runner load. isPathInsideRoot now counts its calls behind start/stopPathContainmentMetrics, matching the existing git command metrics. Scanning a symlink-free tree of 320 entries derived containment 1,760 times before this branch and derives it none now, which is the defect stated exactly rather than inferred from a stopwatch. A second test covers the other direction: a symlink that leaves the root is still checked, and still excluded from results.
This commit is contained in:
@@ -8,11 +8,11 @@ import {
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { startPathContainmentMetrics, stopPathContainmentMetrics } from "./path.js";
|
||||
import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js";
|
||||
import {
|
||||
searchDirectoryEntries,
|
||||
@@ -955,81 +955,56 @@ describe("relative typed-entry configuration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A home-directory scan is dominated by per-entry bookkeeping, not by reading the tree. These
|
||||
// budgets are expressed against a plain readdir walk of the same tree, so they mean the same
|
||||
// thing on a slow CI runner as on a developer machine.
|
||||
//
|
||||
// Skipped on Windows: the tree this needs is deeper than MAX_PATH allows, and the directory
|
||||
// listing cache this measures is disabled there because directory metadata cannot validate it.
|
||||
describe.skipIf(isWindows)("home-tree scan cost", () => {
|
||||
const ENTRIES = 5_000;
|
||||
// Reading a tree is cheap; re-deriving each entry's path state is not. A scan that asks whether
|
||||
// every ancestor of every entry is inside the root, or is Git-ignored, costs multiples of the
|
||||
// read and is what made large home directories exceed the client request timeout.
|
||||
describe("home-tree scan cost", () => {
|
||||
const DEPTH = 8;
|
||||
// Long names cost more to normalize, and per-entry path work is spent normalizing them.
|
||||
const SEGMENT_NAME_LENGTH = 48;
|
||||
const CHAINS = 40;
|
||||
let scanRoot: string;
|
||||
|
||||
function buildDeepTree(root: string): void {
|
||||
const chains = Math.ceil(ENTRIES / DEPTH);
|
||||
for (let chain = 0; chain < chains; chain += 1) {
|
||||
const segments: string[] = [];
|
||||
for (let level = 0; level < DEPTH; level += 1) {
|
||||
segments.push(`d${chain * DEPTH + level}`.padEnd(SEGMENT_NAME_LENGTH, "x"));
|
||||
}
|
||||
mkdirSync(path.join(root, ...segments), { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function readWholeTree(root: string): Promise<void> {
|
||||
let level = [root];
|
||||
while (level.length) {
|
||||
const next: string[] = [];
|
||||
for (const directory of level) {
|
||||
const children = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
||||
for (const child of children) {
|
||||
if (child.isDirectory()) next.push(path.join(directory, child.name));
|
||||
}
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
}
|
||||
|
||||
async function measure(run: () => Promise<unknown>): Promise<number> {
|
||||
const startedAt = process.hrtime.bigint();
|
||||
await run();
|
||||
return Number(process.hrtime.bigint() - startedAt) / 1e6;
|
||||
}
|
||||
|
||||
// Nothing matches, so every scan spends its whole budget and the timings are comparable.
|
||||
const scanDeepTree = () =>
|
||||
searchAbsoluteDirectoryPaths({
|
||||
homeDir: scanRoot,
|
||||
query: "nomatchanywhereinthistree",
|
||||
limit: 30,
|
||||
maxDepth: DEPTH + 4,
|
||||
maxDirectoriesScanned: ENTRIES,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
scanRoot = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-scan-cost-")));
|
||||
buildDeepTree(scanRoot);
|
||||
for (let chain = 0; chain < CHAINS; chain += 1) {
|
||||
const segments: string[] = [];
|
||||
for (let level = 0; level < DEPTH; level += 1) segments.push(`c${chain}l${level}`);
|
||||
mkdirSync(path.join(scanRoot, ...segments), { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(scanRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("scans a deep tree within a bounded multiple of reading it", async () => {
|
||||
const readMs = await measure(() => readWholeTree(scanRoot));
|
||||
await scanDeepTree();
|
||||
const scanMs = await measure(scanDeepTree);
|
||||
// Nothing matches, so the scan walks the whole tree instead of stopping at a confident result.
|
||||
const scanWholeTree = () =>
|
||||
searchAbsoluteDirectoryPaths({ homeDir: scanRoot, query: "nomatch", maxDepth: DEPTH + 4 });
|
||||
|
||||
// Re-deriving containment and Git-ignore state for every ancestor of every entry, and
|
||||
// sweeping the directory cache on every miss, put this past 12x. That is what made real
|
||||
// home directories exceed the client request timeout.
|
||||
const ratio = scanMs / readMs;
|
||||
expect({ ratio: Math.round(ratio), withinBudget: ratio < 6 }).toEqual({
|
||||
ratio: expect.any(Number),
|
||||
withinBudget: true,
|
||||
});
|
||||
}, 60_000);
|
||||
async function countContainmentChecks(run: () => Promise<unknown>): Promise<number> {
|
||||
startPathContainmentMetrics();
|
||||
await run();
|
||||
return stopPathContainmentMetrics();
|
||||
}
|
||||
|
||||
it("derives no containment for a tree that cannot leave the root", async () => {
|
||||
const checks = await countContainmentChecks(scanWholeTree);
|
||||
|
||||
expect({ checks, scanned: CHAINS * DEPTH }).toEqual({ checks: 0, scanned: 320 });
|
||||
});
|
||||
|
||||
it.skipIf(isWindows)(
|
||||
"derives containment for symlinked entries, which can leave the root",
|
||||
async () => {
|
||||
const outside = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-scan-away-")));
|
||||
symlinkSync(outside, path.join(scanRoot, "escape"), "dir");
|
||||
|
||||
const checks = await countContainmentChecks(scanWholeTree);
|
||||
|
||||
expect({ checked: checks > 0, escaped: (await scanWholeTree()).includes(outside) }).toEqual({
|
||||
checked: true,
|
||||
escaped: false,
|
||||
});
|
||||
rmSync(outside, { recursive: true, force: true });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -58,7 +58,22 @@ export function createRealpathAwarePathMatcher(target: string): (candidate: stri
|
||||
};
|
||||
}
|
||||
|
||||
let pathContainmentChecks: number | null = null;
|
||||
|
||||
// Containment is re-derived from scratch on every call. A tree walk that asks it per entry, or
|
||||
// per ancestor of every entry, costs more than reading the tree does, so tests count the calls.
|
||||
export function startPathContainmentMetrics(): void {
|
||||
pathContainmentChecks = 0;
|
||||
}
|
||||
|
||||
export function stopPathContainmentMetrics(): number {
|
||||
const checks = pathContainmentChecks ?? 0;
|
||||
pathContainmentChecks = null;
|
||||
return checks;
|
||||
}
|
||||
|
||||
export function isPathInsideRoot(root: string, candidate: string): boolean {
|
||||
if (pathContainmentChecks !== null) pathContainmentChecks += 1;
|
||||
return getRelativePathInsideRoot(root, candidate) !== null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user