fix(apple): preserve asset catalog caller cancellation reason (#6055)

* fix(apple): preserve asset catalog caller cancellation reason

* test(apple): cover asset catalog cancellation checkpoints
This commit is contained in:
Shakti
2026-10-22 00:21:11 +05:30
committed by GitHub
parent 0583fc69be
commit 07fd426e9a
2 changed files with 142 additions and 1 deletions
@@ -12,12 +12,19 @@ import {
parseAppleAssetCatalogRecords,
projectAppleAssetCatalogPage,
} from "../../domain/apple/appleAssetCatalog.js";
import { execFileOutput } from "../../process/ExecFileOutput.js";
import {
ExecFileOutputCancelledError,
execFileOutput,
} from "../../process/ExecFileOutput.js";
const ASSETUTIL = "/usr/bin/assetutil";
const MAX_CATALOG_BYTES = 64 * 1024 * 1024;
const MAX_TOTAL_METADATA_BYTES = 64 * 1024 * 1024;
/** The command owner wraps the exact caller reason; do not replace it with the wrapper. */
const cancellationCause = (cause: unknown, signal?: AbortSignal): unknown =>
cause instanceof ExecFileOutputCancelledError ? cause.cause : signal?.reason;
/** Inspect compiled asset catalog metadata without extracting rendition bytes. */
export const analyzeAppleAssetCatalogs = async (input: {
readonly bundlePath: string;
@@ -275,6 +282,7 @@ const runAssetUtil = async (
throw new ArtifactReaderFailure(
"cancelled",
"Asset catalog inspection cancelled",
{ cause: cancellationCause(cause, signal) },
);
const code =
typeof cause === "object" && cause !== null && "code" in cause
@@ -310,6 +318,7 @@ const digestBounded = async (
throw new ArtifactReaderFailure(
"cancelled",
"Asset catalog inspection cancelled",
{ cause: signal.reason },
);
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
length += bytes.length;
@@ -0,0 +1,132 @@
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Readable } from "node:stream";
import { expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
controller: undefined as AbortController | undefined,
reason: undefined as unknown,
}));
vi.mock("../../src/process/ExecFileOutput.js", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("../../src/process/ExecFileOutput.js")
>();
return {
...actual,
execFileOutput: vi.fn(async () => {
state.controller?.abort(state.reason);
throw new actual.ExecFileOutputCancelledError(state.reason);
}),
};
});
import { ArtifactReaderFailure } from "../../src/artifacts/ArtifactReader.js";
import { DirectoryArtifactReader } from "../../src/artifacts/DirectoryArtifactReader.js";
import { analyzeAppleAssetCatalogs } from "../../src/artifacts/apple/AppleAssetCatalogAnalysis.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const canSimulateDarwin =
process.platform === "darwin" || platformDescriptor?.configurable === true;
/** Run the macOS-only catalog path on non-macOS CI without changing production behavior. */
const withDarwinPlatform = async <T>(run: () => Promise<T>): Promise<T> => {
if (process.platform === "darwin") return run();
if (platformDescriptor?.configurable !== true)
throw new Error("This host cannot simulate the macOS catalog gate");
Object.defineProperty(process, "platform", {
...platformDescriptor,
value: "darwin",
});
try {
return await run();
} finally {
Object.defineProperty(process, "platform", platformDescriptor);
}
};
const createBundle = async (): Promise<string> => {
const bundle = await createTestTempDirectory(
"rea-asset-catalog-cancellation-",
);
await mkdir(join(bundle, "Contents", "Resources"), { recursive: true });
await writeFile(
join(bundle, "Contents", "Resources", "Assets.car"),
"inert catalog fixture",
);
return bundle;
};
const analyze = (bundle: string, signal: AbortSignal) =>
analyzeAppleAssetCatalogs({
bundlePath: bundle,
targetSha256: null,
environment: {},
signal,
analyzeInterfaceBuilder: (async () => ({
graph: { nodes: [], truncated: false },
})) as never,
});
it.skipIf(!canSimulateDarwin)(
"retains the caller reason when assetutil observes cancellation",
async () => {
const bundle = await createBundle();
const reason = { requested: "stop asset catalog" };
const controller = new AbortController();
state.controller = controller;
state.reason = reason;
try {
const failure: unknown = await withDarwinPlatform(() =>
analyze(bundle, controller.signal),
).catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(ArtifactReaderFailure);
if (!(failure instanceof ArtifactReaderFailure))
throw new Error("Expected asset catalog cancellation");
expect(failure.reason).toBe("cancelled");
expect(failure.cancellation?.reason).toBe(reason);
} finally {
await rm(bundle, { recursive: true, force: true });
}
},
);
it.skipIf(!canSimulateDarwin)(
"retains the caller reason when the catalog digest is cancelled",
async () => {
const bundle = await createBundle();
const reason = { requested: "stop during catalog digest" };
const controller = new AbortController();
state.controller = controller;
state.reason = reason;
const open = vi
.spyOn(DirectoryArtifactReader.prototype, "open")
.mockImplementationOnce(
async () =>
new Readable({
read() {
this.push(Buffer.from("inert catalog fixture"));
controller.abort(reason);
this.push(null);
},
}),
);
try {
const failure: unknown = await withDarwinPlatform(() =>
analyze(bundle, controller.signal),
).catch((cause: unknown) => cause);
expect(open).toHaveBeenCalledTimes(1);
expect(failure).toBeInstanceOf(ArtifactReaderFailure);
if (!(failure instanceof ArtifactReaderFailure))
throw new Error("Expected asset catalog digest cancellation");
expect(failure.reason).toBe("cancelled");
expect(failure.cancellation?.reason).toBe(reason);
} finally {
open.mockRestore();
await rm(bundle, { recursive: true, force: true });
}
},
);