build: fat static archive and ubsan fix for external linkers (#12217)

## Summary

> [!IMPORTANT]
> Stacked on #12214. Review that first. (i am targeting `main` so here
you will see the full changeset, including 12214

Two changes that make the static libghostty archive consumable by
external linkers (MSVC link.exe, .NET NativeAOT, Rust, Go, etc.):

**Fat static archive on all platforms**

The static archive previously only bundled vendored deps on macOS (via
libtool). On Windows and Linux the archive contained only the
Zig-compiled code, requiring consumers to find and link freetype,
harfbuzz, glslang, spirv-cross, simdutf, oniguruma, etc. separately.

Now all platforms produce a single fat archive:
- macOS: libtool (unchanged)
- Windows: zig ar qcL --format=coff (MSVC's lib.exe can't read
Zig-produced GNU-format archives, so we use the bundled LLVM archiver)
- Linux: ar -M with MRI scripts (same approach as libghostty-vt)

**MSVC ubsan suppression for C deps**

Zig's ubsan runtime can't be bundled on Windows (LNK4229), leaving
__ubsan_handle_* symbols unresolved. freetype, glslang, spirv-cross, and
highway already suppress ubsan. This adds MSVC-conditional suppression
to seven more: harfbuzz, libpng, dcimgui, wuffs, oniguruma, zlib, and
stb.

Gated on abi == .msvc so ubsan coverage is preserved on Linux/macOS.

## Test plan

- [x] zig build produces a fat ghostty-static.lib (~230MB) with ~200
object files
- [x] MSVC's lib /LIST can read the archive
- [x] .NET NativeAOT consumer resolves all symbols (0 unresolved)
- [x] Linux/macOS builds unaffected (ubsan remains enabled)
This commit is contained in:
Mitchell Hashimoto
2026-04-23 09:33:05 -07:00
committed by GitHub
10 changed files with 104 additions and 56 deletions

View File

@@ -60,6 +60,12 @@ pub fn build(b: *std.Build) !void {
"-DIMGUI_USE_WCHAR32=1",
"-DIMGUI_DISABLE_OBSOLETE_FUNCTIONS=1",
});
if (target.result.abi == .msvc) {
try flags.appendSlice(b.allocator, &.{
"-fno-sanitize=undefined",
"-fno-sanitize-trap=undefined",
});
}
if (freetype) try flags.appendSlice(b.allocator, &.{
"-DIMGUI_ENABLE_FREETYPE=1",
});

View File

