fix(artifacts): harden reader lifecycle boundaries (#951)

* fix(artifacts): retain ASAR filesystem failure reasons

* fix(artifacts): avoid blocking on replaced FIFO entries

* test(artifacts): type the ASAR reader iterator

* fix(artifacts): classify filesystem-shaped ASAR errors

* test: account for POSIX FIFO fixture utility
This commit is contained in:
morluto
2026-10-07 20:00:24 +08:00
committed by GitHub
parent e0d76ae80d
commit 2366bda37b
6 changed files with 147 additions and 9 deletions
+1
View File
@@ -5,6 +5,7 @@
"vitepress": {
"entry": ["docs/.vitepress/config.ts"]
},
"ignoreBinaries": ["mkfifo"],
"ignoreDependencies": ["electron", "esbuild", "webpack"],
"ignoreExportsUsedInFile": true,
"rules": {
+23 -8
View File
@@ -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" &&
+1 -1
View File
@@ -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();
@@ -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) => {
@@ -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");
}
},
);
+43
View File
@@ -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();