ffi M5.A revert: drop compiler synthesis, require explicit Into(Block) impls

Reconsidered the M5.A.2 cleanup. The compiler-synthesised trampoline
path was hidden behaviour — a user reading their code couldn't tell
how `xx my_closure : Block` worked without reading lower.zig. That's
exactly the kind of magic sx's design has been pushing against.

New design (strict mode):

1. Stdlib's modules/std/objc_block.sx hand-rolls
   `__block_invoke_void` + `Into(Block) for Closure() -> void` and
   the same pair for `Closure(bool) -> void` (restored from M5.A.2).
   These are readable reference implementations of the bridge ABI.

2. The compiler intercept fires NO synthesis — instead, when
   `tryUserConversion` can't find a reachable `Into(Block)` impl for
   the closure's signature, it emits a focused diagnostic:
     "no `Into(Block) for <Closure-sig>` impl — add a per-signature
      `__block_invoke_<sig>` trampoline + Into impl alongside the
      existing ones in modules/std/objc_block.sx, or declare it in
      your own code"

3. Per-signature declarations live in stdlib (for common signatures)
   or in user code (for app-specific ones). 96-objc-block-multi-arg
   now demonstrates the user-side pattern in-file — it declares its
   own `__block_invoke_void_s32_p` + `Into(Block) for Closure(s32,
   *void) -> void` impl alongside its main().

Net effect:
- Every block bridge is source-visible. No hidden compiler magic.
- Users see exactly how the Apple ABI shape is constructed in sx
  source — stdlib serves as the reference implementation.
- Compiler enforces the discipline: missing impl → clear diagnostic
  pointing at the template.
- Coverage for arbitrary signatures requires conscious user opt-in,
  not silent fallthrough.

Removed from lower.zig: `tryClosureToBlockConversion`,
`emitBlockInvokeTrampoline`, `mangleClosureSigForBlock`,
`mangleTypeForBlock`, and the `block_invoke_trampolines` dedup
state field. Net: the synthesis machinery is gone; only the
detection helper `isClosureToBlockCast` remains, used by the
diagnostic.

190/190 example tests pass; chess on iOS-sim green.
This commit is contained in:
agra
2026-05-27 00:34:26 +03:00
parent 26329fe7ba
commit 07f25689ff
3 changed files with 131 additions and 272 deletions

View File

@@ -1,20 +1,44 @@
// M5.A — `xx closure : Block` for an arbitrary closure signature.
// `xx closure : Block` for an arbitrary closure signature.
//
// Pre-M5.A: the stdlib hand-rolled `Into(Block) for Closure(s32, *void) -> s32`
// didn't exist — this code wouldn't compile. Only `Closure() -> void`
// and `Closure(bool) -> void` shapes were supported.
// The stdlib (modules/std/objc_block.sx) declares hand-rolled
// `Into(Block) for Closure() -> void` and `Closure(bool) -> void`
// impls — the two most common Apple block shapes. Other signatures
// need a per-shape `__block_invoke_<sig>` trampoline + `Into(Block)`
// impl declared somewhere reachable (stdlib if shared, in-file if
// app-specific).
//
// Post-M5.A: the compiler synthesises `__block_invoke_i_i_p` for this
// signature on the fly. The block's invoke trampoline forwards
// `(__sx_default_context, sx_env, arg0, arg1)` to the captured closure
// and returns the s32 result.
// This test exercises the user-declared variant: signature
// `Closure(s32, *void) -> void` (a two-arg block — not in stdlib).
// If the impl is missing, the compiler emits a focused diagnostic
// pointing at modules/std/objc_block.sx as the template.
#import "modules/std.sx";
#import "modules/std/objc_block.sx";
// Trampoline matching `void (^)(int, void*)` — the C ABI Apple's
// runtime calls. Forwards through to the sx closure with the
// standard `(__sx_ctx, env, ...args)` shape.
__block_invoke_void_s32_p :: (block_self: *Block, arg0: s32, arg1: *void) callconv(.c) {
typed_fn : (*void, s32, *void) -> void = xx block_self.sx_fn;
typed_fn(block_self.sx_env, arg0, arg1);
}
impl Into(Block) for Closure(s32, *void) -> void {
convert :: (self: Closure(s32, *void) -> void) -> Block {
.{
isa = @_NSConcreteStackBlock,
flags = 0,
reserved = 0,
invoke = xx @__block_invoke_void_s32_p,
descriptor = xx @__sx_block_descriptor,
sx_env = self.env,
sx_fn = self.fn_ptr,
};
}
}
// Side-effect capture so we can observe both args reached the
// closure body, even though void-returning trampolines are the
// well-tested shape.
// closure body.
g_sum: s32 = 0;
g_tag: *void = null;

