feat(C2): unit-first JIT symbol resolution — program-owned dylibs beat process images

runJITFromObject now takes priority dylibs (the #import c unit's
linked objects first, then #library deps in declaration order) and
attaches a per-path search generator for each AHEAD of the
process-wide fallback, so a vendored symbol can never lose to a
same-named export of an image the host process happens to carry
(libz via LLVM, libsqlite3 via CoreServices). loadLibrary reports
the name dlopen succeeded on; the c-import handle records its dylib
path; temp link inputs are per-pid so concurrent runs can't clobber
each other. Flips the C0.3 shadowing pin to from_unit: true.
This commit is contained in:
agra
2026-06-12 16:56:35 +03:00
parent 2a2f43eada
commit 0bd8f3e5ce
5 changed files with 61 additions and 20 deletions

View File

@@ -1,9 +1,10 @@
// Pins the C2 gap (PLAN-C C0.3): a `#source` unit defining a symbol
// that ALSO lives in an OS image already loaded into the compiler
// process (libz, via libLLVM) loses under `sx run` — the JIT resolves
// globally and the earlier-loaded OS copy wins. The unit's
// zlibCompileFlags answers 0xDEADBEEF (3735928559); the OS one answers
// real flag bits. After C2 (unit-first resolution) this prints true.
// Unit-first JIT resolution (PLAN-C C2): a `#source` unit defining a
// symbol that ALSO lives in an OS image already loaded into the
// compiler process (libz, via libLLVM) still wins — the unit's dylib
// is a priority symbol-search target ahead of the process-wide
// fallback. The unit's zlibCompileFlags answers 0xDEADBEEF; the OS
// one answers real flag bits, so `true` proves the unit won.
// Regression (PLAN-C C0.3 xfail, flipped by C2.1).
#import "modules/std.sx";
zshadow :: #import c {

View File

@@ -1 +1 @@
from_unit: false
from_unit: true

View File

@@ -80,6 +80,9 @@ pub fn cSourceCacheKey(
/// Handle returned from loadCObjectsForJIT — caller must call unload() after JIT.
pub const CImportHandle = struct {
dylib_handle: ?*anyopaque = null,
/// Where the unit's linked dylib lives for THIS run; the JIT adds it
/// as a priority symbol-search target ahead of the process images.
dylib_path: ?[:0]const u8 = null,
temp_paths: []const []const u8 = &.{},
allocator: std.mem.Allocator,
@@ -415,10 +418,12 @@ pub fn loadCObjectsForJIT(
var temp_paths = std.ArrayList([]const u8).empty;
// Write each .o buffer to a temp file
// Write each .o buffer to a temp file (per-pid names: concurrent
// `sx run` processes must not clobber each other's link inputs)
const pid = std.c.getpid();
var obj_paths = std.ArrayList([]const u8).empty;
for (obj_bufs, 0..) |buf, i| {
const path = try std.fmt.allocPrint(allocator, "/tmp/sx_c_{d}.o", .{i});
const path = try std.fmt.allocPrint(allocator, "/tmp/sx_c_{d}_{d}.o", .{ pid, i });
const start = c.LLVMGetBufferStart(buf);
const size = c.LLVMGetBufferSize(buf);
const data = @as([*]const u8, @ptrCast(start))[0..size];
@@ -432,8 +437,8 @@ pub fn loadCObjectsForJIT(
}
// Link into a shared library
const dylib_path = "/tmp/sx_c_import.dylib";
try temp_paths.append(allocator, try allocator.dupe(u8, dylib_path));
const dylib_path = try std.fmt.allocPrintSentinel(allocator, "/tmp/sx_c_import_{d}.dylib", .{pid}, 0);
try temp_paths.append(allocator, dylib_path);
var argv = std.ArrayList([]const u8).empty;
try argv.append(allocator, "cc");
@@ -477,6 +482,7 @@ pub fn loadCObjectsForJIT(
return .{
.dylib_handle = handle,
.dylib_path = dylib_path,
.temp_paths = try temp_paths.toOwnedSlice(allocator),
.allocator = allocator,
};

View File

@@ -270,15 +270,21 @@ pub fn main(init: std.process.Init) !void {
defer c_handle.unload(io);
timer.record("c-import");
// dlopen #library dependencies so JIT can resolve foreign symbols
// dlopen #library dependencies so JIT can resolve foreign symbols.
// Program-owned dylibs (the #import c unit first, then #library
// deps in declaration order) also become PRIORITY search targets
// for the JIT, consulted before the process-wide fallback.
const libs = extractLibraries(allocator, root) catch std.process.exit(1);
var lib_handles = std.ArrayList(*anyopaque).empty;
var priority_dylibs = std.ArrayList([:0]const u8).empty;
if (c_handle.dylib_path) |cp| priority_dylibs.append(allocator, cp) catch {};
defer {
for (lib_handles.items) |h| _ = std.c.dlclose(h);
}
for (libs) |lib_name| {
if (loadLibrary(allocator, lib_name, target_config.lib_paths)) |handle| {
lib_handles.append(allocator, handle) catch {};
if (loadLibrary(allocator, lib_name, target_config.lib_paths)) |loaded| {
lib_handles.append(allocator, loaded.handle) catch {};
priority_dylibs.append(allocator, loaded.path) catch {};
} else {
const e = std.c.dlerror();
if (e) |msg| std.debug.print("warning: could not load library '{s}': {s}\n", .{ lib_name, std.mem.span(msg) });
@@ -304,7 +310,7 @@ pub fn main(init: std.process.Init) !void {
const marker = "--- build done ---\n";
_ = std.c.write(1, marker.ptr, marker.len);
}
const exit_code = sx.target.runJITFromObject(obj_buf) catch {
const exit_code = sx.target.runJITFromObject(obj_buf, priority_dylibs.items) catch {
// JIT failed — fall back to AOT
timer.record("jit-fail");
runAOT(allocator, io, path, target_config, &timer, enable_cache, stdlib_paths) catch std.process.exit(1);
@@ -924,8 +930,15 @@ fn extractFrameworks(allocator: std.mem.Allocator, root: *const sx.ast.Node) ![]
return try fws.toOwnedSlice(allocator);
}
const LoadedLibrary = struct {
handle: *anyopaque,
/// The name dlopen succeeded on (full path or bare name) — reused
/// verbatim as the JIT's priority search target for this library.
path: [:0]const u8,
};
/// Try to dlopen a library by name, searching user paths, host paths, and common naming conventions.
fn loadLibrary(allocator: std.mem.Allocator, lib_name: []const u8, user_lib_paths: []const []const u8) ?*anyopaque {
fn loadLibrary(allocator: std.mem.Allocator, lib_name: []const u8, user_lib_paths: []const []const u8) ?LoadedLibrary {
const is_macos = comptime @import("builtin").os.tag == .macos;
const suffixes: []const []const u8 = if (is_macos) &.{ ".dylib", ".so" } else &.{ ".so", ".dylib" };
@@ -944,7 +957,7 @@ fn loadLibrary(allocator: std.mem.Allocator, lib_name: []const u8, user_lib_path
for (paths) |dir| {
for (suffixes) |sfx| {
const full = std.fmt.allocPrintSentinel(allocator, "{s}/lib{s}{s}", .{ dir, lib_name, sfx }, 0) catch continue;
if (std.c.dlopen(full.ptr, .{ .NOW = true })) |h| return h;
if (std.c.dlopen(full.ptr, .{ .NOW = true })) |h| return .{ .handle = h, .path = full };
}
}
}
@@ -952,7 +965,7 @@ fn loadLibrary(allocator: std.mem.Allocator, lib_name: []const u8, user_lib_path
// Fallback: bare name (let dlopen search its default paths)
for (suffixes) |sfx| {
const bare = std.fmt.allocPrintSentinel(allocator, "lib{s}{s}", .{ lib_name, sfx }, 0) catch continue;
if (std.c.dlopen(bare.ptr, .{ .NOW = true })) |h| return h;
if (std.c.dlopen(bare.ptr, .{ .NOW = true })) |h| return .{ .handle = h, .path = bare };
}
return null;

View File

@@ -198,7 +198,12 @@ pub const TargetConfig = struct {
/// Execute a precompiled object file in-process using LLVM's ORC JIT.
/// Takes ownership of obj_buf. Returns the exit code from main().
pub fn runJITFromObject(obj_buf: c.LLVMMemoryBufferRef) !u8 {
/// `priority_dylibs` are consulted for symbols BEFORE the process-wide
/// search, in order: dylibs that belong to the program (the `#import c`
/// unit's linked objects, then `#library` deps in declaration order)
/// must win over a same-named export of an image the host process
/// happens to carry (libz via LLVM, libsqlite3 via CoreServices, ...).
pub fn runJITFromObject(obj_buf: c.LLVMMemoryBufferRef, priority_dylibs: []const [:0]const u8) !u8 {
// Create LLJIT with default builder (no custom TM needed — .o is precompiled)
var jit: c.LLVMOrcLLJITRef = null;
var err = c.LLVMOrcCreateLLJIT(&jit, null);
@@ -210,9 +215,25 @@ pub fn runJITFromObject(obj_buf: c.LLVMMemoryBufferRef) !u8 {
}
defer _ = c.LLVMOrcDisposeLLJIT(jit);
// Add process symbols so JIT can find libc (printf, etc.)
const jd = c.LLVMOrcLLJITGetMainJITDylib(jit);
const prefix = c.LLVMOrcLLJITGetGlobalPrefix(jit);
// Program-owned dylibs first (generators run in attachment order).
// A failed generator is skipped: resolution then degrades to the
// process-wide search below, exactly the pre-priority behavior.
for (priority_dylibs) |path| {
var pgen: c.LLVMOrcDefinitionGeneratorRef = null;
err = c.LLVMOrcCreateDynamicLibrarySearchGeneratorForPath(&pgen, path.ptr, prefix, null, null);
if (err != null) {
const msg = c.LLVMGetErrorMessage(err);
defer c.LLVMDisposeErrorMessage(msg);
std.debug.print("warning: JIT could not search '{s}': {s}\n", .{ path, std.mem.span(msg) });
continue;
}
c.LLVMOrcJITDylibAddGenerator(jd, pgen);
}
// Process-wide fallback so the JIT finds libc (printf, etc.)
var gen: c.LLVMOrcDefinitionGeneratorRef = null;
err = c.LLVMOrcCreateDynamicLibrarySearchGeneratorForProcess(&gen, prefix, null, null);
if (err != null) {