ffi M4.0c: -dealloc frees state through captured __sx_allocator

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.
This commit is contained in:
agra
2026-05-26 22:30:48 +03:00
parent 2bbd63d929
commit 92ac51445d
2 changed files with 64 additions and 24 deletions

View File

@@ -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 @__<Cls>_state_ivar)
/// free(state)
/// Body (M4.0c):
/// %state = object_getIvar(self, load @__<Cls>_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 @__<Cls>_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);

View File

@@ -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