perf(native): skip llvm-nm's dyld-info pass when the symbol table names every export (#3200)

* perf(native): skip llvm-nm's dyld-info pass when the symbol table names every export

inspect_macho ran `nm -gjU`, whose default dyld-info pass takes seconds on
large apps (Keynote arm64 5.8 s, x86_64 10.2 s) yet under -gU can only add
export-trie names that dyld_info -exports already reports. Run
`nm -gjU --no-dyldinfo` and rerun the default pass only when dyld_info
reports an export the symbol table lacks (e.g. strip -T), so its exact
names still disambiguate dyld_info rows.

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

* fix(native): fall back to nm's dyld-info pass for unreadable export rows

Account for every dyld_info export line instead of only the rows the
parser returns, so an unfamiliar row format reruns the default nm pass.

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-13 23:21:03 -07:00
1 parent 7484401601
commit e5791229f3
5 files changed
+164 -1

No files matched your search

+47 -1
View File
@@ -41,9 +41,15 @@ interface NativeMachoInspectionContext {
const DISCOVERY_COMMANDS = [["lipo", ["-detailed_info"]]] as const;
// llvm-nm's default dyld-info pass fakes up symbols the symbol table lacks
// from dyld metadata. It takes seconds on large apps (Keynote arm64: 5.8 s
// versus 0.01 s without it), and under -gU it can only add export-trie names.
const SYMBOL_TABLE_EXPORTS = ["nm", ["-gjU", "--no-dyldinfo"]] as const;
const DYLD_INFO_EXPORTS = ["nm", ["-gjU"]] as const;
const REQUIRED_SLICE_COMMANDS = [
["otool", ["-h", "-l"]],
["nm", ["-gjU"]],
SYMBOL_TABLE_EXPORTS,
["dyld_info", ["-imports"]],
["dyld_info", ["-exports"]],
["dwarfdump", ["--uuid"]],
@@ -111,6 +117,18 @@ export const inspectNativeMacho = async (
else if (optional.error._tag === "AnalysisCapabilityUnavailableError")
limitations.push(VTOOL_UNAVAILABLE_LIMITATION);
else return err(optional.error);
const nmIndex = captures.findIndex(({ tool }) => tool === "nm");
const dyldExports = captures.filter(({ tool }) => tool === "dyld_info")[1];
if (nmIndex < 0 || dyldExports === undefined)
throw new TypeError("Missing nm or dyld_info export capture");
// A stripped symbol table (strip -T) can omit names the export trie keeps.
// Only then does llvm-nm's dyld-info pass add names, and its exact names
// disambiguate dyld_info rows, so rerun it rather than lose them.
if (!namesEveryExport(captures[nmIndex]!.stdout, dyldExports)) {
const withDyldInfo = await runSliceCommand(DYLD_INFO_EXPORTS);
if (!withDyldInfo.ok) return err(withDyldInfo.error);
captures[nmIndex] = withDyldInfo.value;
}
const otool = captures.find(({ tool }) => tool === "otool");
if (otool === undefined) throw new TypeError("Missing otool capture");
let load: ReturnType<typeof parseOtoolLoadCommands>;
@@ -293,6 +311,34 @@ const captureLookup =
return capture;
};
/**
* Whether every dyld_info export row names a symbol-table export. A row this
* parser cannot read counts as uncovered, so an unfamiliar format falls back.
*/
const namesEveryExport = (
nmOutput: string,
dyldExports: NativeCommandCapture,
): boolean => {
const names = new Set(parseNmExports(nmOutput).map(({ name }) => name));
return sliceOutput(dyldExports)
.split(/\r?\n/u)
.every((rawLine, index) => {
const line = rawLine.trim();
if (
line.length === 0 ||
(index === 0 && line.endsWith(":")) ||
/^-?exports:$/u.test(line) ||
/^offset\s+symbol$/u.test(line)
)
return true;
const rows = parseDyldSymbols(line, "exports", null, names);
// The parser withholds a row only when nm names both of its readings.
if (rows.length === 0)
return names.has(/^0x[\da-f]+\s+(.+)$/iu.exec(line)?.[1] ?? "");
return rows.length === 1 && names.has(rows[0]!.name);
});
};
const parseNmExports = (output: string) =>
output
.split(/\r?\n/u)
@@ -552,3 +552,91 @@ it("retains annotation parsing without nm name evidence", () => {
},
]);
});
/** Replay fixtures, answering nm by whether its dyld-info pass is disabled. */
const symbolTableRunner = (
outputs: Readonly<Record<string, string>>,
nm: { readonly symbolTable: string; readonly withDyldInfo: string },
) => {
const nmArguments: (readonly string[])[] = [];
const replay = (output: string) =>
new FixtureRunner({ ...outputs, nm: output });
const runner: NativeCommandRunner = {
run: (tool, arguments_) => {
if (tool !== "nm") return replay("").run(tool, arguments_);
nmArguments.push(arguments_);
return replay(
arguments_.includes("--no-dyldinfo") ? nm.symbolTable : nm.withDyldInfo,
).run(tool, arguments_);
},
};
return { runner, nmArguments };
};
/** nm arguments in provenance, which records the artifact operand as a placeholder. */
const nmCommands = (result: unknown) =>
(result as { provenance: { tool: string; command: string[] }[] }).provenance
.filter(({ tool }) => tool === "nm")
.map(({ command }) => [...command.slice(1, -1), "/private/fixture"]);
it("reads exports from the symbol table without llvm-nm's dyld-info pass", async () => {
const names = await fixture("dyld-inventory/nm.txt");
const { runner, nmArguments } = symbolTableRunner(
{ "dyld_info:-exports": await fixture("dyld-inventory/exports.txt") },
{ symbolTable: names, withDyldInfo: names },
);
const execution = await new NativeMacOSProvider({}, runner, "darwin")
.createClient(machoTarget("/private/fixture"))
.execute("inspect_macho", {});
if (!execution.ok) throw execution.error;
expect(nmArguments).toEqual([
["-gjU", "--no-dyldinfo", "-arch", "arm64", "/private/fixture"],
]);
expect(nmCommands(execution.value.result)).toEqual(nmArguments);
});
it("reruns nm with its dyld-info pass when a stripped symbol table omits trie exports", async () => {
const trieNames = await fixture("dyld-inventory/stripped-swift-nm.txt");
const { runner, nmArguments } = symbolTableRunner(
{
"dyld_info:-exports": await fixture(
"dyld-inventory/stripped-swift-exports.txt",
),
},
{ symbolTable: "", withDyldInfo: trieNames },
);
const execution = await new NativeMacOSProvider({}, runner, "darwin")
.createClient(machoTarget("/private/fixture"))
.execute("inspect_macho", {});
if (!execution.ok) throw execution.error;
const rerun = ["-gjU", "-arch", "arm64", "/private/fixture"];
expect(nmArguments).toEqual([
["-gjU", "--no-dyldinfo", "-arch", "arm64", "/private/fixture"],
rerun,
]);
// Provenance names the nm run whose output the exports came from.
expect(nmCommands(execution.value.result)).toEqual([rerun]);
const exported = (
execution.value.result as { exports: { items: { name: string }[] } }
).exports.items.map(({ name }) => name);
expect(exported).toEqual(trieNames.trim().split("\n").sort());
});
it("reruns nm with its dyld-info pass when a dyld_info export row is unreadable", async () => {
const names = await fixture("dyld-inventory/nm.txt");
const exportsOutput = await fixture("dyld-inventory/exports.txt");
const { runner, nmArguments } = symbolTableRunner(
{
"dyld_info:-exports": `${exportsOutput.trimEnd()}\n <unrecognized export row>\n`,
},
{ symbolTable: names, withDyldInfo: names },
);
const execution = await new NativeMacOSProvider({}, runner, "darwin")
.createClient(machoTarget("/private/fixture"))
.execute("inspect_macho", {});
if (!execution.ok) throw execution.error;
expect(nmArguments.map((arguments_) => arguments_.slice(0, 2))).toEqual([
["-gjU", "--no-dyldinfo"],
["-gjU", "-arch"],
]);
});
+14
View File
@@ -50,6 +50,20 @@ xcrun dyld_info -exports /tmp/libre.dylib
xcrun nm -gjU /tmp/libre.dylib
```
`stripped-swift-exports.txt` and `stripped-swift-nm.txt` are `xcrun dyld_info
-exports` and the default `xcrun nm -gjU` for a Swift dylib whose symbol table
was stripped with `strip -T`. Its export trie keeps all six names, while
`xcrun nm -gjU --no-dyldinfo` prints none:
```sh
printf 'public func hello() -> Int { 1 }\npublic struct S { public init() {} ; public func f() -> Int { 2 } }\n' > /tmp/m.swift
xcrun swiftc -emit-library -module-name M /tmp/m.swift -o /tmp/libMT.dylib
xcrun strip -T /tmp/libMT.dylib
xcrun dyld_info -exports /tmp/libMT.dylib
xcrun nm -gjU /tmp/libMT.dylib
xcrun nm -gjU --no-dyldinfo /tmp/libMT.dylib
```
`codesign-missing.txt` is stderr from `codesign -d --verbose=4` on a nonexistent
path (exit 1). `codesign-unsigned.txt` is stderr from the same command on a copy
of the fixture after `codesign --remove-signature` (exit 1). Only the latter is
@@ -0,0 +1,9 @@
fixture [arm64]:
-exports:
offset symbol
0x00000598 _$s1M1SV1fSiyF
0x00000594 _$s1M1SVACycfC
0x000005A4 _$s1M1SVMa
0x000005CC _$s1M1SVMn
0x00004010 _$s1M1SVN
0x00000588 _$s1M5helloSiyF
@@ -0,0 +1,6 @@
_$s1M1SV1fSiyF
_$s1M1SVACycfC
_$s1M1SVMa
_$s1M1SVMn
_$s1M1SVN
_$s1M5helloSiyF