@@ -123,6 +123,15 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu
try flags.appendSlice(b.allocator, &.{
"-DHAVE_STDBOOL_H",
});
// Disable ubsan for MSVC: Zig's ubsan runtime cannot be bundled
// on Windows (LNK4229), leaving __ubsan_handle_* unresolved when
// the static archive is consumed by an external linker.
if (target.result.abi == .msvc) {
try flags.appendSlice(b.allocator, &.{
"-fno-sanitize=undefined",
"-fno-sanitize-trap=undefined",
});
}
if (target.result.os.tag != .windows) {
try flags.appendSlice(b.allocator, &.{
"-DHAVE_UNISTD_H",

View File

@@ -54,6 +54,12 @@ pub fn build(b: *std.Build) !void {
"-DPNG_INTEL_SSE_OPT=0",
"-DPNG_MIPS_MSA_OPT=0",
});
if (target.result.abi == .msvc) {
try flags.appendSlice(b.allocator, &.{
"-fno-sanitize=undefined",
"-fno-sanitize-trap=undefined",
});
}
lib.addCSourceFiles(.{
.root = upstream.path(""),

View File

@@ -103,6 +103,12 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu
var flags: std.ArrayList([]const u8) = .empty;
defer flags.deinit(b.allocator);
if (target.result.abi == .msvc) {
try flags.appendSlice(b.allocator, &.{
"-fno-sanitize=undefined",
"-fno-sanitize-trap=undefined",
});
}
lib.addCSourceFiles(.{
.root = upstream.path(""),
.flags = flags.items,

View File

@@ -20,6 +20,10 @@ pub fn build(b: *std.Build) !void {
var flags: std.ArrayList([]const u8) = .empty;
defer flags.deinit(b.allocator);
try flags.append(b.allocator, "-DWUFFS_IMPLEMENTATION");
if (target.result.abi == .msvc) {
try flags.append(b.allocator, "-fno-sanitize=undefined");
try flags.append(b.allocator, "-fno-sanitize-trap=undefined");
}
inline for (@import("src/c.zig").defines) |key| {
try flags.append(b.allocator, "-D" ++ key);
}

View File

@@ -33,6 +33,12 @@ pub fn build(b: *std.Build) !void {
"-DHAVE_STDINT_H",
"-DHAVE_STDDEF_H",
});
if (target.result.abi == .msvc) {
try flags.appendSlice(b.allocator, &.{
"-fno-sanitize=undefined",
"-fno-sanitize-trap=undefined",
});
}
if (target.result.os.tag != .windows) {
try flags.append(b.allocator, "-DZ_HAVE_UNISTD_H");
}

View File

@@ -0,0 +1,47 @@
//! Combines multiple static archives into a single fat archive.
//! Uses libtool on Darwin and a cross-platform MRI-script build tool
//! on all other platforms (including Windows).
const std = @import("std");
const LibtoolStep = @import("LibtoolStep.zig");
/// Combine multiple static archives into a single fat archive.
///
/// `name` identifies the library (e.g. "ghostty-internal", "ghostty-vt").
/// Output uses a `-fat` suffix to distinguish the combined archive from
/// the single-library archive in the build cache.
pub fn create(
b: *std.Build,
target: std.Build.ResolvedTarget,
name: []const u8,
sources: []const std.Build.LazyPath,
) struct { step: *std.Build.Step, output: std.Build.LazyPath } {
if (target.result.os.tag.isDarwin()) {
const libtool = LibtoolStep.create(b, .{
.name = name,
.out_name = b.fmt("lib{s}-fat.a", .{name}),
.sources = @constCast(sources),
});
return .{ .step = libtool.step, .output = libtool.output };
}
// On non-Darwin, use a build tool that generates an MRI script and
// pipes it to `zig ar -M`. This works on all platforms including
// Windows (the previous /bin/sh approach did not).
const tool = b.addExecutable(.{
.name = "combine_archives",
.root_module = b.createModule(.{
.root_source_file = b.path("src/build/combine_archives.zig"),
.target = b.graph.host,
}),
});
const run = b.addRunArtifact(tool);
run.addArg(b.graph.zig_exe);
const out_name = if (target.result.os.tag == .windows)
b.fmt("{s}-fat.lib", .{name})
else
b.fmt("lib{s}-fat.a", .{name});
const output = run.addOutputFileArg(out_name);
for (sources) |source| run.addFileArg(source);
return .{ .step = &run.step, .output = output };
}

View File

@@ -2,9 +2,9 @@ const GhosttyLib = @This();
const std = @import("std");
const RunStep = std.Build.Step.Run;
const CombineArchivesStep = @import("CombineArchivesStep.zig");
const Config = @import("Config.zig");
const SharedDeps = @import("SharedDeps.zig");
const LibtoolStep = @import("LibtoolStep.zig");
const LipoStep = @import("LipoStep.zig");
/// The step that generates the file.
@@ -48,29 +48,18 @@ pub fn initStatic(
}
// Add our dependencies. Get the list of all static deps so we can
// build a combined archive if necessary.
// build a combined archive.
var lib_list = try deps.add(lib);
try lib_list.append(b.allocator, lib.getEmittedBin());
if (!deps.config.target.result.os.tag.isDarwin()) return .{
.step = &lib.step,
.output = lib.getEmittedBin(),
.dsym = null,
.pkg_config = null,
.pkg_config_static = null,
};
// Create a static lib that contains all our dependencies.
const libtool = LibtoolStep.create(b, .{
.name = "ghostty",
.out_name = "libghostty-fat.a",
.sources = lib_list.items,
});
libtool.step.dependOn(&lib.step);
// Combine all archives into a single fat static library so
// consumers only need to link one file.
const combined = CombineArchivesStep.create(b, deps.config.target, "ghostty-internal", lib_list.items);
combined.step.dependOn(&lib.step);
return .{
.step = libtool.step,
.output = libtool.output,
.step = combined.step,
.output = combined.output,
// Static libraries cannot have dSYMs because they aren't linked.
.dsym = null,

View File

@@ -4,9 +4,9 @@ const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const RunStep = std.Build.Step.Run;
const CombineArchivesStep = @import("CombineArchivesStep.zig");
const Config = @import("Config.zig");
const GhosttyZig = @import("GhosttyZig.zig");
const LibtoolStep = @import("LibtoolStep.zig");
const LipoStep = @import("LipoStep.zig");
const SharedDeps = @import("SharedDeps.zig");
const XCFrameworkStep = @import("XCFrameworkStep.zig");
@@ -287,7 +287,7 @@ fn initLib(
try sources.append(b.allocator, lib.getEmittedBin());
try sources.appendSlice(b.allocator, zig.simd_libs.items);
const combined = combineArchives(b, target, sources.items);
const combined = CombineArchivesStep.create(b, target, "ghostty-vt", sources.items);
combined.step.dependOn(&lib.step);
return .{
@@ -312,40 +312,6 @@ fn initLib(
};
}
/// Combine multiple static archives into a single fat archive.
/// Uses libtool on Darwin and ar MRI scripts on other platforms.
fn combineArchives(
b: *std.Build,
target: std.Build.ResolvedTarget,
sources: []const std.Build.LazyPath,
) struct { step: *std.Build.Step, output: std.Build.LazyPath } {
if (target.result.os.tag.isDarwin()) {
const libtool = LibtoolStep.create(b, .{
.name = "ghostty-vt",
.out_name = "libghostty-vt.a",
.sources = @constCast(sources),
});
return .{ .step = libtool.step, .output = libtool.output };
}
// On non-Darwin, use a build tool that generates an MRI script and
// pipes it to `zig ar -M`. This works on all platforms including
// Windows (the previous /bin/sh approach did not).
const tool = b.addExecutable(.{
.name = "combine_archives",
.root_module = b.createModule(.{
.root_source_file = b.path("src/build/combine_archives.zig"),
.target = b.graph.host,
}),
});
const run = b.addRunArtifact(tool);
run.addArg(b.graph.zig_exe);
const output = run.addOutputFileArg("libghostty-vt.a");
for (sources) |source| run.addFileArg(source);
return .{ .step = &run.step, .output = output };
}
/// Returns the Libs.private value for the pkg-config file.
/// Vendored C++ dependencies are built in no-libcxx mode so consumers
/// don't need libc++. System-provided simdutf still requires it.

View File

@@ -412,7 +412,16 @@ pub fn add(
// C files
step.linkLibC();
step.addIncludePath(b.path("src/stb"));
step.addCSourceFiles(.{ .files = &.{"src/stb/stb.c"} });
// Disable ubsan for MSVC: Zig's ubsan runtime cannot be bundled
// on Windows (LNK4229), leaving __ubsan_handle_* unresolved when
// the static archive is consumed by an external linker.
step.addCSourceFiles(.{
.files = &.{"src/stb/stb.c"},
.flags = if (step.rootModuleTarget().abi == .msvc)
&.{ "-fno-sanitize=undefined", "-fno-sanitize-trap=undefined" }
else
&.{},
});
if (step.rootModuleTarget().os.tag == .linux) {
step.addIncludePath(b.path("src/apprt/gtk"));
}