mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
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:
@@ -5,6 +5,7 @@
|
||||
"vitepress": {
|
||||
"entry": ["docs/.vitepress/config.ts"]
|
||||
},
|
||||
"ignoreBinaries": ["mkfifo"],
|
||||
"ignoreDependencies": ["electron", "esbuild", "webpack"],
|
||||
"ignoreExportsUsedInFile": true,
|
||||
"rules": {
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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
@@ -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();
|
||||
Reference in New Issue
Block a user