Skip to content

perf(android): ship libcraft.so without its DWARF, and keep the DWARF for symbols - #217

Merged
glennmichael123 merged 1 commit into
mainfrom
fix/android-strip-libcraft
Sep 16, 2026
Merged

glennmichael123 merged 1 commit into
mainfrom
fix/android-strip-libcraft

Conversation

@glennmichael123

Copy link
Copy Markdown
Contributor

Closes #204.

What was wrong

Since #199, every generated Android app's APK carries the Zig JNI library, and at -Doptimize=ReleaseSafe each one was built with debug info:

5,761,056  zig-out/android/arm64-v8a/libcraft.so
5,565,632  zig-out/android/x86_64/libcraft.so

AGP normally strips native libraries at packaging time with the NDK's strip. A generated project configures no NDK, so that task had nothing to run, and roughly 11 MB of mostly DWARF shipped in a universal APK.

The three questions from the issue

  1. Strip, and where. In build.zig, for every non-Debug build, with the NDK's llvm-objcopy. That is the tool AGP would have used, and the NDK is already a hard requirement for building these libraries (fix(android): make every Zig decline speak, and prove Zig answers #205).
  2. Is the debug info worth keeping? In the APK, no: nothing on a device reads it. Next to the build, yes. Only the DWARF is stripped (--strip-debug, not --strip-all), so the symbol table stays and a native crash tombstone still names functions. The DWARF moves to zig-out/android-symbols/<abi>/libcraft.so.debug, linked from the library by .gnu_debuglink. That is what ndk-stack -sym and Play Console's native debug symbols expect. A -Doptimize=Debug build installs the library as built.
  3. ABI splits. Not done here. Which ABIs an artifact carries is a packaging decision for each app, and Play already delivers per-ABI splits from an AAB. That leaves one question the issue raised unanswered by this PR, deliberately.

Why not Zig's ObjCopy step

It was the first thing tried. b.addObjCopy(..., .{ .strip = .debug, .separate_debug_file = ... }) configures fine on both 0.17.0-dev.1441 and 1963, and then fails at build time:

failed command: zig objcopy --strip-debug --extract-to=.../libcraft.so.debug ...
error: unimplemented

lib/compiler/objcopy.zig reaches fatal("unimplemented") for any ELF to ELF copy, in every strip mode. --strip-all alone fails the same way. So the step runs llvm-objcopy twice through addSystemCommand: once with --only-keep-debug, then with --strip-debug --add-gnu-debuglink=<debug file>. androidTool finds the binary under the NDK's host prebuilt directory, the same way androidSysroot finds the sysroot. An NDK without it fails the build step naming the path, and does not stop zig build test.

How it is held

The Android E2E runner now inspects the library it is about to ship before either leg starts. It reads the ELF section table directly (elfSectionNames), because a macOS host has no readelf. It refuses to run when:

  • x86_64/libcraft.so still carries any .debug_* section;
  • it has no .gnu_debuglink;
  • android-symbols/x86_64/libcraft.so.debug is missing, or holds no .debug_info, which means the strip discarded the DWARF rather than moving it.

protocol.test.ts builds minimal ELF64 files in memory to cover each case, including the exact shape #204 found. The reader was also run against a real Zig-built ELF shared library, which lists .debug_info, .debug_line and the rest. The runner logs the shipped size, so the saving shows up in the job log.

Verification

  • Locally: bun run test, bun run typecheck, bun run verify:packages, Pickier, the protocol tests and the android package tests. build.zig configures on Zig 1441 and 1963.
  • The NDK build and the strip itself run only in this PR's Android emulator job; there is no NDK on the machine this was written on. That job builds build-android-all -Doptimize=ReleaseSafe, then the runner checks the result and runs both legs on the stripped library, including registered:103, declines:0 on the runtime leg.

@glennmichael123 glennmichael123 mentioned this pull request Sep 16, 2026
12 tasks
@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown

✅ Binary Size Report

Metric Value
Current Size 14933KB (14.58MB)
Change 0KB (0%) unchanged
Size limits
  • Warning: 14.50MB
  • Maximum: 16.00MB

@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown

✅ Binary load time

rounds:    25 interleaved
base:      p50 19.5ms   p95 21.2ms   (18.9–22.6ms)
head:      p50 19.5ms   p95 20.7ms   (18.9–20.8ms)
delta:     -0.1%  (fails above +20.0%)

No binary load time regression.
What this measures

craft --help: process spawn, dynamic linking and argument parsing.
It never opens a window, so it cannot see a change in window or
webview startup — real startup is benchmarks/startup.bench.ts, which
needs a display.

Both binaries are measured interleaved on this runner and compared by
p50, rather than against a number recorded on another machine. On
byte-identical binaries that method reads within ~3.5%; the old one
swung 45%.

… for symbols

Every generated app's APK carries the Zig JNI library, and at ReleaseSafe
each was about 5.5 MB, most of it debug info nothing read. AGP would strip
it with the NDK's strip, but a generated project configures no NDK, so it
never did.

Release builds now run the NDK's llvm-objcopy after linking:
--only-keep-debug writes zig-out/android-symbols/<abi>/libcraft.so.debug,
and --strip-debug --add-gnu-debuglink produces the library the generator
installs. Only the DWARF goes: the symbol table stays so a tombstone still
names functions, and ndk-stack or Play Console can take the .debug files.
Debug builds install the library as built.

Not Zig's own ObjCopy step: ELF to ELF copying is fatal("unimplemented") in
zig objcopy at 0.17.0-dev.1963, for every strip mode. The NDK is already
required to build these libraries at all.

The Android E2E runner now refuses to start when the x86_64 library still
carries any .debug_ section, has no .gnu_debuglink, or has no symbols file
holding .debug_info beside it. It reads the ELF section table itself, and
protocol.test.ts covers the shape #204 found.

Closes #204
@glennmichael123
glennmichael123 force-pushed the fix/android-strip-libcraft branch from 7094453 to 3b34336 Compare September 16, 2026 17:14
@glennmichael123

Copy link
Copy Markdown
Contributor Author

Measured on this PR's Android emulator job, which built build-android-all -Doptimize=ReleaseSafe with the NDK:

x86_64/libcraft.so ships at 1.34 MB; its symbols are 4.93 MB beside it
ok   android-shim — 7/7 cases, no zig
ok   android-runtime — 7/7 cases, zig served registered:103, declines:0

5,565,632 bytes (5.31 MiB) before → 1.34 MiB shipped, about a quarter of the size. The runtime leg ran on the stripped library: it loaded, bound all 103 natives, and declined nothing.

@glennmichael123
glennmichael123 merged commit 44ce7b8 into main Sep 16, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(android): the shipped libcraft.so carries debug info, ~5.5 MB per ABI

1 participant