mem: delete .heap_alloc/.heap_free IR ops + the silent libc-malloc escape

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.
This commit is contained in:
agra
2026-05-25 12:49:26 +03:00
parent 8e21cc5f73
commit b263704664
6 changed files with 31 additions and 100 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
};
}