feat(dist): bundled-zig link backend for hermetic macOS/Linux/Windows builds

Drive a bundled `zig` as `zig cc` for the AOT link step, supplying lld + CRT
+ libc (musl/glibc/mingw) so `sx build` produces native binaries with no host
toolchain. Default Linux output is static musl (portable-anywhere).

- src/zig_backend.zig: discover zig ($SX_ZIG / bundled-next-to-exe / PATH);
  bundled-vs-PATH provenance gates auto-activation.
- src/target.zig: selectZigLinker + emitZigLinkArgv + zigTargetTriple, dispatched
  before the per-OS branches; macOS/Linux/Windows in scope.
- src/ir/emit_llvm.zig: LLVMNormalizeTargetTriple so vendor-less zig triples
  (e.g. x86_64-windows-gnu) parse to the correct OS/object format (COFF not ELF).
- src/main.zig: --self-contained / --no-self-contained; linux-musl, linux-musl-arm,
  windows-gnu shorthands; de-vendor linux/linux-arm to match the corpus runner.
- examples/1660: Windows Win32 print-42 + exit(0) via kernel32 (ir-only off-Windows).

Auto-activates only for a bundled zig; a PATH-only zig engages under
--self-contained, so native dev/CI builds are never silently rerouted.

Docs: readme Cross-Compilation, design/bundled-zig-link-backend-design.md, current/PLAN-DIST.md.
This commit is contained in:
agra
2026-06-16 15:56:06 +03:00
parent 0e0ee40528
commit b6a7378af4
13 changed files with 845 additions and 10 deletions

View File

