From 07fd426e9a4caaf710b3a356c60abbffa28d4bd6 Mon Sep 17 00:00:00 2001 From: Shakti Date: Thu, 22 Oct 2026 00:21:11 +0530 Subject: [PATCH] fix(apple): preserve asset catalog caller cancellation reason (#6055) * fix(apple): preserve asset catalog caller cancellation reason * test(apple): cover asset catalog cancellation checkpoints --- .../apple/AppleAssetCatalogAnalysis.ts | 11 +- .../assetCatalogCancellationReason.test.ts | 132 ++++++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 tests/process-global/assetCatalogCancellationReason.test.ts diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts index 0388a69c17..202c169ea2 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts @@ -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; diff --git a/tests/process-global/assetCatalogCancellationReason.test.ts b/tests/process-global/assetCatalogCancellationReason.test.ts new file mode 100644 index 0000000000..fc1344c08b --- /dev/null +++ b/tests/process-global/assetCatalogCancellationReason.test.ts @@ -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 (run: () => Promise): Promise => { + 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 => { + 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 }); + } + }, +);