build: normalize input archives before Darwin libtool merge (#11999)

## Root cause

Zig 0.15.2 can produce macOS `.a` archives where some 64-bit Mach-O
members are only 4-byte aligned inside the archive. Recent Apple
`libtool -static` does not handle that layout correctly: it emits `not
8-byte aligned` warnings and, in the failing case, silently drops those
members when creating the combined static library.

In Ghostty, this happened in the Darwin `libtool` merge step that builds
`libghostty-fat.a`. The x86_64 input `libghostty.a` still contained the
expected `libghostty_zcu.o` and about 97 exported `_ghostty_` symbols,
but after `libtool -static` the output archive contained only 4 SIMD
symbols because `libghostty_zcu.o` had been discarded. The same warning
pattern also appeared in third-party input archives such as
`libfreetype.a` and `libz.a`, so this was not only a `libghostty.a`
problem.

## What needed to be done

The inputs to Apple `libtool` needed to be normalized before they were
merged.

The safest fix is to copy each input archive and run `ranlib -D` on the
copy before passing it to `libtool`. `ranlib` rewrites the archive into
a form that Apple’s linker tools accept, fixing the alignment/layout
issue without changing the archive’s semantic contents.

## Why this approach

An `ar x` -> `ar rcs` workaround can also make the warnings go away, but
it is a broader and riskier transformation. Extracting archive members
into a flat directory is not semantics-preserving:

- duplicate member basenames can collide
- non-`.o` members can be lost
- member order can change

That means an `ar`-based rearchive can silently change valid archives
while fixing alignment. `ranlib -D` avoids those hazards because it
rewrites the archive in place instead of flattening it through the
filesystem.

`-D` is also important because plain `ranlib` is not deterministic. In
local testing, `ranlib -D` still fixed the alignment issue, preserved
all 97 `_ghostty_` symbols, produced no `libtool` warnings, and was
byte-stable across repeated runs.

## Validation

This was reproduced directly:

- before normalization, running `libtool -static` on the affected x86_64
`libghostty.a` produced a `libghostty_zcu.o not 8-byte aligned` warning
and the output archive dropped from 97 `_ghostty_` symbols to 4
- after `ranlib -D`, the same `libtool -static` command preserved all 97
`_ghostty_` symbols and emitted no alignment warnings

After applying the normalization step, a clean `zig build` succeeded,
and the final macOS xcframework archive contained 97 `_ghostty_` symbols
in both the `x86_64` and `arm64` slices.

## Summary

This was not a Metal issue, not an Xcode project issue, and not a
stale-cache issue. The actual root cause was an Apple `libtool`
interoperability problem with Zig-produced macOS archives. The required
fix was to normalize each archive before the Darwin `libtool` merge
step, and `ranlib -D` is the least invasive way to do that while
preserving archive semantics.
This commit is contained in:
Jeffrey C. Ollie
2026-03-30 16:22:20 -05:00
committed by GitHub

View File

@@ -33,7 +33,15 @@ pub fn create(b: *std.Build, opts: Options) *LibtoolStep {
const run_step = RunStep.create(b, b.fmt("libtool {s}", .{opts.name}));
run_step.addArgs(&.{ "libtool", "-static", "-o" });
const output = run_step.addOutputFileArg(opts.out_name);
for (opts.sources) |source| run_step.addFileArg(source);
for (opts.sources, 0..) |source, i| {
run_step.addFileArg(normalizeArchive(
b,
opts.name,
opts.out_name,
i,
source,
));
}
self.* = .{
.step = &run_step.step,
@@ -42,3 +50,29 @@ pub fn create(b: *std.Build, opts: Options) *LibtoolStep {
return self;
}
fn normalizeArchive(
b: *std.Build,
step_name: []const u8,
out_name: []const u8,
index: usize,
source: LazyPath,
) LazyPath {
// Newer Xcode libtool can drop 64-bit archive members if the input
// archive layout doesn't match what it expects. ranlib rewrites the
// archive without flattening members through the filesystem, so we
// normalize each source archive first. This is a Zig/toolchain
// interoperability workaround, not a Ghostty archive format change.
const run_step = RunStep.create(
b,
b.fmt("ranlib {s} #{d}", .{ step_name, index }),
);
run_step.addArgs(&.{
"/bin/sh",
"-c",
"/bin/cp \"$1\" \"$2\" && /usr/bin/ranlib \"$2\"",
"_",
});
run_step.addFileArg(source);
return run_step.addOutputFileArg(b.fmt("{d}-{s}", .{ index, out_name }));
}