diff --git a/apps/server/src/project/temporaryFolder.test.ts b/apps/server/src/project/temporaryFolder.test.ts index 6b983de659f4d..9110681bde812 100644 --- a/apps/server/src/project/temporaryFolder.test.ts +++ b/apps/server/src/project/temporaryFolder.test.ts @@ -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], @@ -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(() => {})]; + 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(() => {}) : 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); +}); diff --git a/apps/server/src/project/temporaryFolder.ts b/apps/server/src/project/temporaryFolder.ts index 790f081af0464..3f4b8141f0845 100644 --- a/apps/server/src/project/temporaryFolder.ts +++ b/apps/server/src/project/temporaryFolder.ts @@ -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; +} +interface FolderInspection { + readonly resolved: string; + readonly tmpfs: boolean; } const exists = (path: string) => @@ -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(); + const settled = new Map(); + 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; @@ -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.