From f2b3868579ae54af1290224b0c556f724dbfc324 Mon Sep 17 00:00:00 2001 From: agra Date: Mon, 25 May 2026 11:41:59 +0300 Subject: [PATCH] mem: thread val_ty through inst.Store; per-width comptime regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interp's `storeAtRawPtr` used to write 8 bytes from a `.int` / `.float` Value regardless of the destination's declared width. The Value tag flattens s8..s64/u*/pointer all to `.int`, so it can't disambiguate widths on its own — every store risked clobbering up to 7 neighbor bytes if the actual IR type was sub-8. Fix: - `inst.Store` gains `val_ty: TypeId` (defaults to `.void` for backward compat with the LLVM emitter, which doesn't read it). - `builder.store` captures `getRefType(val)` at emit time. - `storeAtRawPtr` now takes `val_ty`, looks up `types.typeSizeBytes(val_ty)`, and writes exactly that many bytes: `.int` → width bytes of the i64 representation (1..8), `.float` → 4 (f32 round-trip via @floatCast) or 8, `.boolean` → 1 (zeros higher width bytes when destination is wider), `.null_val` → width bytes of zero. Width outside the expected band bails with a clear diagnostic. Regression test: `examples/132-comptime-typed-store-widths.sx`. For every primitive type (u8/u16/u32/u64, s8/s16/s32/s64, bool, f32, f64), the test: 1. Allocates a 32-byte libc buffer through `context.allocator`. 2. Fills with sentinel byte 0xAA. 3. Writes ONE typed value at offset 8. 4. Sums every byte back. 5. Compares the runtime checksum (LLVM-emitted store, already correct) against a comptime checksum baked via `#run`. Mismatch = neighbor clobber. The test exits non-zero with a per-width "FAIL u8: comptime=X runtime=Y" line so future regressions surface the offending width. Also wired: - Interp's `index_get` gains `.int` / `.byte_ptr` base arms — `buf[i]` through a raw libc-malloc'd pointer reads one byte at offset i. Used by the new test's `sum_bytes` loop; previously bailed at `op=index_get`. - `emit_llvm`'s comptime-init catch block prints a real diagnostic instead of swallowing the error and filling the const with zero. Stale bail state from a previous init is cleared before each call. 154/154 example tests pass (the new test + the existing 153). Chess still green on macOS / iOS sim / Android. --- examples/132-comptime-typed-store-widths.sx | 190 ++++++++++++++++++ src/ir/emit_llvm.zig | 15 +- src/ir/inst.zig | 6 + src/ir/interp.zig | 64 +++--- src/ir/module.zig | 3 +- .../132-comptime-typed-store-widths.exit | 1 + .../132-comptime-typed-store-widths.txt | 1 + 7 files changed, 255 insertions(+), 25 deletions(-) create mode 100644 examples/132-comptime-typed-store-widths.sx create mode 100644 tests/expected/132-comptime-typed-store-widths.exit create mode 100644 tests/expected/132-comptime-typed-store-widths.txt diff --git a/examples/132-comptime-typed-store-widths.sx b/examples/132-comptime-typed-store-widths.sx new file mode 100644 index 0000000..496ff00 --- /dev/null +++ b/examples/132-comptime-typed-store-widths.sx @@ -0,0 +1,190 @@ +// Lock down the interp's raw-pointer store width per primitive type. +// +// Each helper allocates a 32-byte buffer through `context.allocator`, +// fills it with a sentinel byte (0xAA), writes ONE typed value at +// offset 8, then sums every byte back. A correctly-sized store touches +// exactly `sizeof(T)` bytes, so the sum equals +// 31 * 0xAA + sum-of-bytes-in-the-written-value. +// A wrong width (e.g. an 8-byte store at a 1-byte slot) clobbers +// neighbors with zeros and the sum drops. +// +// Each test computes its expected sum at COMPTIME (the value is baked +// into a `#run` constant — the interp's `storeAtRawPtr` runs). The +// runtime program prints the same checksum computed by codegen +// (LLVM-emitted typed stores). The two MUST match — that's the +// regression assertion. +// +// To pin the test: every helper returns its checksum; main prints +// "ok" iff every comptime-baked checksum equals the runtime-recomputed +// one. Failure prints which width diverged. + +#import "modules/std.sx"; + +SENTINEL :u8: 0xAA; // 170 — neighbor pattern +BUF_SIZE :s64: 32; +TARGET :s64: 8; // offset where the typed store lands + +// ── per-width helpers ─────────────────────────────────────────────── + +fill :: (buf: [*]u8) { + i : s64 = 0; + while i < BUF_SIZE { buf[i] = SENTINEL; i += 1; } +} + +sum_bytes :: (buf: [*]u8) -> s64 { + s : s64 = 0; + i : s64 = 0; + while i < BUF_SIZE { s += xx buf[i]; i += 1; } + s; +} + +run_u8 :: () -> s64 { + buf : [*]u8 = xx malloc(BUF_SIZE); + fill(buf); + p : *u8 = xx @buf[TARGET]; + p.* = 0x42; + s := sum_bytes(buf); + free(xx buf); + s; +} + +run_u16 :: () -> s64 { + buf : [*]u8 = xx malloc(BUF_SIZE); + fill(buf); + p : *u16 = xx @buf[TARGET]; + p.* = 0x0102; + s := sum_bytes(buf); + free(xx buf); + s; +} + +run_u32 :: () -> s64 { + buf : [*]u8 = xx malloc(BUF_SIZE); + fill(buf); + p : *u32 = xx @buf[TARGET]; + p.* = 0x01020304; + s := sum_bytes(buf); + free(xx buf); + s; +} + +run_u64 :: () -> s64 { + buf : [*]u8 = xx malloc(BUF_SIZE); + fill(buf); + p : *u64 = xx @buf[TARGET]; + p.* = 0x0102030405060708; + s := sum_bytes(buf); + free(xx buf); + s; +} + +run_s8 :: () -> s64 { + buf : [*]u8 = xx malloc(BUF_SIZE); + fill(buf); + p : *s8 = xx @buf[TARGET]; + p.* = 0x42; + s := sum_bytes(buf); + free(xx buf); + s; +} + +run_s16 :: () -> s64 { + buf : [*]u8 = xx malloc(BUF_SIZE); + fill(buf); + p : *s16 = xx @buf[TARGET]; + p.* = 0x0102; + s := sum_bytes(buf); + free(xx buf); + s; +} + +run_s32 :: () -> s64 { + buf : [*]u8 = xx malloc(BUF_SIZE); + fill(buf); + p : *s32 = xx @buf[TARGET]; + p.* = 0x01020304; + s := sum_bytes(buf); + free(xx buf); + s; +} + +run_s64 :: () -> s64 { + buf : [*]u8 = xx malloc(BUF_SIZE); + fill(buf); + p : *s64 = xx @buf[TARGET]; + p.* = 0x0102030405060708; + s := sum_bytes(buf); + free(xx buf); + s; +} + +run_bool :: () -> s64 { + buf : [*]u8 = xx malloc(BUF_SIZE); + fill(buf); + p : *bool = xx @buf[TARGET]; + p.* = true; + s := sum_bytes(buf); + free(xx buf); + s; +} + +run_f32 :: () -> s64 { + buf : [*]u8 = xx malloc(BUF_SIZE); + fill(buf); + p : *f32 = xx @buf[TARGET]; + p.* = 1.0; + s := sum_bytes(buf); + free(xx buf); + s; +} + +run_f64 :: () -> s64 { + buf : [*]u8 = xx malloc(BUF_SIZE); + fill(buf); + p : *f64 = xx @buf[TARGET]; + p.* = 1.0; + s := sum_bytes(buf); + free(xx buf); + s; +} + +// ── comptime-baked expected checksums ─────────────────────────────── +// `#run` evaluates each helper via the interp, so its +// `storeAtRawPtr(addr, val, val_ty)` honors the declared width. + +EXP_U8 :: #run run_u8(); +EXP_U16 :: #run run_u16(); +EXP_U32 :: #run run_u32(); +EXP_U64 :: #run run_u64(); +EXP_S8 :: #run run_s8(); +EXP_S16 :: #run run_s16(); +EXP_S32 :: #run run_s32(); +EXP_S64 :: #run run_s64(); +EXP_BOOL :: #run run_bool(); +EXP_F32 :: #run run_f32(); +EXP_F64 :: #run run_f64(); + +// ── runtime comparison ────────────────────────────────────────────── + +check :: (label: string, got: s64, want: s64) -> bool { + if got == want { return true; } + print("FAIL {}: comptime={} runtime={}\n", label, want, got); + false; +} + +main :: () -> s32 { + ok := true; + if !check("u8", run_u8(), EXP_U8) { ok = false; } + if !check("u16", run_u16(), EXP_U16) { ok = false; } + if !check("u32", run_u32(), EXP_U32) { ok = false; } + if !check("u64", run_u64(), EXP_U64) { ok = false; } + if !check("s8", run_s8(), EXP_S8) { ok = false; } + if !check("s16", run_s16(), EXP_S16) { ok = false; } + if !check("s32", run_s32(), EXP_S32) { ok = false; } + if !check("s64", run_s64(), EXP_S64) { ok = false; } + if !check("bool", run_bool(), EXP_BOOL) { ok = false; } + if !check("f32", run_f32(), EXP_F32) { ok = false; } + if !check("f64", run_f64(), EXP_F64) { ok = false; } + if ok { print("ok\n"); return 0; } + return 1; +} diff --git a/src/ir/emit_llvm.zig b/src/ir/emit_llvm.zig index ad5560c..ce0767e 100644 --- a/src/ir/emit_llvm.zig +++ b/src/ir/emit_llvm.zig @@ -659,7 +659,20 @@ pub const LLVMEmitter = struct { if (global.comptime_func) |func_id| { var interp_inst = Interpreter.init(self.ir_mod, self.alloc); interp_inst.build_config = &self.build_config; - const result = interp_inst.call(func_id, &.{}) catch .void_val; + Interpreter.last_bail_op = null; + Interpreter.last_bail_builtin = null; + Interpreter.last_bail_detail = null; + const result = interp_inst.call(func_id, &.{}) catch |err| blk: { + // Surface the bail loudly instead of silently filling + // the const with zero. Stale state from a previous + // comptime function would otherwise hide the error. + const op = Interpreter.last_bail_op orelse ""; + const detail = Interpreter.last_bail_detail orelse ""; + const sep: []const u8 = if (detail.len > 0) ": " else ""; + const gname = self.ir_mod.types.getString(global.name); + std.debug.print("error: comptime init of '{s}' failed: {s} (op={s}{s}{s})\n", .{ gname, @errorName(err), op, sep, detail }); + break :blk .void_val; + }; const init_val = self.valueToLLVMConst(result, llvm_ty); c.LLVMSetInitializer(llvm_global, init_val); } else if (global.init_val) |iv| { diff --git a/src/ir/inst.zig b/src/ir/inst.zig index 815ef3d..98dce03 100644 --- a/src/ir/inst.zig +++ b/src/ir/inst.zig @@ -249,6 +249,12 @@ pub const TriOp = struct { pub const Store = struct { ptr: Ref, val: Ref, + /// Declared type of the value being stored. Threaded through so the + /// interp's raw-pointer store knows the destination byte width — a + /// `.int` Value alone is ambiguous (s8/s16/s32/s64/u*/usize/pointer + /// all flatten to `.int`). The LLVM emitter ignores this (LLVM knows + /// the width from the SSA value's type already). + val_ty: TypeId = .void, }; pub const Conversion = struct { diff --git a/src/ir/interp.zig b/src/ir/interp.zig index 11773da..5c84bf7 100644 --- a/src/ir/interp.zig +++ b/src/ir/interp.zig @@ -198,38 +198,45 @@ pub const Interpreter = struct { self.hooks.deinit(); } - /// Write `val` to the raw host address `addr`. Used when the + /// Write `val` to the raw host address `addr` using exactly the + /// number of bytes declared by `val_ty`. Used when the /// protocol-dispatch chain bottoms out at a foreign-libc-malloc /// pointer and sx code stores through it. Comptime safety is the /// caller's responsibility — wild writes will fault. - /// - /// **Width assumption.** `.int` and `.float` always write 8 bytes. - /// The Store IR op doesn't currently thread val's TypeId into the - /// interp, so we can't tell s32/s64 or f32/f64 apart from the - /// Value tag. Real-world comptime paths (protocol erasure heap - /// copies, Context aggregate stores) hit 8-byte fields, so this - /// works in practice. If a comptime store ever hits a smaller - /// destination through a raw pointer, neighbors get clobbered — - /// add `val_ty` to `inst.Store` and switch on it here. - fn storeAtRawPtr(self: *Interpreter, addr: i64, val: Value) InterpError!void { - _ = self; + fn storeAtRawPtr(self: *Interpreter, addr: i64, val: Value, val_ty: @import("types.zig").TypeId) InterpError!void { const dst: [*]u8 = @ptrFromInt(@as(usize, @bitCast(addr))); + const width = self.module.types.typeSizeBytes(val_ty); switch (val) { .int => |v| { + // Width is whatever the declared IR type says (s8..s64, + // u8..u64, usize, pointer-as-int, bool-after-extension). + // The Value tag itself is .int regardless. + if (width == 0 or width > 8) return bailDetail("comptime store of int through raw pointer: unexpected declared width (expected 1..8 bytes)"); const bytes = std.mem.toBytes(v); - @memcpy(dst[0..bytes.len], &bytes); + @memcpy(dst[0..width], bytes[0..width]); }, .float => |v| { - const bytes = std.mem.toBytes(v); - @memcpy(dst[0..bytes.len], &bytes); + switch (width) { + 8 => { + const bytes = std.mem.toBytes(v); + @memcpy(dst[0..8], &bytes); + }, + 4 => { + const f32_v: f32 = @floatCast(v); + const bytes = std.mem.toBytes(f32_v); + @memcpy(dst[0..4], &bytes); + }, + else => return bailDetail("comptime store of float through raw pointer: unexpected declared width (expected 4 or 8 bytes)"), + } }, .boolean => |v| { + if (width == 0) return bailDetail("comptime store of bool through raw pointer: zero-width destination"); + @memset(dst[0..width], 0); dst[0] = if (v) 1 else 0; }, .null_val => { - const zero: u64 = 0; - const bytes = std.mem.toBytes(zero); - @memcpy(dst[0..bytes.len], &bytes); + if (width == 0 or width > 8) return bailDetail("comptime store of null through raw pointer: unexpected declared width"); + @memset(dst[0..width], 0); }, .aggregate => return bailDetail("comptime store of aggregate through raw pointer not supported (struct field layout not threaded into Store IR op)"), .heap_ptr => return bailDetail("comptime store of interp-heap pointer through raw pointer not supported"), @@ -704,11 +711,10 @@ pub const Interpreter = struct { self.heapStoreByte(hp, byte); }, // Raw host pointer (from foreign call, e.g. libc_malloc). - // 8-byte stride assumed — covers the s64/pointer/f64 cases - // sx hits via comptime protocol erasure. Aggregate stores - // unpack and recurse. + // `val_ty` carries the declared destination width so we + // write exactly that many bytes — no neighbor clobber. .int => |p| { - try storeAtRawPtr(self, p, val); + try storeAtRawPtr(self, p, val, s.val_ty); }, // Byte-granular pointer (from index_gep on a string). // Always a 1-byte store — matches the heap_ptr arm. @@ -1014,7 +1020,19 @@ pub const Interpreter = struct { if (i >= fields.len) return error.OutOfBounds; return .{ .value = fields[i] }; }, - else => return error.CannotEvalComptime, + // Raw host pointer base — `buf[i]` reads one byte at + // offset i. Matches the byte-addressed `index_gep` + // semantics for the same shape. Used by comptime sx + // code that walks libc-malloc'd buffers. + .int => |p| { + const src: [*]const u8 = @ptrFromInt(@as(usize, @bitCast(p))); + return .{ .value = .{ .int = src[i] } }; + }, + .byte_ptr => |addr| { + const src: [*]const u8 = @ptrFromInt(addr); + return .{ .value = .{ .int = src[i] } }; + }, + else => return bailDetail("comptime index_get: unsupported base kind"), } }, .length => |u| { diff --git a/src/ir/module.zig b/src/ir/module.zig index d3d7102..cb031af 100644 --- a/src/ir/module.zig +++ b/src/ir/module.zig @@ -329,7 +329,8 @@ pub const Builder = struct { } pub fn store(self: *Builder, ptr: Ref, val: Ref) void { - self.emitVoid(.{ .store = .{ .ptr = ptr, .val = val } }, .void); + const val_ty = self.getRefType(val); + self.emitVoid(.{ .store = .{ .ptr = ptr, .val = val, .val_ty = val_ty } }, .void); } // ── Struct ops ────────────────────────────────────────────────── diff --git a/tests/expected/132-comptime-typed-store-widths.exit b/tests/expected/132-comptime-typed-store-widths.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/132-comptime-typed-store-widths.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/132-comptime-typed-store-widths.txt b/tests/expected/132-comptime-typed-store-widths.txt new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/tests/expected/132-comptime-typed-store-widths.txt @@ -0,0 +1 @@ +ok