diff --git a/examples/202-slice-of-protocol-variadic.sx b/examples/202-slice-of-protocol-variadic.sx new file mode 100644 index 0000000..739d016 --- /dev/null +++ b/examples/202-slice-of-protocol-variadic.sx @@ -0,0 +1,31 @@ +// Slice-of-protocol variadic `..xs: []P` — the RUNTIME counterpart to the +// comptime pack `..xs: P`. Each trailing arg is `xx`-erased to a `P` protocol +// value {ctx, vtable} and packed into a runtime `[]P`, so the elements can be +// indexed by a RUNTIME index and dispatched through the protocol interface +// (unlike a pack, which is comptime-only — see examples/163). +// +// This is the type-safe way to iterate a heterogeneous arg list at runtime: +// concrete per-position types are erased to the constraint protocol. + +#import "modules/std.sx"; + +Show :: protocol { show :: () -> string; } +A :: struct { x: s64; } +B :: struct { s: string; } +impl Show for A { show :: (self: *A) -> string => "A"; } +impl Show for B { show :: (self: *B) -> string => "B"; } + +// Runtime loop over a []Show: runtime index + protocol-method dispatch. +each :: (..xs: []Show) -> void { + i := 0; + while i < xs.len { + print("[{}]={}\n", i, xs[i].show()); + i = i + 1; + } +} + +main :: () -> s32 { + each(A.{ x = 1 }, B.{ s = "hi" }, A.{ x = 3 }); // heterogeneous, erased to Show + each(); // empty is fine (len 0) + 0; +} diff --git a/issues/0052-slice-of-protocol-variadic-not-erased.md b/issues/0052-slice-of-protocol-variadic-not-erased.md new file mode 100644 index 0000000..d06b976 --- /dev/null +++ b/issues/0052-slice-of-protocol-variadic-not-erased.md @@ -0,0 +1,68 @@ +**FIXED.** `packVariadicCallArgs` ([src/ir/lower.zig](../src/ir/lower.zig)) +now detects a protocol element type and `xx`-erases each arg into the +`[N]P` array via `buildProtocolErasure`, instead of storing the raw concrete +value. Regression: [examples/202-slice-of-protocol-variadic.sx](../examples/202-slice-of-protocol-variadic.sx). + +# Symptom + +A slice-of-protocol variadic `..xs: []P` (P a protocol) compiles but **crashes +at runtime** (Bus error) the moment an element is used: + +``` +Bus error at address 0x3fff +``` + +# Reproduction + +```sx +#import "modules/std.sx"; +Show :: protocol { show :: () -> string; } +A :: struct { x: s64; } +impl Show for A { show :: (self: *A) -> string => "A"; } + +each :: (..xs: []Show) -> void { + i := 0; + while i < xs.len { print("{}\n", xs[i].show()); i = i + 1; } +} +main :: () -> s32 { each(A.{ x = 1 }, A.{ x = 2 }); 0; } +``` + +# Root cause + +`packVariadicCallArgs` packs trailing args into a `[N x elem_ty]` stack array. +It special-cased `is_any = (elem_ty == .any)` (box each arg to `Any`), but for +any other non-builtin `elem_ty` it stored the **raw lowered arg** into the +array slot. When `elem_ty` is a protocol struct (16 bytes `{ctx, vtable}`), an +8-byte concrete `A` value was written into a protocol-sized slot — a +size/type mismatch producing a garbage `{ctx, vtable}`. Indexing it and +calling `.show()` then jumped through a bad vtable → Bus error. + +# Fix + +Mirror the `xx` cast: when the slice element type is a protocol, erase each arg +to the protocol value before storing. + +```zig +const elem_is_protocol = blk: { + if (elem_ty.isBuiltin()) break :blk false; + const ei = self.module.types.get(elem_ty); + break :blk ei == .@"struct" and ei.@"struct".is_protocol; +}; +// ... per arg, in the non-`is_any` path: +} else if (elem_is_protocol) { + var source_ty = self.inferExprType(arg_node); + if (source_ty == .unresolved) source_ty = self.builder.getRefType(val); + if (source_ty != elem_ty) val = self.buildProtocolErasure(val, arg_node, source_ty, elem_ty); +} +``` + +This makes `..xs: []P` the runtime, protocol-erased counterpart to the +comptime heterogeneous pack `..xs: P` (which stays comptime-only per Decision +1). See specs.md §"Variadic Heterogeneous Type Packs" for the full +form-comparison table. + +# Verification + +`examples/202-slice-of-protocol-variadic.sx` prints `[0]=A [1]=B [2]=A` and the +empty call is a no-op. `zig build test` + `bash tests/run_examples.sh` (237) +green. diff --git a/specs.md b/specs.md index d6654c8..fb52f47 100644 --- a/specs.md +++ b/specs.md @@ -963,6 +963,11 @@ path_join :: (..parts: []string) -> string { ... } trailing args into a stack-allocated `[N x T]` and passes a slice over it. - For `[]Any`, each trailing arg is boxed into `Any` (type tag + payload) before packing; `args[i]` reads back the boxed value. +- For `[]Protocol` (the element type is a protocol, e.g. `..xs: []Show`), each + trailing arg is `xx`-erased to a protocol value `{ctx, vtable}` (impl-driven, + like `xx`) and packed into a runtime `[N]Protocol`. `xs[runtime_i].method()` + then dispatches through the protocol — this is the **runtime** counterpart to + the comptime heterogeneous pack `..xs: Protocol`. - A `..` spread at the call site unpacks an existing slice/array into the variadic tail: `sum(..arr)`. - The heterogeneous comptime-pack form `..$args: []Type` binds per-position @@ -972,18 +977,31 @@ path_join :: (..parts: []string) -> string { ... } A **pack** is a comptime sequence of per-position-typed arguments. Unlike a slice variadic (`..xs: []T`, one uniform element type, a runtime slice), a pack -binds a *distinct* type to each position and exists only at compile time. Three -declaration forms exist: +binds a *distinct* type to each position and exists only at compile time. -| Form | Element typing | Body view | -|---|---|---| -| `..xs: []T` | uniform `T` | runtime `[]T` slice | -| `..$xs: []Type` | per-position comptime *types* | comptime type list | -| `..xs: Protocol` | per-position — each arg conforms to `Protocol` with its own type-arg | per-position-typed pack | +The full family of variadic/pack forms and how they differ: -The third form is the heterogeneous pack. `map :: (mapper: ..., ..sources: -ValueListenable) -> ...` accepts any number of trailing args, each some -`ValueListenable(T)` for a possibly-different `T`. +| Form | Element types | Lives at | `xs[i]` index | `xs[i]` yields | `xs.len` | +|---|---|---|---|---|---| +| `..xs: []T` | one uniform `T` | **runtime** (slice) | runtime or comptime | `T` | runtime | +| `..xs: []Any` | mixed, **boxed** to `Any` | **runtime** (slice) | runtime or comptime | `Any` (match/unwrap to use) | runtime | +| `..xs: []P` *(P a protocol)* | mixed, **erased** to `P` `{ctx,vtable}` | **runtime** (slice) | runtime or comptime | `P` (call protocol methods) | runtime | +| `..xs: P` *(pack)* | per-position **concrete**, each conforms to `P` | **comptime** (no runtime value) | comptime only (literal / `inline for` cursor) | the concrete element, **viewed through `P`** | comptime int | +| `..$args` / `..$xs: []Type` | per-position comptime **types** | **comptime** | comptime only | element value/type (reflection) | comptime int | + +Key axis — **concrete vs erased, comptime vs runtime**: +- `..xs: P` (pack) keeps each element's *concrete* type but is **comptime-only**: + `xs[i]` needs a compile-time index (a literal or an `inline for` cursor); a + runtime index is an error (a pack has no runtime representation). Use it when + you need per-position types (monomorphization, `xs.T` / `xs.value` projection). +- `..xs: []P` (slice of protocol) **erases** each element to the protocol value + but is **runtime**: `xs[runtime_i].method()` works in an ordinary loop. Use it + when you need to iterate the args at runtime and only the protocol interface + matters. It is the runtime counterpart to the pack. + +The heterogeneous pack (`..xs: P`) is what powers `map :: (mapper: ..., +..sources: ValueListenable) -> ...`: it accepts any number of trailing args, +each some `ValueListenable(T)` for a possibly-different `T`. A pack is **not a runtime value** — it lowers to N typed positional parameters (zero overhead). The body refers to elements only through the comptime forms diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 1ac0430..d41cae6 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -8341,6 +8341,14 @@ pub const Lowering = struct { // Determine if we need to box as Any (for ..Any params) or use raw type const is_any = (elem_ty == .any); + // `..xs: []P` (slice of a protocol): each concrete arg must be erased to + // a protocol value {ctx, vtable}, not stored raw (which would be a + // size/type mismatch — a heap of garbage vtables → crash on dispatch). + const elem_is_protocol = blk: { + if (elem_ty.isBuiltin()) break :blk false; + const ei = self.module.types.get(elem_ty); + break :blk ei == .@"struct" and ei.@"struct".is_protocol; + }; // Allocate stack array [N x ElemType] const array_elem = if (is_any) TypeId.any else elem_ty; @@ -8388,6 +8396,16 @@ pub const Lowering = struct { if (source_ty != .any) { val = self.builder.boxAny(val, source_ty); } + } else if (elem_is_protocol) { + // Erase each concrete arg to the protocol value via the same + // impl-driven `xx` machinery, so the runtime `[]P` holds real + // {ctx, vtable} values and `xs[i].method()` dispatches. + const arg_node = c.args[fixed_count + i]; + var source_ty = self.inferExprType(arg_node); + if (source_ty == .unresolved) source_ty = self.builder.getRefType(val); + if (source_ty != elem_ty) { + val = self.buildProtocolErasure(val, arg_node, source_ty, elem_ty); + } } const idx_ref = self.builder.constInt(@intCast(i), .s64); const elem_ptr = self.builder.emit(.{ .index_gep = .{ .lhs = array_slot, .rhs = idx_ref } }, self.module.types.ptrTo(array_elem)); diff --git a/tests/expected/202-slice-of-protocol-variadic.exit b/tests/expected/202-slice-of-protocol-variadic.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/202-slice-of-protocol-variadic.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/202-slice-of-protocol-variadic.txt b/tests/expected/202-slice-of-protocol-variadic.txt new file mode 100644 index 0000000..750afe6 --- /dev/null +++ b/tests/expected/202-slice-of-protocol-variadic.txt @@ -0,0 +1,3 @@ +[0]=A +[1]=B +[2]=A