diff --git a/packages/core/src/__test-utils__/vitest-setup.ts b/packages/core/src/__test-utils__/vitest-setup.ts index 0ad97a4494..6b0c4de5cc 100644 --- a/packages/core/src/__test-utils__/vitest-setup.ts +++ b/packages/core/src/__test-utils__/vitest-setup.ts @@ -15,7 +15,7 @@ import { afterEach, expect } from "vitest"; import { createRequire, syncBuiltinESMExports } from "node:module"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; import { isMainThread } from "node:worker_threads"; import { assertOutsideRealFusionPath } from "../test-safety.js"; @@ -40,7 +40,16 @@ const requireFromHere = createRequire(import.meta.url); const fs = requireFromHere("node:fs") as FsModule; const fsPromises = requireFromHere("node:fs/promises") as FsPromisesModule; const childProcess = requireFromHere("node:child_process") as ChildProcessModule; -const { mkdtempSync, mkdirSync, rmSync, realpathSync, existsSync } = fs; +const { + appendFileSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + realpathSync, + existsSync, + writeFileSync, +} = fs; type EmitWarningArgs = Parameters; type EmitWarningRestArgs = EmitWarningArgs extends [string | Error, ...infer Rest] ? Rest : never; @@ -160,6 +169,102 @@ const WORKER_ROOT = join(tmpdir(), "fusion-test-workers"); try { mkdirSync(WORKER_ROOT, { recursive: true }); } catch { /* ignore */ } process.env.FUSION_TEST_WORKER_ROOT = WORKER_ROOT; +const REAL_TMPDIR = (() => { + try { + return realpathSync(tmpdir()); + } catch { + return resolve(tmpdir()); + } +})(); + +const TMPDIR_REDIRECT_REGISTRY = join(WORKER_ROOT, ".redir-pids"); +let tmpdirRedirectSink: string | null = null; +let tmpdirRedirectExitCleanupInstalled = false; +let tmpdirRedirectSweepComplete = false; + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return code === "EPERM"; + } +} + +function sweepDeadTmpdirRedirectSinks(): void { + if (tmpdirRedirectSweepComplete) return; + tmpdirRedirectSweepComplete = true; + + let ownerPids: number[]; + try { + ownerPids = Array.from(new Set( + readFileSync(TMPDIR_REDIRECT_REGISTRY, "utf8") + .split(/\r?\n/) + .map((line) => Number.parseInt(line, 10)) + .filter((pid) => Number.isInteger(pid) && pid > 0), + )); + } catch { + return; + } + + const liveOwnerPids: number[] = []; + for (const ownerPid of ownerPids) { + if (ownerPid === process.pid || isProcessAlive(ownerPid)) { + liveOwnerPids.push(ownerPid); + continue; + } + + try { + rmSync(join(WORKER_ROOT, `redir-${ownerPid}`), { recursive: true, force: true }); + } catch { + // Ignore stale-sink cleanup failures; global teardown still owns WORKER_ROOT. + } + } + + try { + writeFileSync(TMPDIR_REDIRECT_REGISTRY, liveOwnerPids.length > 0 ? `${liveOwnerPids.join("\n")}\n` : ""); + } catch { + // Best-effort only; stale entries are harmless and swept by future workers. + } +} + +function ensureTmpdirRedirectSink(): string { + if (tmpdirRedirectSink) return tmpdirRedirectSink; + + sweepDeadTmpdirRedirectSinks(); + const sink = join(WORKER_ROOT, `redir-${process.pid}`); + mkdirSync(sink, { recursive: true }); + try { + appendFileSync(TMPDIR_REDIRECT_REGISTRY, `${process.pid}\n`); + } catch { + // Best-effort only; the process exit hook and global teardown still clean up. + } + tmpdirRedirectSink = sink; + + if (!tmpdirRedirectExitCleanupInstalled) { + tmpdirRedirectExitCleanupInstalled = true; + process.once("exit", () => { + try { + rmSync(sink, { recursive: true, force: true }); + } catch { + // Best-effort only. vitest globalTeardown also sweeps WORKER_ROOT. + } + }); + } + + return sink; +} + +function redirectTmpdirPrefix(prefix: T): T { + if (typeof prefix !== "string") return prefix; + + const parent = dirname(prefix); + if (parent !== tmpdir() && parent !== REAL_TMPDIR) return prefix; + + return join(ensureTmpdirRedirectSink(), basename(prefix)) as T; +} + function ensureIsolatedHome(): void { const existingHome = process.env.HOME ?? process.env.USERPROFILE; if (existingHome && existingHome.includes(tmpdir()) && existingHome.includes(TEST_HOME_PREFIX)) { @@ -290,8 +395,9 @@ function installFsGuards(): void { return originalFs.cpSync(src, dest, options as Parameters[2]); }) as typeof fs.cpSync; mutableFs.mkdtempSync = ((prefix, options) => { - guardOne(prefix, "fs.mkdtempSync"); - return originalFs.mkdtempSync(prefix, options as Parameters[1]); + const redirectedPrefix = redirectTmpdirPrefix(prefix); + guardOne(redirectedPrefix, "fs.mkdtempSync"); + return originalFs.mkdtempSync(redirectedPrefix, options as Parameters[1]); }) as typeof fs.mkdtempSync; mutableFs.openSync = ((path, flags, mode) => { guardOne(path, "fs.openSync"); @@ -408,8 +514,9 @@ function installFsGuards(): void { return originalFsPromises.open(...args); }) as typeof fsPromises.open; mutableFsPromises.mkdtemp = (async (...args: Parameters) => { - guardOne(args[0], "fs.promises.mkdtemp"); - return originalFsPromises.mkdtemp(...args); + const redirectedPrefix = redirectTmpdirPrefix(args[0]); + guardOne(redirectedPrefix, "fs.promises.mkdtemp"); + return originalFsPromises.mkdtemp(redirectedPrefix, args[1]); }) as typeof fsPromises.mkdtemp; mutableFsPromises.truncate = (async (...args: Parameters) => { guardOne(args[0], "fs.promises.truncate"); diff --git a/packages/core/src/__tests__/vitest-setup-tmp-redirect.test.ts b/packages/core/src/__tests__/vitest-setup-tmp-redirect.test.ts new file mode 100644 index 0000000000..0d5885d15f --- /dev/null +++ b/packages/core/src/__tests__/vitest-setup-tmp-redirect.test.ts @@ -0,0 +1,68 @@ +import { mkdtempSync, mkdirSync, rmSync, realpathSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, sep } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const createdPaths: string[] = []; + +function remember(path: string): string { + createdPaths.push(path); + return path; +} + +function expectUnderWorkerRoot(path: string): void { + const workerRoot = process.env.FUSION_TEST_WORKER_ROOT; + expect(workerRoot).toBeTruthy(); + expect(path.startsWith(`${workerRoot}${sep}`)).toBe(true); + expect(dirname(path)).toBe(join(workerRoot!, `redir-${process.pid}`)); +} + +afterEach(() => { + for (const path of createdPaths.splice(0).reverse()) { + rmSync(path, { recursive: true, force: true }); + } +}); + +describe("vitest setup tmpdir mkdtemp redirect", () => { + it("redirects sync mkdtemp prefixes rooted directly at the OS temp dir", () => { + const path = remember(mkdtempSync(join(tmpdir(), "fn-redirect-sync-"))); + + expectUnderWorkerRoot(path); + }); + + it("redirects async mkdtemp prefixes rooted directly at the OS temp dir", async () => { + const path = remember(await mkdtemp(join(tmpdir(), "fn-redirect-async-"))); + + expectUnderWorkerRoot(path); + }); + + it("redirects the realpath spelling of the OS temp dir when it differs", () => { + const realTmpdir = realpathSync(tmpdir()); + if (realTmpdir === tmpdir()) { + expect(realTmpdir).toBe(tmpdir()); + return; + } + + const path = remember(mkdtempSync(join(realTmpdir, "fn-redirect-realpath-"))); + + expectUnderWorkerRoot(path); + }); + + it("leaves nested temp-root prefixes unchanged", () => { + const parent = remember(join(tmpdir(), `fn-redirect-parent-${process.pid}-${Date.now()}`)); + mkdirSync(parent, { recursive: true }); + + const path = remember(mkdtempSync(join(parent, "nested-"))); + + expect(path.startsWith(`${parent}${sep}`)).toBe(true); + }); + + it("leaves non-string prefixes untouched", () => { + const prefix = Buffer.from(join(tmpdir(), "fn-redirect-buffer-")); + + const path = remember(mkdtempSync(prefix)); + + expect(path.startsWith(`${tmpdir()}${sep}`)).toBe(true); + }); +}); diff --git a/scripts/lib/test-quarantine.json b/scripts/lib/test-quarantine.json index 1060baefe7..c8f1c06d6d 100644 --- a/scripts/lib/test-quarantine.json +++ b/scripts/lib/test-quarantine.json @@ -11,11 +11,6 @@ "reason": "Flake: vi.mock('node:child_process') occasionally doesn't take under workspace-concurrent runs, letting real git binary leak and report staged files unrelated to test scope (trips FileScopeViolationError). Same logic covered by real-git fixture tests in reliability-interactions/workflow-and-file-scope. FN-6206.", "quarantinedAt": "2026-06-10" }, - { - "file": "packages/engine/src/__tests__/merger-file-scope-invariant.test.ts", - "reason": "Flake: same mock-contention mode as the sibling changeset-file test above (vi.mock('node:child_process') not taking under concurrent load). FN-6206.", - "quarantinedAt": "2026-06-10" - }, { "file": "packages/engine/src/__tests__/merger-ai-cleanup.test.ts", "reason": "Flake observed during FN-6206 verification: `pruneExistingAiMergeWorktrees skips active-session paths` failed in full `pnpm --filter @fusion/engine test` runs while the file passed standalone, indicating suite-order/concurrency sensitivity. Follow-up FN-6207.",