@@ -255,10 +255,16 @@ pub const LLVMEmitter = struct {
const llvm_module = c.LLVMModuleCreateWithNameInContext(module_name, ctx);
const builder = c.LLVMCreateBuilderInContext(ctx);
// Set target triple
const triple_owned = target_config.triple == null;
const triple = target_config.triple orelse c.LLVMGetDefaultTargetTriple();
defer if (triple_owned) c.LLVMDisposeMessage(@constCast(triple));
// Set target triple. Normalize first: zig-scheme, vendor-less triples
// (e.g. "x86_64-windows-gnu") would otherwise have "windows" land in
// LLVM's vendor slot under its positional parser, leaving OS=unknown
// and the object format silently falling back to ELF. Normalization is
// LLVM's own reordering — not a hand-maintained translation table.
const raw_owned = target_config.triple == null;
const raw_triple = target_config.triple orelse c.LLVMGetDefaultTargetTriple();
defer if (raw_owned) c.LLVMDisposeMessage(@constCast(raw_triple));
const triple = c.LLVMNormalizeTargetTriple(raw_triple);
defer c.LLVMDisposeMessage(triple);
c.LLVMSetTarget(llvm_module, triple);

View File

@@ -50,11 +50,17 @@ pub fn main(init: std.process.Init) !void {
else if (std.mem.eql(u8, raw, "macos-x86"))
try macosTripleForArch(allocator, "x86_64")
else if (std.mem.eql(u8, raw, "linux") or std.mem.eql(u8, raw, "linux-x86"))
"x86_64-unknown-linux-gnu"
"x86_64-linux-gnu"
else if (std.mem.eql(u8, raw, "linux-arm"))
"aarch64-unknown-linux-gnu"
"aarch64-linux-gnu"
else if (std.mem.eql(u8, raw, "linux-musl"))
"x86_64-linux-musl"
else if (std.mem.eql(u8, raw, "linux-musl-arm"))
"aarch64-linux-musl"
else if (std.mem.eql(u8, raw, "windows"))
"x86_64-windows-msvc"
else if (std.mem.eql(u8, raw, "windows-gnu"))
"x86_64-windows-gnu"
else if (std.mem.eql(u8, raw, "ios") or std.mem.eql(u8, raw, "ios-arm"))
"arm64-apple-ios14.0"
else if (std.mem.eql(u8, raw, "ios-sim") or std.mem.eql(u8, raw, "ios-sim-arm"))
@@ -88,6 +94,10 @@ pub fn main(init: std.process.Init) !void {
i += 1;
if (i >= args.len) { std.debug.print("error: --linker requires a value\n", .{}); return; }
target_config.linker = args[i];
} else if (std.mem.eql(u8, arg, "--self-contained")) {
target_config.self_contained = .on;
} else if (std.mem.eql(u8, arg, "--no-self-contained")) {
target_config.self_contained = .off;
} else if (std.mem.eql(u8, arg, "--sysroot")) {
i += 1;
if (i >= args.len) { std.debug.print("error: --sysroot requires a value\n", .{}); return; }

View File

@@ -1,6 +1,8 @@
const std = @import("std");
const builtin = @import("builtin");
const llvm = @import("llvm_api.zig");
const c = llvm.c;
const zig_backend = @import("zig_backend.zig");
/// One `#jni_main #jni_class("...")` declaration's Java-source emission.
/// Populated by lowering and surfaced to the sx Android bundler in
@@ -78,6 +80,13 @@ pub const TargetConfig = struct {
/// The object is kept at `.sx-tmp/main.o` (its link-time path, so the
/// debug map resolves when lldb is run from the project root).
emit_obj: bool = false,
/// Self-contained link backend (bundled `zig cc`). `.auto` uses it when a
/// `zig` is discoverable, the target is Linux, and no explicit `--linker`
/// was given; `.on` forces it (error if no zig / non-Linux target); `.off`
/// uses the system `cc`. See design/bundled-zig-link-backend-design.md.
self_contained: SelfContained = .auto,
pub const SelfContained = enum { auto, on, off };
pub const OptLevel = enum {
none,
@@ -194,8 +203,119 @@ pub const TargetConfig = struct {
pub fn getLinker(self: TargetConfig) []const u8 {
return self.linker orelse "cc";
}
/// True when this target is in scope for the bundled-`zig` backend:
/// the three desktop OSes (macOS, Linux, Windows). iOS/Android/wasm keep
/// their specialized toolchains.
pub fn zigBackendInScope(self: TargetConfig) bool {
return self.isMacOS() or self.isLinux() or self.isWindows();
}
/// The zig `-target` for the bundled-zig link backend. sx triples already
/// use zig's scheme, so this is pure pass-through; only the null
/// (host-default) case synthesizes a portable triple from the host arch +
/// host OS (musl on Linux for static portability, mingw on Windows).
/// Caller owns the returned slice.
pub fn zigTargetTriple(self: TargetConfig, allocator: std.mem.Allocator) ![]const u8 {
if (self.triple) |t| return allocator.dupe(u8, std.mem.span(t));
const arch: []const u8 = if (self.isAarch64()) "aarch64" else "x86_64";
const os_abi: []const u8 = switch (builtin.os.tag) {
.linux => "linux-musl",
.macos => "macos-none",
.windows => "windows-gnu",
else => "linux-musl",
};
return std.fmt.allocPrint(allocator, "{s}-{s}", .{ arch, os_abi });
}
};
/// Decide whether the link step drives the bundled-`zig` backend
/// (`zig cc -target …`) or the system linker. Returns the zig path to use,
/// or null for the system linker. Errors loudly when a self-contained link is
/// requested but cannot be satisfied — never silently falls back in that case.
///
/// Auto mode engages ONLY for a *bundled* zig (a real distribution): a
/// PATH-only zig is a dev convenience and never hijacks a native build, so the
/// dev/CI corpus keeps using the system toolchain. `--self-contained` forces
/// the backend with either bundled or PATH zig.
/// See design/bundled-zig-link-backend-design.md §5.5.
fn selectZigLinker(allocator: std.mem.Allocator, tc: TargetConfig) !?[]const u8 {
switch (tc.self_contained) {
.off => return null,
.on => {
if (!tc.zigBackendInScope()) {
std.debug.print("error: --self-contained supports macOS/Linux/Windows targets only\n", .{});
return error.LinkError;
}
const found = zig_backend.discoverZig(allocator) orelse {
std.debug.print("error: --self-contained requested but no usable `zig` was found (set $SX_ZIG or put zig on PATH)\n", .{});
return error.LinkError;
};
return found.path;
},
.auto => {
if (!tc.zigBackendInScope()) return null;
if (tc.linker != null) return null; // explicit --linker wins
const found = zig_backend.discoverZig(allocator) orelse return null;
if (!found.bundled) return null; // PATH zig does not auto-engage
return found.path;
},
}
}
/// Build the `zig cc` link argv (shared across macOS/Linux/Windows). zig cc is
/// a clang-compatible driver, so `-o`/`-L`/`-l`/`-framework`/extra objects all
/// pass through. `-static` is added only for musl (the portable Linux path);
/// macOS cannot static-link libSystem and Windows uses dynamic mingw.
fn emitZigLinkArgv(
argv: *std.ArrayList([]const u8),
allocator: std.mem.Allocator,
zig_path: []const u8,
output_obj: []const u8,
extra_objects: []const []const u8,
output_bin: []const u8,
libraries: []const []const u8,
frameworks: []const []const u8,
tc: TargetConfig,
) !void {
try argv.appendSlice(allocator, &.{ zig_path, "cc" });
if (tc.isMacOS()) {
// The object stays Mach-O (emitted from Apple's `apple-darwin` triple,
// which LLVM needs), but zig's -target parser rejects that scheme — so
// hand it zig's vendor-less `<arch>-macos`. No -static (libSystem can't
// be statically linked). Cross-to-macOS needs an SDK (out of scope).
const arch: []const u8 = if (tc.isAarch64()) "aarch64" else "x86_64";
try argv.appendSlice(allocator, &.{ "-target", try std.fmt.allocPrint(allocator, "{s}-macos", .{arch}) });
} else {
const ztriple = try tc.zigTargetTriple(allocator);
try argv.appendSlice(allocator, &.{ "-target", ztriple });
if (std.mem.indexOf(u8, ztriple, "musl") != null) try argv.append(allocator, "-static");
}
try argv.appendSlice(allocator, &.{ output_obj, "-o", output_bin });
for (extra_objects) |eo| try argv.append(allocator, eo);
if (tc.sysroot) |sr| {
try argv.append(allocator, try std.fmt.allocPrint(allocator, "--sysroot={s}", .{sr}));
}
for (tc.lib_paths) |lp| {
try argv.append(allocator, try std.fmt.allocPrint(allocator, "-L{s}", .{lp}));
}
for (libraries) |lib| {
try argv.append(allocator, try std.fmt.allocPrint(allocator, "-l{s}", .{lib}));
}
// Frameworks are Apple-only; ignored on Linux/Windows.
if (tc.isMacOS()) {
for (frameworks) |fw| {
try argv.append(allocator, "-framework");
try argv.append(allocator, fw);
}
}
for (tc.extra_link_flags) |flag| {
var it = std.mem.tokenizeScalar(u8, flag, ' ');
while (it.next()) |part| try argv.append(allocator, part);
}
}
/// Execute a precompiled object file in-process using LLVM's ORC JIT.
/// Takes ownership of obj_buf. Returns the exit code from main().
/// `priority_dylibs` are consulted for symbols BEFORE the process-wide
@@ -345,7 +465,11 @@ pub fn discoverAppleSdk(allocator: std.mem.Allocator, io: std.Io, sdk_name: []co
pub fn link(allocator: std.mem.Allocator, io: std.Io, output_obj: []const u8, extra_objects: []const []const u8, output_bin: []const u8, libraries: []const []const u8, frameworks: []const []const u8, target_config: TargetConfig, has_jni_main: bool) !void {
var argv = std.ArrayList([]const u8).empty;
if (target_config.isIOS()) {
if (try selectZigLinker(allocator, target_config)) |zig_path| {
// Bundled-zig backend: macOS/Linux/Windows linked uniformly via
// `zig cc`, which supplies lld + CRT + libc with no host toolchain.
try emitZigLinkArgv(&argv, allocator, zig_path, output_obj, extra_objects, output_bin, libraries, frameworks, target_config);
} else if (target_config.isIOS()) {
// iOS: clang driver with -isysroot pointing at the iOS SDK.
// -l libraries are generally wrong for iOS (Apple ships system code
// as frameworks); user-declared #library still pass through.
@@ -652,7 +776,6 @@ pub fn postProcessWasmHtml(allocator: std.mem.Allocator, io: std.Io, html_path:
/// Common library paths for the host OS, computed at comptime.
pub const host_lib_paths = blk: {
const builtin = @import("builtin");
var paths: []const []const u8 = &.{};
if (builtin.os.tag == .macos) {
if (builtin.cpu.arch == .aarch64) {

107
src/zig_backend.zig Normal file
View File

@@ -0,0 +1,107 @@
//! Discovery for the bundled-`zig` link backend.
//!
//! When `sx build` links a native binary, it can drive a bundled `zig` as
//! `zig cc` instead of the host's system `cc`. `zig cc` brings its own lld,
//! CRT objects, and libc (musl/glibc/mingw) for the target — making a
//! distributed sx able to finish a build with no host toolchain installed.
//!
//! This module only *locates* a usable `zig`. The decision of whether to use
//! it, and the construction of the `zig cc -target … -static` argv, live in
//! `target.zig` (which has the TargetConfig it needs). Design-of-record:
//! `design/bundled-zig-link-backend-design.md`.
const std = @import("std");
const builtin = @import("builtin");
extern "c" fn _NSGetExecutablePath(buf: [*]u8, len: *u32) c_int;
extern "c" fn access(path: [*:0]const u8, mode: c_int) c_int;
/// Trace discovery when `SX_DEBUG_ZIG` is set (mirrors `SX_DEBUG_STDLIB`).
fn dbg(comptime fmt: []const u8, args: anytype) void {
if (std.c.getenv("SX_DEBUG_ZIG") != null) std.debug.print("[sx] " ++ fmt, args);
}
fn fileExists(path: []const u8) bool {
var buf: [4096]u8 = undefined;
if (path.len >= buf.len) return false;
@memcpy(buf[0..path.len], path);
buf[path.len] = 0;
return access(@ptrCast(&buf), 0) == 0; // 0 == F_OK
}
/// Path of the running `sx` binary. Mirrors imports.zig's resolver (no Io
/// dependency): `_NSGetExecutablePath` on Darwin, `/proc/self/exe` on Linux.
fn selfExePath(buf: []u8) ![]const u8 {
switch (builtin.os.tag) {
.macos, .ios => {
var len: u32 = @intCast(buf.len);
if (_NSGetExecutablePath(buf.ptr, &len) != 0) return error.PathBufferTooSmall;
return std.mem.sliceTo(buf[0..buf.len], 0);
},
.linux => return std.posix.readlink("/proc/self/exe", buf),
else => return error.UnsupportedHostOS,
}
}
/// A discovered `zig`. `bundled` distinguishes a distribution-bundled (or
/// deliberately-pinned) zig — which auto-activates the backend — from a
/// PATH-resolved one, which is a dev convenience and only used when forced
/// via `--self-contained`.
pub const Found = struct {
path: []const u8,
bundled: bool,
};
/// Resolution order (first hit wins):
/// 1. $SX_ZIG — explicit override (bundled=true)
/// 2. <exe_dir>/../libexec/zig/zig — install layout (bundled=true)
/// 3. <exe_dir>/../../zig-bundle/zig — dev vendored layout (bundled=true)
/// 4. `zig` on $PATH — dev fallback (bundled=false)
/// Returns an allocator-owned path + provenance, or null if none resolve.
pub fn discoverZig(allocator: std.mem.Allocator) ?Found {
// 1. Explicit override — a deliberate pin, treated as bundled.
if (std.c.getenv("SX_ZIG")) |env| {
const p = std.mem.span(env);
if (fileExists(p)) {
dbg("zig: SX_ZIG={s}\n", .{p});
return .{ .path = allocator.dupe(u8, p) catch return null, .bundled = true };
}
dbg("zig: SX_ZIG={s} (not found, ignoring)\n", .{p});
}
// 2 & 3. Exe-relative candidates — a real distribution.
var buf: [4096]u8 = undefined;
if (selfExePath(&buf)) |exe| {
const exe_dir = std.fs.path.dirname(exe) orelse exe;
const rels = [_][]const u8{ "../libexec/zig/zig", "../../zig-bundle/zig" };
for (rels) |rel| {
const cand = std.fs.path.join(allocator, &.{ exe_dir, rel }) catch continue;
if (fileExists(cand)) {
dbg("zig: bundled={s}\n", .{cand});
return .{ .path = cand, .bundled = true };
}
dbg("zig: tried {s} (absent)\n", .{cand});
allocator.free(cand);
}
} else |_| {}
// 4. $PATH fallback — dev convenience; does not auto-engage.
if (findOnPath(allocator, "zig")) |p| {
dbg("zig: PATH={s}\n", .{p});
return .{ .path = p, .bundled = false };
}
dbg("zig: none found — falling back to system cc\n", .{});
return null;
}
fn findOnPath(allocator: std.mem.Allocator, name: []const u8) ?[]const u8 {
const path_env = std.c.getenv("PATH") orelse return null;
var it = std.mem.tokenizeScalar(u8, std.mem.span(path_env), ':');
while (it.next()) |dir| {
const cand = std.fs.path.join(allocator, &.{ dir, name }) catch continue;
if (fileExists(cand)) return cand;
allocator.free(cand);
}
return null;
}