View File

@@ -52,17 +52,58 @@ __sx_block_descriptor : BlockDescriptor = .{
size = 48,
};
// M5.A — `xx closure : Block` casts are handled by the compiler.
// For every closure signature seen at a cast site, the compiler
// synthesises:
// 1. A C-ABI trampoline `__block_invoke_<sig>` matching Apple's
// `__block_literal.invoke` calling convention.
// 2. Inline Block-struct construction at the cast site, with
// `invoke` pointing at the synthesised trampoline and
// `sx_env`/`sx_fn` taken from the closure value.
// Per-signature invoke trampolines. Each one reads sx_env + sx_fn from
// its block_self argument and tail-calls the closure through a typed
// fn-ptr cast. One per Apple block signature we support.
//
// User-facing surface: `xx my_closure : Block` for ANY closure
// signature. No per-signature stdlib boilerplate. The shared
// infrastructure above (`Block`, `BlockDescriptor`,
// `_NSConcreteStackBlock`, `__sx_block_descriptor`) is referenced
// by the synthesised code; users only need to `#import` this module.
// Adding a new signature: write a `__block_invoke_<sig>` trampoline
// matching the closure's calling convention and an
// `impl Into(Block) for Closure(<sig>)` that points its `invoke`
// field at the trampoline. The `xx closure : Block` cast finds the
// impl via `Into` protocol dispatch.
//
// Signature: `void (^)(void)` — no args, no return. The single most
// common Apple block shape (UIView animation bodies, dispatch_async, etc).
__block_invoke_void :: (block_self: *Block) callconv(.c) {
// `sx_fn` is the closure trampoline — an sx-side function with the
// implicit __sx_ctx at slot 0 and env at slot 1. We're a callconv(.c)
// entry, so the call site needs ctx prepended; the typed fn-pointer
// type stays default-conv to enable that.
typed_fn : (*void) -> void = xx block_self.sx_fn;
typed_fn(block_self.sx_env);
}
impl Into(Block) for Closure() -> void {
convert :: (self: Closure() -> void) -> Block {
.{
isa = @_NSConcreteStackBlock,
flags = 0,
reserved = 0,
invoke = xx @__block_invoke_void,
descriptor = xx @__sx_block_descriptor,
sx_env = self.env,
sx_fn = self.fn_ptr,
};
}
}
// Signature: `void (^)(BOOL)` — UIView animation completion handlers and
// similar one-arg-bool callbacks.
__block_invoke_bool :: (block_self: *Block, arg0: bool) callconv(.c) {
typed_fn : (*void, bool) -> void = xx block_self.sx_fn;
typed_fn(block_self.sx_env, arg0);
}
impl Into(Block) for Closure(bool) -> void {
convert :: (self: Closure(bool) -> void) -> Block {
.{
isa = @_NSConcreteStackBlock,
flags = 0,
reserved = 0,
invoke = xx @__block_invoke_bool,
descriptor = xx @__sx_block_descriptor,
sx_env = self.env,
sx_fn = self.fn_ptr,
};
}
}

View File

