From 94e638d08415255a5231d901714abeb95492e253 Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Fri, 10 Apr 2026 08:20:40 +0200 Subject: [PATCH 1/3] build: produce fat static archive on all platforms The static libghostty archive previously only bundled vendored dependencies on macOS (via libtool). On Windows and Linux the archive contained only the Zig-compiled code, leaving consumers to discover and link freetype, harfbuzz, glslang, spirv-cross, simdutf, oniguruma, and other vendored deps separately. Now all platforms produce a single fat archive: - macOS: libtool (unchanged) - Windows: zig ar qcL --format=coff (LLVM archiver with the L flag to flatten nested archives; MSVC's lib.exe cannot read Zig-produced GNU-format archives) - Linux: ar -M with MRI scripts (same as libghostty-vt) This makes the static library self-contained for consumers like .NET NativeAOT that link via the platform linker (MSVC link.exe) and need all symbols resolved from a single archive. --- src/build/GhosttyLib.zig | 80 +++++++++++++++++++++++++++++++--------- 1 file changed, 62 insertions(+), 18 deletions(-) diff --git a/src/build/GhosttyLib.zig b/src/build/GhosttyLib.zig index a6667e33407..7e496892818 100644 --- a/src/build/GhosttyLib.zig +++ b/src/build/GhosttyLib.zig @@ -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 = combineArchives(b, deps.config.target, 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, @@ -232,6 +221,61 @@ pub fn installHeader(self: *const GhosttyLib) void { b.getInstallStep().dependOn(&header_install.step); } +/// Combine multiple static archives into a single fat archive. +/// Uses libtool on Darwin, lib.exe on Windows, 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 } { + const os_tag = target.result.os.tag; + + if (os_tag.isDarwin()) { + const libtool = LibtoolStep.create(b, .{ + .name = "ghostty", + .out_name = "libghostty-fat.a", + .sources = @constCast(sources), + }); + return .{ .step = libtool.step, .output = libtool.output }; + } + + if (os_tag == .windows) { + // Zig's bundled LLVM archiver can flatten COFF archives with + // the L modifier. MSVC's lib.exe cannot read Zig-produced + // GNU-format archives, so we use zig ar instead. + const run = RunStep.create(b, "combine-archives ghostty"); + run.addArgs(&.{ b.graph.zig_exe, "ar", "qcL", "--format=coff" }); + const output = run.addOutputFileArg("ghostty-fat.lib"); + for (sources) |source| run.addFileArg(source); + return .{ .step = &run.step, .output = output }; + } + + // On Linux and other platforms, use an MRI script with ar -M to + // combine archives directly without extracting. + const run = RunStep.create(b, "combine-archives ghostty"); + run.addArgs(&.{ + "/bin/sh", "-c", + \\set -e + \\out="$1"; shift + \\script="CREATE $out" + \\for a in "$@"; do + \\ script="$script + \\ADDLIB $a" + \\done + \\script="$script + \\SAVE + \\END" + \\echo "$script" | ar -M + , + "_", + }); + const output = run.addOutputFileArg("libghostty-fat.a"); + for (sources) |source| run.addFileArg(source); + + return .{ .step = &run.step, .output = output }; +} + const PkgConfigFiles = struct { shared: std.Build.LazyPath, static: std.Build.LazyPath, From a10854654d47e43c5a8240cdbbe8cebac7195b07 Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Fri, 10 Apr 2026 08:46:23 +0200 Subject: [PATCH 2/3] build: disable ubsan in C deps for MSVC static linking Zig's ubsan runtime cannot be bundled on Windows (LNK4229), leaving __ubsan_handle_* symbols unresolved when the static archive is consumed by an external linker like MSVC link.exe. freetype, glslang, spirv-cross, and highway already suppress ubsan unconditionally. Add MSVC-conditional suppression to the seven C dependencies that were missing it: harfbuzz, libpng, dcimgui, wuffs, oniguruma, zlib, and stb. The fix is gated on abi == .msvc so ubsan coverage is preserved on Linux and macOS where bundle_ubsan_rt works. --- pkg/dcimgui/build.zig | 6 ++++++ pkg/harfbuzz/build.zig | 9 +++++++++ pkg/libpng/build.zig | 6 ++++++ pkg/oniguruma/build.zig | 6 ++++++ pkg/wuffs/build.zig | 4 ++++ pkg/zlib/build.zig | 6 ++++++ src/build/SharedDeps.zig | 11 ++++++++++- 7 files changed, 47 insertions(+), 1 deletion(-) diff --git a/pkg/dcimgui/build.zig b/pkg/dcimgui/build.zig index 01a5879d6ae..924e7c932ab 100644 --- a/pkg/dcimgui/build.zig +++ b/pkg/dcimgui/build.zig @@ -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", }); diff --git a/pkg/harfbuzz/build.zig b/pkg/harfbuzz/build.zig index 6d8f3be70ad..b482bc8a3cf 100644 --- a/pkg/harfbuzz/build.zig +++ b/pkg/harfbuzz/build.zig @@ -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", diff --git a/pkg/libpng/build.zig b/pkg/libpng/build.zig index dbedac632d8..8734b28f94f 100644 --- a/pkg/libpng/build.zig +++ b/pkg/libpng/build.zig @@ -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(""), diff --git a/pkg/oniguruma/build.zig b/pkg/oniguruma/build.zig index efc013b4344..d142e5eb19c 100644 --- a/pkg/oniguruma/build.zig +++ b/pkg/oniguruma/build.zig @@ -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, diff --git a/pkg/wuffs/build.zig b/pkg/wuffs/build.zig index 3d9f83daa04..95cef3e09d0 100644 --- a/pkg/wuffs/build.zig +++ b/pkg/wuffs/build.zig @@ -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); } diff --git a/pkg/zlib/build.zig b/pkg/zlib/build.zig index 6bde60ec790..64db13aa1da 100644 --- a/pkg/zlib/build.zig +++ b/pkg/zlib/build.zig @@ -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"); } diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 5ca5368d7bf..b68be92d011 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -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")); } From 08a2d9b224210208ab795835c1ad4187309e289a Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Mon, 13 Apr 2026 17:29:55 +0200 Subject: [PATCH 3/3] build: share combineArchives and fix internal archive names Extract CombineArchivesStep.zig so both GhosttyLib and GhosttyLibVt use the same archive-combining logic. Uses libtool on Darwin and the cross-platform combine_archives build tool elsewhere. Renames the internal library's fat archive outputs from ghostty to ghostty-internal, matching the pkg-config rename from PR 12214. --- src/build/CombineArchivesStep.zig | 47 ++++++++++++++++++++++++ src/build/GhosttyLib.zig | 59 ++----------------------------- src/build/GhosttyLibVt.zig | 38 ++------------------ 3 files changed, 51 insertions(+), 93 deletions(-) create mode 100644 src/build/CombineArchivesStep.zig diff --git a/src/build/CombineArchivesStep.zig b/src/build/CombineArchivesStep.zig new file mode 100644 index 00000000000..98e6b037c8a --- /dev/null +++ b/src/build/CombineArchivesStep.zig @@ -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 }; +} diff --git a/src/build/GhosttyLib.zig b/src/build/GhosttyLib.zig index 7e496892818..b762da8bbf8 100644 --- a/src/build/GhosttyLib.zig +++ b/src/build/GhosttyLib.zig @@ -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. @@ -54,7 +54,7 @@ pub fn initStatic( // Combine all archives into a single fat static library so // consumers only need to link one file. - const combined = combineArchives(b, deps.config.target, lib_list.items); + const combined = CombineArchivesStep.create(b, deps.config.target, "ghostty-internal", lib_list.items); combined.step.dependOn(&lib.step); return .{ @@ -221,61 +221,6 @@ pub fn installHeader(self: *const GhosttyLib) void { b.getInstallStep().dependOn(&header_install.step); } -/// Combine multiple static archives into a single fat archive. -/// Uses libtool on Darwin, lib.exe on Windows, 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 } { - const os_tag = target.result.os.tag; - - if (os_tag.isDarwin()) { - const libtool = LibtoolStep.create(b, .{ - .name = "ghostty", - .out_name = "libghostty-fat.a", - .sources = @constCast(sources), - }); - return .{ .step = libtool.step, .output = libtool.output }; - } - - if (os_tag == .windows) { - // Zig's bundled LLVM archiver can flatten COFF archives with - // the L modifier. MSVC's lib.exe cannot read Zig-produced - // GNU-format archives, so we use zig ar instead. - const run = RunStep.create(b, "combine-archives ghostty"); - run.addArgs(&.{ b.graph.zig_exe, "ar", "qcL", "--format=coff" }); - const output = run.addOutputFileArg("ghostty-fat.lib"); - for (sources) |source| run.addFileArg(source); - return .{ .step = &run.step, .output = output }; - } - - // On Linux and other platforms, use an MRI script with ar -M to - // combine archives directly without extracting. - const run = RunStep.create(b, "combine-archives ghostty"); - run.addArgs(&.{ - "/bin/sh", "-c", - \\set -e - \\out="$1"; shift - \\script="CREATE $out" - \\for a in "$@"; do - \\ script="$script - \\ADDLIB $a" - \\done - \\script="$script - \\SAVE - \\END" - \\echo "$script" | ar -M - , - "_", - }); - const output = run.addOutputFileArg("libghostty-fat.a"); - for (sources) |source| run.addFileArg(source); - - return .{ .step = &run.step, .output = output }; -} - const PkgConfigFiles = struct { shared: std.Build.LazyPath, static: std.Build.LazyPath, diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index c59f40de7fd..65f945bfd78 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -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.