ffi M4.0b: thread context.allocator through sx-defined +alloc

Two converging paths now allocate the state struct via the protocol's
allocator instead of raw malloc:

(1) sx-side `Cls.alloc()`: compiler intercepts in `lowerObjcStaticCall`
    when the receiver is a sx-defined `#objc_class` and the method is
    the niladic `alloc`. Emits the inline alloc-and-init sequence
    using the caller's `current_ctx_ref` as the context — so
    `push Context.{ allocator = my_arena } { let f := SxFoo.alloc(); }`
    honors `my_arena` end-to-end. The msgSend dispatch is bypassed
    entirely for this case.

(2) Obj-C-runtime `[Cls alloc]` (Info.plist principal class, NSCoder,
    UIKit reflection): the synthesized `+alloc` IMP shim reads
    `__sx_default_context.allocator` and calls into the same shared
    helper. The IMP has `has_implicit_ctx = false` and runs with no
    caller-side context — the default GPA is the right policy choice
    for "everything Apple's runtime instantiates".

Shared helper `emitObjcDefinedAllocAndInit(fcd, cls_ref, ctx_addr)`
does the work: `class_createInstance` → `ctx.allocator.alloc(STATE_SIZE)`
via the inline-protocol fn-ptr → memset 0 → store allocator at
state[0] (the M4.0a slot, captured for -dealloc's later use) →
`object_setIvar(instance, __sx_state_ivar, state)`. Loud failures
on missing globals via the diagnostics system.

The sx-side interception must explicitly bitcast the
`class_createInstance` result from `*void` to the method's declared
return type (`*<Cls>` or `?*<Cls>`). lowerVarDecl reads the Ref's IR
type when no type annotation is present, and coerceToType is a
no-op for ptr→ptr — without the bitcast, `let f := SxFoo.alloc();`
binds `f` at `*void` and downstream `f.class` / `f.method()` fails
to find anything.

-dealloc still uses `free(state)` (M4.0c rewrites it). 184/184 tests
pass; chess on iOS-sim green.
This commit is contained in:
agra
2026-05-26 22:27:33 +03:00
parent 8d7164f45f
commit 2bbd63d929
2 changed files with 160 additions and 51 deletions

View File

@@ -4944,6 +4944,52 @@ pub const Lowering = struct {
const class_slot_ptr = self.builder.emit(.{ .global_addr = class_slot_gid }, self.module.types.ptrTo(vptr_ty));
const class_obj = self.builder.emit(.{ .load = .{ .operand = class_slot_ptr } }, vptr_ty);
// M4.0b: intercept `Cls.alloc()` for sx-defined classes — emit the
// inline alloc-and-init sequence using the caller's `context.allocator`
// instead of going through `objc_msgSend` (which would land in the
// +alloc IMP and use `__sx_default_context.allocator`). This honors
// a surrounding `push Context.{ allocator = ... }`.
if (!fcd.is_foreign and
fcd.runtime == .objc_class and
method_args.len == 0 and
std.mem.eql(u8, method.name, "alloc"))
{
const ctx_addr = if (self.current_ctx_ref != Ref.none)
self.current_ctx_ref
else blk: {
// Fallback: no current ctx (e.g. compiler-internal callers).
// Use the default context — same as the IMP would.
const default_ctx_gi = self.global_names.get("__sx_default_context") orelse {
if (self.diagnostics) |d| {
d.addFmt(.err, span, "Cls.alloc() on sx-defined class '{s}': no current context and __sx_default_context missing", .{fcd.name});
}
return Ref.none;
};
break :blk self.builder.emit(.{ .global_addr = default_ctx_gi.id }, vptr_ty);
};
const instance = self.emitObjcDefinedAllocAndInit(fcd, class_obj, ctx_addr) orelse return Ref.none;
// class_createInstance returns *void; bitcast to the method's
// declared return type (typically `*<Cls>` or `?*<Cls>`) so
// downstream `let f := Cls.alloc();` binds f at the right type
// (lowerVarDecl reads the Ref's IR type when no annotation is
// present). coerceToType is a no-op for ptr→ptr; we need an
// explicit bitcast IR op to retype the Ref.
if (ret_ty == vptr_ty) return instance;
// Optional-wrapped returns (e.g. `-> ?*Cls`): emit optional_wrap.
if (!ret_ty.isBuiltin()) {
const ret_info = self.module.types.get(ret_ty);
if (ret_info == .optional) {
const inner = ret_info.optional.child;
const cast = if (inner == vptr_ty)
instance
else
self.builder.emit(.{ .bitcast = .{ .operand = instance, .from = vptr_ty, .to = inner } }, inner);
return self.builder.optionalWrap(cast, ret_ty);
}
}
return self.builder.emit(.{ .bitcast = .{ .operand = instance, .from = vptr_ty, .to = ret_ty } }, ret_ty);
}
// Load the SEL from its slot.
const sel_slot_gid = self.internObjcSelector(derived.sel);
const sel_slot_ptr = self.builder.emit(.{ .global_addr = sel_slot_gid }, self.module.types.ptrTo(vptr_ty));
@@ -12307,26 +12353,27 @@ pub const Lowering = struct {
}
/// Synthesize the `+alloc` IMP for an sx-defined `#objc_class`.
/// Class method (registered on the metaclass by emit_llvm) — when
/// `[SxFoo alloc]` runs (from sx, UIKit, Info.plist, ...), this
/// IMP fires and returns a fully-initialised instance whose
/// `__sx_state` ivar points at a zero-init state struct.
/// Class method registered on the metaclass — when `[SxFoo alloc]`
/// runs from Apple's runtime (Info.plist principal class,
/// NSCoder unarchive, UIKit reflection), this IMP fires.
///
/// C-ABI: `(cls: id, _cmd: SEL) -> id`
/// C-ABI: `(cls: id, _cmd: SEL) -> id`. No implicit ctx.
///
/// Body:
/// Body (M4.0):
/// %instance = class_createInstance(cls, 0)
/// %state = malloc(STATE_SIZE)
/// %ctx_addr = &__sx_default_context
/// %state = ctx_addr.allocator.alloc(STATE_SIZE)
/// memset(state, 0, STATE_SIZE)
/// %iv = load @__<Cls>_state_ivar
/// object_setIvar(instance, iv, state)
/// state[0] = allocator ← capture for -dealloc
/// object_setIvar(instance, __sx_state_ivar, state)
/// ret instance
///
/// STATE_SIZE = max(typeSizeBytes(__<Cls>State), 1) — we always
/// allocate at least one byte so the ivar is never null. State
/// is freed in `-dealloc` (M1.2 A.6).
/// Sx-side `Cls.alloc()` is intercepted at the call site (see
/// `lowerObjcStaticCall`) and emits the same sequence inline with
/// `current_ctx_ref` as the ctx — so `push Context.{ allocator = ... }`
/// flows through to per-instance allocator capture without going via
/// the IMP.
fn emitObjcDefinedClassAllocImp(self: *Lowering, fcd: *const ast.ForeignClassDecl) void {
// Save+restore builder state.
const saved_func = self.builder.func;
const saved_block = self.builder.current_block;
const saved_counter = self.builder.inst_counter;
@@ -12355,63 +12402,117 @@ pub const Lowering = struct {
const entry = self.builder.appendBlock(entry_name, &.{});
self.builder.switchToBlock(entry);
// (1) %instance = class_createInstance(cls, 0)
// ctx_addr = &__sx_default_context — IMP runs in Apple's runtime
// context, no implicit sx ctx to inherit, so use the process-wide
// default allocator. Sx-side callers bypass this IMP entirely
// (compiler intercepts Cls.alloc()) and use their own
// `context.allocator`.
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 }, "emitObjcDefinedClassAllocImp: __sx_default_context global missing for class '{s}' (compiler bug — scan pass did not register the default context)", .{fcd.name});
}
return;
};
const ctx_addr = self.builder.emit(.{ .global_addr = default_ctx_gi.id }, ptr_void);
const cls_ref = Ref.fromIndex(0);
const instance = self.emitObjcDefinedAllocAndInit(fcd, cls_ref, ctx_addr) orelse return;
self.builder.ret(instance, ptr_void);
self.builder.finalize();
}
/// Shared inline sequence: allocate Obj-C instance + sx state struct,
/// capture the allocator, bind to the `__sx_state` ivar. Used by both
/// the `+alloc` IMP (ctx_addr = &__sx_default_context) and the sx-side
/// `Cls.alloc()` interception (ctx_addr = current_ctx_ref).
///
/// Returns the new instance pointer, or `null` if a required global is
/// missing (compiler bug — should be impossible after scan pass).
fn emitObjcDefinedAllocAndInit(
self: *Lowering,
fcd: *const ast.ForeignClassDecl,
cls_ref: Ref,
ctx_addr: Ref,
) ?Ref {
const ptr_void = self.module.types.ptrTo(.void);
// (1) instance = class_createInstance(cls, 0)
const create_fid = self.ensureCRuntimeDecl("class_createInstance", &.{ ptr_void, .u64 }, ptr_void);
const create_args = self.alloc.alloc(Ref, 2) catch return;
const create_args = self.alloc.alloc(Ref, 2) catch return null;
create_args[0] = cls_ref;
create_args[1] = self.builder.constInt(0, .u64);
const instance = self.builder.emit(.{ .call = .{
.callee = create_fid,
.args = create_args,
} }, ptr_void);
const instance = self.builder.emit(.{ .call = .{ .callee = create_fid, .args = create_args } }, ptr_void);
// STATE_SIZE — compute the layout size of the state struct.
// Always at least 1 so we have a non-null pointer to bind.
// STATE_SIZE = max(typeSizeBytes(__<Cls>State), 1).
const state_struct_ty = self.objcDefinedStateStructType(fcd);
const raw_size = self.module.types.typeSizeBytes(state_struct_ty);
const state_size: u64 = if (raw_size == 0) 1 else @intCast(raw_size);
const size_const = self.builder.constInt(@intCast(state_size), .u64);
// (2) %state = malloc(STATE_SIZE)
const malloc_fid = self.ensureCRuntimeDecl("malloc", &.{.u64}, ptr_void);
const malloc_args = self.alloc.alloc(Ref, 1) catch return;
malloc_args[0] = size_const;
const state = self.builder.emit(.{ .call = .{
.callee = malloc_fid,
.args = malloc_args,
// (2) Dispatch through Context.allocator at ctx_addr:
// allocator = (*ctx_addr).field[0]
// state = allocator.alloc(size) (via inline-protocol fn-ptr)
const ctx_ty = self.module.types.findByName(self.module.types.internString("Context")) orelse {
if (self.diagnostics) |d| {
d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedAllocAndInit: Context type not found in module for class '{s}' (compiler bug)", .{fcd.name});
}
return null;
};
const ctx_info = self.module.types.get(ctx_ty);
if (ctx_info != .@"struct" or ctx_info.@"struct".fields.len < 1) {
if (self.diagnostics) |d| {
d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedAllocAndInit: Context has unexpected shape for class '{s}' (compiler bug)", .{fcd.name});
}
return null;
}
const allocator_ty = ctx_info.@"struct".fields[0].ty;
const ctx_val = self.builder.load(ctx_addr, ctx_ty);
const allocator = self.builder.structGet(ctx_val, 0, allocator_ty);
const alloc_ctx = self.builder.structGet(allocator, 0, ptr_void);
const alloc_fn_ptr = self.builder.structGet(allocator, 1, ptr_void);
const call_args = self.alloc.dupe(Ref, &.{ ctx_addr, alloc_ctx, size_const }) catch return null;
const state = self.builder.emit(.{ .call_indirect = .{
.callee = alloc_fn_ptr,
.args = call_args,
} }, ptr_void);
// (3) memset(state, 0, STATE_SIZE)
// (3) memset(state, 0, STATE_SIZE) — zero everything including the
// allocator slot; the next store re-writes the allocator slot.
const memset_fid = self.ensureCRuntimeDecl("memset", &.{ ptr_void, .s32, .u64 }, ptr_void);
const memset_args = self.alloc.alloc(Ref, 3) catch return;
const memset_args = self.alloc.alloc(Ref, 3) catch return null;
memset_args[0] = state;
memset_args[1] = self.builder.constInt(0, .s32);
memset_args[2] = size_const;
_ = self.builder.emit(.{ .call = .{
.callee = memset_fid,
.args = memset_args,
} }, ptr_void);
_ = self.builder.emit(.{ .call = .{ .callee = memset_fid, .args = memset_args } }, ptr_void);
// (4) object_setIvar(instance, load(@__<Cls>_state_ivar), state)
const ivar_global_name = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{fcd.name}) catch return;
// (4) Capture allocator at state[0] — `-dealloc` reads it back.
const state_alloc_addr = self.builder.emit(.{ .struct_gep = .{
.base = state,
.field_index = 0,
.base_type = state_struct_ty,
} }, ptr_void);
self.builder.store(state_alloc_addr, allocator);
// (5) object_setIvar(instance, load(@__<Cls>_state_ivar), state)
const ivar_global_name = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{fcd.name}) catch return null;
defer self.alloc.free(ivar_global_name);
const ivar_global_id = self.lookupGlobalIdByName(ivar_global_name) orelse return;
const ivar_addr = self.builder.emit(.{ .global_addr = ivar_global_id }, ptr_void);
const ivar_handle = self.builder.load(ivar_addr, ptr_void);
const ivar_global_id = self.lookupGlobalIdByName(ivar_global_name) orelse {
if (self.diagnostics) |d| {
d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedAllocAndInit: ivar global '{s}' missing (scan-pass bug)", .{ivar_global_name});
}
return null;
};
const ivar_addr_v = self.builder.emit(.{ .global_addr = ivar_global_id }, ptr_void);
const ivar_handle = self.builder.load(ivar_addr_v, ptr_void);
const set_ivar_fid = self.ensureCRuntimeDecl("object_setIvar", &.{ ptr_void, ptr_void, ptr_void }, .void);
const set_args = self.alloc.alloc(Ref, 3) catch return;
const set_args = self.alloc.alloc(Ref, 3) catch return null;
set_args[0] = instance;
set_args[1] = ivar_handle;
set_args[2] = state;
_ = self.builder.emit(.{ .call = .{
.callee = set_ivar_fid,
.args = set_args,
} }, .void);
_ = self.builder.emit(.{ .call = .{ .callee = set_ivar_fid, .args = set_args } }, .void);
// (5) ret instance
self.builder.ret(instance, ptr_void);
self.builder.finalize();
return instance;
}
/// Emit a C-ABI IMP trampoline for a CLASS method (no `*Self`

View File

@@ -800,10 +800,16 @@ declare ptr @object_getIvar(ptr, ptr) #0
define ptr @__SxFoo_alloc_imp(ptr %0, ptr %1) #0 {
entry:
%call = call ptr @class_createInstance(ptr %0, i64 0)
%callN = call ptr @malloc(i64 32)
%callN = call ptr @memset(ptr %callN, i32 0, i64 32)
%load = load ptr, ptr @__SxFoo_state_ivar, align 8
call void @object_setIvar(ptr %call, ptr %load, ptr %callN)
%load = load { { ptr, ptr, ptr }, ptr }, ptr @__sx_default_context, align 8
%sg = extractvalue { { ptr, ptr, ptr }, ptr } %load, 0
%sgN = extractvalue { ptr, ptr, ptr } %sg, 0
%sgN = extractvalue { ptr, ptr, ptr } %sg, 1
%icall = call ptr %sgN(ptr @__sx_default_context, ptr %sgN, i64 32)
%callN = call ptr @memset(ptr %icall, i32 0, i64 32)
%gep = getelementptr inbounds { { ptr, ptr, ptr }, i32 }, ptr %icall, i32 0, i32 0
store { ptr, ptr, ptr } %sg, ptr %gep, align 8
%loadN = load ptr, ptr @__SxFoo_state_ivar, align 8
call void @object_setIvar(ptr %call, ptr %loadN, ptr %icall)
ret ptr %call
}
@@ -882,3 +888,5 @@ declare ptr @object_getClass(ptr)
declare ptr @objc_getProtocol(ptr)
declare i8 @class_addProtocol(ptr, ptr)