lang: slice-of-protocol variadic ..xs: []P erases each arg to the protocol

packVariadicCallArgs stored the raw concrete arg into a [N x P] array when the
element type was a protocol, so an 8-byte struct landed in a 16-byte {ctx,
vtable} slot -> garbage vtable -> Bus error on dispatch. Now, when the slice
element type is a protocol, each arg is xx-erased to the protocol value via
buildProtocolErasure (same impl-driven machinery as the xx cast). This makes
..xs: []P the runtime, protocol-erased counterpart to the comptime
heterogeneous pack ..xs: P (which stays comptime-only): xs[runtime_i].method()
now works in an ordinary loop.

specs.md: full variadic/pack form-comparison table (concrete-vs-erased,
comptime-vs-runtime). Regression: examples/202. Issue 0052 (FIXED). 237 green.
This commit is contained in:
agra
2026-05-30 01:50:29 +03:00
parent 82bdcd634a
commit ab572359ae
6 changed files with 149 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,3 @@
[0]=A
[1]=B
[2]=A