ffi 2.16c green: TL fallback via C-helper runtime + always-omit env in #jni_call

`#jni_call` collapses to a single surface — env is *always* implicit:
either picked up from the lexically-enclosing `#jni_env(env) { ... }`
block's Ref (cheap, register-resident, no TL touch) or from the
runtime's thread-local slot via `sx_jni_env_tl_get()` (one fn call
per dispatch). The explicit-env shape is gone — chess and the
existing tests migrate cleanly by wrapping their helper-fn bodies
in `#jni_env(env) { ... }`.

The TL slot lives outside the user's IR module so the LLVM ORC JIT
can load object files cleanly without `orc_rt` for TLS support:

  library/vendors/sx_jni_runtime/sx_jni_env_tl.c:
    static _Thread_local void *sx_jni_env_tl_slot;
    void *sx_jni_env_tl_get(void) { return sx_jni_env_tl_slot; }
    void sx_jni_env_tl_set(void *env) { sx_jni_env_tl_slot = env; }

Linkage:
- sx-the-compiler links the .c file via build.zig so the JIT
  process-symbol generator resolves `sx_jni_env_tl_get`/`_set`.
- AOT targets get the same .c file auto-linked via the lowering
  pass: when lower touches the TL externs, it sets
  `needs_jni_env_tl_runtime`, and `Compilation.lowerToIR` appends a
  synthetic `CImportInfo` to `lowering_extra_c_sources` that
  `collectCImportSources` merges with user-written ones.

Lowering-side changes:
- `getJniEnvTlFids` lazily declares the two externs (parallel
  to `getSelRegisterNameFid`) and flips `needs_jni_env_tl_runtime`.
- `#jni_env(env) { body }` emits save→set→body→restore via three
  `call` ops to the externs; the inner body sees env via the
  lexical-direct stack.
- `lowerJniCall` resolves env from `jni_env_stack` (top) or the TL
  fallback. The explicit-env branch is gone.
