perf(artifacts): run the asset catalog and Interface Builder bundle walks together (#3285)

* perf(artifacts): run the asset catalog and Interface Builder bundle walks together

inspect_asset_catalog walked the app bundle for Assets.car files and then
walked it again for the Interface Builder graph that supplies resource
keys. The analyses are independent, so start the Interface Builder one
first and let a catalog failure cancel it and win, as the sequential
order did. Keynote 4.20 s -> 2.89 s with identical output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(artifacts): hold asset catalog and Interface Builder work open to pin overlap

Keep the Interface Builder stub pending until catalog inspection starts,
and keep catalog inspection pending while asserting an early Interface
Builder failure has not escaped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-14 01:22:27 -07:00
1 parent ca7450beff
commit 04345767a8
2 files changed
+166 -40

No files matched your search

@@ -2,10 +2,11 @@ import { createHash } from "node:crypto";
import { mkdir, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { encodeNibArchiveFixture } from "./NibArchive.fixture.js";
import type { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
import {
analyzeAppleAssetCatalogs,
@@ -206,3 +207,90 @@ it("keeps recognized image fields on NIBArchive controls out of resource joins",
});
expect(result.resource_key_matches).toEqual([]);
});
describe("Apple asset catalog and Interface Builder concurrency", () => {
const catalogBundle = async () => {
const bundle = await createTestTempDirectory("rea-asset-catalog-ib-");
const resources = join(bundle, "Contents", "Resources");
await mkdir(resources, { recursive: true });
await writeFile(join(resources, "Assets.car"), "catalog");
return bundle;
};
const emptyGraph = {
graph: { nodes: [], truncated: false },
} as unknown as Awaited<ReturnType<typeof analyzeInterfaceBuilderBundle>>;
it("keeps the Interface Builder analysis running while catalogs are inspected", async () => {
// The Interface Builder analysis can finish only after catalog inspection
// starts, so a sequential implementation would never complete.
let catalogStarted!: () => void;
const catalogStart = new Promise<void>((resolve) => {
catalogStarted = resolve;
});
const result = await analyzeAppleAssetCatalogs({
environment: {},
bundlePath: await catalogBundle(),
targetSha256: "a".repeat(64),
analyzeInterfaceBuilder: async () => {
await catalogStart;
return emptyGraph;
},
runAssetUtil: async () => {
catalogStarted();
return "[]";
},
});
expect(result.catalogs).toHaveLength(1);
}, 5_000);
it("cancels the Interface Builder analysis and reports a catalog failure", async () => {
let cancelled = false;
let settled = false;
const rejected = await analyzeAppleAssetCatalogs({
environment: {},
bundlePath: await catalogBundle(),
targetSha256: "a".repeat(64),
analyzeInterfaceBuilder: ({ signal }) =>
new Promise((_, reject) => {
signal?.addEventListener("abort", () => {
cancelled = true;
setImmediate(() => {
settled = true;
reject(new Error("interface builder cancelled"));
});
});
}),
runAssetUtil: () => Promise.reject(new Error("assetutil failed")),
}).catch((cause: unknown) => cause);
expect(rejected).toMatchObject({ message: "assetutil failed" });
expect(cancelled).toBe(true);
expect(settled).toBe(true);
});
it("reports an early Interface Builder failure only after catalogs are inspected", async () => {
let finishCatalog!: (output: string) => void;
let catalogStarted = false;
let settled = false;
const analysis = analyzeAppleAssetCatalogs({
environment: {},
bundlePath: await catalogBundle(),
targetSha256: "a".repeat(64),
analyzeInterfaceBuilder: () =>
Promise.reject(new Error("interface builder failed")),
runAssetUtil: () => {
catalogStarted = true;
return new Promise((resolve) => {
finishCatalog = resolve;
});
},
}).finally(() => {
settled = true;
});
analysis.catch(() => undefined);
await vi.waitFor(() => expect(catalogStarted).toBe(true));
await new Promise((resolve) => setImmediate(resolve));
expect(settled).toBe(false);
finishCatalog("[]");
await expect(analysis).rejects.toThrow("interface builder failed");
});
});
@@ -29,6 +29,7 @@ export const analyzeAppleAssetCatalogs = async (input: {
path: string,
signal?: AbortSignal,
) => Promise<string>;
readonly analyzeInterfaceBuilder?: typeof analyzeInterfaceBuilderBundle;
}) => {
if (process.platform !== "darwin" && input.runAssetUtil === undefined)
throw new ArtifactReaderFailure(
@@ -36,6 +37,81 @@ export const analyzeAppleAssetCatalogs = async (input: {
"Apple asset catalog inspection requires macOS assetutil",
);
const page = appleAssetCatalogInputSchema.parse(input.page ?? {});
// The Interface Builder graph only supplies resource keys for the join. Its
// bundle walk is independent of the catalog walk, so both run together; a
// catalog failure cancels it and is reported as the sequential order would.
const interfaceBuilderCancel = new AbortController();
const interfaceBuilderAnalysis = (
input.analyzeInterfaceBuilder ?? analyzeInterfaceBuilderBundle
)({
bundlePath: input.bundlePath,
targetSha256: input.targetSha256,
signal:
input.signal === undefined
? interfaceBuilderCancel.signal
: AbortSignal.any([input.signal, interfaceBuilderCancel.signal]),
}).then(
(value) => ({ ok: true, value }) as const,
(error: unknown) => ({ ok: false, error }) as const,
);
let catalogs: Awaited<ReturnType<typeof collectAssetCatalogs>>["catalogs"];
let uninspected: readonly string[];
try {
({ catalogs, uninspected } = await collectAssetCatalogs(input));
} catch (cause: unknown) {
interfaceBuilderCancel.abort();
await interfaceBuilderAnalysis;
throw cause;
}
const analyzed = await interfaceBuilderAnalysis;
if (!analyzed.ok) throw analyzed.error;
const interfaceBuilder = analyzed.value;
const resourceReferences = collectInterfaceBuilderResourceKeys(
interfaceBuilder.graph.nodes,
);
const result = projectAppleAssetCatalogPage({
targetSha256: input.targetSha256,
catalogs,
offset: page.offset,
limit: page.limit,
resourceReferences,
});
return {
...result,
limitations: [
...result.limitations,
// An app without a compiled catalog is an observed empty inventory, not
// a missing host tool.
...(catalogs.length === 0
? [
"No compiled Assets.car catalog was found among the app bundle's regular files (symlinks are not followed); any resource keys are reported as unmatched.",
]
: []),
...(uninspected.length === 0
? []
: [
`Asset catalog entries that are not regular files were not inspected, so resource keys reported as unmatched may resolve there: ${uninspected.join(", ")}`,
]),
"Exact resource-key joins use only recognized image or asset key fields on decoded Interface Builder resource objects; other archive resource references are not inferred.",
...(interfaceBuilder.graph.truncated
? [
"Interface Builder resource-key joins may be incomplete because the decoded UI graph was truncated.",
]
: []),
],
};
};
/** Inspect every regular Assets.car in the bundle; non-file entries are listed. */
const collectAssetCatalogs = async (input: {
readonly bundlePath: string;
readonly environment: Readonly<NodeJS.ProcessEnv>;
readonly signal?: AbortSignal;
readonly runAssetUtil?: (
path: string,
signal?: AbortSignal,
) => Promise<string>;
}) => {
const reader = new DirectoryArtifactReader(input.bundlePath);
const catalogs: {
path: string;
@@ -77,45 +153,7 @@ export const analyzeAppleAssetCatalogs = async (input: {
"path",
`Asset catalog entries are not regular files and were not inspected: ${uninspected.join(", ")}`,
);
const interfaceBuilder = await analyzeInterfaceBuilderBundle({
bundlePath: input.bundlePath,
targetSha256: input.targetSha256,
...(input.signal === undefined ? {} : { signal: input.signal }),
});
const resourceReferences = collectInterfaceBuilderResourceKeys(
interfaceBuilder.graph.nodes,
);
const result = projectAppleAssetCatalogPage({
targetSha256: input.targetSha256,
catalogs,
offset: page.offset,
limit: page.limit,
resourceReferences,
});
return {
...result,
limitations: [
...result.limitations,
// An app without a compiled catalog is an observed empty inventory, not
// a missing host tool.
...(catalogs.length === 0
? [
"No compiled Assets.car catalog was found among the app bundle's regular files (symlinks are not followed); any resource keys are reported as unmatched.",
]
: []),
...(uninspected.length === 0
? []
: [
`Asset catalog entries that are not regular files were not inspected, so resource keys reported as unmatched may resolve there: ${uninspected.join(", ")}`,
]),
"Exact resource-key joins use only recognized image or asset key fields on decoded Interface Builder resource objects; other archive resource references are not inferred.",
...(interfaceBuilder.graph.truncated
? [
"Interface Builder resource-key joins may be incomplete because the decoded UI graph was truncated.",
]
: []),
],
};
return { catalogs, uninspected };
};
const inspectAssetCatalogEntry = async (input: {