From 92ac51445d0882426d9595b745cc5b67e6de7a4f Mon Sep 17 00:00:00 2001 From: agra Date: Tue, 26 May 2026 22:30:48 +0300 Subject: [PATCH] ffi M4.0c: -dealloc frees state through captured __sx_allocator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The synthesized -dealloc IMP now loads `state->__sx_allocator` (the slot captured at +alloc time by M4.0a + M4.0b) and dispatches `allocator.dealloc(state)` through the inline-protocol fn-ptr at slot 2. Old behaviour was `free(state)` — went straight to libc, ignoring whatever allocator the instance was constructed with. After this commit, the per-instance allocator design from M1.2 A.5 is finally end-to-end correct: push Context.{ allocator = arena } { f := SxFoo.alloc(); ← arena.alloc(STATE_SIZE) + capture // ... use f ... } // refcount → 0 ⇒ -dealloc: // load state->__sx_allocator = arena // arena.dealloc(state) ← same allocator round-trips TrackingAllocator now sees the alloc/dealloc pair; the deferred M1.2 A.5 work is done. Closes the loop on M4.0. The dealloc IMP passes `__sx_default_context` as the implicit __sx_ctx when invoking the dealloc fn-ptr — the IMP itself has no caller-side ctx (it's called by Apple's runtime at refcount-zero), and the default GPA is the right baseline for any nested allocations the dealloc body might perform. Each compiler-internal lookup that "can't fail" (Context type, __sx_default_context global) emits a loud diagnostic instead of silent fall-through, per the silent-error budget. 184/184 example tests pass; chess on iOS-sim green. --- src/ir/lower.zig | 78 ++++++++++++++----- .../142-objc-class-method-lowering.ir | 10 ++- 2 files changed, 64 insertions(+), 24 deletions(-) diff --git a/src/ir/lower.zig b/src/ir/lower.zig index c2ba56f..d169a4c 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -12607,27 +12607,22 @@ pub const Lowering = struct { } /// Synthesize the `-dealloc` IMP for an sx-defined `#objc_class`. - /// Runs when the Obj-C runtime drops the last retain on an - /// instance. + /// Runs when the Obj-C runtime drops the last retain on an instance. /// - /// C-ABI: `(self: id, _cmd: SEL) -> void` + /// C-ABI: `(self: id, _cmd: SEL) -> void`. No implicit sx ctx. /// - /// Body: - /// %state = object_getIvar(self, load @___state_ivar) - /// free(state) + /// Body (M4.0c): + /// %state = object_getIvar(self, load @___state_ivar) + /// %allocator = load struct_gep(state, 0) ← __sx_allocator (M4.0a) + /// allocator.dealloc(state) ← via inline-protocol fn-ptr /// object_setIvar(self, ivar, null) - /// // [super dealloc] via objc_msgSendSuper2(&super, sel_dealloc) - /// %sup = alloca { *void, *void } - /// store self into sup.0 (receiver) - /// store @___class into sup.1 (current class — runtime climbs) - /// %sel_dealloc = sel_registerName("dealloc") - /// objc_msgSendSuper2(%sup, %sel_dealloc) + /// [super dealloc] // objc_msgSendSuper2(&super, sel_dealloc) /// ret void /// - /// `free(null)` is well-defined as no-op per C standard, so we - /// skip the null check. The state-ivar nil-out prevents UAF if - /// super-dealloc somehow re-reads our ivar (paranoia — NSObject - /// doesn't). + /// The state struct's first field is the allocator captured at + /// +alloc time (M4.0a + M4.0b). Reading it back lets -dealloc free + /// through the same allocator the instance was constructed with — + /// the per-instance allocator design from M1.2 A.5, now realised. fn emitObjcDefinedClassDeallocImp(self: *Lowering, fcd: *const ast.ForeignClassDecl) void { const saved_func = self.builder.func; const saved_block = self.builder.current_block; @@ -12672,11 +12667,52 @@ pub const Lowering = struct { get_args[1] = ivar_handle; const state = self.builder.emit(.{ .call = .{ .callee = get_ivar_fid, .args = get_args } }, ptr_void); - // (2) free(state) — free(NULL) is a safe no-op. - const free_fid = self.ensureCRuntimeDecl("free", &.{ptr_void}, .void); - const free_args = self.alloc.alloc(Ref, 1) catch return; - free_args[0] = state; - _ = self.builder.emit(.{ .call = .{ .callee = free_fid, .args = free_args } }, .void); + // (2) Free state through the captured allocator (M4.0a + M4.0b): + // allocator = load struct_gep(state, 0) ← __sx_allocator field + // allocator.dealloc(state) ← inline-protocol fn-ptr at field 2 + // Compare to the old `free(state)` — that ignored the per-instance + // allocator and went straight to libc. Now `push Context.{ allocator = arena }` + // round-trips correctly: arena.alloc on construction, arena.dealloc here. + 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 }, "emitObjcDefinedClassDeallocImp: Context type not found for class '{s}' (compiler bug)", .{fcd.name}); + } + return; + }; + 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 }, "emitObjcDefinedClassDeallocImp: Context has unexpected shape for class '{s}'", .{fcd.name}); + } + return; + } + const allocator_ty = ctx_info.@"struct".fields[0].ty; + + const state_struct_ty = self.objcDefinedStateStructType(fcd); + const state_alloc_addr = self.builder.emit(.{ .struct_gep = .{ + .base = state, + .field_index = 0, + .base_type = state_struct_ty, + } }, ptr_void); + const allocator = self.builder.load(state_alloc_addr, allocator_ty); + + // Default-context address for the implicit __sx_ctx the dealloc + // fn-ptr takes as its first arg (the dealloc body might allocate + // internally; default GPA is the safe baseline). + 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 }, "emitObjcDefinedClassDeallocImp: __sx_default_context global missing for class '{s}'", .{fcd.name}); + } + return; + }; + const default_ctx_addr = self.builder.emit(.{ .global_addr = default_ctx_gi.id }, ptr_void); + const alloc_ctx = self.builder.structGet(allocator, 0, ptr_void); + const dealloc_fn_ptr = self.builder.structGet(allocator, 2, ptr_void); + const dealloc_args = self.alloc.dupe(Ref, &.{ default_ctx_addr, alloc_ctx, state }) catch return; + _ = self.builder.emit(.{ .call_indirect = .{ + .callee = dealloc_fn_ptr, + .args = dealloc_args, + } }, .void); // (3) object_setIvar(self, ivar, null) const set_ivar_fid = self.ensureCRuntimeDecl("object_setIvar", &.{ ptr_void, ptr_void, ptr_void }, .void); diff --git a/tests/expected/142-objc-class-method-lowering.ir b/tests/expected/142-objc-class-method-lowering.ir index 1af5b83..5bfdafc 100644 --- a/tests/expected/142-objc-class-method-lowering.ir +++ b/tests/expected/142-objc-class-method-lowering.ir @@ -824,11 +824,15 @@ define void @__SxFoo_dealloc_imp(ptr %0, ptr %1) #0 { entry: %load = load ptr, ptr @__SxFoo_state_ivar, align 8 %call = call ptr @object_getIvar(ptr %0, ptr %load) - call void @free(ptr %call) + %gep = getelementptr inbounds { { ptr, ptr, ptr }, i32 }, ptr %call, i32 0, i32 0 + %loadN = load { ptr, ptr, ptr }, ptr %gep, align 8 + %sg = extractvalue { ptr, ptr, ptr } %loadN, 0 + %sgN = extractvalue { ptr, ptr, ptr } %loadN, 2 + call void %sgN(ptr @__sx_default_context, ptr %sg, ptr %call) call void @object_setIvar(ptr %0, ptr %load, ptr null) %alloca = alloca { ptr, ptr }, align 8 - %gep = getelementptr inbounds { ptr, ptr }, ptr %alloca, i32 0, i32 0 - store ptr %0, ptr %gep, align 8 + %gepN = getelementptr inbounds { ptr, ptr }, ptr %alloca, i32 0, i32 0 + store ptr %0, ptr %gepN, align 8 %loadN = load ptr, ptr @__SxFoo_class, align 8 %gepN = getelementptr inbounds { ptr, ptr }, ptr %alloca, i32 0, i32 1 store ptr %loadN, ptr %gepN, align 8