From 2bbd63d9294150278f7ce987caa68a5b34a9f5d6 Mon Sep 17 00:00:00 2001 From: agra Date: Tue, 26 May 2026 22:27:33 +0300 Subject: [PATCH] ffi M4.0b: thread context.allocator through sx-defined +alloc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (`*` or `?*`). 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. --- src/ir/lower.zig | 195 +++++++++++++----- .../142-objc-class-method-lowering.ir | 16 +- 2 files changed, 160 insertions(+), 51 deletions(-) diff --git a/src/ir/lower.zig b/src/ir/lower.zig index a5fcbf6..c2ba56f 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -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 `*` or `?*`) 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 @___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(__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(__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(@___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(@___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` diff --git a/tests/expected/142-objc-class-method-lowering.ir b/tests/expected/142-objc-class-method-lowering.ir index ed6e159..1af5b83 100644 --- a/tests/expected/142-objc-class-method-lowering.ir +++ b/tests/expected/142-objc-class-method-lowering.ir @@ -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) + +