mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
fix(server): bound watcher work for late ignored directories
Refresh Git exclusions when ignored directories appear after subscription and bound Linux and native classification work. Preserve tracked-file notifications, ignore-update barriers, and watcher recovery. Fixes #4558. Supersedes #4808 by @Marcus172; this change incorporates six commits originally authored by Marek Rom and adds verified follow-up fixes. The independent session-context leak is fixed by #5208. Combined isolated daemon verification passed. Co-authored-by: Marek Rom <marek.marcus.rom@gmail.com>
This commit is contained in:
co-authored by
Marek Rom
parent
9978988e35
commit
c3e1e084a0
@@ -180,6 +180,37 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps: *server_test_steps
|
||||
|
||||
# native-recursive.ts serves darwin and win32, but windows-latest above is the only
|
||||
# runner that exercises it. macOS — the platform the reported watcher bug was on —
|
||||
# otherwise has no server-test coverage. Scoped to the observer/watcher suites to
|
||||
# bound runtime.
|
||||
server-tests-macos:
|
||||
name: server-tests (macos-14, file observation)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build server dependencies
|
||||
run: npm run build:server-deps
|
||||
|
||||
- name: Run file observation tests
|
||||
run: |
|
||||
npm run test:unit --workspace=@getpaseo/server -- \
|
||||
src/server/file-observer \
|
||||
src/server/workspace-git-service.observation.test.ts \
|
||||
src/server/workspace-git-service.observation.integration.test.ts \
|
||||
--bail=1
|
||||
|
||||
desktop-tests-ubuntu:
|
||||
name: desktop-tests (ubuntu-latest)
|
||||
needs: changes
|
||||
|
||||
@@ -10,12 +10,12 @@ Closing an observation must remain safe after its root is renamed or removed. Wo
|
||||
|
||||
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. The initial Linux scan conservatively emits existing files discovered before their directory watcher was installed; WorkspaceGit batches these into one refresh. Benchmark this startup cost with pre-populated trees.
|
||||
|
||||
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root.
|
||||
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Classification is admission-controlled to 32 concurrent `fs.stat` calls, since `fs.stat` runs on the libuv threadpool the whole daemon shares. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root.
|
||||
|
||||
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
|
||||
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. `nativeTrackedFileCount` and other per-file counters are native-backend diagnostics only — the Linux backend keeps no per-file inventory and reports zero — so a cross-platform assertion belongs on delivered events, not those counters. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
|
||||
|
||||
Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module.
|
||||
|
||||
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events and watcher recovery, never from a healthy-watcher timer.
|
||||
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events, newly observed directories, and watcher recovery, never from a healthy-watcher timer. `git ls-files` reports an ignored directory only once it exists, so new directory topology is a trigger in its own right; the refresh is edge-triggered per directory and debounced so a dependency install collapses into a couple of Git runs.
|
||||
|
||||
The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak.
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"measure:agent-tools": "tsx scripts/measure-agent-tools-context.ts",
|
||||
"measure:git-observation": "tsx scripts/measure-git-observation.ts",
|
||||
"measure:file-observer": "tsx scripts/measure-file-observer.ts",
|
||||
"analyze:observer-metrics": "tsx scripts/analyze-observer-metrics.ts",
|
||||
"repro:file-observer-teardown": "tsx scripts/reproduce-file-observer-teardown.ts",
|
||||
"speech:models": "tsx scripts/list-speech-models.ts",
|
||||
"speech:download": "tsx scripts/download-speech-models.ts",
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
interface Sample {
|
||||
uptimeSeconds: number;
|
||||
trackedFiles: number;
|
||||
renames: number;
|
||||
classifications: number;
|
||||
pendingClassifications: number;
|
||||
pendingWork: number;
|
||||
maxReconciliationMs: number;
|
||||
eventLoopMaxMs: number;
|
||||
rssBytes: number;
|
||||
failures: number;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" ? value : undefined;
|
||||
}
|
||||
|
||||
// Pino lines are prefixed with a plain-text timestamp; the JSON payload starts
|
||||
// at the first `{`. Lines that aren't JSON (or aren't ws_runtime_metrics) are skipped.
|
||||
function parseLogLine(line: string): Record<string, unknown> | undefined {
|
||||
const start = line.indexOf("{");
|
||||
if (start < 0) return undefined;
|
||||
try {
|
||||
return asRecord(JSON.parse(line.slice(start)));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function extractObserverRecord(root: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
const workspaceService = asRecord(asRecord(root.git)?.workspaceService);
|
||||
return asRecord(workspaceService?.fileObserver);
|
||||
}
|
||||
|
||||
function buildSample(root: Record<string, unknown>, observer: Record<string, unknown>): Sample {
|
||||
const eventLoopDelay = asRecord(root.eventLoopDelay);
|
||||
const memory = asRecord(root.memory);
|
||||
return {
|
||||
uptimeSeconds: asNumber(root.uptimeSeconds) ?? 0,
|
||||
trackedFiles: asNumber(observer.nativeTrackedFileCount) ?? 0,
|
||||
renames: asNumber(observer.nativeRenameEventCount) ?? 0,
|
||||
classifications: asNumber(observer.nativeClassificationCount) ?? 0,
|
||||
pendingClassifications: asNumber(observer.pendingClassificationCount) ?? 0,
|
||||
pendingWork: asNumber(observer.pendingReconciliationWorkCount) ?? 0,
|
||||
maxReconciliationMs: asNumber(observer.maxReconciliationDurationMs) ?? 0,
|
||||
eventLoopMaxMs: asNumber(eventLoopDelay?.maxMs) ?? 0,
|
||||
rssBytes: asNumber(memory?.rss) ?? 0,
|
||||
failures:
|
||||
(asNumber(observer.observerFailureCount) ?? 0) +
|
||||
(asNumber(observer.reconciliationFailureCount) ?? 0) +
|
||||
(asNumber(observer.directoryLimitFailureCount) ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function readSamples(path: string): Promise<Sample[]> {
|
||||
const samples: Sample[] = [];
|
||||
const lines = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
|
||||
for await (const line of lines) {
|
||||
const root = parseLogLine(line);
|
||||
if (!root) continue;
|
||||
const observer = extractObserverRecord(root);
|
||||
if (!observer) continue;
|
||||
samples.push(buildSample(root, observer));
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
// A daemon restart resets uptime, so a decrease starts a new run.
|
||||
function splitRuns(samples: Sample[]): Sample[][] {
|
||||
const runs: Sample[][] = [];
|
||||
let current: Sample[] = [];
|
||||
let previous = Number.NEGATIVE_INFINITY;
|
||||
for (const sample of samples) {
|
||||
if (sample.uptimeSeconds < previous && current.length > 0) {
|
||||
runs.push(current);
|
||||
current = [];
|
||||
}
|
||||
current.push(sample);
|
||||
previous = sample.uptimeSeconds;
|
||||
}
|
||||
if (current.length > 0) runs.push(current);
|
||||
return runs;
|
||||
}
|
||||
|
||||
function peak(run: Sample[], select: (sample: Sample) => number): number {
|
||||
return run.reduce((highest, sample) => Math.max(highest, select(sample)), 0);
|
||||
}
|
||||
|
||||
const logPath = process.argv[2];
|
||||
if (!logPath) {
|
||||
console.error("usage: analyze-observer-metrics <path-to-daemon.log>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const runs = splitRuns(await readSamples(logPath));
|
||||
const header = [
|
||||
"run".padStart(3),
|
||||
"samples".padStart(7),
|
||||
"hours".padStart(6),
|
||||
"peakTracked".padStart(11),
|
||||
"renames".padStart(10),
|
||||
"classify".padStart(10),
|
||||
"peakPendClass".padStart(13),
|
||||
"peakPendWork".padStart(12),
|
||||
"maxReconS".padStart(9),
|
||||
"peakLoopS".padStart(9),
|
||||
"peakRssMB".padStart(9),
|
||||
"fails".padStart(5),
|
||||
].join(" ");
|
||||
console.log(header);
|
||||
runs.forEach((run, index) => {
|
||||
console.log(
|
||||
[
|
||||
String(index + 1).padStart(3),
|
||||
String(run.length).padStart(7),
|
||||
(run[run.length - 1].uptimeSeconds / 3600).toFixed(1).padStart(6),
|
||||
String(peak(run, (s) => s.trackedFiles)).padStart(11),
|
||||
String(peak(run, (s) => s.renames)).padStart(10),
|
||||
String(peak(run, (s) => s.classifications)).padStart(10),
|
||||
String(peak(run, (s) => s.pendingClassifications)).padStart(13),
|
||||
String(peak(run, (s) => s.pendingWork)).padStart(12),
|
||||
(peak(run, (s) => s.maxReconciliationMs) / 1000).toFixed(1).padStart(9),
|
||||
(peak(run, (s) => s.eventLoopMaxMs) / 1000).toFixed(1).padStart(9),
|
||||
(peak(run, (s) => s.rssBytes) / 1048576).toFixed(0).padStart(9),
|
||||
String(peak(run, (s) => s.failures)).padStart(5),
|
||||
].join(" "),
|
||||
);
|
||||
});
|
||||
@@ -1,10 +1,14 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { monitorEventLoopDelay, performance } from "node:perf_hooks";
|
||||
import { promisify } from "node:util";
|
||||
import { createFileObserver, type FileChange } from "../src/server/file-observer/index.js";
|
||||
import {
|
||||
createFileObserver,
|
||||
type FileChange,
|
||||
type FileObserverSubscription,
|
||||
} from "../src/server/file-observer/index.js";
|
||||
|
||||
const DIRECTORY_COUNT = readPositiveInteger("PASEO_WATCH_BENCH_DIRS", 500);
|
||||
const ROOT_COUNT = readPositiveInteger("PASEO_WATCH_BENCH_ROOTS", 1);
|
||||
@@ -17,8 +21,58 @@ const SUSTAINED_EDIT_INTERVAL_MS = readPositiveInteger(
|
||||
500,
|
||||
);
|
||||
const REPETITIONS = readPositiveInteger("PASEO_WATCH_BENCH_REPETITIONS", 1);
|
||||
const MODE = resolveMode();
|
||||
const LATE_IGNORED_DIRS = readPositiveInteger("PASEO_WATCH_BENCH_LATE_IGNORED_DIRS", 2_000);
|
||||
const LATE_IGNORED_FILES = readPositiveInteger("PASEO_WATCH_BENCH_LATE_IGNORED_FILES", 20_000);
|
||||
// directory-churn mode exercises removeSubtree/reconcileSubtree/paths.collapse
|
||||
// in packages/server/src/server/file-observer/internal/native-recursive.ts,
|
||||
// which the healthy and late-ignored fixtures never touch: they only
|
||||
// mkdir/writeFile, and every delete happens after unsubscribe.
|
||||
const CHURN_DIRECTORY_COUNT = readPositiveInteger("PASEO_WATCH_BENCH_CHURN_DIRECTORIES", 20_000);
|
||||
const CHURN_FILES_PER_DIRECTORY = readPositiveInteger(
|
||||
"PASEO_WATCH_BENCH_CHURN_FILES_PER_DIRECTORY",
|
||||
3,
|
||||
);
|
||||
const CHURN_ROUNDS = readPositiveInteger("PASEO_WATCH_BENCH_CHURN_ROUNDS", 3);
|
||||
const CHURN_DIRECTORIES_PER_ROUND = readPositiveInteger(
|
||||
"PASEO_WATCH_BENCH_CHURN_DIRECTORIES_PER_ROUND",
|
||||
500,
|
||||
);
|
||||
// Sustained rename churn (the production symptom this mode approximates) can
|
||||
// leave a single reconciliation running for minutes once the tracked set is
|
||||
// large; give it room to finish instead of calling that a timeout failure.
|
||||
const CHURN_SETTLE_TIMEOUT_MS = readPositiveInteger(
|
||||
"PASEO_WATCH_BENCH_CHURN_SETTLE_TIMEOUT_MS",
|
||||
300_000,
|
||||
);
|
||||
// Sequential `await`-per-entry construction lets the observer's pending
|
||||
// fs.stat() classifications drain between filesystem calls, so the storm this
|
||||
// mode exists to reproduce never piles up: the sequential version could never
|
||||
// push pendingClassificationCount past 32, nowhere near the 2,080 bound
|
||||
// (32 concurrent + 2,048 queued) a later fix is measured against. Firing
|
||||
// batches of concurrent mkdir/writeFile calls overwhelms the libuv threadpool
|
||||
// that both the harness's own fs calls and the observer's classify() stat()
|
||||
// calls share, which is what produces a real backlog. The batch size is
|
||||
// derived from this process's actual open-file limit (see
|
||||
// resolveLateIgnoredBatchSize below) rather than hardcoded, so a replay on a
|
||||
// machine with a much lower `ulimit -n` (common on Linux/CI, historically on
|
||||
// macOS too) degrades to a smaller burst instead of throwing EMFILE mid-run.
|
||||
const LATE_IGNORED_BATCH_SIZE = resolveLateIgnoredBatchSize();
|
||||
// Reuses the same descriptor-aware sizing as LATE_IGNORED_BATCH_SIZE above:
|
||||
// directory-churn mode needs concurrent, not sequential, filesystem calls to
|
||||
// outrun the audit's 500ms quiet window and force scopes to pile up.
|
||||
const CHURN_BATCH_SIZE = resolveLateIgnoredBatchSize();
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
type BenchMode = "healthy" | "late-ignored" | "directory-churn";
|
||||
|
||||
function resolveMode(): BenchMode {
|
||||
const raw = process.env.PASEO_WATCH_BENCH_MODE;
|
||||
if (raw === "late-ignored") return "late-ignored";
|
||||
if (raw === "directory-churn") return "directory-churn";
|
||||
return "healthy";
|
||||
}
|
||||
|
||||
interface Measurement {
|
||||
backend: string;
|
||||
run: number;
|
||||
@@ -40,6 +94,8 @@ interface Measurement {
|
||||
sustainedNativeClassificationCount: number | null;
|
||||
sustainedNativeShallowScanCount: number | null;
|
||||
sustainedMissedPaths: number;
|
||||
lateIgnoredTrackedFileCount: number | null;
|
||||
peakPendingClassificationCount: number;
|
||||
teardownMs: number;
|
||||
startupEvents: number;
|
||||
deliveredEvents: number;
|
||||
@@ -53,6 +109,16 @@ interface Measurement {
|
||||
kernelWatchCount: number | null;
|
||||
scopedReconciliationCount: number | null;
|
||||
fullReconciliationCount: number | null;
|
||||
// Absolute values (not deltas) read straight off diagnostics: the observer
|
||||
// instance is fresh per run, so "max"/"last" already scope to this run.
|
||||
maxReconciliationDurationMs: number;
|
||||
lastReconciliationDurationMs: number;
|
||||
// directory-churn mode only; null for healthy/late-ignored.
|
||||
nativeRenameEventCount: number | null;
|
||||
churnDirectoryCount: number | null;
|
||||
churnFileCount: number | null;
|
||||
churnRounds: number | null;
|
||||
churnDirectoriesChurned: number | null;
|
||||
}
|
||||
|
||||
interface FixtureRoot {
|
||||
@@ -65,7 +131,7 @@ async function main(): Promise<void> {
|
||||
const isChild = process.argv.includes("--child");
|
||||
|
||||
if (isChild) {
|
||||
process.stdout.write(`${JSON.stringify(await measure(1))}\n`);
|
||||
process.stdout.write(`${JSON.stringify(await runMeasurement(1))}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -80,6 +146,10 @@ async function main(): Promise<void> {
|
||||
if (!evaluation.passed) process.exitCode = 1;
|
||||
}
|
||||
|
||||
function runMeasurement(run: number): Promise<Measurement> {
|
||||
return MODE === "directory-churn" ? measureDirectoryChurn(run) : measure(run);
|
||||
}
|
||||
|
||||
async function measureInChild(run: number): Promise<Measurement> {
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
@@ -98,7 +168,7 @@ async function measure(run: number): Promise<Measurement> {
|
||||
const ignoredRoot = join(root, "ignored");
|
||||
const trackedRoot = join(root, "tracked");
|
||||
await Promise.all([
|
||||
mkdir(ignoredRoot, { recursive: true }),
|
||||
MODE === "healthy" ? mkdir(ignoredRoot, { recursive: true }) : Promise.resolve(),
|
||||
mkdir(trackedRoot, { recursive: true }),
|
||||
]);
|
||||
for (let index = 0; index < DIRECTORY_COUNT; index += 1) {
|
||||
@@ -135,7 +205,7 @@ async function measure(run: number): Promise<Measurement> {
|
||||
firstObservedAt.set(event.path, performance.now());
|
||||
}
|
||||
},
|
||||
{ ignore: [ignoredRoot] },
|
||||
{ ignore: MODE === "healthy" ? [ignoredRoot] : [] },
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -144,7 +214,40 @@ async function measure(run: number): Promise<Measurement> {
|
||||
const startupEvents = delivered.length;
|
||||
const kernelWatchCount = await readKernelWatchCount();
|
||||
|
||||
let peakPendingClassificationCount = 0;
|
||||
let lateIgnoredTrackedFileCount: number | null = null;
|
||||
let samplePending: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
try {
|
||||
samplePending = setInterval(() => {
|
||||
peakPendingClassificationCount = Math.max(
|
||||
peakPendingClassificationCount,
|
||||
observer.getDiagnostics().pendingClassificationCount,
|
||||
);
|
||||
}, 50);
|
||||
samplePending.unref();
|
||||
|
||||
if (MODE === "late-ignored") {
|
||||
// The whole point: the ignored tree appears only after the observation
|
||||
// exists. Construction runs in concurrent batches (not one
|
||||
// mkdir/writeFile at a time) so filesystem events arrive faster than
|
||||
// the observer can classify them — otherwise nothing ever piles up.
|
||||
for (const target of roots) {
|
||||
await mkdir(target.ignoredRoot, { recursive: true });
|
||||
await runInBatches(LATE_IGNORED_DIRS, LATE_IGNORED_BATCH_SIZE, (index) =>
|
||||
mkdir(join(target.ignoredRoot, `package-${index}`), { recursive: true }),
|
||||
);
|
||||
await runInBatches(LATE_IGNORED_FILES, LATE_IGNORED_BATCH_SIZE, (index) =>
|
||||
writeFile(
|
||||
join(target.ignoredRoot, `package-${index % LATE_IGNORED_DIRS}`, `module-${index}.js`),
|
||||
`${index}\n`,
|
||||
),
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
||||
lateIgnoredTrackedFileCount = observer.getDiagnostics().nativeTrackedFileCount;
|
||||
}
|
||||
|
||||
for (let index = 0; index < IGNORED_FILE_COUNT; index += 1) {
|
||||
const target = roots[index % roots.length];
|
||||
await writeFile(join(target.ignoredRoot, `artifact-${index}.txt`), `${index}\n`);
|
||||
@@ -263,6 +366,8 @@ async function measure(run: number): Promise<Measurement> {
|
||||
sustainedMissedPaths: sustainedPaths.filter(
|
||||
(path) => !delivered.some((event) => event.path === path),
|
||||
).length,
|
||||
lateIgnoredTrackedFileCount,
|
||||
peakPendingClassificationCount,
|
||||
teardownMs,
|
||||
startupEvents,
|
||||
deliveredEvents: delivered.length,
|
||||
@@ -282,14 +387,307 @@ async function measure(run: number): Promise<Measurement> {
|
||||
diagnosticsAfter.scopedReconciliationCount - diagnosticsBefore.scopedReconciliationCount,
|
||||
fullReconciliationCount:
|
||||
diagnosticsAfter.fullReconciliationCount - diagnosticsBefore.fullReconciliationCount,
|
||||
maxReconciliationDurationMs: diagnosticsAfter.maxReconciliationDurationMs,
|
||||
lastReconciliationDurationMs: diagnosticsAfter.lastReconciliationDurationMs,
|
||||
nativeRenameEventCount: null,
|
||||
churnDirectoryCount: null,
|
||||
churnFileCount: null,
|
||||
churnRounds: null,
|
||||
churnDirectoriesChurned: null,
|
||||
};
|
||||
} finally {
|
||||
if (samplePending) clearInterval(samplePending);
|
||||
await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe()));
|
||||
await observer.close();
|
||||
await rm(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// directory-churn mode. Unlike healthy/late-ignored, this builds one large
|
||||
// tracked tree and then repeatedly makes whole known directories disappear —
|
||||
// the only way to invoke removeSubtree, reconcileSubtree, and paths.collapse
|
||||
// in native-recursive.ts.
|
||||
async function measureDirectoryChurn(run: number): Promise<Measurement> {
|
||||
const totalChurnTargets = CHURN_ROUNDS * CHURN_DIRECTORIES_PER_ROUND;
|
||||
if (totalChurnTargets > CHURN_DIRECTORY_COUNT) {
|
||||
throw new Error(
|
||||
`PASEO_WATCH_BENCH_CHURN_ROUNDS (${CHURN_ROUNDS}) * ` +
|
||||
`PASEO_WATCH_BENCH_CHURN_DIRECTORIES_PER_ROUND (${CHURN_DIRECTORIES_PER_ROUND}) = ` +
|
||||
`${totalChurnTargets} must not exceed PASEO_WATCH_BENCH_CHURN_DIRECTORIES ` +
|
||||
`(${CHURN_DIRECTORY_COUNT})`,
|
||||
);
|
||||
}
|
||||
|
||||
const base = await mkdtemp(join(tmpdir(), "paseo-watch-churn-"));
|
||||
const root = join(base, "root");
|
||||
await mkdir(root, { recursive: true });
|
||||
const observer = createFileObserver();
|
||||
let subscription: FileObserverSubscription | null = null;
|
||||
|
||||
try {
|
||||
// 1. Build a large, entirely-tracked tree before subscribing. Nothing
|
||||
// here is ignored: removeSubtree and reconcileSubtree scale with the size
|
||||
// of `this.files` / `this.directories`, so the fixture needs a big
|
||||
// tracked set for their cost to show up at all.
|
||||
await runInBatches(CHURN_DIRECTORY_COUNT, CHURN_BATCH_SIZE, (index) =>
|
||||
mkdir(join(root, `dir-${index}`)),
|
||||
);
|
||||
await runInBatches(
|
||||
CHURN_DIRECTORY_COUNT * CHURN_FILES_PER_DIRECTORY,
|
||||
CHURN_BATCH_SIZE,
|
||||
(flatIndex) => {
|
||||
const directoryIndex = Math.floor(flatIndex / CHURN_FILES_PER_DIRECTORY);
|
||||
const fileIndex = flatIndex % CHURN_FILES_PER_DIRECTORY;
|
||||
return writeFile(
|
||||
join(root, `dir-${directoryIndex}`, `file-${fileIndex}.txt`),
|
||||
`${flatIndex}\n`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const rssBefore = process.memoryUsage().rss;
|
||||
const cpuBefore = process.cpuUsage();
|
||||
const diagnosticsBefore = observer.getDiagnostics();
|
||||
const eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
|
||||
eventLoopDelay.enable();
|
||||
const delivered: FileChange[] = [];
|
||||
let error: Error | null = null;
|
||||
|
||||
const setupStarted = performance.now();
|
||||
subscription = await observer.subscribe(root, (nextError, events) => {
|
||||
error ??= nextError;
|
||||
delivered.push(...events);
|
||||
});
|
||||
const setupMs = performance.now() - setupStarted;
|
||||
|
||||
// 2. Let the initial inventory settle before churning anything.
|
||||
const initialSettled = await waitFor(() => {
|
||||
const diagnostics = observer.getDiagnostics();
|
||||
return (
|
||||
diagnostics.pendingEventCount === 0 &&
|
||||
diagnostics.pendingReconciliationWorkCount === 0 &&
|
||||
diagnostics.reconciliationInFlightCount === 0
|
||||
);
|
||||
}, 120_000);
|
||||
if (!initialSettled) {
|
||||
throw new Error("Timed out waiting for directory-churn initial inventory to settle");
|
||||
}
|
||||
if (error) throw error;
|
||||
const startupEvents = delivered.length;
|
||||
const kernelWatchCount = await readKernelWatchCount();
|
||||
|
||||
// 3. Drive directory churn. Each round deletes half of a batch of known
|
||||
// directories outright (their parent's next shallow scan finds them gone
|
||||
// and calls removeSubtree) and replaces the other half atomically in
|
||||
// place: rm the old directory, then rename a freshly built sibling onto
|
||||
// the same path. The backend's classify() stat() resolves after the
|
||||
// rename completes, sees a directory at a path it already knew as a
|
||||
// directory, and treats it as the directory itself changing —
|
||||
// populating recursiveScopes and driving reconcileSubtree. Firing every
|
||||
// round's operations concurrently, faster than the audit's 500ms quiet
|
||||
// window, is what forces the resulting scopes onto one large
|
||||
// reconciliation instead of many small ones, which is what the
|
||||
// production incident this mode approximates looked like.
|
||||
const diagnosticsBeforeChurn = observer.getDiagnostics();
|
||||
const churnCpuBefore = process.cpuUsage();
|
||||
const churnStarted = performance.now();
|
||||
let nextDirectoryIndex = 0;
|
||||
for (let round = 0; round < CHURN_ROUNDS; round += 1) {
|
||||
const batch: number[] = [];
|
||||
for (let i = 0; i < CHURN_DIRECTORIES_PER_ROUND; i += 1) {
|
||||
batch.push(nextDirectoryIndex);
|
||||
nextDirectoryIndex += 1;
|
||||
}
|
||||
const deleteCount = Math.floor(batch.length / 2);
|
||||
const deleteTargets = batch.slice(0, deleteCount);
|
||||
const replaceTargets = batch.slice(deleteCount);
|
||||
await runInBatches(deleteTargets.length, CHURN_BATCH_SIZE, (offset) =>
|
||||
rm(join(root, `dir-${deleteTargets[offset]}`), { recursive: true, force: true }),
|
||||
);
|
||||
await runInBatches(replaceTargets.length, CHURN_BATCH_SIZE, (offset) =>
|
||||
replaceDirectoryAtomically(
|
||||
join(root, `dir-${replaceTargets[offset]}`),
|
||||
CHURN_FILES_PER_DIRECTORY,
|
||||
),
|
||||
);
|
||||
}
|
||||
const churnSettled = await waitFor(() => {
|
||||
const diagnostics = observer.getDiagnostics();
|
||||
return (
|
||||
diagnostics.pendingEventCount === 0 &&
|
||||
diagnostics.pendingReconciliationWorkCount === 0 &&
|
||||
diagnostics.reconciliationInFlightCount === 0
|
||||
);
|
||||
}, CHURN_SETTLE_TIMEOUT_MS);
|
||||
if (!churnSettled) {
|
||||
throw new Error(
|
||||
`Timed out waiting for directory-churn reconciliation to settle within ` +
|
||||
`${CHURN_SETTLE_TIMEOUT_MS}ms`,
|
||||
);
|
||||
}
|
||||
const churnDurationMs = performance.now() - churnStarted;
|
||||
const churnCpu = process.cpuUsage(churnCpuBefore);
|
||||
const churnCpuMs = (churnCpu.user + churnCpu.system) / 1_000;
|
||||
if (error) throw error;
|
||||
const diagnosticsAfterChurn = observer.getDiagnostics();
|
||||
|
||||
const teardownStarted = performance.now();
|
||||
await subscription.unsubscribe();
|
||||
const teardownMs = performance.now() - teardownStarted;
|
||||
const diagnosticsAfter = observer.getDiagnostics();
|
||||
const cpu = process.cpuUsage(cpuBefore);
|
||||
eventLoopDelay.disable();
|
||||
|
||||
return {
|
||||
backend: "production",
|
||||
run,
|
||||
setupMs,
|
||||
editLatencyMs: 0,
|
||||
editLatencyP50Ms: 0,
|
||||
editLatencyP95Ms: 0,
|
||||
editLatencyP99Ms: 0,
|
||||
burstDurationMs: churnDurationMs,
|
||||
burstCpuMs: churnCpuMs,
|
||||
sustainedDurationMs: 0,
|
||||
sustainedCpuMs: 0,
|
||||
sustainedScopedReconciliationCount: null,
|
||||
sustainedFullReconciliationCount: null,
|
||||
sustainedNativeEventCount: null,
|
||||
sustainedNativeChangeEventCount: null,
|
||||
sustainedNativeRenameEventCount: null,
|
||||
sustainedNativePathlessEventCount: null,
|
||||
sustainedNativeClassificationCount: null,
|
||||
sustainedNativeShallowScanCount: null,
|
||||
sustainedMissedPaths: 0,
|
||||
lateIgnoredTrackedFileCount: null,
|
||||
peakPendingClassificationCount: 0,
|
||||
teardownMs,
|
||||
startupEvents,
|
||||
deliveredEvents: delivered.length,
|
||||
// Nothing is ignored in this mode's fixture; see the comment at the
|
||||
// CHURN_DIRECTORY_COUNT declaration above.
|
||||
ignoredEvents: 0,
|
||||
missedTrackedPaths: 0,
|
||||
cpuUserMs: cpu.user / 1_000,
|
||||
cpuSystemMs: cpu.system / 1_000,
|
||||
rssDeltaMiB: (process.memoryUsage().rss - rssBefore) / 1024 / 1024,
|
||||
eventLoopDelayP99Ms: eventLoopDelay.percentile(99) / 1_000_000,
|
||||
eventLoopDelayMaxMs: eventLoopDelay.max / 1_000_000,
|
||||
kernelWatchCount,
|
||||
scopedReconciliationCount:
|
||||
diagnosticsAfter.scopedReconciliationCount - diagnosticsBefore.scopedReconciliationCount,
|
||||
fullReconciliationCount:
|
||||
diagnosticsAfter.fullReconciliationCount - diagnosticsBefore.fullReconciliationCount,
|
||||
maxReconciliationDurationMs: diagnosticsAfter.maxReconciliationDurationMs,
|
||||
lastReconciliationDurationMs: diagnosticsAfter.lastReconciliationDurationMs,
|
||||
nativeRenameEventCount:
|
||||
diagnosticsAfterChurn.nativeRenameEventCount -
|
||||
diagnosticsBeforeChurn.nativeRenameEventCount,
|
||||
churnDirectoryCount: CHURN_DIRECTORY_COUNT,
|
||||
churnFileCount: CHURN_DIRECTORY_COUNT * CHURN_FILES_PER_DIRECTORY,
|
||||
churnRounds: CHURN_ROUNDS,
|
||||
churnDirectoriesChurned: totalChurnTargets,
|
||||
};
|
||||
} finally {
|
||||
if (subscription) await subscription.unsubscribe();
|
||||
await observer.close();
|
||||
await rm(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Builds a fresh sibling directory, then rm+rename it onto `directory`. This
|
||||
// is the atomic-replace pattern dev-server builds use to swap output
|
||||
// directories in place, and the specific shape that drives reconcileSubtree:
|
||||
// see the comment inside measureDirectoryChurn's churn loop above.
|
||||
async function replaceDirectoryAtomically(directory: string, fileCount: number): Promise<void> {
|
||||
const staging = `${directory}.replacement`;
|
||||
await mkdir(staging);
|
||||
for (let fileIndex = 0; fileIndex < fileCount; fileIndex += 1) {
|
||||
await writeFile(join(staging, `file-${fileIndex}.txt`), `${fileIndex}\n`);
|
||||
}
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await rename(staging, directory);
|
||||
}
|
||||
|
||||
function resolveLateIgnoredBatchSize(): number {
|
||||
// A batch this small still produces a genuine burst instead of degenerating
|
||||
// back toward the sequential, one-at-a-time construction this code
|
||||
// replaces — see the comment on LATE_IGNORED_BATCH_SIZE above for why a
|
||||
// burst is required at all.
|
||||
const floor = 64;
|
||||
// Never exceed this regardless of how generous the descriptor limit is.
|
||||
// 2,000 already produces peak pending classifications in the ~2,700-3,200
|
||||
// range on a high-limit machine, comfortably clearing the 2,080 bound the
|
||||
// later fix imposes, so there is no reason to risk more file descriptors
|
||||
// for a larger, noisier number.
|
||||
const ceiling = 2_000;
|
||||
// Used when this runtime cannot report its own descriptor limit at all.
|
||||
// Small enough to be safe under a default Linux/CI soft limit of roughly
|
||||
// 1,024-4,096, still large enough to produce a real burst.
|
||||
const fallback = 512;
|
||||
const softLimit = readOpenFileSoftLimit();
|
||||
if (softLimit === null) return fallback;
|
||||
// Leave headroom for everything else the process holds open at the same
|
||||
// time: stdio, the fixture's own already-created directories/files, the
|
||||
// observer's native watch handle, and Node's own bookkeeping fds.
|
||||
const quarter = Math.floor(softLimit / 4);
|
||||
return Math.min(ceiling, Math.max(floor, quarter));
|
||||
}
|
||||
|
||||
function readOpenFileSoftLimit(): number | null {
|
||||
// Node's diagnostic report is the only cross-platform way to read this
|
||||
// process's own open-file (RLIMIT_NOFILE) soft limit without shelling out
|
||||
// to `ulimit -n`, which is a shell builtin with no direct Node API.
|
||||
// `header.fileDescriptorSoftLimit` is checked first for forward/backward
|
||||
// compatibility, but it does not exist on this script's tested runtime
|
||||
// (verified empirically: `undefined` on Node v24.15.0/darwin). The field
|
||||
// that does exist there, and is expected to exist on Linux too since it
|
||||
// comes from the same libuv getrlimit() call, is
|
||||
// `userLimits.open_files.soft`.
|
||||
let report: unknown;
|
||||
try {
|
||||
report = process.report?.getReport();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof report !== "object" || report === null) return null;
|
||||
const asRecord = report as Record<string, unknown>;
|
||||
return (
|
||||
parseDescriptorLimit(readNestedField(asRecord, ["header", "fileDescriptorSoftLimit"])) ??
|
||||
parseDescriptorLimit(readNestedField(asRecord, ["userLimits", "open_files", "soft"]))
|
||||
);
|
||||
}
|
||||
|
||||
function readNestedField(source: Record<string, unknown>, path: string[]): unknown {
|
||||
let current: unknown = source;
|
||||
for (const key of path) {
|
||||
if (typeof current !== "object" || current === null) return undefined;
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function parseDescriptorLimit(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
|
||||
if (typeof value === "string" && value.trim().toLowerCase() === "unlimited") {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function runInBatches(
|
||||
count: number,
|
||||
batchSize: number,
|
||||
createEntry: (index: number) => Promise<unknown>,
|
||||
): Promise<void> {
|
||||
for (let start = 0; start < count; start += batchSize) {
|
||||
const end = Math.min(start + batchSize, count);
|
||||
const batch: Promise<unknown>[] = [];
|
||||
for (let index = start; index < end; index += 1) batch.push(createEntry(index));
|
||||
await Promise.all(batch);
|
||||
}
|
||||
}
|
||||
|
||||
async function readKernelWatchCount(): Promise<number | null> {
|
||||
if (process.platform !== "linux") return null;
|
||||
let count = 0;
|
||||
@@ -311,6 +709,11 @@ function fixtureDescription() {
|
||||
sustainedEdits: SUSTAINED_EDIT_COUNT,
|
||||
sustainedEditIntervalMs: SUSTAINED_EDIT_INTERVAL_MS,
|
||||
repetitions: REPETITIONS,
|
||||
mode: MODE,
|
||||
churnDirectories: CHURN_DIRECTORY_COUNT,
|
||||
churnFilesPerDirectory: CHURN_FILES_PER_DIRECTORY,
|
||||
churnRounds: CHURN_ROUNDS,
|
||||
churnDirectoriesPerRound: CHURN_DIRECTORIES_PER_ROUND,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
node: process.version,
|
||||
@@ -321,6 +724,12 @@ function evaluate(results: Measurement[]): {
|
||||
passed: boolean;
|
||||
failures: string[];
|
||||
} {
|
||||
if (MODE === "directory-churn") {
|
||||
// This phase exists to characterize a known-bad code path (removeSubtree
|
||||
// and reconcileSubtree scanning the whole tracked set per disappeared
|
||||
// directory), not to gate CI on a threshold yet.
|
||||
return { passed: true, failures: [] };
|
||||
}
|
||||
const failures: string[] = [];
|
||||
for (const result of results) {
|
||||
evaluateStandaloneResult(result, failures);
|
||||
@@ -332,7 +741,10 @@ function evaluateStandaloneResult(result: Measurement, failures: string[]): void
|
||||
if (result.missedTrackedPaths !== 0) {
|
||||
failures.push(`observer missed ${result.missedTrackedPaths} tracked paths`);
|
||||
}
|
||||
if (result.ignoredEvents !== 0) {
|
||||
// In late-ignored mode, nonzero ignoredEvents is the bug reproducing, not a
|
||||
// regression: the observer is deliberately given the ignored tree only
|
||||
// after it subscribes, so it has no way to exclude it yet.
|
||||
if (MODE !== "late-ignored" && result.ignoredEvents !== 0) {
|
||||
failures.push(`observer emitted ${result.ignoredEvents} excluded events`);
|
||||
}
|
||||
if (result.sustainedMissedPaths !== 0) {
|
||||
|
||||
@@ -320,6 +320,7 @@ test("closing the observer service releases every subscription and rejects reuse
|
||||
nativeTrackedFileCount: 0,
|
||||
pendingEventCount: 0,
|
||||
pendingReconciliationWorkCount: 0,
|
||||
pendingClassificationCount: 0,
|
||||
reconciliationInFlightCount: 0,
|
||||
reconciliationCount: 0,
|
||||
scopedReconciliationCount: 0,
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface FileObserverDiagnostics {
|
||||
nativeTrackedFileCount: number;
|
||||
pendingEventCount: number;
|
||||
pendingReconciliationWorkCount: number;
|
||||
pendingClassificationCount: number;
|
||||
reconciliationInFlightCount: number;
|
||||
reconciliationCount: number;
|
||||
scopedReconciliationCount: number;
|
||||
@@ -120,6 +121,7 @@ class FileObserverService implements FileObserver {
|
||||
let nativeTrackedFileCount = 0;
|
||||
let pendingEventCount = 0;
|
||||
let pendingReconciliationWorkCount = 0;
|
||||
let pendingClassificationCount = 0;
|
||||
let reconciliationInFlightCount = 0;
|
||||
for (const observation of observations) {
|
||||
const diagnostics = observation.getDiagnostics();
|
||||
@@ -127,6 +129,7 @@ class FileObserverService implements FileObserver {
|
||||
nativeTrackedFileCount += diagnostics.nativeTrackedFileCount;
|
||||
pendingEventCount += diagnostics.pendingEventCount;
|
||||
pendingReconciliationWorkCount += diagnostics.pendingReconciliationWorkCount;
|
||||
pendingClassificationCount += diagnostics.pendingClassificationCount;
|
||||
reconciliationInFlightCount += Number(diagnostics.reconciliationInFlight);
|
||||
}
|
||||
return {
|
||||
@@ -135,6 +138,7 @@ class FileObserverService implements FileObserver {
|
||||
nativeTrackedFileCount,
|
||||
pendingEventCount,
|
||||
pendingReconciliationWorkCount,
|
||||
pendingClassificationCount,
|
||||
reconciliationInFlightCount,
|
||||
...metrics,
|
||||
};
|
||||
@@ -178,6 +182,7 @@ function emptyDiagnostics(): FileObserverDiagnostics {
|
||||
nativeTrackedFileCount: 0,
|
||||
pendingEventCount: 0,
|
||||
pendingReconciliationWorkCount: 0,
|
||||
pendingClassificationCount: 0,
|
||||
reconciliationInFlightCount: 0,
|
||||
...createObserverMetrics(),
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface ObserverMetrics extends Omit<
|
||||
| "nativeTrackedFileCount"
|
||||
| "pendingEventCount"
|
||||
| "pendingReconciliationWorkCount"
|
||||
| "pendingClassificationCount"
|
||||
| "reconciliationInFlightCount"
|
||||
> {}
|
||||
|
||||
@@ -14,6 +15,7 @@ export interface BackendDiagnostics {
|
||||
nativeHandleCount: number;
|
||||
nativeTrackedFileCount: number;
|
||||
pendingReconciliationWorkCount: number;
|
||||
pendingClassificationCount: number;
|
||||
reconciliationInFlight: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import { createFileObserver } from "../index.js";
|
||||
import { createLinuxBackend } from "./linux.js";
|
||||
import { createObserverPaths } from "./paths.js";
|
||||
|
||||
// This backend keeps no file-level inventory and runs no periodic full-tree
|
||||
// safety audit (see the comment on MAX_QUEUED_CLASSIFICATIONS in linux.ts), so
|
||||
// a shed classification could never be recovered later the way the native
|
||||
// backend's directory audit recovers one. Overflow must fail the observation
|
||||
// loudly instead of silently dropping a create or delete.
|
||||
test("the linux backend fails the observation when the classification queue overflows", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "linux-classify-"));
|
||||
const paths = createObserverPaths(process.platform);
|
||||
const notifications = new EventEmitter();
|
||||
const observer = createFileObserver();
|
||||
let active = true;
|
||||
let failure: Error | null = null;
|
||||
const backend = createLinuxBackend(
|
||||
{
|
||||
root,
|
||||
metrics: observer.getDiagnostics(),
|
||||
isActive: () => active,
|
||||
isIgnored: () => false,
|
||||
isPathInside: paths.isInside,
|
||||
queueEvent: () => {},
|
||||
fail: (error) => {
|
||||
// Mirror the real Observation host: a failure ends the subscription,
|
||||
// so isActive() must flip immediately and further events must not be
|
||||
// processed.
|
||||
active = false;
|
||||
failure = error;
|
||||
},
|
||||
},
|
||||
paths,
|
||||
(_directory, listener) => {
|
||||
notifications.on("change", listener);
|
||||
return {
|
||||
close: () => notifications.removeAllListeners(),
|
||||
on: (event: string, onError: (error: Error) => void) => notifications.on(event, onError),
|
||||
} as never;
|
||||
},
|
||||
);
|
||||
try {
|
||||
await backend.start();
|
||||
// Emitted synchronously: no stat can settle before the last event lands,
|
||||
// so the queue grows monotonically until it overflows. Once it does,
|
||||
// isActive() flips false and the backend's own listener guard skips the
|
||||
// rest of the burst, so it's safe to keep emitting unconditionally.
|
||||
for (let index = 0; index < 20_000; index += 1) {
|
||||
notifications.emit("change", "rename", `module-${index}.js`);
|
||||
}
|
||||
expect(active).toBe(false);
|
||||
expect(failure).toBeInstanceOf(Error);
|
||||
expect(failure?.message).toMatch(/exceeded \d+ queued classifications/);
|
||||
} finally {
|
||||
active = false;
|
||||
await backend.close();
|
||||
await observer.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("an ignore update drains classifications queued below the new excluded root", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "linux-ignore-queue-"));
|
||||
const ignored = join(root, "generated");
|
||||
await mkdir(ignored);
|
||||
const tracked = join(root, "tracked.txt");
|
||||
await writeFile(tracked, "tracked");
|
||||
// The backend is injected for deterministic queue admission, but its real
|
||||
// temporary root uses the host filesystem's path format.
|
||||
const paths = createObserverPaths(process.platform);
|
||||
let onRootChange: ((eventType: string, filename: string | null) => void) | null = null;
|
||||
const observer = createFileObserver();
|
||||
let active = true;
|
||||
let ignoreGenerated = false;
|
||||
const delivered: string[] = [];
|
||||
const backend = createLinuxBackend(
|
||||
{
|
||||
root,
|
||||
metrics: observer.getDiagnostics(),
|
||||
isActive: () => active,
|
||||
isIgnored: (path) => ignoreGenerated && paths.isInside(ignored, path),
|
||||
isPathInside: paths.isInside,
|
||||
queueEvent: (type, path) => {
|
||||
if (type === "create") delivered.push(path);
|
||||
},
|
||||
fail: (error) => {
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
paths,
|
||||
(directory, listener) => {
|
||||
if (directory === root) onRootChange = listener;
|
||||
return {
|
||||
close: () => {},
|
||||
on: () => {},
|
||||
} as never;
|
||||
},
|
||||
);
|
||||
try {
|
||||
await backend.start();
|
||||
delivered.length = 0;
|
||||
for (let index = 0; index < 6_000; index += 1) {
|
||||
onRootChange?.("rename", `generated/file-${index}.js`);
|
||||
}
|
||||
onRootChange?.("rename", "tracked.txt");
|
||||
expect(backend.getDiagnostics().pendingClassificationCount).toBeGreaterThan(0);
|
||||
ignoreGenerated = true;
|
||||
await backend.updateIgnore();
|
||||
expect(backend.getDiagnostics().pendingClassificationCount).toBeLessThanOrEqual(1);
|
||||
await expect.poll(() => delivered.includes(tracked)).toBe(true);
|
||||
} finally {
|
||||
active = false;
|
||||
await backend.close();
|
||||
await observer.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -7,22 +7,46 @@ import { isMissingPathError, toError } from "./paths.js";
|
||||
|
||||
const MAX_WATCHED_DIRECTORIES = 5_000;
|
||||
const RECONCILIATION_DELAY_MS = 50;
|
||||
// fs.stat runs on the libuv threadpool, which is four threads and shared by the
|
||||
// whole daemon. One stat per native event pins it and unrelated filesystem work
|
||||
// stalls. The concurrency limit bounds that.
|
||||
//
|
||||
// Unlike the native backend, this backend keeps no file-level inventory
|
||||
// (nativeTrackedFileCount is always 0, and its own reconcile() diffs
|
||||
// directories, never files) and runs no periodic full-tree safety audit. A
|
||||
// classification shed here can never be recovered later, so overflow must
|
||||
// fail the observation loudly instead of silently dropping a create or
|
||||
// delete. WorkspaceGit already handles this failure mode: it catches the
|
||||
// existing MAX_WATCHED_DIRECTORIES overflow and falls back to bounded
|
||||
// polling. The cap is generous — it should only trip under genuine
|
||||
// pathology, not an ordinary large burst.
|
||||
const MAX_CONCURRENT_CLASSIFICATIONS = 32;
|
||||
const MAX_QUEUED_CLASSIFICATIONS = 16_384;
|
||||
|
||||
interface DirectorySnapshot {
|
||||
directories: Set<string>;
|
||||
filesDiscoveredBeforeCoverage: Set<string>;
|
||||
}
|
||||
|
||||
type WatchDirectory = (
|
||||
directory: string,
|
||||
listener: (eventType: string, filename: string | null) => void,
|
||||
) => FSWatcher;
|
||||
|
||||
const watchDirectory: WatchDirectory = (directory, listener) => watch(directory, listener);
|
||||
|
||||
export function createLinuxBackend(
|
||||
host: ObservationHost,
|
||||
paths: ObserverPaths,
|
||||
observe: WatchDirectory = watchDirectory,
|
||||
): ObservationBackend {
|
||||
return new LinuxBackend(host, paths);
|
||||
return new LinuxBackend(host, paths, observe);
|
||||
}
|
||||
|
||||
class LinuxBackend implements ObservationBackend {
|
||||
private readonly watchers = new Map<string, FSWatcher>();
|
||||
private readonly classifications = new Set<Promise<void>>();
|
||||
private readonly classificationQueue: Array<{ path: string }> = [];
|
||||
private readonly pendingScopes = new Set<string>();
|
||||
private reconcileTail: Promise<void> = Promise.resolve();
|
||||
private queueDepth = 0;
|
||||
@@ -33,6 +57,7 @@ class LinuxBackend implements ObservationBackend {
|
||||
constructor(
|
||||
private readonly host: ObservationHost,
|
||||
private readonly paths: ObserverPaths,
|
||||
private readonly observe: WatchDirectory,
|
||||
) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -41,6 +66,8 @@ class LinuxBackend implements ObservationBackend {
|
||||
}
|
||||
|
||||
async updateIgnore(): Promise<void> {
|
||||
this.discardIgnoredClassifications();
|
||||
const inFlight = [...this.classifications];
|
||||
for (const [directory, watcher] of this.watchers) {
|
||||
if (directory !== this.host.root && this.host.isIgnored(directory)) {
|
||||
watcher.close();
|
||||
@@ -49,6 +76,7 @@ class LinuxBackend implements ObservationBackend {
|
||||
}
|
||||
this.cancelQueued();
|
||||
await this.enqueue([this.host.root]);
|
||||
await Promise.allSettled(inFlight);
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
@@ -67,14 +95,17 @@ class LinuxBackend implements ObservationBackend {
|
||||
pendingReconciliationWorkCount:
|
||||
this.pendingScopes.size +
|
||||
this.classifications.size +
|
||||
this.classificationQueue.length +
|
||||
this.queueDepth +
|
||||
Number(this.timer !== null),
|
||||
pendingClassificationCount: this.classifications.size + this.classificationQueue.length,
|
||||
reconciliationInFlight: this.inFlight,
|
||||
};
|
||||
}
|
||||
|
||||
private async finishClose(): Promise<void> {
|
||||
await this.reconcileTail;
|
||||
this.classificationQueue.length = 0;
|
||||
await Promise.allSettled(this.classifications);
|
||||
for (const watcher of this.watchers.values()) watcher.close();
|
||||
this.watchers.clear();
|
||||
@@ -90,7 +121,7 @@ class LinuxBackend implements ObservationBackend {
|
||||
`Recursive file observation exceeded ${MAX_WATCHED_DIRECTORIES} directories under ${this.host.root}`,
|
||||
);
|
||||
}
|
||||
const watcher = watch(directory, (eventType, filename) => {
|
||||
const watcher = this.observe(directory, (eventType, filename) => {
|
||||
if (!this.host.isActive()) return;
|
||||
const path = filename ? resolve(directory, filename.toString()) : directory;
|
||||
if (!this.host.isIgnored(path)) {
|
||||
@@ -247,6 +278,45 @@ class LinuxBackend implements ObservationBackend {
|
||||
}
|
||||
|
||||
private classify(path: string): void {
|
||||
if (this.classificationQueue.length >= MAX_QUEUED_CLASSIFICATIONS) {
|
||||
// This backend has no file-level inventory and no full-tree safety
|
||||
// audit to fall back on (see the comment on MAX_QUEUED_CLASSIFICATIONS
|
||||
// above), so a shed here would silently and permanently lose this
|
||||
// create/delete. Fail loudly instead, the same degradation path as
|
||||
// MAX_WATCHED_DIRECTORIES overflow.
|
||||
this.host.fail(
|
||||
new Error(
|
||||
`Recursive file observation exceeded ${MAX_QUEUED_CLASSIFICATIONS} queued classifications under ${this.host.root}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.classificationQueue.push({ path });
|
||||
this.pumpClassifications();
|
||||
}
|
||||
|
||||
private discardIgnoredClassifications(): void {
|
||||
let kept = 0;
|
||||
for (const classification of this.classificationQueue) {
|
||||
if (!this.host.isIgnored(classification.path)) {
|
||||
this.classificationQueue[kept++] = classification;
|
||||
}
|
||||
}
|
||||
this.classificationQueue.length = kept;
|
||||
}
|
||||
|
||||
private pumpClassifications(): void {
|
||||
while (
|
||||
this.classifications.size < MAX_CONCURRENT_CLASSIFICATIONS &&
|
||||
this.classificationQueue.length > 0
|
||||
) {
|
||||
const next = this.classificationQueue.shift();
|
||||
if (!next) return;
|
||||
this.startClassification(next.path);
|
||||
}
|
||||
}
|
||||
|
||||
private startClassification(path: string): void {
|
||||
this.host.metrics.nativeClassificationCount += 1;
|
||||
let classification!: Promise<void>;
|
||||
classification = stat(path)
|
||||
@@ -258,7 +328,10 @@ class LinuxBackend implements ObservationBackend {
|
||||
}
|
||||
this.host.fail(toError(error));
|
||||
})
|
||||
.finally(() => this.classifications.delete(classification));
|
||||
.finally(() => {
|
||||
this.classifications.delete(classification);
|
||||
if (this.host.isActive()) this.pumpClassifications();
|
||||
});
|
||||
this.classifications.add(classification);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -129,3 +130,360 @@ test("a file announced only as changed remains visible to coalesced deletion sca
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a native ignore update drains classifications queued below the new excluded root", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "native-ignore-queue-"));
|
||||
const ignored = join(root, "generated");
|
||||
await mkdir(ignored);
|
||||
const tracked = join(root, "tracked.txt");
|
||||
await writeFile(tracked, "tracked");
|
||||
const paths = createObserverPaths(process.platform);
|
||||
const notifications = new EventEmitter();
|
||||
const observer = createFileObserver();
|
||||
let active = true;
|
||||
let ignoreGenerated = false;
|
||||
const delivered: string[] = [];
|
||||
const backend = createNativeRecursiveBackend(
|
||||
{
|
||||
root,
|
||||
metrics: observer.getDiagnostics(),
|
||||
isActive: () => active,
|
||||
isIgnored: (path) => ignoreGenerated && paths.isInside(ignored, path),
|
||||
isPathInside: paths.isInside,
|
||||
queueEvent: (type, path) => {
|
||||
if (type === "create") delivered.push(path);
|
||||
},
|
||||
fail: (error) => {
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
paths,
|
||||
(_root, listener) => {
|
||||
notifications.on("change", listener);
|
||||
return {
|
||||
close: () => notifications.removeAllListeners(),
|
||||
on: (event, onError) => notifications.on(event, onError),
|
||||
};
|
||||
},
|
||||
);
|
||||
try {
|
||||
await backend.start();
|
||||
for (let index = 0; index < 2_000; index += 1) {
|
||||
notifications.emit("change", "rename", join(ignored, `file-${index}.js`));
|
||||
}
|
||||
notifications.emit("change", "rename", tracked);
|
||||
expect(backend.getDiagnostics().pendingClassificationCount).toBeGreaterThan(0);
|
||||
ignoreGenerated = true;
|
||||
await backend.updateIgnore();
|
||||
expect(backend.getDiagnostics().pendingClassificationCount).toBeLessThanOrEqual(1);
|
||||
await expect.poll(() => delivered.includes(tracked)).toBe(true);
|
||||
} finally {
|
||||
active = false;
|
||||
await backend.close();
|
||||
await observer.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// One fs.stat per native event is how a single dependency install pins the
|
||||
// libuv threadpool and the daemon stops answering.
|
||||
test("a rename burst does not spawn one stat per event", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "native-classify-"));
|
||||
const paths = createObserverPaths(process.platform);
|
||||
const notifications = new EventEmitter();
|
||||
const observer = createFileObserver();
|
||||
let active = true;
|
||||
const backend = createNativeRecursiveBackend(
|
||||
{
|
||||
root,
|
||||
metrics: observer.getDiagnostics(),
|
||||
isActive: () => active,
|
||||
isIgnored: () => false,
|
||||
isPathInside: paths.isInside,
|
||||
queueEvent: () => {},
|
||||
fail: () => {},
|
||||
},
|
||||
paths,
|
||||
(_root, listener) => {
|
||||
notifications.on("change", listener);
|
||||
return {
|
||||
close: () => notifications.removeAllListeners(),
|
||||
on: (event, onError) => notifications.on(event, onError),
|
||||
};
|
||||
},
|
||||
);
|
||||
try {
|
||||
await backend.start();
|
||||
// Emitted synchronously: no stat can settle before the last event lands.
|
||||
for (let index = 0; index < 5_000; index += 1) {
|
||||
notifications.emit("change", "rename", join(root, `package-${index}`, "index.js"));
|
||||
}
|
||||
expect(backend.getDiagnostics().pendingClassificationCount).toBeLessThanOrEqual(2_080);
|
||||
} finally {
|
||||
active = false;
|
||||
await backend.close();
|
||||
await observer.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// The shed path is only safe because this backend has a real files/entries
|
||||
// inventory to diff against. Prove it does the work: force a shed onto an
|
||||
// already-tracked directory and confirm the create still surfaces.
|
||||
test("a classification shed onto the scoped audit still recovers the file", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "native-shed-"));
|
||||
const paths = createObserverPaths(process.platform);
|
||||
const shedDirectory = join(root, "shed");
|
||||
await mkdir(shedDirectory);
|
||||
const events: FileChange[] = [];
|
||||
const notifications = new EventEmitter();
|
||||
const observer = createFileObserver();
|
||||
let active = true;
|
||||
const backend = createNativeRecursiveBackend(
|
||||
{
|
||||
root,
|
||||
metrics: observer.getDiagnostics(),
|
||||
isActive: () => active,
|
||||
isIgnored: () => false,
|
||||
isPathInside: paths.isInside,
|
||||
queueEvent: (type, path) => events.push({ type, path }),
|
||||
fail: (error) => {
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
paths,
|
||||
(_root, listener) => {
|
||||
notifications.on("change", listener);
|
||||
return {
|
||||
close: () => notifications.removeAllListeners(),
|
||||
on: (event, onError) => notifications.on(event, onError),
|
||||
};
|
||||
},
|
||||
);
|
||||
try {
|
||||
await backend.start(); // The initial full audit tracks "shed" as known.
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] });
|
||||
|
||||
// Jam the queue exactly as in the burst test above: 32 in flight, 2,048
|
||||
// queued, all against a directory that never existed.
|
||||
const fillerDirectory = join(root, "filler");
|
||||
for (let index = 0; index < 2_048 + 32; index += 1) {
|
||||
notifications.emit("change", "rename", join(fillerDirectory, `file-${index}.js`));
|
||||
}
|
||||
expect(backend.getDiagnostics().pendingClassificationCount).toBe(2_080);
|
||||
|
||||
// A real file lands in the already-tracked "shed" directory while the
|
||||
// queue is full, so this event must be shed rather than stat-ed. Written
|
||||
// synchronously so no queued stat can settle and free a slot before this
|
||||
// event is classified -- that would let it enqueue normally instead of
|
||||
// exercising the shed branch.
|
||||
const shedPath = join(shedDirectory, "recovered.txt");
|
||||
writeFileSync(shedPath, "content");
|
||||
notifications.emit("change", "rename", shedPath);
|
||||
|
||||
// 2s clears the mandatory-audit deadline (500ms quiet / 5s max-dirty)
|
||||
// that the shed forces by adding "shed" to localScopes, but stays well
|
||||
// under the optional-only deadline (8s) that the ambient per-event
|
||||
// change-scope audit alone would use. Every event -- shed or not --
|
||||
// already schedules that ambient audit, so advancing 8s would recover
|
||||
// this file even if the shed branch were a no-op. The short window is
|
||||
// what actually proves the shed's own requestAudit call did the work.
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
vi.useRealTimers();
|
||||
|
||||
await expect
|
||||
.poll(() => events.some((event) => event.type === "create" && event.path === shedPath))
|
||||
.toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
active = false;
|
||||
await backend.close();
|
||||
await observer.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// C1 regression: removeSubtree unlinks its root from the parent's
|
||||
// DirectoryEntry as part of tearing down the old subtree. reconcileSubtree
|
||||
// re-populates the index below `directory` via mergeInventory but must also
|
||||
// re-link `directory` itself back into its parent -- walkSubtree only
|
||||
// descends through that link, so without the re-link a later ancestor
|
||||
// removal can no longer reach anything below the reconciled directory.
|
||||
test("a recursive-scope reconcile re-links its directory so an ancestor removal still finds nested files", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "native-relink-"));
|
||||
const paths = createObserverPaths(process.platform);
|
||||
const deepDirectory = join(root, "a", "b", "c");
|
||||
const deepFile = join(deepDirectory, "deep.txt");
|
||||
await mkdir(deepDirectory, { recursive: true });
|
||||
await writeFile(deepFile, "content");
|
||||
const events: FileChange[] = [];
|
||||
const notifications = new EventEmitter();
|
||||
const observer = createFileObserver();
|
||||
let active = true;
|
||||
const backend = createNativeRecursiveBackend(
|
||||
{
|
||||
root,
|
||||
metrics: observer.getDiagnostics(),
|
||||
isActive: () => active,
|
||||
isIgnored: () => false,
|
||||
isPathInside: paths.isInside,
|
||||
queueEvent: (type, path) => events.push({ type, path }),
|
||||
fail: (error) => {
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
paths,
|
||||
(_root, listener) => {
|
||||
notifications.on("change", listener);
|
||||
return {
|
||||
close: () => notifications.removeAllListeners(),
|
||||
on: (event, onError) => notifications.on(event, onError),
|
||||
};
|
||||
},
|
||||
);
|
||||
const settled = () =>
|
||||
expect
|
||||
.poll(
|
||||
() => {
|
||||
const diagnostics = backend.getDiagnostics();
|
||||
return (
|
||||
diagnostics.pendingReconciliationWorkCount === 0 && !diagnostics.reconciliationInFlight
|
||||
);
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
try {
|
||||
await backend.start(); // Tracks a, a/b, a/b/c, and deep.txt.
|
||||
|
||||
// A rename event landing on the already-known directory a/b produces a
|
||||
// local scope on its parent "a" (rebuilding a's DirectoryEntry from a
|
||||
// fresh shallow scan) and, once classify()'s real stat() confirms a/b is
|
||||
// still a directory, a recursive scope on a/b itself -- the same "known
|
||||
// directory changes in place" shape the directory-churn benchmark
|
||||
// exercises via atomic replace.
|
||||
notifications.emit("change", "rename", join(root, "a", "b"));
|
||||
await settled();
|
||||
|
||||
// Remove the ancestor while the native event names only a deleted file.
|
||||
// The scoped scan must report its missing directory as well as that file.
|
||||
await rm(join(root, "a"), { recursive: true, force: true });
|
||||
notifications.emit("change", "rename", deepFile);
|
||||
await settled();
|
||||
|
||||
expect(events.filter((event) => event.type === "delete").map((event) => event.path)).toContain(
|
||||
deepFile,
|
||||
);
|
||||
expect(events).toContainEqual({ type: "delete", path: deepDirectory });
|
||||
} finally {
|
||||
active = false;
|
||||
await backend.close();
|
||||
await observer.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
test("a full native inventory audit reports a removed directory", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "native-full-audit-"));
|
||||
const directory = join(root, "removed");
|
||||
await mkdir(directory);
|
||||
await writeFile(join(directory, "tracked.txt"), "before");
|
||||
const paths = createObserverPaths(process.platform);
|
||||
const events: FileChange[] = [];
|
||||
const observer = createFileObserver();
|
||||
const backend = createNativeRecursiveBackend(
|
||||
{
|
||||
root,
|
||||
metrics: observer.getDiagnostics(),
|
||||
isActive: () => true,
|
||||
isIgnored: () => false,
|
||||
isPathInside: paths.isInside,
|
||||
queueEvent: (type, path) => events.push({ type, path }),
|
||||
fail: (error) => {
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
paths,
|
||||
() => ({ close: () => {}, on: () => {} }),
|
||||
);
|
||||
try {
|
||||
await backend.start();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await backend.updateIgnore(); // Uses the same full inventory diff as the safety audit.
|
||||
expect(events).toContainEqual({ type: "delete", path: directory });
|
||||
} finally {
|
||||
await backend.close();
|
||||
await observer.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// I2 regression: startClassification queues an unconditional "create" event
|
||||
// before onPresent runs. When a file's parent directory has no entries
|
||||
// bucket yet (a coalesced directory-creation event that never arrived),
|
||||
// declining to track the path at all announces a create it can never
|
||||
// retract -- no future scan can rediscover a path that is already gone from
|
||||
// disk to emit its matching delete.
|
||||
test("a file classified with no parent bucket yet still gets its delete emitted", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "native-orphan-"));
|
||||
const paths = createObserverPaths(process.platform);
|
||||
const newDirectory = join(root, "newdir");
|
||||
const events: FileChange[] = [];
|
||||
const notifications = new EventEmitter();
|
||||
const observer = createFileObserver();
|
||||
let active = true;
|
||||
const backend = createNativeRecursiveBackend(
|
||||
{
|
||||
root,
|
||||
metrics: observer.getDiagnostics(),
|
||||
isActive: () => active,
|
||||
isIgnored: () => false,
|
||||
isPathInside: paths.isInside,
|
||||
queueEvent: (type, path) => events.push({ type, path }),
|
||||
fail: (error) => {
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
paths,
|
||||
(_root, listener) => {
|
||||
notifications.on("change", listener);
|
||||
return {
|
||||
close: () => notifications.removeAllListeners(),
|
||||
on: (event, onError) => notifications.on(event, onError),
|
||||
};
|
||||
},
|
||||
);
|
||||
try {
|
||||
await backend.start(); // Tracks the (empty) root only.
|
||||
|
||||
// A brand-new directory with no directory-created event processed for
|
||||
// it yet (coalesced away) has no entries bucket: entries.get(scope) is
|
||||
// undefined for a file inside it.
|
||||
await mkdir(newDirectory);
|
||||
const filePath = join(newDirectory, "orphan.txt");
|
||||
await writeFile(filePath, "content");
|
||||
notifications.emit("change", "rename", filePath);
|
||||
|
||||
// The file must stay tracked so a later removal can still find and
|
||||
// retract the create that startClassification already queued.
|
||||
await expect.poll(() => backend.getDiagnostics().nativeTrackedFileCount).toBe(1);
|
||||
expect(events.some((event) => event.type === "create" && event.path === filePath)).toBe(true);
|
||||
|
||||
// Remove the whole directory before any audit ever scans it. Nothing
|
||||
// will see filePath as "on disk and gone from a known listing" through
|
||||
// the normal directory-scan diff, since newdir never had one.
|
||||
await rm(newDirectory, { recursive: true, force: true });
|
||||
|
||||
await expect
|
||||
.poll(() => events.some((event) => event.type === "delete" && event.path === filePath), {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(true);
|
||||
expect(backend.getDiagnostics().nativeTrackedFileCount).toBe(0);
|
||||
} finally {
|
||||
active = false;
|
||||
await backend.close();
|
||||
await observer.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,12 @@ import type { ObserverPaths } from "./paths.js";
|
||||
import { isMissingPathError, toError } from "./paths.js";
|
||||
|
||||
const MAX_TRACKED_ENTRIES = 250_000;
|
||||
// fs.stat runs on the libuv threadpool, which is four threads and shared by the
|
||||
// whole daemon. One stat per native event pins it and unrelated filesystem work
|
||||
// stalls. Beyond the queue bound, fall back to a directory scan, which finds the
|
||||
// same paths at a fraction of the cost.
|
||||
const MAX_CONCURRENT_CLASSIFICATIONS = 32;
|
||||
const MAX_QUEUED_CLASSIFICATIONS = 2_048;
|
||||
const AUDIT_QUIET_MS = 500;
|
||||
const AUDIT_MAX_DIRTY_MS = 5_000;
|
||||
const OPTIONAL_AUDIT_QUIET_MS = 8_000;
|
||||
@@ -47,9 +53,20 @@ export function createNativeRecursiveBackend(
|
||||
class NativeRecursiveBackend implements ObservationBackend {
|
||||
private watcher: ReturnType<WatchDirectory> | null = null;
|
||||
private files = new Set<string>();
|
||||
// Files classified before their parent directory has an entries bucket.
|
||||
// walkSubtree can only reach files through that bucket, so these stay
|
||||
// tracked directly until a scoped audit builds the bucket and adopts them.
|
||||
// Bounded by every file classified within one full-audit window (up to
|
||||
// FULL_AUDIT_MAX_DIRTY_MS, 5 minutes) whose parent never got a bucket, not
|
||||
// by the size of the tracked set.
|
||||
private orphanFiles = new Set<string>();
|
||||
private directories = new Set<string>();
|
||||
private entries = new Map<string, DirectoryEntry>();
|
||||
private readonly classifications = new Set<Promise<void>>();
|
||||
private readonly classificationQueue: Array<{
|
||||
path: string;
|
||||
onPresent: (isDirectory: boolean) => void;
|
||||
}> = [];
|
||||
private readonly localScopes = new Set<string>();
|
||||
private readonly changeScopes = new Set<string>();
|
||||
private readonly recursiveScopes = new Set<string>();
|
||||
@@ -85,11 +102,14 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
}
|
||||
|
||||
async updateIgnore(): Promise<void> {
|
||||
this.discardIgnoredClassifications();
|
||||
const inFlight = [...this.classifications];
|
||||
this.generation += 1;
|
||||
this.cancelAudits();
|
||||
this.fullAuditRequested = true;
|
||||
this.auditQueued = true;
|
||||
await this.enqueueAudit(true, this.generation);
|
||||
await Promise.allSettled(inFlight);
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
@@ -110,18 +130,22 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
this.changeScopes.size +
|
||||
this.recursiveScopes.size +
|
||||
this.classifications.size +
|
||||
this.classificationQueue.length +
|
||||
this.queueDepth +
|
||||
Number(this.auditTimer !== null || this.auditQueued || this.auditDirty),
|
||||
pendingClassificationCount: this.classifications.size + this.classificationQueue.length,
|
||||
reconciliationInFlight: this.inFlight,
|
||||
};
|
||||
}
|
||||
|
||||
private async finishClose(): Promise<void> {
|
||||
await this.reconcileTail;
|
||||
this.classificationQueue.length = 0;
|
||||
await Promise.allSettled(this.classifications);
|
||||
this.watcher?.close();
|
||||
this.watcher = null;
|
||||
this.files.clear();
|
||||
this.orphanFiles.clear();
|
||||
this.directories.clear();
|
||||
this.entries.clear();
|
||||
}
|
||||
@@ -154,10 +178,21 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
}
|
||||
this.classify(path, (isDirectory) => {
|
||||
if (!isDirectory) {
|
||||
// Remember files as soon as we announce them. A coalesced delete must
|
||||
// still be found by the next audit, even before the first directory scan.
|
||||
this.files.add(path);
|
||||
this.entries.get(scope)?.files.add(path);
|
||||
const entry = this.entries.get(scope);
|
||||
if (entry) {
|
||||
// Remember files as soon as we announce them. A coalesced delete must
|
||||
// still be found by the next audit, even before the first directory scan.
|
||||
this.files.add(path);
|
||||
entry.files.add(path);
|
||||
} else {
|
||||
// No inventory for the parent yet. startClassification already
|
||||
// announced this as a create, so it must stay findable for a
|
||||
// matching delete: track it directly until a scoped audit builds
|
||||
// the bucket and adopts it (see orphanFiles above).
|
||||
this.files.add(path);
|
||||
this.orphanFiles.add(path);
|
||||
this.requestAudit(scope);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (knownDirectory) this.requestAudit(path, true);
|
||||
@@ -203,7 +238,10 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
if (fullAudit) {
|
||||
const next = await this.scanTree(this.host.root);
|
||||
if (!this.canCommit(generation)) return;
|
||||
if (emitDiff) this.queueDiff(next.files, this.files);
|
||||
if (emitDiff) {
|
||||
this.queueDiff(next.files, this.files);
|
||||
this.queueRemovedDirectories(next.directories, this.directories);
|
||||
}
|
||||
this.replaceInventory(next);
|
||||
this.lastFullAuditAt = performance.now();
|
||||
this.host.metrics.fullReconciliationCount += 1;
|
||||
@@ -278,10 +316,12 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
for (const directory of new Set(localScopes)) {
|
||||
await this.reconcileDirectory(directory, generation);
|
||||
if (!this.canCommit(generation)) return;
|
||||
await new Promise<void>((done) => setImmediate(done));
|
||||
}
|
||||
for (const directory of new Set(recursiveScopes)) {
|
||||
await this.reconcileSubtree(directory, generation);
|
||||
if (!this.canCommit(generation)) return;
|
||||
await new Promise<void>((done) => setImmediate(done));
|
||||
}
|
||||
// Parent scans are shallow, so a queued parent cannot cover its children.
|
||||
for (const directory of new Set(changeScopes)) {
|
||||
@@ -291,6 +331,7 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
if (alreadyCovered) continue;
|
||||
await this.reconcileDirectory(directory, generation);
|
||||
if (!this.canCommit(generation)) return;
|
||||
await new Promise<void>((done) => setImmediate(done));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,6 +364,7 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
if (!next.directories.has(path)) this.removeSubtree(path, true);
|
||||
}
|
||||
for (const path of next.files) {
|
||||
this.orphanFiles.delete(path);
|
||||
if (!previous.files.has(path)) {
|
||||
this.files.add(path);
|
||||
this.host.queueEvent("create", path);
|
||||
@@ -377,11 +419,23 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
if (!this.directories.has(directory) || this.host.isIgnored(directory)) return;
|
||||
const inventory = await this.scanTree(directory);
|
||||
if (!this.canCommit(generation)) return;
|
||||
const previousFiles = new Set(
|
||||
[...this.files].filter((path) => this.host.isPathInside(directory, path)),
|
||||
);
|
||||
const previousFiles = new Set<string>();
|
||||
for (const tracked of this.walkSubtree(directory)) {
|
||||
const entry = this.entries.get(tracked);
|
||||
if (!entry) continue;
|
||||
for (const file of entry.files) previousFiles.add(file);
|
||||
}
|
||||
this.queueDiff(inventory.files, previousFiles);
|
||||
this.queueRemovedDirectories(inventory.directories, this.walkSubtree(directory));
|
||||
this.removeSubtree(directory, false);
|
||||
// removeSubtree unlinks `directory` from its own parent's directories set
|
||||
// as part of tearing down the old subtree. Mirror reconcileUnknownSubtree
|
||||
// and re-link it, guarded on the directory still existing -- it can
|
||||
// vanish between the `this.directories.has` check above and this scan --
|
||||
// or walkSubtree can no longer reach anything under it from the root.
|
||||
if (inventory.directories.has(directory)) {
|
||||
this.entries.get(dirname(directory))?.directories.add(directory);
|
||||
}
|
||||
this.mergeInventory(inventory, false);
|
||||
}
|
||||
|
||||
@@ -394,8 +448,16 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
}
|
||||
}
|
||||
|
||||
private queueRemovedDirectories(next: Set<string>, previous: Iterable<string>): void {
|
||||
for (const directory of previous) {
|
||||
if (!next.has(directory)) this.host.queueEvent("delete", directory);
|
||||
}
|
||||
}
|
||||
|
||||
private replaceInventory(inventory: Inventory): void {
|
||||
this.files = inventory.files;
|
||||
// A full scan gives every existing file a directory-index home.
|
||||
this.orphanFiles.clear();
|
||||
this.directories = inventory.directories;
|
||||
this.entries = inventory.entries;
|
||||
}
|
||||
@@ -404,6 +466,7 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
for (const directory of inventory.directories) this.directories.add(directory);
|
||||
for (const [directory, entry] of inventory.entries) this.entries.set(directory, entry);
|
||||
for (const path of inventory.files) {
|
||||
this.orphanFiles.delete(path);
|
||||
const added = !this.files.has(path);
|
||||
this.files.add(path);
|
||||
if (emitCreates && added) this.host.queueEvent("create", path);
|
||||
@@ -411,17 +474,50 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
this.assertWithinLimit(this.files.size + this.directories.size);
|
||||
}
|
||||
|
||||
// Depends on the invariant that every tracked directory has an `entries`
|
||||
// bucket, and that `this.directories` and `this.entries` are written and
|
||||
// cleared together. A root with no bucket yields nothing here, which makes
|
||||
// `removeSubtree` a near no-op for that root — it deletes nothing from
|
||||
// `entries`/`directories` and only cleans up matching `orphanFiles` — so
|
||||
// nothing enforces the invariant beyond this comment.
|
||||
private *walkSubtree(root: string): Generator<string> {
|
||||
const stack = [root];
|
||||
while (stack.length > 0) {
|
||||
const directory = stack.pop();
|
||||
if (directory === undefined) continue;
|
||||
const entry = this.entries.get(directory);
|
||||
if (!entry) continue;
|
||||
yield directory;
|
||||
for (const child of entry.directories) stack.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
private removeSubtree(root: string, emitDeletes: boolean): void {
|
||||
for (const path of this.files) {
|
||||
if (!this.host.isPathInside(root, path)) continue;
|
||||
this.files.delete(path);
|
||||
if (emitDeletes) this.host.queueEvent("delete", path);
|
||||
}
|
||||
for (const directory of this.directories) {
|
||||
if (!this.host.isPathInside(root, directory)) continue;
|
||||
this.directories.delete(directory);
|
||||
for (const directory of this.walkSubtree(root)) {
|
||||
const entry = this.entries.get(directory);
|
||||
if (entry) {
|
||||
for (const file of entry.files) {
|
||||
if (!this.files.delete(file)) continue;
|
||||
if (emitDeletes) this.host.queueEvent("delete", file);
|
||||
}
|
||||
// A native rename can name only a deleted file. The scoped scan still
|
||||
// knows its parent disappeared, so report that topology change too.
|
||||
if (emitDeletes) this.host.queueEvent("delete", directory);
|
||||
}
|
||||
this.entries.delete(directory);
|
||||
this.directories.delete(directory);
|
||||
}
|
||||
// Orphaned files have no directory-index home, so walkSubtree cannot
|
||||
// find them. This set is bounded by every file classified within one
|
||||
// full-audit window (up to FULL_AUDIT_MAX_DIRTY_MS, 5 minutes) whose
|
||||
// parent never got a bucket, not by the tracked-set size.
|
||||
for (const path of this.orphanFiles) {
|
||||
if (!this.host.isPathInside(root, path)) continue;
|
||||
this.orphanFiles.delete(path);
|
||||
if (this.files.delete(path) && emitDeletes) this.host.queueEvent("delete", path);
|
||||
}
|
||||
// `root` can be a tracked file rather than a directory.
|
||||
if (this.files.delete(root) && emitDeletes) this.host.queueEvent("delete", root);
|
||||
const parent = this.entries.get(dirname(root));
|
||||
parent?.directories.delete(root);
|
||||
parent?.files.delete(root);
|
||||
@@ -578,6 +674,38 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
}
|
||||
|
||||
private classify(path: string, onPresent: (isDirectory: boolean) => void): void {
|
||||
if (this.classificationQueue.length >= MAX_QUEUED_CLASSIFICATIONS) {
|
||||
// Shed load onto the scoped audit, which reads the directory once instead
|
||||
// of stat-ing every entry in it.
|
||||
this.requestAudit(dirname(path));
|
||||
return;
|
||||
}
|
||||
this.classificationQueue.push({ path, onPresent });
|
||||
this.pumpClassifications();
|
||||
}
|
||||
|
||||
private discardIgnoredClassifications(): void {
|
||||
let kept = 0;
|
||||
for (const classification of this.classificationQueue) {
|
||||
if (!this.host.isIgnored(classification.path)) {
|
||||
this.classificationQueue[kept++] = classification;
|
||||
}
|
||||
}
|
||||
this.classificationQueue.length = kept;
|
||||
}
|
||||
|
||||
private pumpClassifications(): void {
|
||||
while (
|
||||
this.classifications.size < MAX_CONCURRENT_CLASSIFICATIONS &&
|
||||
this.classificationQueue.length > 0
|
||||
) {
|
||||
const next = this.classificationQueue.shift();
|
||||
if (!next) return;
|
||||
this.startClassification(next.path, next.onPresent);
|
||||
}
|
||||
}
|
||||
|
||||
private startClassification(path: string, onPresent: (isDirectory: boolean) => void): void {
|
||||
this.host.metrics.nativeClassificationCount += 1;
|
||||
let classification!: Promise<void>;
|
||||
classification = stat(path)
|
||||
@@ -593,7 +721,10 @@ class NativeRecursiveBackend implements ObservationBackend {
|
||||
}
|
||||
this.host.fail(toError(error));
|
||||
})
|
||||
.finally(() => this.classifications.delete(classification));
|
||||
.finally(() => {
|
||||
this.classifications.delete(classification);
|
||||
if (this.host.isActive()) this.pumpClassifications();
|
||||
});
|
||||
this.classifications.add(classification);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { win32 } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createObserverPaths } from "./paths.js";
|
||||
|
||||
// m3 regression: plain lexicographic sort does not put a parent immediately
|
||||
// before its descendants. A sibling whose name extends the parent's with a
|
||||
// character that sorts below the separator (POSIX "/" = 0x2F, so "-" and "."
|
||||
// qualify) lands between them, so the single backward look collapse()
|
||||
// relies on misses the real parent and keeps a redundant descendant scope.
|
||||
// This is this repo's own layout: packages/app, packages/app-web,
|
||||
// packages/app/src.
|
||||
describe("collapse", () => {
|
||||
test("drops a redundant descendant but keeps a sibling whose name extends the parent's", () => {
|
||||
const paths = createObserverPaths("linux");
|
||||
const parent = "/repo/packages/app";
|
||||
const sibling = "/repo/packages/app-web";
|
||||
const descendant = "/repo/packages/app/src";
|
||||
|
||||
expect(paths.collapse([parent, sibling, descendant]).sort()).toEqual([parent, sibling].sort());
|
||||
});
|
||||
|
||||
test("keeps unrelated roots and drops nested duplicates regardless of input order", () => {
|
||||
const paths = createObserverPaths("linux");
|
||||
const a = "/repo/a";
|
||||
const aChild = "/repo/a/child";
|
||||
const b = "/repo/b";
|
||||
|
||||
expect(paths.collapse([aChild, b, a]).sort()).toEqual([a, b].sort());
|
||||
});
|
||||
|
||||
// The selected path strategy supplies separators and case comparison, so
|
||||
// Windows paths can be checked on every CI host.
|
||||
test("collapses win32 paths case-insensitively even with mismatched casing", () => {
|
||||
const paths = createObserverPaths("win32");
|
||||
const sep = win32.sep;
|
||||
const parent = `C:${sep}repo${sep}packages${sep}app`;
|
||||
const sibling = `C:${sep}repo${sep}packages${sep}APP-web`;
|
||||
const descendant = `c:${sep}repo${sep}packages${sep}app${sep}src`;
|
||||
|
||||
expect(paths.collapse([parent, sibling, descendant]).sort()).toEqual([parent, sibling].sort());
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { resolve, sep } from "node:path";
|
||||
import { posix, win32 } from "node:path";
|
||||
|
||||
export interface ObserverPaths {
|
||||
isInside(root: string, path: string): boolean;
|
||||
@@ -8,6 +8,8 @@ export interface ObserverPaths {
|
||||
}
|
||||
|
||||
export function createObserverPaths(platform: NodeJS.Platform): ObserverPaths {
|
||||
const pathApi = platform === "win32" ? win32 : posix;
|
||||
|
||||
function comparable(path: string): string {
|
||||
return platform === "win32" ? path.toLowerCase() : path;
|
||||
}
|
||||
@@ -15,13 +17,34 @@ export function createObserverPaths(platform: NodeJS.Platform): ObserverPaths {
|
||||
function isInside(root: string, path: string): boolean {
|
||||
const comparedRoot = comparable(root);
|
||||
const comparedPath = comparable(path);
|
||||
return comparedPath === comparedRoot || comparedPath.startsWith(`${comparedRoot}${sep}`);
|
||||
return (
|
||||
comparedPath === comparedRoot || comparedPath.startsWith(`${comparedRoot}${pathApi.sep}`)
|
||||
);
|
||||
}
|
||||
|
||||
function collapse(paths: string[]): string[] {
|
||||
return [...new Set(paths)]
|
||||
.sort((left, right) => left.length - right.length)
|
||||
.filter((path, index, all) => !all.slice(0, index).some((parent) => isInside(parent, path)));
|
||||
// Plain lexicographic sort does not put a parent immediately before its
|
||||
// descendants: a sibling whose name extends the parent's with a
|
||||
// character that sorts below the separator (e.g. "app" vs "app-web")
|
||||
// lands between them. Sort on a key with the separator swapped for a
|
||||
// character below everything instead, so "app" < "app/src" < "app-web"
|
||||
// holds and one backward look is enough.
|
||||
const decorated = [...new Set(paths)].map((path) => ({
|
||||
path,
|
||||
key: comparable(path).split(pathApi.sep).join("\0"),
|
||||
}));
|
||||
decorated.sort((left, right) => {
|
||||
if (left.key < right.key) return -1;
|
||||
if (left.key > right.key) return 1;
|
||||
return 0;
|
||||
});
|
||||
const kept: string[] = [];
|
||||
for (const { path } of decorated) {
|
||||
const previous = kept[kept.length - 1];
|
||||
if (previous !== undefined && isInside(previous, path)) continue;
|
||||
kept.push(path);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -29,7 +52,7 @@ export function createObserverPaths(platform: NodeJS.Platform): ObserverPaths {
|
||||
collapse,
|
||||
normalizeIgnoredRoots(root, paths) {
|
||||
const inside = paths
|
||||
.map((path) => resolve(path))
|
||||
.map((path) => pathApi.resolve(path))
|
||||
.filter((path) => path !== root && isInside(root, path));
|
||||
return collapse(inside);
|
||||
},
|
||||
|
||||
@@ -95,6 +95,7 @@ export function createNoopWorkspaceGitService(
|
||||
nativeTrackedFileCount: 0,
|
||||
pendingEventCount: 0,
|
||||
pendingReconciliationWorkCount: 0,
|
||||
pendingClassificationCount: 0,
|
||||
reconciliationInFlightCount: 0,
|
||||
reconciliationCount: 0,
|
||||
scopedReconciliationCount: 0,
|
||||
|
||||
@@ -282,6 +282,7 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService {
|
||||
nativeTrackedFileCount: 0,
|
||||
pendingEventCount: 0,
|
||||
pendingReconciliationWorkCount: 0,
|
||||
pendingClassificationCount: 0,
|
||||
reconciliationInFlightCount: 0,
|
||||
reconciliationCount: 0,
|
||||
scopedReconciliationCount: 0,
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type pino from "pino";
|
||||
import pino from "pino";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js";
|
||||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
import { createFileObserver } from "./file-observer/index.js";
|
||||
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
||||
import type { FileChange, SubscribeToFileChanges } from "./file-observer/index.js";
|
||||
import type { FileChange, FileObserver, SubscribeToFileChanges } from "./file-observer/index.js";
|
||||
|
||||
function createLogger(): pino.Logger {
|
||||
const logger = {
|
||||
@@ -413,3 +414,90 @@ test("recursive observation updates tracked state and prunes ignored storms", as
|
||||
expect(getCheckoutDiff).not.toHaveBeenCalled();
|
||||
expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(0);
|
||||
}, 30_000);
|
||||
|
||||
test("a late Git-ignored tree is pruned while tracked changes still notify consumers", async () => {
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-real-ignore-")));
|
||||
const repoDir = path.join(tempDir, "repo");
|
||||
mkdirSync(repoDir);
|
||||
const git = (...args: string[]) =>
|
||||
execFileSync("git", args, { cwd: repoDir, encoding: "utf8" }).trim();
|
||||
git("init", "-q");
|
||||
writeFileSync(path.join(repoDir, ".gitignore"), "node_modules/\n");
|
||||
const trackedPath = path.join(repoDir, "tracked.txt");
|
||||
writeFileSync(trackedPath, "before\n");
|
||||
git("add", ".gitignore", "tracked.txt");
|
||||
git("-c", "user.name=Test", "-c", "user.email=test@localhost", "commit", "-qm", "fixture");
|
||||
|
||||
const observer = createFileObserver();
|
||||
const ignoredDir = path.join(repoDir, "node_modules");
|
||||
const completedIgnoreUpdates: string[][] = [];
|
||||
let notifications = 0;
|
||||
let expectTrackedEdit = false;
|
||||
let trackedBatchReachedConsumer = false;
|
||||
// Observe the real subscription without replacing Git, filesystem, or watcher behavior.
|
||||
const fileObserver: FileObserver = {
|
||||
subscribe: async (directory, callback, options) => {
|
||||
const realSubscription = await observer.subscribe(
|
||||
directory,
|
||||
(error, events) => {
|
||||
const includesTrackedEdit =
|
||||
expectTrackedEdit && events.some((e) => e.path === trackedPath);
|
||||
const before = notifications;
|
||||
callback(error, events);
|
||||
if (includesTrackedEdit && notifications > before) trackedBatchReachedConsumer = true;
|
||||
},
|
||||
options,
|
||||
);
|
||||
return {
|
||||
updateIgnore: async (paths) => {
|
||||
await realSubscription.updateIgnore(paths);
|
||||
completedIgnoreUpdates.push([...paths]);
|
||||
},
|
||||
unsubscribe: () => realSubscription.unsubscribe(),
|
||||
};
|
||||
},
|
||||
getDiagnostics: () => observer.getDiagnostics(),
|
||||
close: () => observer.close(),
|
||||
};
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(tempDir, "home"),
|
||||
fileObserver,
|
||||
});
|
||||
let subscription: Awaited<ReturnType<typeof service.requestWorkingTreeWatch>> | undefined;
|
||||
try {
|
||||
subscription = await service.requestWorkingTreeWatch(repoDir, () => {
|
||||
notifications += 1;
|
||||
});
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
const directory = path.join(ignoredDir, `dependency-${index}`);
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(path.join(directory, "index.js"), "first");
|
||||
}
|
||||
expect(git("ls-files", "-o", "-i", "--directory", "--exclude-standard")).toBe("node_modules/");
|
||||
await expect
|
||||
.poll(() => completedIgnoreUpdates.some((paths) => paths.includes(ignoredDir)), {
|
||||
timeout: 5_000,
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
const atIgnoreBoundary = observer.getDiagnostics();
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
writeFileSync(path.join(ignoredDir, `dependency-${index}`, "second.js"), "second");
|
||||
}
|
||||
expectTrackedEdit = true;
|
||||
writeFileSync(trackedPath, "after\n");
|
||||
await expect.poll(() => trackedBatchReachedConsumer, { timeout: 5_000 }).toBe(true);
|
||||
const afterTrackedEdit = observer.getDiagnostics();
|
||||
expect(afterTrackedEdit.nativeHandleCount).toBe(atIgnoreBoundary.nativeHandleCount);
|
||||
expect(afterTrackedEdit.nativeTrackedFileCount).toBe(atIgnoreBoundary.nativeTrackedFileCount);
|
||||
expect(
|
||||
afterTrackedEdit.nativeClassificationCount - atIgnoreBoundary.nativeClassificationCount,
|
||||
).toBeLessThanOrEqual(1);
|
||||
} finally {
|
||||
subscription?.unsubscribe();
|
||||
await service.dispose();
|
||||
await observer.close();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
@@ -148,6 +148,50 @@ function getWatcherSubscribeCallCount(
|
||||
.length;
|
||||
}
|
||||
|
||||
// Recovery backoff ladder: base 30s, doubling each attempt, capped at 300s
|
||||
// (base * 2 ** 4). Mirrors WATCH_RECOVERY_BASE_DELAY_MS /
|
||||
// WATCH_RECOVERY_MAX_BACKOFF_STEPS / WATCH_RECOVERY_MAX_DELAY_MS in
|
||||
// workspace-git-service.ts.
|
||||
const WATCH_RECOVERY_LADDER_DELAYS_MS = [30_000, 60_000, 120_000, 240_000, 300_000, 300_000];
|
||||
|
||||
/**
|
||||
* Drives a watch target (working tree or repository metadata) through six
|
||||
* recovery cycles, proving three things about `advanceWatchRecoveryLadder`:
|
||||
* - recovery keeps retrying well past the old cap of 3 attempts (this walks
|
||||
* it to 6, i.e. 7 total subscriptions),
|
||||
* - the delay between attempts follows 30s/60s/120s/240s and then plateaus
|
||||
* at 300s rather than growing without bound, and
|
||||
* - a recovered subscription that emits an event (proving it is live) and
|
||||
* then errors again immediately — before surviving the 300s durability
|
||||
* window — does NOT reset the ladder back to the 30s base. Each "advance
|
||||
* by 30s only" check below would observe a premature retry if a reset had
|
||||
* happened.
|
||||
*/
|
||||
async function driveWatchRecoveryLadder(
|
||||
watcher: ReturnType<typeof createWatcherHarness>,
|
||||
directory: string,
|
||||
): Promise<void> {
|
||||
for (const [index, delayMs] of WATCH_RECOVERY_LADDER_DELAYS_MS.entries()) {
|
||||
const expectedCallCount = index + 2;
|
||||
if (delayMs > 30_000) {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
// Not yet reset to the 30s base: no new attempt after only 30s.
|
||||
expect(getWatcherSubscribeCallCount(watcher, directory)).toBe(expectedCallCount - 1);
|
||||
await vi.advanceTimersByTimeAsync(delayMs - 30_000);
|
||||
} else {
|
||||
await vi.advanceTimersByTimeAsync(delayMs);
|
||||
}
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherSubscribeCallCount(watcher, directory)).toBe(expectedCallCount);
|
||||
});
|
||||
const recoveredWatcher = getWatcherRecordsForDirectory(watcher, directory)[index + 1];
|
||||
recoveredWatcher?.callback(null, [
|
||||
{ path: path.join(directory, `recovered-${index + 1}.txt`), type: "update" },
|
||||
]);
|
||||
recoveredWatcher?.callback(new Error(`recovered watcher stopped ${index + 1}`), []);
|
||||
}
|
||||
}
|
||||
|
||||
function createService(
|
||||
watcher: ReturnType<typeof createWatcherHarness>,
|
||||
overrides?: Record<string, unknown>,
|
||||
@@ -1788,7 +1832,7 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("watcher recovery remains capped when recovered subscriptions emit events before failing", async () => {
|
||||
test("watcher recovery keeps retrying past the old attempt cap and the ladder does not reset on immediate re-failure", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||
const service = createService(watcher, {
|
||||
@@ -1803,50 +1847,27 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
const checkoutWatcher = getWatcherRecordsForDirectory(watcher, REPO_CWD)[0];
|
||||
|
||||
checkoutWatcher?.callback(new Error("watcher stopped"), []);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(2);
|
||||
});
|
||||
getWatcherRecordsForDirectory(watcher, REPO_CWD)[1]?.callback(null, [
|
||||
{ path: path.join(REPO_CWD, "recovered-1.txt"), type: "update" },
|
||||
]);
|
||||
getWatcherRecordsForDirectory(watcher, REPO_CWD)[1]?.callback(
|
||||
new Error("recovered watcher stopped"),
|
||||
[],
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(3);
|
||||
});
|
||||
getWatcherRecordsForDirectory(watcher, REPO_CWD)[2]?.callback(null, [
|
||||
{ path: path.join(REPO_CWD, "recovered-2.txt"), type: "update" },
|
||||
]);
|
||||
getWatcherRecordsForDirectory(watcher, REPO_CWD)[2]?.callback(
|
||||
new Error("recovered watcher stopped again"),
|
||||
[],
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(4);
|
||||
});
|
||||
getWatcherRecordsForDirectory(watcher, REPO_CWD)[3]?.callback(null, [
|
||||
{ path: path.join(REPO_CWD, "recovered-3.txt"), type: "update" },
|
||||
]);
|
||||
getWatcherRecordsForDirectory(watcher, REPO_CWD)[3]?.callback(
|
||||
new Error("last recovered watcher stopped"),
|
||||
[],
|
||||
);
|
||||
await driveWatchRecoveryLadder(watcher, REPO_CWD);
|
||||
|
||||
const statusCallsAtCap = getCheckoutStatus.mock.calls.length;
|
||||
// Past the old cap of 3 recovery attempts (4 total subscriptions), the
|
||||
// service keeps retrying instead of giving up permanently.
|
||||
expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(
|
||||
WATCH_RECOVERY_LADDER_DELAYS_MS.length + 1,
|
||||
);
|
||||
const statusCallsAfterLadder = getCheckoutStatus.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(300_000);
|
||||
expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(4);
|
||||
expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsAtCap);
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(
|
||||
WATCH_RECOVERY_LADDER_DELAYS_MS.length + 2,
|
||||
);
|
||||
});
|
||||
expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsAfterLadder);
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("repository watcher recovery remains capped after recovered subscriptions emit events", async () => {
|
||||
test("repository watcher recovery keeps retrying past the old attempt cap and the ladder does not reset on immediate re-failure", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||
const service = createService(watcher, {
|
||||
@@ -1862,21 +1883,21 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
new Error("repository watcher stopped"),
|
||||
[],
|
||||
);
|
||||
await driveWatchRecoveryLadder(watcher, GIT_DIR);
|
||||
|
||||
for (const [recoveryIndex, delayMs] of [30_000, 60_000, 120_000].entries()) {
|
||||
await vi.advanceTimersByTimeAsync(delayMs);
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherRecordsForDirectory(watcher, GIT_DIR)).toHaveLength(recoveryIndex + 2);
|
||||
});
|
||||
const recoveredWatcher = getWatcherRecordsForDirectory(watcher, GIT_DIR)[recoveryIndex + 1];
|
||||
recoveredWatcher?.callback(null, [{ path: path.join(GIT_DIR, "HEAD"), type: "update" }]);
|
||||
recoveredWatcher?.callback(new Error("recovered repository watcher stopped"), []);
|
||||
}
|
||||
|
||||
const statusCallsAtCap = getCheckoutStatus.mock.calls.length;
|
||||
// Past the old cap of 3 recovery attempts (4 total subscriptions), the
|
||||
// service keeps retrying instead of giving up permanently.
|
||||
expect(getWatcherSubscribeCallCount(watcher, GIT_DIR)).toBe(
|
||||
WATCH_RECOVERY_LADDER_DELAYS_MS.length + 1,
|
||||
);
|
||||
const statusCallsAfterLadder = getCheckoutStatus.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(300_000);
|
||||
expect(getWatcherSubscribeCallCount(watcher, GIT_DIR)).toBe(4);
|
||||
expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsAtCap);
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherSubscribeCallCount(watcher, GIT_DIR)).toBe(
|
||||
WATCH_RECOVERY_LADDER_DELAYS_MS.length + 2,
|
||||
);
|
||||
});
|
||||
expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsAfterLadder);
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
@@ -1982,6 +2003,441 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("pruneKnownDirectories case-folds a Windows-shaped watch root", () => {
|
||||
// `pruneKnownDirectories` only needs to fold case when the watch root
|
||||
// *looks* like a Windows path (drive letter or UNC) — see
|
||||
// `looksLikeDefiniteWindowsPath` in `../utils/path.js`, the same rule
|
||||
// `isPathInsideRoot`/`isRealpathInsideRoot` used before this method was
|
||||
// rewritten from realpath comparisons to string prefixes (see the
|
||||
// class-level comment on `pruneKnownDirectories`). macOS's real
|
||||
// filesystem is already case-insensitive, so a test built from real
|
||||
// directories on disk would pass whether or not that fold logic
|
||||
// exists — a synthetic Windows-shaped root exercises the comparison
|
||||
// logic itself instead of relying on host filesystem behavior.
|
||||
//
|
||||
// Driven directly against the private target rather than through
|
||||
// `registerWorkspace`, for two independent reasons, both checked before
|
||||
// settling on this shape:
|
||||
//
|
||||
// 1. Reachability: `registerWorkspace` resolves `cwd` through
|
||||
// `node:path`'s `resolve()` before anything else runs, and on this
|
||||
// POSIX test host that rewrites a drive-letter path into an ordinary
|
||||
// POSIX one, destroying the shape this test depends on. This step can
|
||||
// be routed around — mocking `git rev-parse --show-toplevel`'s stdout
|
||||
// lets `target.watchPath` carry the raw Windows-shaped string, since
|
||||
// `parseGitRevParsePath` does not call `resolve()` — but
|
||||
// `loadIgnoredDirs` then calls `resolve(rootPath, rel)` on that same
|
||||
// watch path to build `ignoredDirectories`, which on POSIX prefixes it
|
||||
// with `process.cwd()` and breaks the shared prefix the fold logic
|
||||
// compares against. Two independent `resolve()` calls, each assuming
|
||||
// host-platform semantics, block the public path from both sides.
|
||||
// 2. Observability: even granting reachability, `knownDirectories` has
|
||||
// exactly one reader outside this method — the Set-lookup fast path in
|
||||
// `noteWorkingTreeDirectories` — and that method's fallback branch
|
||||
// re-derives ignored-ness via `isPathInsideRoot`, which already folds
|
||||
// case correctly on its own. Verified by deleting this method's fold
|
||||
// logic (forcing `foldCase = false`) and running this file plus the
|
||||
// integration suite: only this test failed. No public-surface
|
||||
// assertion distinguishes a correctly pruned directory from one left
|
||||
// stale, because the redundant check downstream produces the same
|
||||
// outcome either way.
|
||||
const service = createService(createWatcherHarness());
|
||||
const target = {
|
||||
watchPath: "C:/Users/dev/Repo",
|
||||
knownDirectories: new Set<string>(["C:/Users/dev/Repo/Deps/package-a"]),
|
||||
ignoredDirectories: new Set<string>(["C:/Users/dev/Repo/deps"]),
|
||||
};
|
||||
|
||||
(
|
||||
service as unknown as { pruneKnownDirectories: (t: typeof target) => void }
|
||||
).pruneKnownDirectories(target);
|
||||
|
||||
expect(target.knownDirectories.has("C:/Users/dev/Repo/Deps/package-a")).toBe(false);
|
||||
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("a previously unseen directory refreshes the ignore set", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
// A fresh worktree has no ignored directories on disk yet.
|
||||
let ignoredDirectories = "";
|
||||
const runGitCommand = vi.fn(async (args: string[]) => ({
|
||||
stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : ignoredDirectories,
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
}));
|
||||
const service = createService(watcher, {
|
||||
getWorkspaceGitSelfHealPhaseMs: () => 1_000,
|
||||
runGitCommand,
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1);
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
});
|
||||
const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD);
|
||||
expect(checkoutWatcher?.ignore).not.toContain(path.join(REPO_CWD, "deps"));
|
||||
|
||||
// Dependencies get installed. No .gitignore is touched.
|
||||
ignoredDirectories = "deps/\n";
|
||||
checkoutWatcher?.callback(null, [
|
||||
{ path: path.join(REPO_CWD, "deps", "package-a", "index.js"), type: "create" },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(checkoutWatcher?.updateIgnore).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([path.join(REPO_CWD, "deps")]),
|
||||
);
|
||||
});
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("a failed ignore refresh does not permanently mark a directory known", async () => {
|
||||
// Regression for the failure path reintroducing the original #4558 bug:
|
||||
// `noteWorkingTreeDirectories` marks a newly seen directory known and
|
||||
// schedules a debounced refresh, but `loadIgnoredDirs` deliberately keeps
|
||||
// the previous ignore set when `git ls-files` fails (a transient failure
|
||||
// must not un-ignore the whole dependency tree). If that success/failure
|
||||
// distinction is not carried back, the directory stays "known" forever
|
||||
// with the fast-path Set lookup in `noteWorkingTreeDirectories` skipping
|
||||
// it on every later event — so no refresh is ever attempted again and
|
||||
// the directory is watched forever, same as before #4558 was fixed.
|
||||
const watcher = createWatcherHarness();
|
||||
let ignoredDirectories = "";
|
||||
let lsFilesCallCount = 0;
|
||||
const runGitCommand = vi.fn(async (args: string[]) => {
|
||||
if (args[0] === "rev-parse") {
|
||||
return { stdout: `${REPO_CWD}\n`, stderr: "", truncated: false, exitCode: 0, signal: null };
|
||||
}
|
||||
lsFilesCallCount += 1;
|
||||
// The seed load at registration (call 1) succeeds. The refresh
|
||||
// triggered by the newly discovered "deps" directory (call 2) fails
|
||||
// transiently.
|
||||
if (lsFilesCallCount === 2) {
|
||||
throw new Error("git ls-files timed out");
|
||||
}
|
||||
return {
|
||||
stdout: ignoredDirectories,
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
};
|
||||
});
|
||||
const service = createService(watcher, {
|
||||
getWorkspaceGitSelfHealPhaseMs: () => 1_000,
|
||||
runGitCommand,
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1);
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
});
|
||||
const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD);
|
||||
expect(lsFilesCallCount).toBe(1);
|
||||
|
||||
// Dependencies get installed. No .gitignore is touched, so this only
|
||||
// schedules a refresh via directory discovery — the one wired to fail.
|
||||
ignoredDirectories = "deps/\n";
|
||||
checkoutWatcher?.callback(null, [
|
||||
{ path: path.join(REPO_CWD, "deps", "package-a", "index.js"), type: "create" },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(lsFilesCallCount).toBe(2);
|
||||
});
|
||||
// The failed refresh must not have applied "deps" to the live watcher.
|
||||
expect(checkoutWatcher?.updateIgnore).not.toHaveBeenCalled();
|
||||
|
||||
// A later write under the same directory. If the directory were left
|
||||
// permanently "known" by the failed refresh, `noteWorkingTreeDirectories`
|
||||
// would silently skip it here and no second refresh would ever happen.
|
||||
checkoutWatcher?.callback(null, [
|
||||
{ path: path.join(REPO_CWD, "deps", "package-a", "index2.js"), type: "create" },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(lsFilesCallCount).toBe(3);
|
||||
expect(checkoutWatcher?.updateIgnore).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([path.join(REPO_CWD, "deps")]),
|
||||
);
|
||||
});
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("repeat writes in known directories do not re-run git ls-files", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
let ignoredDirectories = "";
|
||||
const runGitCommand = vi.fn(async (args: string[]) => ({
|
||||
stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : ignoredDirectories,
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
}));
|
||||
const service = createService(watcher, {
|
||||
getWorkspaceGitSelfHealPhaseMs: () => 1_000,
|
||||
runGitCommand,
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1);
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
});
|
||||
const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD);
|
||||
// Registration itself performs one ls-files call to seed the ignore set. Measure the
|
||||
// burst's effect as a delta from this baseline, not an absolute count, so the assertion
|
||||
// cannot pass vacuously off of setup's own call.
|
||||
const lsFilesAtSetup = runGitCommand.mock.calls.filter(
|
||||
(call) => call[0][0] === "ls-files",
|
||||
).length;
|
||||
|
||||
// One burst in a new directory: one debounced refresh, not one per event.
|
||||
ignoredDirectories = "deps/\n";
|
||||
for (let index = 0; index < 500; index += 1) {
|
||||
checkoutWatcher?.callback(null, [
|
||||
{ path: path.join(REPO_CWD, "deps", `module-${index}.js`), type: "create" },
|
||||
]);
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
const lsFilesAfterBurst = runGitCommand.mock.calls.filter(
|
||||
(call) => call[0][0] === "ls-files",
|
||||
).length;
|
||||
// The burst must have refreshed the ignore set at least once...
|
||||
expect(lsFilesAfterBurst - lsFilesAtSetup).toBeGreaterThanOrEqual(1);
|
||||
// ...but debounced into at most 2 calls, not one per event. (The brief's original absolute
|
||||
// bound was <= 3 total calls with a baseline of 1 from setup, i.e. at most 2 burst-triggered
|
||||
// refreshes — preserve that numeric intent here as a delta.)
|
||||
expect(lsFilesAfterBurst - lsFilesAtSetup).toBeLessThanOrEqual(2);
|
||||
|
||||
// The directory is known now. Further writes trigger nothing.
|
||||
for (let index = 0; index < 200; index += 1) {
|
||||
checkoutWatcher?.callback(null, [
|
||||
{ path: path.join(REPO_CWD, "src", `file-${index}.ts`), type: "update" },
|
||||
]);
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
const lsFilesAtEnd = runGitCommand.mock.calls.filter(
|
||||
(call) => call[0][0] === "ls-files",
|
||||
).length;
|
||||
expect(lsFilesAtEnd).toBeLessThanOrEqual(lsFilesAfterBurst + 1);
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("a deleted known directory is dropped, so a later event under it re-runs git ls-files", async () => {
|
||||
// Regression for the finding that `knownDirectories` only ever grows:
|
||||
// nothing previously removed an entry once its directory was deleted
|
||||
// from disk, so a long-lived daemon over a churning tree (deps
|
||||
// installed and removed, build output cycling) would accumulate
|
||||
// tombstones for the life of the target and slow every future
|
||||
// `pruneKnownDirectories` pass. `removeDeletedKnownDirectories` must
|
||||
// drop a directory (and anything nested under it) once a `delete`
|
||||
// event reports the directory path itself — proven here by making a
|
||||
// write reappear under the same path afterward and asserting it costs
|
||||
// a fresh `git ls-files` refresh rather than being silently skipped by
|
||||
// the known-directory fast path.
|
||||
const watcher = createWatcherHarness();
|
||||
const runGitCommand = vi.fn(async (args: string[]) => ({
|
||||
stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : "",
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
}));
|
||||
const service = createService(watcher, {
|
||||
getWorkspaceGitSelfHealPhaseMs: () => 1_000,
|
||||
runGitCommand,
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1);
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
});
|
||||
const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD);
|
||||
const lsFilesCallCount = () =>
|
||||
runGitCommand.mock.calls.filter((call) => call[0][0] === "ls-files").length;
|
||||
const lsFilesAtSetup = lsFilesCallCount();
|
||||
|
||||
const depsDir = path.join(REPO_CWD, "deps");
|
||||
// First sight of `deps`: marks it known and schedules one debounced refresh.
|
||||
checkoutWatcher?.callback(null, [{ path: path.join(depsDir, "index.js"), type: "create" }]);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(lsFilesCallCount()).toBeGreaterThan(lsFilesAtSetup);
|
||||
});
|
||||
const lsFilesAfterDepsDiscovery = lsFilesCallCount();
|
||||
|
||||
// A nested directory under `deps` becomes known too. A directory delete
|
||||
// does not guarantee a delete event for everything beneath it, so
|
||||
// removal must drop this along with `deps` itself.
|
||||
const nestedDir = path.join(depsDir, "nested");
|
||||
checkoutWatcher?.callback(null, [{ path: path.join(nestedDir, "index.js"), type: "create" }]);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(lsFilesCallCount()).toBeGreaterThan(lsFilesAfterDepsDiscovery);
|
||||
});
|
||||
const lsFilesAfterNestedDiscovery = lsFilesCallCount();
|
||||
|
||||
// Steady state: another write in a known directory triggers nothing.
|
||||
checkoutWatcher?.callback(null, [{ path: path.join(depsDir, "index2.js"), type: "update" }]);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(lsFilesCallCount()).toBe(lsFilesAfterNestedDiscovery);
|
||||
|
||||
// `deps` (the parent) is deleted from disk. The watcher reports the
|
||||
// directory path itself, not just files under it.
|
||||
checkoutWatcher?.callback(null, [{ path: depsDir, type: "delete" }]);
|
||||
|
||||
// A write reappears directly under `deps`. If the delete had not
|
||||
// dropped it from `knownDirectories`, the known-directory fast path
|
||||
// would skip this event and no refresh would run.
|
||||
checkoutWatcher?.callback(null, [
|
||||
{ path: path.join(depsDir, "reinstalled.js"), type: "create" },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(lsFilesCallCount()).toBeGreaterThan(lsFilesAfterNestedDiscovery);
|
||||
});
|
||||
const lsFilesAfterDepsRediscovery = lsFilesCallCount();
|
||||
|
||||
// The formerly nested `deps/nested` must have been dropped too, even
|
||||
// though only `deps` itself received a delete event.
|
||||
checkoutWatcher?.callback(null, [
|
||||
{ path: path.join(nestedDir, "reinstalled.js"), type: "create" },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(lsFilesCallCount()).toBeGreaterThan(lsFilesAfterDepsRediscovery);
|
||||
});
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("a large event batch refreshes ignored roots without scanning every new directory", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
const runGitCommand = vi.fn(async (args: string[]) => ({
|
||||
stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : "",
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
}));
|
||||
const service = createService(watcher, {
|
||||
getWorkspaceGitSelfHealPhaseMs: () => 1_000,
|
||||
runGitCommand,
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1);
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
});
|
||||
const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD);
|
||||
const lsFilesCallCount = () =>
|
||||
runGitCommand.mock.calls.filter((call) => call[0][0] === "ls-files").length;
|
||||
const lsFilesAtSetup = lsFilesCallCount();
|
||||
|
||||
// Establish one directory as known and steady before the flood.
|
||||
const markerDir = path.join(REPO_CWD, "marker");
|
||||
checkoutWatcher?.callback(null, [{ path: path.join(markerDir, "file.js"), type: "create" }]);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(lsFilesCallCount()).toBeGreaterThan(lsFilesAtSetup);
|
||||
});
|
||||
const lsFilesAfterMarker = lsFilesCallCount();
|
||||
|
||||
// Steady state: another write under the marker directory triggers nothing.
|
||||
checkoutWatcher?.callback(null, [{ path: path.join(markerDir, "file2.js"), type: "update" }]);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(lsFilesCallCount()).toBe(lsFilesAfterMarker);
|
||||
|
||||
// A single discovery refreshes Git's complete ignored-directory inventory.
|
||||
// The other directories in this batch need not enter the known cache.
|
||||
const floodEvents = Array.from({ length: 50_000 }, (_, index) => ({
|
||||
path: path.join(REPO_CWD, "gen", `d${index}`, "file.js"),
|
||||
type: "create" as const,
|
||||
}));
|
||||
checkoutWatcher?.callback(null, floodEvents);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(lsFilesCallCount()).toBeGreaterThan(lsFilesAfterMarker);
|
||||
});
|
||||
const lsFilesAfterFlood = lsFilesCallCount();
|
||||
|
||||
// The flood must not evict an unrelated known directory by processing
|
||||
// every entry before the cap is checked. Its later edit stays on the
|
||||
// cache fast path and does not trigger a redundant Git refresh.
|
||||
checkoutWatcher?.callback(null, [{ path: path.join(markerDir, "file3.js"), type: "create" }]);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(lsFilesCallCount()).toBe(lsFilesAfterFlood);
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("watcher recovery keeps retrying after repeated failures", async () => {
|
||||
// The initial subscribe must succeed so the target starts out healthy; only subscribe
|
||||
// attempts made *after* that (i.e. recovery attempts) should fail. `failDirectories` is
|
||||
// read live on every `subscribe()` call, so mutating it after registration is enough —
|
||||
// no need to fork the harness.
|
||||
const failDirectories = new Set<string>();
|
||||
const watcher = createWatcherHarness({ failDirectories });
|
||||
let ignoredDirectories = "node_modules/\n";
|
||||
const runGitCommand = vi.fn(async (args: string[]) => ({
|
||||
stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : ignoredDirectories,
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
}));
|
||||
const service = createService(watcher, {
|
||||
getWorkspaceGitSelfHealPhaseMs: () => 1_000,
|
||||
runGitCommand,
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1);
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
});
|
||||
const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD);
|
||||
const subscribeCallsBeforeFailure = getWatcherSubscribeCallCount(watcher, REPO_CWD);
|
||||
|
||||
// From here on, every further subscribe attempt for this directory fails — a watcher that
|
||||
// cannot be re-established (permission error, unmounted volume, etc).
|
||||
failDirectories.add(REPO_CWD);
|
||||
|
||||
// Force the live subscription to fail and enter recovery. A rejected updateIgnore is the
|
||||
// most direct trigger: it nulls target.subscription and schedules recovery.
|
||||
checkoutWatcher?.updateIgnore.mockRejectedValueOnce(new Error("update failed"));
|
||||
ignoredDirectories = "node_modules/\nbuild/\n";
|
||||
checkoutWatcher?.callback(null, [{ path: path.join(REPO_CWD, ".gitignore"), type: "update" }]);
|
||||
|
||||
// Recovery backs off at WATCH_RECOVERY_BASE_DELAY_MS * 2**(attempt-1) with base 30_000ms,
|
||||
// landing at 30s, 60s, 120s, 240s for the first 4 attempts (WATCH_RECOVERY_MAX_BACKOFF_STEPS
|
||||
// caps the exponent at 4, and WATCH_RECOVERY_MAX_DELAY_MS's 300s ceiling doesn't bind until
|
||||
// the 5th). A 4th recovery attempt needs the first four delays to have elapsed:
|
||||
// 30_000 + 60_000 + 120_000 + 240_000 = 450_000ms. Advance past that with headroom.
|
||||
await vi.advanceTimersByTimeAsync(500_000);
|
||||
|
||||
const subscribeCallsAfterRecoveryWindow = getWatcherSubscribeCallCount(watcher, REPO_CWD);
|
||||
// 1 initial success + 3 capped recovery attempts = 4 total. A 5th call would prove recovery
|
||||
// keeps retrying past the hard cap instead of giving up on this target forever.
|
||||
expect(subscribeCallsAfterRecoveryWindow).toBeGreaterThan(subscribeCallsBeforeFailure + 3);
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("removing an ignored directory updates the watcher without replacement", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
let ignoredDirectories = "node_modules/\nbuild/\n";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { LRUCache } from "lru-cache";
|
||||
import { CheckoutDiffCache } from "./checkout-diff-cache.js";
|
||||
import pLimit from "p-limit";
|
||||
@@ -45,7 +45,9 @@ import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
|
||||
import {
|
||||
createRealpathAwarePathMatcher,
|
||||
getRealpathAwareRelativePath,
|
||||
isPathInsideRoot,
|
||||
isRealpathInsideRoot,
|
||||
looksLikeDefiniteWindowsPath,
|
||||
} from "../utils/path.js";
|
||||
import {
|
||||
createRunGitCommand,
|
||||
@@ -84,13 +86,34 @@ const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000;
|
||||
const FORGE_PR_STATUS_POLL_SLOW_INTERVAL_MS = 120_000;
|
||||
const FORGE_PR_STATUS_POLL_ERROR_BACKOFF_CAP_MS = 300_000;
|
||||
const DEGRADED_GIT_POLL_INTERVAL_MS = 5_000;
|
||||
// A dependency install creates directories for minutes. Collapse the burst into
|
||||
// a couple of `git ls-files` runs rather than one per event.
|
||||
const WORKING_TREE_IGNORE_REFRESH_QUIET_MS = 300;
|
||||
const WORKING_TREE_IGNORE_REFRESH_MAX_DELAY_MS = 2_000;
|
||||
// `knownDirectories` is a memoisation of the fast path in
|
||||
// `noteWorkingTreeDirectories`, not authoritative state — every entry is
|
||||
// rediscoverable from a later watcher event under the same path. Deletion
|
||||
// handling (`removeDeletedKnownDirectories`) keeps it close to the live
|
||||
// directory count, but this cap is the backstop for what that cannot cover
|
||||
// (a missed or coalesced delete event, a backend quirk): clearing the whole
|
||||
// set outright on overflow is always safe, since the only cost is one extra
|
||||
// `git ls-files` refresh the next time a directory under this root is
|
||||
// touched again. Set well below `MAX_TRACKED_ENTRIES` (250_000, the
|
||||
// native-recursive.ts cap on combined file+directory entries per watched
|
||||
// root) because directories are a minority of any tracked tree, and the
|
||||
// observer itself already fails into polling before a single root's
|
||||
// combined entry count could approach that cap.
|
||||
const WORKING_TREE_KNOWN_DIRECTORIES_MAX = 50_000;
|
||||
// Keep whole workspace pipelines below the lower-level Git process pool so daemon control work
|
||||
// retains subprocess and event-loop headroom during large workspace reconciliation bursts.
|
||||
export const WORKSPACE_GIT_REFRESH_CONCURRENCY = 4;
|
||||
export const WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY = 2;
|
||||
export const WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS = 10_000;
|
||||
const WATCH_RECOVERY_BASE_DELAY_MS = 30_000;
|
||||
const WATCH_RECOVERY_MAX_ATTEMPTS = 3;
|
||||
// Giving up permanently leaves polling as the only behaviour until a daemon
|
||||
// restart, which kills running agents. Back off, but keep trying.
|
||||
const WATCH_RECOVERY_MAX_DELAY_MS = 300_000;
|
||||
const WATCH_RECOVERY_MAX_BACKOFF_STEPS = 4;
|
||||
// Auxiliary reads may reuse cached values within this window; snapshots do not expire on read.
|
||||
const WORKSPACE_GIT_AUXILIARY_READ_TTL_MS = 15_000;
|
||||
// Non-forced refresh triggers share this minimum gap to absorb watcher/self-heal bursts; force bypasses it.
|
||||
@@ -444,8 +467,36 @@ interface WorkingTreeWatchTarget {
|
||||
repoRoot: string | null;
|
||||
subscription: FileObserverSubscription | null;
|
||||
ignoredDirectories: Set<string>;
|
||||
// What the live subscription actually accepted, as of the last successful
|
||||
// `updateIgnore`/subscribe — the full resolved ignore list (the watch root's
|
||||
// `.git` plus gitignore-derived `ignoredDirectories`), not just
|
||||
// `ignoredDirectories`. The resolved list can move ahead of this (a fresh
|
||||
// `git ls-files` result) while the subscription still runs on the old set;
|
||||
// comparing against this field instead of the last-computed value keeps a
|
||||
// skipped update from latching forever.
|
||||
appliedIgnoreList: string[];
|
||||
ignoredDirectoriesRefreshPromise: Promise<void> | null;
|
||||
ignoredDirectoriesRefreshRequested: boolean;
|
||||
// Bounded two ways so a long-lived daemon over a churning tree cannot grow
|
||||
// this without limit: `removeDeletedKnownDirectories` drops an entry (and
|
||||
// anything nested under it) once its own path is reported deleted, and
|
||||
// `enforceKnownDirectoriesCap` clears the whole set outright past
|
||||
// `WORKING_TREE_KNOWN_DIRECTORIES_MAX`. See both for why clearing is safe.
|
||||
knownDirectories: Set<string>;
|
||||
// Directories added to `knownDirectories` since the last ignore-list
|
||||
// refresh that actually completed (successfully or not). A failed refresh
|
||||
// (`loadIgnoredDirs` falling back to the previous set) must not leave a
|
||||
// newly seen directory permanently marked known — that would silently
|
||||
// reintroduce the original "watched forever" bug through the failure path.
|
||||
// `replaceWorkingTreeIgnoredDirectories` rolls this set back into
|
||||
// `knownDirectories` on failure so a later event under the same directory
|
||||
// is treated as unseen again and re-arms the refresh; on success it is
|
||||
// simply cleared, since a successful refresh reflects the full current
|
||||
// ignore state and `pruneKnownDirectories` already removes anything that
|
||||
// turned out to be ignored.
|
||||
pendingKnownDirectories: Set<string>;
|
||||
ignoreRefreshTimer: NodeJS.Timeout | null;
|
||||
ignoreRefreshDeadlineMs: number | null;
|
||||
aliases: Set<string>;
|
||||
workspaceKeys: Set<string>;
|
||||
fallbackPolling: boolean;
|
||||
@@ -458,6 +509,13 @@ interface WorkingTreeWatchTarget {
|
||||
interface WatchRecoveryState {
|
||||
attemptCount: number;
|
||||
timer: NodeJS.Timeout | null;
|
||||
// Set when a subscription is accepted, cleared when it is scheduled for
|
||||
// recovery. `attemptCount` only resets to 0 once the subscription this
|
||||
// replaces is proven to have survived at least `WATCH_RECOVERY_MAX_DELAY_MS`
|
||||
// — otherwise a filesystem that accepts `subscribe()` but errors on first
|
||||
// use (SMB/NFS, Docker virtiofs, some FUSE) resets the ladder on every
|
||||
// attempt and the backoff never engages.
|
||||
establishedAt: number | null;
|
||||
}
|
||||
|
||||
function setsEqual(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
|
||||
@@ -1274,20 +1332,27 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
watchPath: string,
|
||||
repoRoot: string | null,
|
||||
): Promise<WorkingTreeWatchTarget> {
|
||||
const ignoredDirectories = repoRoot ? await this.loadIgnoredDirs(watchPath) : new Set<string>();
|
||||
const ignoredDirectories = repoRoot
|
||||
? (await this.loadIgnoredDirs(watchPath)).ignored
|
||||
: new Set<string>();
|
||||
const target: WorkingTreeWatchTarget = {
|
||||
cwd,
|
||||
watchPath,
|
||||
repoRoot,
|
||||
subscription: null,
|
||||
ignoredDirectories,
|
||||
appliedIgnoreList: [],
|
||||
ignoredDirectoriesRefreshPromise: null,
|
||||
ignoredDirectoriesRefreshRequested: false,
|
||||
knownDirectories: new Set(),
|
||||
pendingKnownDirectories: new Set(),
|
||||
ignoreRefreshTimer: null,
|
||||
ignoreRefreshDeadlineMs: null,
|
||||
aliases: new Set([cwd]),
|
||||
workspaceKeys: new Set(),
|
||||
fallbackPolling: false,
|
||||
fallbackPollTimer: null,
|
||||
recovery: { attemptCount: 0, timer: null },
|
||||
recovery: { attemptCount: 0, timer: null, establishedAt: null },
|
||||
listeners: new Set(),
|
||||
closed: false,
|
||||
};
|
||||
@@ -1390,11 +1455,18 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
}
|
||||
|
||||
// Shared by startWorkingTreeSubscription and
|
||||
// replaceWorkingTreeIgnoredDirectories's applied-vs-desired gate so both
|
||||
// compute the same resolved ignore list.
|
||||
private workingTreeIgnoreList(target: WorkingTreeWatchTarget): string[] {
|
||||
return [join(target.watchPath, ".git"), ...target.ignoredDirectories];
|
||||
}
|
||||
|
||||
private async startWorkingTreeSubscription(
|
||||
target: WorkingTreeWatchTarget,
|
||||
options?: { replaceFallback?: boolean },
|
||||
): Promise<boolean> {
|
||||
const ignore = [join(target.watchPath, ".git"), ...target.ignoredDirectories];
|
||||
const ignore = this.workingTreeIgnoreList(target);
|
||||
let watcherErrored = false;
|
||||
let subscribeSettled = false;
|
||||
const markSubscribeSettled = () => {
|
||||
@@ -1426,8 +1498,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
if (watcherErrored) {
|
||||
return;
|
||||
}
|
||||
const discovered = this.noteWorkingTreeDirectories(target, events);
|
||||
if (events.some((event) => basename(event.path) === ".gitignore")) {
|
||||
void this.refreshWorkingTreeIgnoredDirectories(target);
|
||||
} else if (discovered) {
|
||||
this.scheduleWorkingTreeIgnoreRefresh(target);
|
||||
}
|
||||
if (!this.hasRelevantWorkingTreeEvent(target, events)) {
|
||||
return;
|
||||
@@ -1450,6 +1525,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return false;
|
||||
}
|
||||
target.subscription = subscription;
|
||||
target.appliedIgnoreList = ignore;
|
||||
// Do not reset `attemptCount` here — `subscribe()` resolving proves
|
||||
// nothing about the watcher's health on a filesystem where it errors on
|
||||
// first use. Record when this subscription was established instead;
|
||||
// `scheduleWorkingTreeWatchRecovery` only resets the ladder once this
|
||||
// one has survived long enough to prove itself durable.
|
||||
target.recovery.establishedAt = this.deps.now().getTime();
|
||||
if (options?.replaceFallback && target.repoRoot !== null) {
|
||||
target.fallbackPolling = false;
|
||||
if (target.fallbackPollTimer) {
|
||||
@@ -1540,17 +1622,38 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
this.startWorkingTreeWatchFallback(target, reason);
|
||||
}
|
||||
|
||||
private scheduleWorkingTreeWatchRecovery(target: WorkingTreeWatchTarget): void {
|
||||
/**
|
||||
* Advances the recovery backoff ladder and returns the delay to wait
|
||||
* before the next attempt. `attemptCount` only resets to 0 here, and only
|
||||
* when the subscription being replaced was established long enough ago to
|
||||
* have proven itself durable (>= `WATCH_RECOVERY_MAX_DELAY_MS`) — otherwise
|
||||
* a filesystem that accepts `subscribe()` but errors immediately on every
|
||||
* attempt (SMB/NFS, Docker virtiofs, some FUSE) would reset the ladder to 0
|
||||
* on every cycle and the backoff would never engage. Shared by the
|
||||
* working-tree and repo-metadata recovery paths so both apply the same
|
||||
* rule.
|
||||
*/
|
||||
private advanceWatchRecoveryLadder(recovery: WatchRecoveryState): number {
|
||||
if (
|
||||
target.closed ||
|
||||
target.subscription ||
|
||||
target.recovery.timer ||
|
||||
target.recovery.attemptCount >= WATCH_RECOVERY_MAX_ATTEMPTS
|
||||
recovery.establishedAt !== null &&
|
||||
this.deps.now().getTime() - recovery.establishedAt >= WATCH_RECOVERY_MAX_DELAY_MS
|
||||
) {
|
||||
recovery.attemptCount = 0;
|
||||
}
|
||||
recovery.establishedAt = null;
|
||||
recovery.attemptCount += 1;
|
||||
return Math.min(
|
||||
WATCH_RECOVERY_BASE_DELAY_MS *
|
||||
2 ** Math.min(recovery.attemptCount - 1, WATCH_RECOVERY_MAX_BACKOFF_STEPS),
|
||||
WATCH_RECOVERY_MAX_DELAY_MS,
|
||||
);
|
||||
}
|
||||
|
||||
private scheduleWorkingTreeWatchRecovery(target: WorkingTreeWatchTarget): void {
|
||||
if (target.closed || target.subscription || target.recovery.timer) {
|
||||
return;
|
||||
}
|
||||
target.recovery.attemptCount += 1;
|
||||
const delayMs = WATCH_RECOVERY_BASE_DELAY_MS * 2 ** (target.recovery.attemptCount - 1);
|
||||
const delayMs = this.advanceWatchRecoveryLadder(target.recovery);
|
||||
target.recovery.timer = setTimeout(() => {
|
||||
target.recovery.timer = null;
|
||||
void this.recoverWorkingTreeWatch(target);
|
||||
@@ -1592,6 +1695,214 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
await this.refreshWorkingTreeIgnoredDirectories(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* `git ls-files` reports ignored directories only once they exist, so the
|
||||
* ignore set must be re-read when new directory topology appears — not only
|
||||
* when the rules change. Keyed on `dirname` because FSEvents coalescing makes
|
||||
* the directory's own creation event unreliable; a file inside it proves it
|
||||
* exists either way.
|
||||
*/
|
||||
private noteWorkingTreeDirectories(
|
||||
target: WorkingTreeWatchTarget,
|
||||
events: FileChange[],
|
||||
): boolean {
|
||||
if (target.repoRoot === null) {
|
||||
return false;
|
||||
}
|
||||
this.removeDeletedKnownDirectories(target, events);
|
||||
const gitDir = join(target.watchPath, ".git");
|
||||
// `matchesWatchPath` is realpath-aware because it compares an event-reported
|
||||
// directory against `target.watchPath` itself — a config-time root that can
|
||||
// legitimately differ from the watcher's reported form (e.g. a symlinked
|
||||
// temp dir). It is now gated behind the plain Set lookup below, so it only
|
||||
// runs for a directory this target has never seen before, not on every
|
||||
// event.
|
||||
const matchesWatchPath = createRealpathAwarePathMatcher(target.watchPath);
|
||||
// One new directory is enough: the scheduled Git query inventories every
|
||||
// ignored directory that exists by then. Stop before a large event batch
|
||||
// multiplies containment checks across all its new directories.
|
||||
for (const event of events) {
|
||||
const directory = dirname(event.path);
|
||||
// Cheap Set lookup first — the steady-state case where the directory is
|
||||
// already known must never reach the realpath-aware matcher below.
|
||||
if (target.knownDirectories.has(directory) || matchesWatchPath(directory)) {
|
||||
continue;
|
||||
}
|
||||
// `directory` and `gitDir`/`ignoredDirectory` all derive from the same
|
||||
// `target.watchPath` used to build this subscription, and `directory`
|
||||
// itself is the same `dirname(event.path)` value already trusted as a
|
||||
// plain string key by `target.knownDirectories.has()` above — no realpath
|
||||
// resolution needed for containment among values from this one source.
|
||||
if (!isPathInsideRoot(target.watchPath, directory)) {
|
||||
continue;
|
||||
}
|
||||
if (isPathInsideRoot(gitDir, directory)) {
|
||||
continue;
|
||||
}
|
||||
let ignored = false;
|
||||
for (const ignoredDirectory of target.ignoredDirectories) {
|
||||
if (isPathInsideRoot(ignoredDirectory, directory)) {
|
||||
ignored = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ignored) {
|
||||
continue;
|
||||
}
|
||||
target.knownDirectories.add(directory);
|
||||
target.pendingKnownDirectories.add(directory);
|
||||
this.enforceKnownDirectoriesCap(target);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* `knownDirectories` only ever grows in the loop above — nothing
|
||||
* previously dropped an entry once its directory was deleted from disk,
|
||||
* so a long-lived daemon over a tree that creates and deletes directories
|
||||
* (build output, temp dirs, a churning dependency tree) would accumulate
|
||||
* tombstones for the life of the target and slow every future
|
||||
* `pruneKnownDirectories` pass along with it. `FileChange` events arrive
|
||||
* for files too, and a delete of `dir/file.txt` does not mean `dir` is
|
||||
* gone, so this only acts once the deleted path is itself confirmed a
|
||||
* known directory — a cheap `Set.has()` first, so the common case (a file
|
||||
* delete) costs one lookup and returns, same as the fast path this method
|
||||
* feeds into. A directory delete does not guarantee a delete event for
|
||||
* everything beneath it (coalescing, an `rm -rf` of a populated tree), so
|
||||
* once a deleted path is confirmed known, this drops the whole known
|
||||
* subtree the same way `pruneKnownDirectories` drops a newly ignored one —
|
||||
* the deleted path is the containment root here instead of an ignored
|
||||
* root.
|
||||
*/
|
||||
private removeDeletedKnownDirectories(
|
||||
target: WorkingTreeWatchTarget,
|
||||
events: FileChange[],
|
||||
): void {
|
||||
if (target.knownDirectories.size === 0) {
|
||||
return;
|
||||
}
|
||||
for (const event of events) {
|
||||
if (event.type !== "delete" || !target.knownDirectories.has(event.path)) {
|
||||
continue;
|
||||
}
|
||||
for (const directory of target.knownDirectories) {
|
||||
if (!isPathInsideRoot(event.path, directory)) {
|
||||
continue;
|
||||
}
|
||||
target.knownDirectories.delete(directory);
|
||||
// Not required for correctness — both refresh outcomes tolerate a
|
||||
// stale entry that no longer resolves to anything live: a
|
||||
// successful refresh clears `pendingKnownDirectories` outright, and
|
||||
// a failed one's `rearmPendingKnownDirectories` no-ops a delete of
|
||||
// an already-absent key. Dropping it here keeps
|
||||
// `pendingKnownDirectories` a subset of `knownDirectories` at all
|
||||
// times instead of "true except right after a deletion."
|
||||
target.pendingKnownDirectories.delete(directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* See `WORKING_TREE_KNOWN_DIRECTORIES_MAX` for why clearing on overflow is
|
||||
* always safe. Runs only when this batch discovered at least one new
|
||||
* directory, since that is the only way `knownDirectories` grows.
|
||||
*/
|
||||
private enforceKnownDirectoriesCap(target: WorkingTreeWatchTarget): void {
|
||||
if (target.knownDirectories.size <= WORKING_TREE_KNOWN_DIRECTORIES_MAX) {
|
||||
return;
|
||||
}
|
||||
target.knownDirectories.clear();
|
||||
target.pendingKnownDirectories.clear();
|
||||
}
|
||||
|
||||
private scheduleWorkingTreeIgnoreRefresh(target: WorkingTreeWatchTarget): void {
|
||||
if (this.disposed || target.closed || target.repoRoot === null) {
|
||||
return;
|
||||
}
|
||||
const now = this.deps.now().getTime();
|
||||
target.ignoreRefreshDeadlineMs ??= now + WORKING_TREE_IGNORE_REFRESH_MAX_DELAY_MS;
|
||||
if (target.ignoreRefreshTimer) {
|
||||
clearTimeout(target.ignoreRefreshTimer);
|
||||
}
|
||||
const delayMs = Math.max(
|
||||
0,
|
||||
Math.min(WORKING_TREE_IGNORE_REFRESH_QUIET_MS, target.ignoreRefreshDeadlineMs - now),
|
||||
);
|
||||
target.ignoreRefreshTimer = setTimeout(() => {
|
||||
target.ignoreRefreshTimer = null;
|
||||
target.ignoreRefreshDeadlineMs = null;
|
||||
void this.refreshWorkingTreeIgnoredDirectories(target);
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* String-prefix containment, not `isRealpathInsideRoot`: every entry on
|
||||
* both sides is a plain path built from `target.watchPath` by this same
|
||||
* class (`dirname(event.path)` for `knownDirectories`, `resolve(watchPath,
|
||||
* ...)` for `ignoredDirectories` in `loadIgnoredDirs`) — no realpath
|
||||
* divergence to reconcile, per the same reasoning as
|
||||
* `noteWorkingTreeDirectories`. Normalizing each ignored root once up front
|
||||
* instead of recomputing per pair turns a 30k-known x 40-ignored scan from
|
||||
* ~26s of synchronous realpath syscalls into a bounded number of cheap
|
||||
* string comparisons.
|
||||
*
|
||||
* Case-folding still needs to happen for a Windows-looking root, same as
|
||||
* `isPathInsideRoot`/`isRealpathInsideRoot` in `../utils/path.js` — a
|
||||
* case-insensitive filesystem can report a known directory whose case
|
||||
* diverges from the git-reported ignored root. Since both sides derive
|
||||
* from the single `target.watchPath` for this target, whether to fold is
|
||||
* one decision for the whole call (`looksLikeDefiniteWindowsPath`
|
||||
* reused from `../utils/path.js`, not reimplemented here), not a per-pair
|
||||
* one — so it costs nothing beyond the already-precomputed prefixes.
|
||||
*/
|
||||
private pruneKnownDirectories(target: WorkingTreeWatchTarget): void {
|
||||
if (target.ignoredDirectories.size === 0) {
|
||||
return;
|
||||
}
|
||||
const foldCase = looksLikeDefiniteWindowsPath(target.watchPath);
|
||||
const comparable = (path: string) =>
|
||||
foldCase ? path.replaceAll("\\", "/").toLowerCase() : path;
|
||||
const ignoredPrefixes: string[] = [];
|
||||
const foldedIgnoredExact = foldCase ? new Set<string>() : null;
|
||||
for (const ignoredDirectory of target.ignoredDirectories) {
|
||||
const ignored = comparable(ignoredDirectory);
|
||||
ignoredPrefixes.push(`${ignored}/`);
|
||||
foldedIgnoredExact?.add(ignored);
|
||||
}
|
||||
for (const directory of target.knownDirectories) {
|
||||
const comparableDirectory = comparable(directory);
|
||||
const comparableDirectoryWithSep = `${comparableDirectory}/`;
|
||||
const isIgnored =
|
||||
(foldedIgnoredExact ?? target.ignoredDirectories).has(comparableDirectory) ||
|
||||
ignoredPrefixes.some((prefix) => comparableDirectoryWithSep.startsWith(prefix));
|
||||
if (isIgnored) {
|
||||
target.knownDirectories.delete(directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undoes the "mark known" side effect of `noteWorkingTreeDirectories` for
|
||||
* every directory discovered since the last refresh that actually
|
||||
* completed, because this refresh did not — it fell back to the previous
|
||||
* ignore set (see `loadIgnoredDirs`). Leaving them in `knownDirectories`
|
||||
* would make the steady-state Set-lookup fast path in
|
||||
* `noteWorkingTreeDirectories` skip them forever, even though this refresh
|
||||
* never determined whether they are ignored. Removing them here re-arms
|
||||
* discovery: the next event under any of them is treated as unseen again,
|
||||
* so `noteWorkingTreeDirectories` schedules another refresh attempt.
|
||||
*/
|
||||
private rearmPendingKnownDirectories(target: WorkingTreeWatchTarget): void {
|
||||
if (target.pendingKnownDirectories.size === 0) {
|
||||
return;
|
||||
}
|
||||
for (const directory of target.pendingKnownDirectories) {
|
||||
target.knownDirectories.delete(directory);
|
||||
}
|
||||
target.pendingKnownDirectories.clear();
|
||||
}
|
||||
|
||||
private hasRelevantWorkingTreeEvent(
|
||||
target: WorkingTreeWatchTarget,
|
||||
events: FileChange[],
|
||||
@@ -1649,31 +1960,57 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
private async replaceWorkingTreeIgnoredDirectories(
|
||||
target: WorkingTreeWatchTarget,
|
||||
): Promise<void> {
|
||||
const ignoredDirectories = await this.loadIgnoredDirs(target.watchPath);
|
||||
if (target.closed || this.haveSamePaths(target.ignoredDirectories, ignoredDirectories)) {
|
||||
const { ignored: ignoredDirectories, failed } = await this.loadIgnoredDirs(
|
||||
target.watchPath,
|
||||
target.ignoredDirectories,
|
||||
);
|
||||
if (target.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.ignoredDirectories = ignoredDirectories;
|
||||
if (failed) {
|
||||
// A failed `git ls-files` must not leave directories discovered since
|
||||
// the last completed refresh permanently marked known — see the
|
||||
// `pendingKnownDirectories` field comment. Re-arm them so the next
|
||||
// event under any of them is treated as unseen again and schedules
|
||||
// another refresh attempt.
|
||||
this.rearmPendingKnownDirectories(target);
|
||||
} else {
|
||||
target.pendingKnownDirectories.clear();
|
||||
}
|
||||
if (!this.haveSamePaths(target.ignoredDirectories, ignoredDirectories)) {
|
||||
target.ignoredDirectories = ignoredDirectories;
|
||||
this.pruneKnownDirectories(target);
|
||||
}
|
||||
if (target.fallbackPolling) {
|
||||
return;
|
||||
}
|
||||
const subscription = target.subscription;
|
||||
if (subscription) {
|
||||
try {
|
||||
await subscription.updateIgnore([join(target.watchPath, ".git"), ...ignoredDirectories]);
|
||||
} catch (error) {
|
||||
target.subscription = null;
|
||||
if (!target.closed && !target.fallbackPolling) {
|
||||
this.startWorkingTreeWatchFallback(target, "watcher_update_failed");
|
||||
this.scheduleWorkingTreeWatchRecovery(target);
|
||||
}
|
||||
this.logger.warn(
|
||||
{ err: error, cwd: target.cwd },
|
||||
"Failed to update working tree watcher ignore paths",
|
||||
);
|
||||
return;
|
||||
if (!subscription) {
|
||||
return;
|
||||
}
|
||||
// Compare against the resolved list the live subscription actually
|
||||
// accepted (`appliedIgnoreList`), not what git last reported
|
||||
// (`ignoredDirectories` alone) — the comparison and the value passed to
|
||||
// `updateIgnore` have to be the same array, or a skipped update would
|
||||
// otherwise latch forever.
|
||||
const next = this.workingTreeIgnoreList(target);
|
||||
if (this.haveSamePaths(new Set(target.appliedIgnoreList), new Set(next))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await subscription.updateIgnore(next);
|
||||
target.appliedIgnoreList = next;
|
||||
} catch (error) {
|
||||
target.subscription = null;
|
||||
if (!target.closed && !target.fallbackPolling) {
|
||||
this.startWorkingTreeWatchFallback(target, "watcher_update_failed");
|
||||
this.scheduleWorkingTreeWatchRecovery(target);
|
||||
}
|
||||
this.logger.warn(
|
||||
{ err: error, cwd: target.cwd },
|
||||
"Failed to update working tree watcher ignore paths",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (target.closed || target.fallbackPolling) {
|
||||
return;
|
||||
@@ -1770,7 +2107,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
subscription: null,
|
||||
fallbackPolling: false,
|
||||
fallbackPollTimer: null,
|
||||
recovery: { attemptCount: 0, timer: null },
|
||||
recovery: { attemptCount: 0, timer: null, establishedAt: null },
|
||||
intervalId: null,
|
||||
fetchInFlight: false,
|
||||
bufferedFetchMetadataEvents: [],
|
||||
@@ -1901,6 +2238,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return false;
|
||||
}
|
||||
target.subscription = subscription;
|
||||
// See `advanceWatchRecoveryLadder`: do not reset `attemptCount` on
|
||||
// subscribe success alone, only record when this subscription was
|
||||
// established.
|
||||
target.recovery.establishedAt = this.deps.now().getTime();
|
||||
if (options?.replaceFallback) {
|
||||
target.fallbackPolling = false;
|
||||
if (target.fallbackPollTimer) {
|
||||
@@ -1941,16 +2282,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
|
||||
private scheduleRepoMetadataWatchRecovery(target: RepoGitTarget): void {
|
||||
if (
|
||||
target.closed ||
|
||||
target.subscription ||
|
||||
target.recovery.timer ||
|
||||
target.recovery.attemptCount >= WATCH_RECOVERY_MAX_ATTEMPTS
|
||||
) {
|
||||
if (target.closed || target.subscription || target.recovery.timer) {
|
||||
return;
|
||||
}
|
||||
target.recovery.attemptCount += 1;
|
||||
const delayMs = WATCH_RECOVERY_BASE_DELAY_MS * 2 ** (target.recovery.attemptCount - 1);
|
||||
const delayMs = this.advanceWatchRecoveryLadder(target.recovery);
|
||||
target.recovery.timer = setTimeout(() => {
|
||||
target.recovery.timer = null;
|
||||
void this.recoverRepoMetadataWatch(target);
|
||||
@@ -2570,7 +2905,28 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
target.forgePrStatusPollKey = null;
|
||||
}
|
||||
|
||||
private async loadIgnoredDirs(rootPath: string): Promise<Set<string>> {
|
||||
/**
|
||||
* `previous` is what the caller should get back on failure. `runGitCommand`
|
||||
* rejects on any non-zero exit or timeout, and a timeout is plausible under
|
||||
* exactly the churn storm this ignore-refresh path targets. Returning an
|
||||
* empty set on failure would read to the caller as "nothing is ignored" —
|
||||
* indistinguishable from a real, freshly-computed empty result — and
|
||||
* `replaceWorkingTreeIgnoredDirectories` treats its return value as
|
||||
* authoritative, replacing `target.ignoredDirectories` and re-ingesting
|
||||
* every directory that was previously ignored. Returning the previous set
|
||||
* instead means a failed refresh leaves the watcher's ignore state
|
||||
* unchanged rather than un-ignoring the entire dependency tree.
|
||||
*
|
||||
* `failed` lets the caller distinguish "ran fresh and happened to match
|
||||
* `previous`" from "the git command itself failed" — the two are
|
||||
* indistinguishable by comparing sets alone, but `replaceWorkingTreeIgnoredDirectories`
|
||||
* needs to know which one happened to decide whether directories newly
|
||||
* marked known since the last refresh can be trusted or must be re-armed.
|
||||
*/
|
||||
private async loadIgnoredDirs(
|
||||
rootPath: string,
|
||||
previous: Set<string> = new Set(),
|
||||
): Promise<{ ignored: Set<string>; failed: boolean }> {
|
||||
const ignored = new Set<string>();
|
||||
try {
|
||||
const result = await this.deps.runGitCommand(
|
||||
@@ -2588,13 +2944,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
ignored.add(resolve(rootPath, rel));
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.debug(
|
||||
this.logger.warn(
|
||||
{ err: error, rootPath },
|
||||
"Failed to load gitignore directories; falling back to name-based skip only",
|
||||
"Failed to load gitignore directories; keeping previous ignore set",
|
||||
);
|
||||
return { ignored: previous, failed: true };
|
||||
}
|
||||
|
||||
return ignored;
|
||||
return { ignored, failed: false };
|
||||
}
|
||||
|
||||
private async refreshWorkspaceTarget(
|
||||
@@ -3332,6 +3689,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
clearTimeout(target.recovery.timer);
|
||||
target.recovery.timer = null;
|
||||
}
|
||||
if (target.ignoreRefreshTimer) {
|
||||
clearTimeout(target.ignoreRefreshTimer);
|
||||
target.ignoreRefreshTimer = null;
|
||||
}
|
||||
target.ignoreRefreshDeadlineMs = null;
|
||||
|
||||
if (target.subscription) {
|
||||
const subscription = target.subscription;
|
||||
|
||||
@@ -138,7 +138,15 @@ function shouldCompareAsWindows(left: string, right: string): boolean {
|
||||
return looksLikeDefiniteWindowsPath(left) || looksLikeDefiniteWindowsPath(right);
|
||||
}
|
||||
|
||||
function looksLikeDefiniteWindowsPath(value: string): boolean {
|
||||
/**
|
||||
* True when `value`'s shape identifies it as a Windows path (drive letter,
|
||||
* `\\?\` device namespace, or UNC), independent of the host platform. Callers
|
||||
* that fold case for Windows-looking paths (this module's own comparisons,
|
||||
* and `pruneKnownDirectories` in `workspace-git-service.ts`, which needs the
|
||||
* same decision without paying for a realpath syscall) must all agree on this
|
||||
* one rule — do not reimplement the pattern.
|
||||
*/
|
||||
export function looksLikeDefiniteWindowsPath(value: string): boolean {
|
||||
return (
|
||||
/^[a-zA-Z]:[\\/]/u.test(value) ||
|
||||
/^[/\\]{2}\?[/\\]/u.test(value) ||
|
||||
|
||||
@@ -17,6 +17,7 @@ const gatedCiJobs = new Map([
|
||||
["typecheck", { name: "typecheck", contract: "quality" }],
|
||||
["server-tests-ubuntu", { name: "server-tests (ubuntu-latest)", contracts: ["server", "hub"] }],
|
||||
["server-tests-windows", { name: "server-tests (windows-latest)", contracts: ["server", "hub"] }],
|
||||
["server-tests-macos", { name: "server-tests (macos-14, file observation)", contract: "server" }],
|
||||
["desktop-tests-ubuntu", { name: "desktop-tests (ubuntu-latest)", contract: "desktop" }],
|
||||
["desktop-tests-windows", { name: "desktop-tests (windows-latest)", contract: "desktop" }],
|
||||
["app-tests", { name: "app-tests", contract: "app" }],
|
||||
|
||||
Reference in New Issue
Block a user