diff --git a/knip.json b/knip.json index 9ce07cf8e..5ac657f65 100644 --- a/knip.json +++ b/knip.json @@ -5,6 +5,7 @@ "vitepress": { "entry": ["docs/.vitepress/config.ts"] }, + "ignoreBinaries": ["mkfifo"], "ignoreDependencies": ["electron", "esbuild", "webpack"], "ignoreExportsUsedInFile": true, "rules": { diff --git a/src/artifacts/AsarArtifactReader.ts b/src/artifacts/AsarArtifactReader.ts index 674595a04..e231dae96 100644 --- a/src/artifacts/AsarArtifactReader.ts +++ b/src/artifacts/AsarArtifactReader.ts @@ -143,14 +143,29 @@ const asarFailure = ( path: string, operation: string, cause: unknown, -): ArtifactReaderFailure => - cause instanceof ArtifactReaderFailure - ? cause - : new ArtifactReaderFailure( - "format", - `Malformed or unreadable ASAR during ${operation}: ${path}`, - { cause }, - ); +): ArtifactReaderFailure => { + if (cause instanceof ArtifactReaderFailure) return cause; + if (isFilesystemFailure(cause)) + return new ArtifactReaderFailure( + "io", + `Could not ${operation} ASAR at ${path}: ${cause.message}`, + { cause }, + ); + return new ArtifactReaderFailure( + "format", + `Malformed ASAR during ${operation}: ${path}`, + { cause }, + ); +}; + +const isFilesystemFailure = ( + cause: unknown, +): cause is NodeJS.ErrnoException & Error => + cause instanceof Error && + "errno" in cause && + typeof cause.errno === "number" && + "syscall" in cause && + typeof cause.syscall === "string"; const isMissingFile = (cause: unknown): boolean => typeof cause === "object" && diff --git a/src/artifacts/DirectoryArtifactReader.ts b/src/artifacts/DirectoryArtifactReader.ts index db4a37fad..cf4205c9e 100644 --- a/src/artifacts/DirectoryArtifactReader.ts +++ b/src/artifacts/DirectoryArtifactReader.ts @@ -85,7 +85,7 @@ export class DirectoryArtifactReader implements ArtifactReader { ); const handle = await open( entry.adapterKey, - constants.O_RDONLY | constants.O_NOFOLLOW, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, ); try { const observed = await handle.stat(); diff --git a/tests/boundary/filesystem/asarInventoryFreshness.test.ts b/tests/boundary/filesystem/asarInventoryFreshness.test.ts index bd867aa68..4d0932935 100644 --- a/tests/boundary/filesystem/asarInventoryFreshness.test.ts +++ b/tests/boundary/filesystem/asarInventoryFreshness.test.ts @@ -110,6 +110,32 @@ describe("ASAR path boundary", () => { }); describe("ASAR inventory freshness", () => { + it("preserves missing-container I/O as an I/O failure instead of malformed format", async () => { + const root = await createTestTempDirectory("rea-asar-missing-"); + const reader = new AsarArtifactReader(join(root, "missing.asar")); + + await expect( + reader.entries()[Symbol.asyncIterator]().next(), + ).rejects.toMatchObject({ + reason: "io", + message: expect.stringContaining("ENOENT"), + }); + }); + + it("preserves an ASAR path replaced by a directory as a filesystem failure", async () => { + const fixture = await archiveFixture(); + await rm(fixture.archive); + await mkdir(fixture.archive); + const reader = new AsarArtifactReader(fixture.archive); + + await expect( + reader.entries()[Symbol.asyncIterator]().next(), + ).rejects.toMatchObject({ + reason: "io", + message: expect.stringContaining("EISDIR"), + }); + }); + it.each(["archive", "bundle"] as const)( "refreshes a replaced archive when inventorying a %s target", async (target) => { diff --git a/tests/boundary/filesystem/directoryArtifactReaderFifoRace.test.ts b/tests/boundary/filesystem/directoryArtifactReaderFifoRace.test.ts new file mode 100644 index 000000000..443de83f2 --- /dev/null +++ b/tests/boundary/filesystem/directoryArtifactReaderFifoRace.test.ts @@ -0,0 +1,53 @@ +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; + +import { expect, it } from "vitest"; + +import { + ProviderProcessSupervisor, + spawnOwnedProviderProcess, +} from "../../../src/process/ProviderProcess.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it.skipIf(process.platform === "win32")( + "rejects a FIFO replacement without blocking the owner process", + async () => { + const root = await createTestTempDirectory("rea-directory-fifo-"); + await writeFile(join(root, "module.js"), "export {}\n"); + const probe = fileURLToPath( + new URL("../../fixtures/directoryReaderFifoProbe.mjs", import.meta.url), + ); + const readerModule = fileURLToPath( + new URL( + "../../../dist/artifacts/DirectoryArtifactReader.js", + import.meta.url, + ), + ); + const launch = await spawnOwnedProviderProcess({ + command: process.execPath, + arguments: [probe, readerModule, root], + runId: `rea-fifo-probe-${randomUUID()}`, + cwd: tmpdir(), + hostEnvironment: process.env, + }); + const supervisor = new ProviderProcessSupervisor({ + ...launch, + ownsProcessLifetime: true, + }); + + try { + const closed = await supervisor.waitForOutputClose(2_000); + expect(closed).toBe(true); + expect(supervisor.snapshot().stdout.text.trim()).toBe("integrity"); + } finally { + const stopped = await supervisor.stop({ + terminationGraceMs: 100, + killGraceMs: 500, + }); + expect(stopped.status).not.toBe("incomplete"); + } + }, +); diff --git a/tests/fixtures/directoryReaderFifoProbe.mjs b/tests/fixtures/directoryReaderFifoProbe.mjs new file mode 100644 index 000000000..cbe472c59 --- /dev/null +++ b/tests/fixtures/directoryReaderFifoProbe.mjs @@ -0,0 +1,43 @@ +import { execFileSync } from "node:child_process"; +import { open, unlink } 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("Directory reader FIFO probe requires module and root paths"); + +const { DirectoryArtifactReader } = await import(pathToFileURL(modulePath)); +const reader = new DirectoryArtifactReader(root); +let selected; +for await (const entry of reader.entries()) + if (entry.path === "module.js") selected = entry; +if (selected === undefined) + throw new Error("Expected inventoried regular file"); + +const fifo = join(root, selected.path); +await unlink(fifo); +execFileSync("mkfifo", [fifo]); + +const opening = reader.open(selected).then( + (stream) => { + stream.destroy(); + return "opened"; + }, + (cause) => + typeof cause === "object" && cause !== null && "reason" in cause + ? String(cause.reason) + : "failed", +); +const outcome = await Promise.race([ + opening, + new Promise((resolve) => setTimeout(() => resolve("blocked"), 100)), +]); +process.stdout.write(`${String(outcome)}\n`); + +if (outcome === "blocked") { + const writer = await open(fifo, "w"); + await writer.close(); + await opening; +} +await reader.close();