- `jni_env_stack_base` tracks per-fn lexical scope so lazy-lowering
  a callee doesn't accidentally see the caller's Ref (Refs are only
  valid inside one fn's instruction stream).

Test migration (mechanical):
- ffi-jni-call-{01..09}: each helper fn wraps `#jni_call(...)`
  bodies in `#jni_env(env) { ... }`. Returning values pass through
  the block as an expression — `#jni_env` now also lowers in
  expression position.

Verified:
- zig build test + tests/run_examples.sh: 130/130 green.
- tests/cross_compile.sh: 3/3 green.
- Chess APK rebuilt + reinstalled on Pixel. Board renders with
  status-bar clearance + info panel intact; no crashes in logcat.
  Safe-insets dispatch through `#jni_env` + lexical-direct now
  fully exercised end-to-end on real hardware.
This commit is contained in:
agra
2026-05-20 13:53:25 +03:00
parent 013cf9f1bb
commit 6a3260ff65
26 changed files with 330 additions and 96 deletions

View File

@@ -28,6 +28,10 @@ pub const Compilation = struct {
import_graph: std.StringHashMap(std.StringHashMap(void)),
sema_result: ?sema.SemaResult = null,
ir_emitter: ?ir.LLVMEmitter = null,
/// C sources requested by the lowering pass (not in the user's AST).
/// E.g. the JNI env TL runtime when `#jni_env` is used. Merged with
/// AST sources in `collectCImportSources`.
lowering_extra_c_sources: std.ArrayList(c_import.CImportInfo) = .empty,
pub fn init(allocator: std.mem.Allocator, io: std.Io, file_path: []const u8, source: [:0]const u8, target_config: TargetConfig, stdlib_paths: []const []const u8) Compilation {
return .{
@@ -145,10 +149,36 @@ pub const Compilation = struct {
return null;
}
/// Collect C import source info from the resolved AST.
/// Collect C import source info — both from user-written `#import c { ... }`
/// blocks in the AST AND from lowering-time auto-injections (currently:
/// the JNI env TL runtime when `#jni_env` / `#jni_call`-with-omitted-env
/// is used). The lower-side auto-injections live in
/// `lowering_extra_c_sources` and are populated by `lowerToIR` based on
/// `Lowering.needs_jni_env_tl_runtime` etc.
pub fn collectCImportSources(self: *Compilation) ![]c_import.CImportInfo {
const root = self.resolved_root orelse self.root orelse return &.{};
return c_import.collectCImportSources(self.allocator, root);
const ast_sources = try c_import.collectCImportSources(self.allocator, root);
if (self.lowering_extra_c_sources.items.len == 0) return ast_sources;
var merged = std.ArrayList(c_import.CImportInfo).empty;
try merged.appendSlice(self.allocator, ast_sources);
try merged.appendSlice(self.allocator, self.lowering_extra_c_sources.items);
return merged.toOwnedSlice(self.allocator);
}
/// Resolve a stdlib-relative path through the configured `stdlib_paths`.
/// Returns the first candidate whose absolute path resolves to an
/// existing file. Used by lower-side auto-injected C sources.
fn resolveStdlibPath(self: *Compilation, rel: []const u8) !?[]const u8 {
for (self.stdlib_paths) |root_path| {
const candidate = try std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ root_path, rel });
if (std.Io.Dir.readFileAlloc(.cwd(), self.io, candidate, self.allocator, .limited(1024 * 1024))) |buf| {
self.allocator.free(buf);
return candidate;
} else |_| {
self.allocator.free(candidate);
}
}
return null;
}
/// Lower the parsed AST to the sx IR module (shadow pipeline).
@@ -168,6 +198,23 @@ pub const Compilation = struct {
lowering.import_graph = &self.import_graph;
lowering.lowerRoot(root);
if (self.diagnostics.hasErrors()) return error.CompileError;
// Auto-link the JNI env TL runtime when lowering used it. The .c file
// ships with the sx library; we resolve it through stdlib_paths so
// consumers don't need to vendor a copy.
if (lowering.needs_jni_env_tl_runtime) {
if (try self.resolveStdlibPath("vendors/sx_jni_runtime/sx_jni_env_tl.c")) |abs_path| {
var sources = std.ArrayList([]const u8).empty;
try sources.append(self.allocator, abs_path);
try self.lowering_extra_c_sources.append(self.allocator, .{
.sources = try sources.toOwnedSlice(self.allocator),
.includes = &.{},
.defines = &.{},
.flags = &.{},
});
}
}
return module;
}

View File

@@ -534,6 +534,10 @@ pub const LLVMEmitter = struct {
c.LLVMSetLinkage(llvm_global, c.LLVMInternalLinkage);
if (global.is_thread_local) {
c.LLVMSetThreadLocal(llvm_global, 1);
}
// Evaluate comptime initializer if present
if (global.comptime_func) |func_id| {
var interp_inst = Interpreter.init(self.ir_mod, self.alloc);

View File

@@ -503,6 +503,10 @@ pub const Global = struct {
init_val: ?ConstantValue = null,
is_extern: bool = false,
is_const: bool = false,
/// Thread-local storage. `global_get` / `global_set` emit normal LLVM
/// load/store instructions; LLVM handles the per-thread access through
/// the `thread_local` attribute on the global.
is_thread_local: bool = false,
/// For comptime globals: the function to interpret to get the init value.
comptime_func: ?FuncId = null,
};

View File

@@ -98,6 +98,10 @@ pub const Lowering = struct {
current_source_file: ?[]const u8 = null, // source file of function currently being lowered
sel_register_name_fid: ?FuncId = null, // lazily-declared `sel_registerName` extern (non-literal selector fallback)
jni_env_stack: std.ArrayList(Ref) = std.ArrayList(Ref).empty, // lexical `#jni_env(env)` Ref stack — top is current scope's env for omitted-env `#jni_call`
jni_env_stack_base: usize = 0, // index above which the currently-lowering fn's `#jni_env` scopes live; outer-fn Refs aren't valid in this fn's instruction stream
jni_env_tl_get_fid: ?FuncId = null, // extern `sx_jni_env_tl_get` (from library/vendors/sx_jni_runtime/sx_jni_env_tl.c)
jni_env_tl_set_fid: ?FuncId = null, // extern `sx_jni_env_tl_set`
needs_jni_env_tl_runtime: bool = false, // set when lowering touches the JNI env TL; signals Compilation to auto-link the runtime .c
foreign_class_map: std.StringHashMap(*const ast.ForeignClassDecl) = std.StringHashMap(*const ast.ForeignClassDecl).init(std.heap.page_allocator), // sx alias → ForeignClassDecl (jni_class / objc_class / swift_class / ... — registered in scan pass)
type_bindings: ?std.StringHashMap(TypeId) = null, // generic type param bindings ($T → concrete TypeId)
current_match_tags: ?[]const u64 = null, // type tags for current match arm (for runtime dispatch)
@@ -736,6 +740,14 @@ pub const Lowering = struct {
const saved_block_terminated = self.block_terminated;
const saved_force_block_value = self.force_block_value;
const saved_source_file = self.current_source_file;
// The `#jni_env` Ref stack is lexical within ONE function's instruction
// stream — Refs from the caller don't dereference correctly in this
// callee's body. Move the visible base to the current top so
// omitted-env `#jni_call` in this fn doesn't accidentally pick up the
// caller's Refs. Defer covers all the early-return paths below.
const saved_jni_env_base = self.jni_env_stack_base;
self.jni_env_stack_base = self.jni_env_stack.items.len;
defer self.jni_env_stack_base = saved_jni_env_base;
self.func_defer_base = self.defer_stack.items.len;
self.block_terminated = false;
self.force_block_value = false;
@@ -1062,15 +1074,27 @@ pub const Lowering = struct {
.insert_expr => |ins| self.lowerInsertExpr(ins.expr),
.block => self.lowerBlock(node),
.jni_env_block => |eb| {
// Lexical-direct env resolution (2.16b): evaluate env once,
// push onto the env stack, lower body, pop. `#jni_call`
// sites inside `eb.body` with an omitted env arg pick up
// the top-of-stack value directly — no thread-local read,
// env stays register-resident across the body.
// Compile-time stack push for lexical-direct env resolution
// (2.16b — `#jni_call` in the same fn picks up env from
// jni_env_stack directly, no TL read).
//
// Runtime TL save/set/restore (2.16c) for cross-function
// helpers: callees in OTHER fns invoked from inside the
// body read the slot via `sx_jni_env_tl_get`. Storage
// lives in a separately-linked C helper (see
// library/vendors/sx_jni_runtime/sx_jni_env_tl.c) so the
// JIT doesn't need orc_rt for TLS.
const env_ref = self.lowerExpr(eb.env);
const fids = self.getJniEnvTlFids();
const ptr_ty = self.module.types.ptrTo(.void);
const saved_tl = self.builder.emit(.{ .call = .{ .callee = fids.get, .args = &.{} } }, ptr_ty);
const set_args = self.alloc.dupe(Ref, &.{env_ref}) catch unreachable;
_ = self.builder.emit(.{ .call = .{ .callee = fids.set, .args = set_args } }, .void);
self.jni_env_stack.append(self.alloc, env_ref) catch unreachable;
defer _ = self.jni_env_stack.pop();
self.lowerBlock(eb.body);
_ = self.jni_env_stack.pop();
const restore_args = self.alloc.dupe(Ref, &.{saved_tl}) catch unreachable;
_ = self.builder.emit(.{ .call = .{ .callee = fids.set, .args = restore_args } }, .void);
},
// Block-local type declarations
.struct_decl => |sd| self.registerStructDecl(&sd),
@@ -1795,6 +1819,23 @@ pub const Lowering = struct {
.spread_expr => self.emitError("spread_expr", node.span),
.chained_comparison => |cc| self.lowerChainedComparison(&cc),
// `#jni_env(env) { body }` in expression position — the block's
// value becomes the env-scope's value. Save→set→body-value→restore.
.jni_env_block => |eb| blk: {
const env_ref = self.lowerExpr(eb.env);
const fids = self.getJniEnvTlFids();
const ptr_ty = self.module.types.ptrTo(.void);
const saved_tl = self.builder.emit(.{ .call = .{ .callee = fids.get, .args = &.{} } }, ptr_ty);
const set_args = self.alloc.dupe(Ref, &.{env_ref}) catch unreachable;
_ = self.builder.emit(.{ .call = .{ .callee = fids.set, .args = set_args } }, .void);
self.jni_env_stack.append(self.alloc, env_ref) catch unreachable;
const value = self.lowerBlockValue(eb.body) orelse self.builder.constInt(0, .void);
_ = self.jni_env_stack.pop();
const restore_args = self.alloc.dupe(Ref, &.{saved_tl}) catch unreachable;
_ = self.builder.emit(.{ .call = .{ .callee = fids.set, .args = restore_args } }, .void);
break :blk value;
},
// Statements that can appear in expression position
.block => |blk| blk: {
// Create a child scope for block-level variable shadowing
@@ -3875,35 +3916,31 @@ pub const Lowering = struct {
}
fn lowerJniCall(self: *Lowering, fic: *const ast.FfiIntrinsicCall) Ref {
// env disambiguation: the method-name slot is always a string literal,
// so its position tells us whether env was omitted.
// omitted → args = target, "name", "sig", method-args... (≥3)
// explicit → args = env, target, "name", "sig", method-args... (≥4)
const env_omitted = fic.args.len >= 3 and fic.args[1].data == .string_literal;
const min_arity: usize = if (env_omitted) 3 else 4;
if (fic.args.len < min_arity) {
// env is always implicit: lexical-direct from the enclosing `#jni_env(env)`
// block (2.16b, cheap), else the thread-local slot the block populated
// at runtime (2.16c, one TL load per call). Surface form is uniform:
// #jni_call(T)(target, "name", "sig", method-args...) (≥3 args)
if (fic.args.len < 3) {
if (self.diagnostics) |d| {
d.add(.err, "#jni_call requires env (optional in #jni_env scope), target, method name, and signature", null);
d.add(.err, "#jni_call requires target, method name, and signature", null);
}
return Ref.none;
}
const ret_ty = self.resolveType(fic.return_type);
const env_ref = if (env_omitted) blk: {
if (self.jni_env_stack.items.len == 0) {
if (self.diagnostics) |d| {
d.add(.err, "#jni_call with omitted env requires an enclosing #jni_env scope", null);
}
return Ref.none;
}
break :blk self.jni_env_stack.items[self.jni_env_stack.items.len - 1];
} else self.lowerExpr(fic.args[0]);
const env_ref = if (self.jni_env_stack.items.len > self.jni_env_stack_base)
self.jni_env_stack.items[self.jni_env_stack.items.len - 1]
else blk: {
const fids = self.getJniEnvTlFids();
const ptr_ty = self.module.types.ptrTo(.void);
break :blk self.builder.emit(.{ .call = .{ .callee = fids.get, .args = &.{} } }, ptr_ty);
};
const target_idx: usize = if (env_omitted) 0 else 1;
const name_idx: usize = target_idx + 1;
const sig_idx: usize = target_idx + 2;
const first_method_arg_idx: usize = target_idx + 3;
const target_idx: usize = 0;
const name_idx: usize = 1;
const sig_idx: usize = 2;
const first_method_arg_idx: usize = 3;
const target_ref = self.lowerExpr(fic.args[target_idx]);
const name_node = fic.args[name_idx];
@@ -8130,6 +8167,37 @@ pub const Lowering = struct {
self.foreign_class_map.put(fcd.name, fcd) catch {};
}
/// Lazily declare the `sx_jni_env_tl_get` / `sx_jni_env_tl_set`
/// runtime externs (step 2.16c). The storage lives in
/// `library/vendors/sx_jni_runtime/sx_jni_env_tl.c` as a
/// `_Thread_local` slot — keeping it OUT of the user's IR module
/// is what lets the LLVM ORC JIT load the module cleanly without
/// orc_rt platform support. AOT targets get the same .c file
/// linked in via `needs_jni_env_tl_runtime`, which Compilation
/// reads to append a synthetic c_import alongside the user's.
fn getJniEnvTlFids(self: *Lowering) struct { get: FuncId, set: FuncId } {
self.needs_jni_env_tl_runtime = true;
const ptr_ty = self.module.types.ptrTo(.void);
if (self.jni_env_tl_get_fid == null) {
const name = self.module.types.internString("sx_jni_env_tl_get");
const fid = self.builder.declareExtern(name, &.{}, ptr_ty);
const func = self.module.getFunctionMut(fid);
func.call_conv = .c;
self.jni_env_tl_get_fid = fid;
}
if (self.jni_env_tl_set_fid == null) {
const name = self.module.types.internString("sx_jni_env_tl_set");
const env_param = self.module.types.internString("env");
var params = std.ArrayList(inst_mod.Function.Param).empty;
params.append(self.alloc, .{ .name = env_param, .ty = ptr_ty }) catch unreachable;
const fid = self.builder.declareExtern(name, params.toOwnedSlice(self.alloc) catch unreachable, .void);
const func = self.module.getFunctionMut(fid);
func.call_conv = .c;
self.jni_env_tl_set_fid = fid;
}
return .{ .get = self.jni_env_tl_get_fid.?, .set = self.jni_env_tl_set_fid.? };
}
/// When a namespaced import (`Ns :: #import "..."`) contains foreign-class
/// declarations, ALSO register them under their qualified name `Ns.Class`
/// so receiver types like `*Ns.Class` can find the fcd. The recursive