@@ -145,7 +145,6 @@ pub const Lowering = struct {
comptime_constants: std.StringHashMap(ComptimeValue) = std.StringHashMap(ComptimeValue).init(std.heap.page_allocator), // compile-time known constants (e.g. OS, ARCH)
diagnostics: ?*errors.DiagnosticList = null, // error reporting with source locations
xx_reentrancy: std.AutoHashMap(u64, void) = std.AutoHashMap(u64, void).init(std.heap.page_allocator), // (src_ty, dst_ty) pairs currently being resolved through user-space Into; prevents infinite monomorphisation when a convert body re-enters the same xx
block_invoke_trampolines: std.StringHashMap(FuncId) = std.StringHashMap(FuncId).init(std.heap.page_allocator), // M5.A — dedup compiler-synthesised `__block_invoke_<sig>` trampolines per mangled closure signature; one entry per Closure(...) type seen at an `xx cl : Block` cast site
pub const ComptimeValue = union(enum) {
int_val: i64,
@@ -11053,246 +11052,21 @@ pub const Lowering = struct {
return result;
}
/// M5.A — try the closureBlock bridge before the user-space Into
/// fallback. If `src_ty` is `Closure(...)` and `dst_ty` is `Block`,
/// synthesise a `__block_invoke_<sig>` trampoline (deduped per
/// signature) and emit IR constructing the Block struct inline.
/// Returns the Block value, or null if the cast doesn't match this
/// pattern (caller falls back to user-space Into).
fn tryClosureToBlockConversion(self: *Lowering, operand: Ref, src_ty: TypeId, dst_ty: TypeId) ?Ref {
// Source must be Closure(...).
if (src_ty.isBuiltin()) return null;
/// Detect the `xx closure : Block` cast pattern so `tryUserConversion`
/// can emit a focused diagnostic when no `Into(Block) for Closure(...)`
/// impl is reachable. Replaces what was briefly a compiler-synthesised
/// trampoline path with a "declare an impl" requirement — the stdlib
/// covers common signatures (see modules/std/objc_block.sx), users
/// add their own for unusual ones.
fn isClosureToBlockCast(self: *Lowering, src_ty: TypeId, dst_ty: TypeId) bool {
if (src_ty.isBuiltin()) return false;
const src_info = self.module.types.get(src_ty);
if (src_info != .closure) return null;
// Destination must be the `Block` struct (declared in std/objc_block.sx).
if (dst_ty.isBuiltin()) return null;
if (src_info != .closure) return false;
if (dst_ty.isBuiltin()) return false;
const dst_info = self.module.types.get(dst_ty);
if (dst_info != .@"struct") return null;
const block_name_id = self.module.types.internString("Block");
if (dst_info.@"struct".name != block_name_id) return null;
const ptr_void = self.module.types.ptrTo(.void);
// Emit (or look up) the trampoline for this signature.
const trampoline_fid = self.emitBlockInvokeTrampoline(src_ty) orelse return null;
// Look up the shared block infrastructure declared in std/objc_block.sx.
const isa_gid = self.global_names.get("_NSConcreteStackBlock") orelse {
if (self.diagnostics) |d| {
d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "xx Closure : Block requires `#import \"modules/std/objc_block.sx\";` (missing `_NSConcreteStackBlock` extern)", .{});
}
return null;
};
const descriptor_gid = self.global_names.get("__sx_block_descriptor") orelse {
if (self.diagnostics) |d| {
d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "xx Closure : Block requires `#import \"modules/std/objc_block.sx\";` (missing `__sx_block_descriptor` global)", .{});
}
return null;
};
// Build the Block struct fields.
const isa_addr = self.builder.emit(.{ .global_addr = isa_gid.id }, ptr_void);
const descriptor_addr = self.builder.emit(.{ .global_addr = descriptor_gid.id }, ptr_void);
const invoke_addr = self.builder.emit(.{ .func_ref = trampoline_fid }, ptr_void);
// Extract the closure's fn_ptr (field 0) and env (field 1).
const closure_fn_ptr = self.builder.structGet(operand, 0, ptr_void);
const closure_env = self.builder.structGet(operand, 1, ptr_void);
// Construct Block { isa, flags=0, reserved=0, invoke, descriptor, sx_env, sx_fn }.
var fields = std.ArrayList(Ref).empty;
defer fields.deinit(self.alloc);
fields.append(self.alloc, isa_addr) catch return null;
fields.append(self.alloc, self.builder.constInt(0, .s32)) catch return null; // flags
fields.append(self.alloc, self.builder.constInt(0, .s32)) catch return null; // reserved
fields.append(self.alloc, invoke_addr) catch return null;
fields.append(self.alloc, descriptor_addr) catch return null;
fields.append(self.alloc, closure_env) catch return null; // sx_env
fields.append(self.alloc, closure_fn_ptr) catch return null; // sx_fn
const owned_fields = self.alloc.dupe(Ref, fields.items) catch return null;
return self.builder.emit(.{ .struct_init = .{ .fields = owned_fields } }, dst_ty);
}
/// Synthesise (and cache) the C-ABI trampoline that bridges Apple's
/// `__block_literal.invoke` calling convention to the sx closure
/// stored in `block_self.sx_fn` + `block_self.sx_env`. One per
/// unique closure signature.
///
/// Signature for a `Closure(A, B) -> R`:
/// `__block_invoke_<sig>(block_self: *Block, a: A, b: B) -> R callconv(.c)`
/// Body:
/// sx_fn = block_self.sx_fn
/// sx_env = block_self.sx_env
/// ret sx_fn(__sx_default_context, sx_env, a, b) // matches sx closure ABI
fn emitBlockInvokeTrampoline(self: *Lowering, closure_ty: TypeId) ?FuncId {
const closure_info = self.module.types.get(closure_ty);
if (closure_info != .closure) return null;
const cinfo = closure_info.closure;
const mangled = self.mangleClosureSigForBlock(cinfo) orelse return null;
defer self.alloc.free(mangled);
if (self.block_invoke_trampolines.get(mangled)) |fid| return fid;
const ptr_void = self.module.types.ptrTo(.void);
// The trampoline name owned by the module's intern pool.
const tramp_name = std.fmt.allocPrint(self.alloc, "__block_invoke_{s}", .{mangled}) catch return null;
const tramp_name_id = self.module.types.internString(tramp_name);
// Hold the mangled string across the cache map's lifetime.
const mangled_owned = self.alloc.dupe(u8, mangled) catch return null;
const block_ty_name = self.module.types.internString("Block");
const block_ty = self.module.types.findByName(block_ty_name) orelse {
if (self.diagnostics) |d| {
d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitBlockInvokeTrampoline: `Block` struct not in module — `#import \"modules/std/objc_block.sx\";` required", .{});
}
self.alloc.free(mangled_owned);
return null;
};
const block_ptr_ty = self.module.types.ptrTo(block_ty);
// Save+restore builder state — emitting a new function mid-pass.
const saved_func = self.builder.func;
const saved_block = self.builder.current_block;
const saved_counter = self.builder.inst_counter;
defer {
self.builder.func = saved_func;
self.builder.current_block = saved_block;
self.builder.inst_counter = saved_counter;
}
// Build the trampoline's param list: (block_self: *Block, user_args...).
var params = std.ArrayList(inst_mod.Function.Param).empty;
params.append(self.alloc, .{
.name = self.module.types.internString("block_self"),
.ty = block_ptr_ty,
}) catch {
self.alloc.free(mangled_owned);
return null;
};
for (cinfo.params, 0..) |pty, i| {
var nbuf: [32]u8 = undefined;
const pname = std.fmt.bufPrint(&nbuf, "arg{d}", .{i}) catch "arg";
params.append(self.alloc, .{
.name = self.module.types.internString(pname),
.ty = pty,
}) catch {
self.alloc.free(mangled_owned);
return null;
};
}
const params_slice = params.toOwnedSlice(self.alloc) catch {
self.alloc.free(mangled_owned);
return null;
};
const fid = self.builder.beginFunction(tramp_name_id, params_slice, cinfo.ret);
const func = self.builder.currentFunc();
func.linkage = .external;
func.call_conv = .c;
func.has_implicit_ctx = false;
const entry_name = self.module.types.internString("entry");
const entry = self.builder.appendBlock(entry_name, &.{});
self.builder.switchToBlock(entry);
// Load block_self struct, extract sx_env (field 5) + sx_fn (field 6).
const block_self_ref = Ref.fromIndex(0);
const block_val = self.builder.load(block_self_ref, block_ty);
const sx_env = self.builder.structGet(block_val, 5, ptr_void);
const sx_fn = self.builder.structGet(block_val, 6, ptr_void);
// Call sx_fn(__sx_default_context, sx_env, user_args...) — matches
// the sx closure ABI (ctx prepended to env + user args).
const default_ctx_gi = self.global_names.get("__sx_default_context") orelse {
if (self.diagnostics) |d| {
d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitBlockInvokeTrampoline: __sx_default_context not in module (compiler bug)", .{});
}
self.alloc.free(mangled_owned);
return null;
};
const default_ctx_addr = self.builder.emit(.{ .global_addr = default_ctx_gi.id }, ptr_void);
const num_user_args = cinfo.params.len;
const total_args = 2 + num_user_args; // ctx + env + user
const call_args = self.alloc.alloc(Ref, total_args) catch {
self.alloc.free(mangled_owned);
return null;
};
call_args[0] = default_ctx_addr;
call_args[1] = sx_env;
var i: usize = 0;
while (i < num_user_args) : (i += 1) {
// Params start at slot 1 (block_self is slot 0); user args
// are slots 1..num_user_args.
call_args[2 + i] = Ref.fromIndex(@intCast(1 + i));
}
const result = self.builder.emit(.{ .call_indirect = .{
.callee = sx_fn,
.args = call_args,
} }, cinfo.ret);
if (cinfo.ret == .void) {
self.builder.retVoid();
} else {
self.builder.ret(result, cinfo.ret);
}
self.builder.finalize();
self.block_invoke_trampolines.put(mangled_owned, fid) catch {
self.alloc.free(mangled_owned);
return null;
};
return fid;
}
/// Compact mangling for a closure signature's `__block_invoke_<sig>`
/// trampoline name. Keys: `v` void, `b` bool, `i` s32, `q` s64, `f`
/// f32, `d` f64, `p` pointer/aggregate (everything that lowers to a
/// machine word). Multi-arg signatures are underscore-joined; return
/// comes first.
fn mangleClosureSigForBlock(self: *Lowering, cinfo: types.TypeInfo.ClosureInfo) ?[]u8 {
var buf = std.ArrayList(u8).empty;
defer buf.deinit(self.alloc);
buf.append(self.alloc, mangleTypeForBlock(cinfo.ret, &self.module.types)) catch return null;
for (cinfo.params) |pty| {
buf.append(self.alloc, '_') catch return null;
buf.append(self.alloc, mangleTypeForBlock(pty, &self.module.types)) catch return null;
}
return self.alloc.dupe(u8, buf.items) catch null;
}
fn mangleTypeForBlock(ty: TypeId, tbl: *const types.TypeTable) u8 {
if (ty == .void) return 'v';
if (ty == .bool) return 'b';
if (ty == .f32) return 'f';
if (ty == .f64) return 'd';
const info = tbl.get(ty);
return switch (info) {
.signed => |w| switch (w) {
1, 8 => 'c',
16 => 's',
32 => 'i',
64 => 'q',
else => 'i',
},
.unsigned => |w| switch (w) {
1, 8 => 'C',
16 => 'S',
32 => 'I',
64 => 'Q',
else => 'I',
},
// Everything else lowers to a machine word at the C ABI.
// 'p' covers pointers, many-pointers, struct ptrs, closures,
// and small structs that pass in a register. Aggregates
// larger than the register cutoff would need a different
// calling convention — out of M5.A scope.
else => 'p',
};
if (dst_info != .@"struct") return false;
const block_name = self.module.types.internString("Block");
return dst_info.@"struct".name == block_name;
}
/// Look up `Into(dst_ty)` impl for `src_ty` and, if found, monomorphise
@@ -11300,14 +11074,6 @@ pub const Lowering = struct {
/// no impl matches (caller falls back to the built-in result, which is
/// the unchanged operand — Phase 3 emits no diagnostic for v0).
fn tryUserConversion(self: *Lowering, operand: Ref, operand_node: *const Node, src_ty: TypeId, dst_ty: TypeId) ?Ref {
// M5.A: compiler-synthesised closure→Block bridge. Runs BEFORE
// the user-space Into lookup so the synthesised trampoline takes
// over from the hand-rolled stdlib impls. Falls through (returns
// null in caller chain) when src isn't Closure or dst isn't Block.
if (self.tryClosureToBlockConversion(operand, src_ty, dst_ty)) |converted| {
return converted;
}
// Reentrancy guard — pack (src, dst) into a u64.
const guard_key: u64 = (@as(u64, src_ty.index()) << 32) | @as(u64, dst_ty.index());
if (self.xx_reentrancy.contains(guard_key)) {
@@ -11335,8 +11101,36 @@ pub const Lowering = struct {
key_buf.appendSlice(self.alloc, self.mangleTypeName(src_ty)) catch return null;
const key = key_buf.items;
const entries = self.param_impl_map.get(key) orelse return null;
if (entries.items.len == 0) return null;
const entries = self.param_impl_map.get(key) orelse {
// M5.A — focused diagnostic for the closure→Block case. When
// a user writes `xx cl : Block` for a closure signature that
// has no `Into(Block) for Closure(<sig>)` impl in stdlib or
// user code, the generic "no Into impl" path returns silently
// and the cast becomes a no-op. Emit a hint pointing at the
// missing impl pattern so they know what to add.
if (self.isClosureToBlockCast(src_ty, dst_ty)) {
if (self.diagnostics) |diags| {
const saved = diags.current_source_file;
diags.current_source_file = operand_node.source_file orelse self.current_source_file;
defer diags.current_source_file = saved;
diags.addFmt(.err, operand_node.span, "no `Into(Block) for {s}` impl — add a per-signature `__block_invoke_<sig>` trampoline + Into impl alongside the existing ones in modules/std/objc_block.sx, or declare it in your own code", .{self.mangleTypeName(src_ty)});
}
return operand;
}
return null;
};
if (entries.items.len == 0) {
if (self.isClosureToBlockCast(src_ty, dst_ty)) {
if (self.diagnostics) |diags| {
const saved = diags.current_source_file;
diags.current_source_file = operand_node.source_file orelse self.current_source_file;
defer diags.current_source_file = saved;
diags.addFmt(.err, operand_node.span, "no `Into(Block) for {s}` impl — add a per-signature `__block_invoke_<sig>` trampoline + Into impl alongside the existing ones in modules/std/objc_block.sx, or declare it in your own code", .{self.mangleTypeName(src_ty)});
}
return operand;
}
return null;
}
// Filter by import visibility: only impls in modules that the current
// file transitively imports (or the current file itself) are reachable.