fix(artifacts): handle early ASAR extraction cancellation (#5672)

* fix(artifacts): own source errors during output setup

* fix(artifacts): join silent source destruction before returning

* fix(artifacts): retain source cleanup failures with primary errors

* fix(artifacts): join source finalization and classify secondary failures

* fix(firmware): separate source cleanup from output rollback

* fix(artifacts): retain live source cleanup dependencies

* fix(artifacts): preserve source failure completion and semantics

* fix(artifacts): retain scope cleanup owners and typed failures

* fix(artifacts): keep cleanup classification and admission recovery

When the operation completed and only scope cleanup failed, keep the cleanup failure's own reason instead of reporting io, matching main (fixes artifactProvider.cleanupRetry). When a typed primary is rebuilt with pending scope cleanup, carry its userMessage and ASAR header admission so callers keep the configured ceiling and setting.

* fix(artifacts): translate plain file-close failures through the reader path

The outer file-cleanup branch rebuilt every ArtifactFileCleanupFailure as a
ProviderAdapterError, so dyld reads lost their io, unavailable, format and
cancelled reasons and their exact file-cleanup cause. Compose separately only
when the failed primary owns its own translation (a domain error, source
failure or scope cleanup failure); raw and reader primaries are already
projected by the file failure with its exact lease.

* fix(artifacts): bound source destruction by cancellation and keep wrapper admission

After cancellation, SafeOutputTree.write could wait indefinitely for a source
whose destruction did not settle, both in the destroy join and in the
iterator's wait for close. Both waits now end at cancellation plus a fixed 1 s
grace, modeled on the process-output closure grace. A destruction that
outlives it is reported as incomplete source cleanup under an exact lease that
releases only once closure is observed without a late error.

ArtifactResourceCleanupFailure and SafeOutputSourceFailure now keep the
primary's admission, matching withCleanup.

* fix(artifacts): keep one settled wait while observing a stuck source

observeClosure raced the same pending close promise against a new timer every
10 ms, so a source whose destruction never settled accumulated one reaction per
poll for as long as it stayed open. Each poll now waits on its own promise that
the close listener or the unref'd timer settles, leaving one pending wait and
one close listener. The lifecycle test also asserts that a late close releases
the retained owner without an explicit retry.

* fix(artifacts): settle pending reads before cancellation

* test(artifacts): observe the consumed ZIP iterator on cancellation

* test(artifacts): validate projected cleanup resources before sorting

* test(mcp): reserve parent capacity for timeout recovery

* fix(artifacts): contain ASAR cancellation before output consumption

* test(artifacts): hash ASAR cancellation fixture bytes
This commit is contained in:
RRFZ authored and GitHub committed 2026-11-05 17:04:37 +08:00
1 parent 585fe56712
commit 1f14d648fa
2 files changed
+116

No files matched your search

+3
View File
@@ -81,6 +81,9 @@ export const readValidatedAsarEntry = (
}
})(),
);
// Cancellation can precede the caller's first read while it prepares output.
// Retain the stream error for that consumer without an unhandled error event.
output.on("error", () => undefined);
const onSourceError = (cause: unknown): void => {
const completedCancellation =
cause instanceof ArtifactReaderFailure &&
@@ -0,0 +1,113 @@
/**
* Given a real ASAR member stream and destination preparation in progress:
* - cancellation returns its original cause, closes the stream, and rolls back;
* - a denied rollback retains its owner until same-client cleanup/retry succeeds;
* - without cancellation, extraction publishes the original bytes.
*/
import { createPackage } from "@electron/asar";
import { createHash } from "node:crypto";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { Readable } from "node:stream";
import { expect, it, vi } from "vitest";
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
import { SafeOutputTree } from "../../../../src/artifacts/SafeOutputTree.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
it.each(["cancel", "denied-rollback", "control"] as const)(
"keeps ASAR destination-setup cancellation inside the extraction owner: %s",
async (mode) => {
const root = await createTestTempDirectory("rea-asar-source-cancel-");
const contents = join(root, "contents");
await mkdir(contents);
const payload = "owned payload\n";
await writeFile(join(contents, "payload.txt"), payload);
const archive = join(root, "fixture.asar");
await createPackage(contents, archive);
const client = new ArtifactProvider(process.env).createClient({
path: archive,
sourcePath: archive,
sha256: createHash("sha256")
.update(await readFile(archive))
.digest("hex"),
kind: "archive",
format: "asar",
});
const output = join(root, "output");
const controller = new AbortController();
const reason = new Error("cancel during destination preparation");
const originalWrite = SafeOutputTree.prototype.write;
const originalRollback = SafeOutputTree.prototype.rollback;
const rollbackOwners: SafeOutputTree[] = [];
let denyRollback = mode === "denied-rollback";
let source: Readable | undefined;
const write = vi
.spyOn(SafeOutputTree.prototype, "write")
.mockImplementation(function (
this: SafeOutputTree,
...args: Parameters<SafeOutputTree["write"]>
) {
source = args[1];
// The real write has entered its first destination-filesystem await.
const pending = originalWrite.apply(this, args);
if (mode !== "control") controller.abort(reason);
return pending;
});
const rollback = vi
.spyOn(SafeOutputTree.prototype, "rollback")
.mockImplementation(async function (this: SafeOutputTree) {
rollbackOwners.push(this);
if (denyRollback) throw new Error("controlled rollback denial");
return originalRollback.call(this);
});
try {
const result = await client.execute(
"extract_artifact",
{ output_root: output },
{ signal: controller.signal },
);
expect(source?.closed).toBe(true);
write.mockRestore();
if (mode === "control") {
expect(result.ok).toBe(true);
expect(await readFile(join(output, "payload.txt"), "utf8")).toBe(
payload,
);
} else {
expect(result).toMatchObject({
ok: false,
error: { _tag: "ArtifactOperationError", reason: "cancelled" },
});
if (result.ok) throw new Error("Expected cancellation");
expect(result.error.cause).toBe(reason);
expect(result.error.cleanupIncomplete).toBe(denyRollback);
if (denyRollback) {
expect(rollbackOwners).toHaveLength(1);
await access(output);
} else
await expect(access(output)).rejects.toMatchObject({
code: "ENOENT",
});
denyRollback = false;
const retry = await client.execute("extract_artifact", {
output_root: output,
});
expect(retry.ok).toBe(true);
expect(await readFile(join(output, "payload.txt"), "utf8")).toBe(
payload,
);
if (mode === "denied-rollback") {
expect(rollbackOwners).toHaveLength(2);
expect(rollbackOwners[1]).toBe(rollbackOwners[0]);
expect(result.error.cleanupIncomplete).toBe(false);
}
}
expect(await client.close()).toMatchObject({ ok: true });
} finally {
denyRollback = false;
write.mockRestore();
await client.close();
rollback.mockRestore();
}
},
);