From b263704664020b8665204641e785ed6d4eb814e0 Mon Sep 17 00:00:00 2001 From: agra Date: Mon, 25 May 2026 12:49:26 +0300 Subject: [PATCH] mem: delete .heap_alloc/.heap_free IR ops + the silent libc-malloc escape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit allocViaContext used to fall back to a direct `.heap_alloc` (libc malloc) when `Context` wasn't registered — i.e. when the program didn't import std.sx. That was a silent escape hatch: a program could appear to allocate fine without a `Context`, sidestepping protocol dispatch entirely. Same shape as the matchContextAllocCall trap we removed, just in a different code path. Now: every site that needs `Context` emits a clear diagnostic when the type isn't in scope, pointing the user at the required import. - `allocViaContext`: the three fallback branches (no implicit_ctx, no Context type, malformed Context struct) all call the new `diagnoseMissingContext("heap allocation")` and return a placeholder. Codegen no longer emits libc malloc as the silent no-import path. - `lowerPush`: the no-Context branches used to silently drop the push and just lower the body. Now diagnose first, then lower (keeping the body's other diagnostics flowing). - `lowerIdentifier` for "context": used to silently fall through to `global_names.get("context")` (which would emit an unresolved identifier with no actionable hint). Now diagnose with the required-import message. With every consumer gone, the `.heap_alloc` and `.heap_free` IR ops are deleted entirely: - `inst.zig`: drop the Op variants. - `interp.zig`: drop the execInst arms. - `emit_llvm.zig`: drop the arms (the `getOrDeclareMalloc/Free` helpers stay — they're still used by the foreign-decl path for user-level `malloc`/`free` foreign bindings). - `print.zig`: drop the printers + the isVoidOp arm. - `emit_llvm.test.zig`: drop the unit test (op no longer exists). 155/155 example tests pass. Unit tests green. Chess green on macOS / iOS sim / Android. A program that doesn't import std.sx and tries to use `context.allocator.alloc` or `push Context.{}` or the `context` identifier now gets a real error: error: heap allocation requires the Context type — add `#import "modules/std.sx";` (or a module that imports it) Closes the last silent allocation-protocol escape. --- src/ir/emit_llvm.test.zig | 30 -------------------------- src/ir/emit_llvm.zig | 31 --------------------------- src/ir/inst.zig | 2 -- src/ir/interp.zig | 16 -------------- src/ir/lower.zig | 44 ++++++++++++++++++++++++++------------- src/ir/print.zig | 8 +------ 6 files changed, 31 insertions(+), 100 deletions(-) diff --git a/src/ir/emit_llvm.test.zig b/src/ir/emit_llvm.test.zig index e6c9cda..fa6c64e 100644 --- a/src/ir/emit_llvm.test.zig +++ b/src/ir/emit_llvm.test.zig @@ -243,36 +243,6 @@ test "emit: comparison and branch" { try std.testing.expect(std.mem.indexOf(u8, ir_str, "br i1") != null); } -test "emit: heap_alloc and heap_free" { - const alloc = std.testing.allocator; - var module = Module.init(alloc); - defer module.deinit(); - - var b = Builder.init(&module); - - // func f() -> void { p = malloc(64); free(p); } - _ = b.beginFunction(str(&module, "heapfn"), &.{}, .void); - const entry = b.appendBlock(str(&module, "entry"), &.{}); - b.switchToBlock(entry); - - const size = b.constInt(64, .s64); - const ptr_ty = module.types.ptrTo(.void); - const ptr = b.emit(.{ .heap_alloc = .{ .operand = size } }, ptr_ty); - b.emit(.{ .heap_free = .{ .operand = ptr } }, .void); - b.retVoid(); - b.finalize(); - - var emitter = LLVMEmitter.init(alloc, &module, "test_heap", .{}); - defer emitter.deinit(); - emitter.emit(); - - try std.testing.expect(emitter.verify()); - - const ir_str = emitter.dumpToString(); - try std.testing.expect(std.mem.indexOf(u8, ir_str, "malloc") != null); - try std.testing.expect(std.mem.indexOf(u8, ir_str, "free") != null); -} - test "emit: function call" { const alloc = std.testing.allocator; var module = Module.init(alloc); diff --git a/src/ir/emit_llvm.zig b/src/ir/emit_llvm.zig index ce0767e..025e369 100644 --- a/src/ir/emit_llvm.zig +++ b/src/ir/emit_llvm.zig @@ -1185,37 +1185,6 @@ pub const LLVMEmitter = struct { } self.advanceRefCounter(); }, - .heap_alloc => |un| { - // malloc(size) → *void - const size = self.coerceArg(self.resolveRef(un.operand), self.sizeType()); - const malloc_fn = self.getOrDeclareMalloc(); - var args = [_]c.LLVMValueRef{size}; - const result = c.LLVMBuildCall2( - self.builder, - self.getMallocType(), - malloc_fn, - &args, - 1, - "heap", - ); - self.mapRef(result); - }, - .heap_free => |un| { - // free(ptr) - const ptr = self.resolveRef(un.operand); - const free_fn = self.getOrDeclareFree(); - var args = [_]c.LLVMValueRef{ptr}; - _ = c.LLVMBuildCall2( - self.builder, - self.getFreeType(), - free_fn, - &args, - 1, - "", - ); - self.advanceRefCounter(); - }, - // ── Globals ─────────────────────────────────────────── .global_get => |gid| { const llvm_global = self.global_map.get(gid.index()) orelse { diff --git a/src/ir/inst.zig b/src/ir/inst.zig index 98dce03..881b86d 100644 --- a/src/ir/inst.zig +++ b/src/ir/inst.zig @@ -128,8 +128,6 @@ pub const Op = union(enum) { alloca: TypeId, // stack allocation, result is *T load: UnaryOp, // load from pointer store: Store, // store value to pointer - heap_alloc: UnaryOp, // context.allocator.alloc(size) → *void - heap_free: UnaryOp, // context.allocator.free(ptr) // ── Struct ops ────────────────────────────────────────────────── struct_init: Aggregate, // construct struct from field values diff --git a/src/ir/interp.zig b/src/ir/interp.zig index bf14331..cd6755a 100644 --- a/src/ir/interp.zig +++ b/src/ir/interp.zig @@ -918,22 +918,6 @@ pub const Interpreter = struct { .ret_void => return .ret_nothing, .@"unreachable" => return error.Unreachable, - // ── Heap operations ───────────────────────────────── - .heap_alloc => |u| { - const size_val = frame.getRef(u.operand); - const size: usize = @intCast(size_val.asInt() orelse return error.TypeError); - const hp = self.heapAlloc(size); - return .{ .value = .{ .heap_ptr = hp } }; - }, - .heap_free => |u| { - const ptr = frame.getRef(u.operand); - switch (ptr) { - .heap_ptr => |hp| self.heapFree(hp), - else => {}, - } - return .{ .value = .void_val }; - }, - // ── Builtin calls ────────────────────────────────── .call_builtin => |bi| { return self.execBuiltin(bi, frame, instruction.ty); diff --git a/src/ir/lower.zig b/src/ir/lower.zig index abe9562..ccfd32d 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -1917,13 +1917,14 @@ pub const Lowering = struct { // current `__sx_ctx` pointer. Every sx function (and // every `push Context.{...}` body) sets `current_ctx_ref` // to a `*Context` it owns, so this is one indirection. - if (self.implicit_ctx_enabled and std.mem.eql(u8, id.name, "context")) { - if (self.current_ctx_ref != Ref.none) { - const ctx_ty = self.module.types.findByName(self.module.types.internString("Context")) orelse { - break :blk self.emitError("context", node.span); - }; - break :blk self.builder.load(self.current_ctx_ref, ctx_ty); + if (std.mem.eql(u8, id.name, "context")) { + if (!self.implicit_ctx_enabled or self.current_ctx_ref == Ref.none) { + break :blk self.diagnoseMissingContext("the `context` identifier"); } + const ctx_ty = self.module.types.findByName(self.module.types.internString("Context")) orelse { + break :blk self.diagnoseMissingContext("the `context` identifier"); + }; + break :blk self.builder.load(self.current_ctx_ref, ctx_ty); } // Check globals (#run constants) if (self.global_names.get(id.name)) |gi| { @@ -5489,6 +5490,19 @@ pub const Lowering = struct { } } + /// Emit a diagnostic for code that needs `Context` (allocator + /// protocol, `push Context.{...}`, the `context` identifier) when + /// the program hasn't registered the type — i.e. doesn't transitively + /// import `modules/std.sx`. Returns a placeholder Ref so the lowering + /// can keep going and surface any additional errors. + fn diagnoseMissingContext(self: *Lowering, what: []const u8) Ref { + if (self.diagnostics) |d| { + const span = ast.Span{ .start = 0, .end = 0 }; + d.addFmt(.err, span, "{s} requires the Context type — add `#import \"modules/std.sx\";` (or a module that imports it)", .{what}); + } + return self.emitPlaceholder("missing-context"); + } + /// Emit `context.allocator.alloc(size)` dispatch — used by internal /// compiler-driven heap copies (e.g. the `xx value` protocol-erasure /// path in `buildProtocolValue`). Routes through whatever allocator is @@ -5496,21 +5510,21 @@ pub const Lowering = struct { /// `push Context.{ allocator = my_alloc, ... }` actually backs every /// allocation including the ones the compiler inserts. /// - /// Falls back to `.heap_alloc` (libc malloc) only when the `context` - /// global hasn't been registered (programs that don't `#import - /// "modules/std.sx"`). All standard sx code imports std.sx via - /// allocators.sx, so the fallback exists strictly for the bootstrapping - /// edge case. + /// If `Context` isn't registered (the program doesn't import std.sx), + /// emits a diagnostic and returns a placeholder. We deliberately do + /// NOT fall back to a direct libc malloc — that was the silent escape + /// hatch that bit us through the implicit-context refactor (see the + /// "Silent unimplemented arms" REJECTED PATTERN in CLAUDE.md). fn allocViaContext(self: *Lowering, size_ref: Ref, void_ptr_ty: TypeId) Ref { if (!self.implicit_ctx_enabled or self.current_ctx_ref == Ref.none) { - return self.builder.emit(.{ .heap_alloc = .{ .operand = size_ref } }, void_ptr_ty); + return self.diagnoseMissingContext("heap allocation"); } const ctx_ty = self.module.types.findByName(self.module.types.internString("Context")) orelse { - return self.builder.emit(.{ .heap_alloc = .{ .operand = size_ref } }, void_ptr_ty); + return self.diagnoseMissingContext("heap allocation"); }; const ctx_ty_info = self.module.types.get(ctx_ty); if (ctx_ty_info != .@"struct" or ctx_ty_info.@"struct".fields.len < 1) { - return self.builder.emit(.{ .heap_alloc = .{ .operand = size_ref } }, void_ptr_ty); + return self.diagnoseMissingContext("heap allocation"); } const allocator_ty = ctx_ty_info.@"struct".fields[0].ty; const ctx = self.builder.load(self.current_ctx_ref, ctx_ty); @@ -6210,10 +6224,12 @@ pub const Lowering = struct { // stack frame, rebinds the lowering's `current_ctx_ref` to it for // the body's lexical scope, then restores. No global, no walk. if (!self.implicit_ctx_enabled) { + _ = self.diagnoseMissingContext("`push Context.{...}`"); self.lowerBlock(ps.body); return; } const ctx_ty = self.module.types.findByName(self.module.types.internString("Context")) orelse { + _ = self.diagnoseMissingContext("`push Context.{...}`"); self.lowerBlock(ps.body); return; }; diff --git a/src/ir/print.zig b/src/ir/print.zig index 4168949..42d0adb 100644 --- a/src/ir/print.zig +++ b/src/ir/print.zig @@ -228,12 +228,6 @@ fn printInst(instruction: *const Inst, ref_idx: u32, tt: *const TypeTable, write try writer.print("store %{d}, %{d}\n", .{ s.ptr.index(), s.val.index() }); return; }, - .heap_alloc => |u| try writer.print("heap_alloc %{d} : ", .{u.operand.index()}), - .heap_free => |u| { - try writer.print("heap_free %{d}\n", .{u.operand.index()}); - return; - }, - // ── Struct ops ────────────────────────────────────────── .struct_init => |agg| { try writer.writeAll("struct_init ["); @@ -522,7 +516,7 @@ fn writeConstant(val: ConstantValue, writer: Writer) !void { fn isVoidOp(op: Op) bool { return switch (op) { - .store, .heap_free, .global_set, .br, .cond_br, .switch_br, .ret, .ret_void, .@"unreachable" => true, + .store, .global_set, .br, .cond_br, .switch_br, .ret, .ret_void, .@"unreachable" => true, else => false, }; }