fix(reference): reject FIFO replacements without blocking

Use the shared nonblocking, no-follow regular-file opener at the source-read boundary and report a nonregular replacement as changed. Keep descriptor identity and extent checks.

The compiled subprocess regression timed out on the baseline and completes without a FIFO writer after this change. Reference reader failure, capacity, cancellation, and import checks pass.
This commit is contained in:
morluto 2026-10-11 04:36:39 +08:00
parent 6636045c9c
commit f1c58cc53f
3 changed files with 75 additions and 3 deletions

View File

@ -1,8 +1,12 @@
import type { BigIntStats } from "node:fs";
import { constants } from "node:fs";
import { open, type FileHandle } from "node:fs/promises";
import type { FileHandle } from "node:fs/promises";
import { dirname } from "node:path";
import {
NonRegularFileReadError,
openRegularFile,
} from "../filesystem/RegularFile.js";
import {
entryFailure,
filesystemFailureDetail,
@ -209,7 +213,7 @@ export const readStableFile = async (
parentBefore.message,
safeSize(expected.size),
);
handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
handle = await openRegularFile(absolute, { symlinks: "reject", signal });
const prepared = await prepareFileRead(request, handle);
if (prepared.status === "failed") return prepared.entry;
const contents = await readFileContents({
@ -240,6 +244,14 @@ export const readStableFile = async (
"File read cancelled",
safeSize(expected.size),
);
if (cause instanceof NonRegularFileReadError)
return entryFailure(
path,
"file",
"changed",
"File is no longer a regular file",
safeSize(expected.size),
);
const message = filesystemFailureDetail(
cause,
"File could not be read safely",

View File

@ -0,0 +1,29 @@
import { fileURLToPath } from "node:url";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { processTest } from "../../support/process/processFixture.js";
processTest.skipIf(process.platform === "win32")(
"rejects a FIFO replacing an admitted source file without waiting for a writer",
async ({ processes }) => {
const root = await createTestTempDirectory("rea-reference-fifo-race-");
const probe = fileURLToPath(
new URL("../../fixtures/referenceReaderFifoProbe.mjs", import.meta.url),
);
const readerModule = fileURLToPath(
new URL(
"../../../dist/reference/ReferenceSourceReaderFile.js",
import.meta.url,
),
);
const result = await processes.run(
process.execPath,
[probe, readerModule, root],
{ timeoutMs: 3_000 },
);
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stdout.trim()).toBe("changed");
},
);

View File

@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { lstat, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
const [modulePath, root] = process.argv.slice(2);
if (modulePath === undefined || root === undefined)
throw new Error("Reference reader FIFO probe requires module and root paths");
const { readStableFile } = await import(pathToFileURL(modulePath));
const absolute = join(root, "source.txt");
await writeFile(absolute, "observed regular file\n");
const expected = await lstat(absolute, { bigint: true });
await unlink(absolute);
execFileSync("mkfifo", [absolute]);
// Capture the directory after replacement to reach the file-open boundary.
// The owning test bounds this child if an O_RDONLY FIFO open blocks again.
const rootIdentity = await lstat(root, { bigint: true });
const result = await readStableFile({
root,
rootIdentity,
absolute,
path: "source.txt",
expected,
});
assert.equal(result.status, "failed");
assert.equal(result.kind, "file");
assert.equal(result.code, "changed");
process.stdout.write("changed\n");