Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions apps/server/src/project/temporaryFolder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@ import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import { assert, it } from "@effect/vitest";
import { assert, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as TestClock from "effect/testing/TestClock";

import { isTemporaryFolder, isTemporaryPath, temporaryFolderWarning } from "./temporaryFolder.ts";
import {
dedupeProbe,
isTemporaryFolder,
isTemporaryPath,
temporaryFolderWarning,
} from "./temporaryFolder.ts";

it.effect.each([
["/tmp/review", true],
Expand Down Expand Up @@ -70,3 +75,45 @@ it.effect("resolves symlinks, and ignores a persistent TMPDIR", () =>
assert.isTrue(yield* isTemporaryFolder(NodePath.join(sandbox, "missing")));
}).pipe(Effect.scoped),
);

it("sends a retry to the name while a probe is unanswered, and reuses a settled answer until it expires", async () => {
let clock = 0;
let calls = 0;
const answers = [new Promise<never>(() => {})];
const probe = dedupeProbe(
{
inspect: (path) => {
calls += 1;
return answers.shift() ?? Promise.resolve({ resolved: path, tmpfs: false });
},
},
() => clock,
1_000,
);
// The first probe never settles, as on a dead mount: later calls get no answer, not another probe.
void probe.inspect("/mnt/dead/a");
expect(await probe.inspect("/mnt/dead/a")).toBeUndefined();
expect(await probe.inspect("/mnt/dead/a")).toBeUndefined();
expect(calls).toBe(1);
// Another path probes on its own, and its answer is reused until the TTL passes.
expect(await probe.inspect("/home/a")).toEqual({ resolved: "/home/a", tmpfs: false });
await probe.inspect("/home/a");
expect(calls).toBe(2);
clock = 1_001;
await probe.inspect("/home/a");
expect(calls).toBe(3);
});

it("keeps an unanswered probe when many other paths fill the result cache", async () => {
let calls = 0;
const probe = dedupeProbe({
inspect: (path) => {
calls += 1;
return path === "/mnt/dead" ? new Promise<never>(() => {}) : Promise.resolve(undefined);
},
});
void probe.inspect("/mnt/dead");
for (let index = 0; index < 300; index += 1) await probe.inspect(`/home/p${index}`);
expect(await probe.inspect("/mnt/dead")).toBeUndefined();
expect(calls).toBe(301);
});
58 changes: 52 additions & 6 deletions apps/server/src/project/temporaryFolder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,15 @@ export function isTemporaryPath(path: string): boolean {
}

export interface FolderProbe {
/** The path with symlinks resolved, and whether its filesystem is a tmpfs. */
readonly inspect: (
path: string,
) => Promise<{ readonly resolved: string; readonly tmpfs: boolean }>;
/**
* The path with symlinks resolved, and whether its filesystem is a tmpfs. No answer means the
* caller goes by the name.
*/
readonly inspect: (path: string) => Promise<FolderInspection | undefined>;
}
interface FolderInspection {
readonly resolved: string;
readonly tmpfs: boolean;
}

const exists = (path: string) =>
Expand All @@ -55,7 +60,48 @@ const exists = (path: string) =>
() => false,
);

const nodeProbe: FolderProbe = {
const RESULT_TTL_MS = 30_000;
const MAX_CACHED_PATHS = 256;

/**
* One probe per path at a time. A probe on a dead mount never settles and pins a libuv thread, so
* a retry during an unanswered probe gets no answer at once (the caller goes by name) and adds no
* reaction to it. Unanswered probes are kept apart from the bounded cache of settled answers.
*/
export function dedupeProbe(
probe: FolderProbe,
now: () => number = Date.now,
resultTtlMs = RESULT_TTL_MS,
): FolderProbe {
const unanswered = new Set<string>();
const settled = new Map<string, { readonly answer: FolderInspection | undefined; at: number }>();
return {
inspect: (path) => {
if (unanswered.has(path)) return Promise.resolve(undefined);
const known = settled.get(path);
if (known !== undefined && now() - known.at < resultTtlMs) {
return Promise.resolve(known.answer);
}
unanswered.add(path);
return probe.inspect(path).then(
(answer) => {
unanswered.delete(path);
settled.delete(path);
settled.set(path, { answer, at: now() });
// Map order is insertion order, so the first key is the oldest.
if (settled.size > MAX_CACHED_PATHS) settled.delete(settled.keys().next().value!);
return answer;
},
() => {
unanswered.delete(path);
return undefined;
},
);
},
};
}

const nodeProbe: FolderProbe = dedupeProbe({
inspect: async (path) => {
// A missing folder is the usual case here, so inspect its nearest existing ancestor.
let existing = path;
Expand All @@ -72,7 +118,7 @@ const nodeProbe: FolderProbe = {
return { resolved: path, tmpfs: false };
}
},
};
});

/**
* A folder the system clears at reboot: a tmpfs, or under a well-known temporary directory.
Expand Down
Loading