Files
sx/issues/0176-protocol-typed-struct-field-method-call-aborts.md
agra 3c738695dc fix: diagnose non-conforming protocol erasure instead of unreachable-thunk SIGABRT (issue 0176)
Erasing a type to a protocol when it conforms only via a free function
(not an explicit impl P for T) built a vtable of unreachable thunks ->
SIGABRT on first dispatch, with no diagnostic. Per specs.md erasure is
impl-driven, not structural, so the erasure was never valid.

Add a conformance gate (firstUnimplementedMethod in buildProtocolValue,
src/ir/lower/protocol.zig): emit a located diagnostic when a protocol
method has no reachable impl, or when an impl method introduces its own
type params (signature mismatch — it bails lazyLowerFunction and would
reach the unreachable thunk). A std.debug.panic tripwire guards the
diagnostics==null path so a non-conforming erasure can never silently
ship as undef. Gate<->thunk equivalence verified bidirectional.

Regressions: protocols/0419 (positive struct-field dispatch),
diagnostics/1197 (no-impl) + 1198 (generic-method signature mismatch).
Updated memory/0808 (it erased a non-conforming type that never
dispatched). Verified by 3+1 adversarial reviews, suite 788/0. Filed
adjacent bug 0178 (protocol impl method type-mismatch silent miscompile).
2026-06-23 02:13:30 +03:00

3.7 KiB

0176 — calling a method through a protocol-typed struct field aborts (exit 133, no diagnostic)

RESOLVED (root cause differs from the title's hypothesis). The crash had nothing to do with struct fields: erasing a type to a protocol when the type conforms only via a FREE FUNCTION (speak :: (self: *Dog)) rather than an explicit impl Speaker for Dog { ... } built a vtable of unreachable thunks → SIGABRT on dispatch. Per specs.md §"Storage and protocol conformance" (erasure is impl-driven, not structural), the repro was never valid. Fix (src/ir/lower/protocol.zig): a conformance gate firstUnimplementedMethod in buildProtocolValue emits a located diagnostic (missing impl, or a signature-mismatch when an impl method introduces its own $T) instead of building unreachable thunks; a std.debug.panic tripwire guards the diagnostics == null path so a non-conforming erasure can never silently ship as undef. Gate↔thunk equivalence verified bidirectional by 3+1 adversarial reviews; suite 788/0. Regressions: examples/protocols/0419-protocols-struct-field-dispatch.sx (positive), examples/diagnostics/1197-diagnostics-protocol-erasure-no-impl.sx + 1198-diagnostics-protocol-erasure-generic-method.sx (negative). Updated examples/memory/0808-*.sx (it relied on a non-conforming erasure that never dispatched). (Adjacent pre-existing bug found + filed: 0178 — protocol impl method with a mismatched return/param TYPE silently miscompiles.)

Symptom

A struct field whose type is a PROTOCOL holds an erased value fine, but calling a method THROUGH that field aborts the process (exit 133, SIGABRT) with no diagnostic. Reading a non-protocol sibling field is fine; constructing the struct is fine. The crash needs the method call-through. Reproduces with BOTH struct-literal init and field assignment, so it is not a struct-literal bug — the protocol field's method dispatch / vtable through a struct slot is the suspect. Pre-existing (reproduces on clean master).

Reproduction

#import "modules/std.sx";
Speaker :: protocol { speak :: (self: *Self) -> i64; }
Dog :: struct { n: i64 = 0; }
speak :: (self: *Dog) -> i64 { return self.n; }
Holder :: struct { s: Speaker; b: i64 = 0; }
main :: () {
  d := Dog.{ n = 42 };
  h : Holder = .{ s = d, b = 5 };   // or:  h.s = d (field assign) — same crash
  print("{}\n", h.s.speak());        // <-- aborts here, exit 133, no output
}

Expected: 42. Observed: silent abort, exit 133. Reading h.b (the non-protocol field) prints 5 fine; the crash is specifically the call through h.s.

Investigation prompt

The erased protocol value stored in a struct field appears to lose its method-table / self pointer, so dispatch through h.s.speak() reads a null/garbage vtable. Compare against a protocol value in a LOCAL variable (s : Speaker = d; s.speak() — does THAT work?) to isolate whether the bug is in storing the erased value into a struct field, or in dispatching through a field access. Suspect the protocol fat-value {vtable/typeinfo, data-ptr} layout when embedded as a struct field: the field store (emitStructInit / field assign) may truncate or mis-place the fat value, or the method-dispatch lowering for field.method() may not load the full protocol header. Look at how a protocol local dispatches vs how a protocol struct-field dispatches (src/ir/lower/expr.zig method-call / field-access lowering + src/backend/llvm protocol dispatch). Follow the no-silent-fallback rule. Verify: the repro prints 42; both struct-literal and field-assign init; a protocol field reassigned to a different concrete type dispatches correctly. Add a examples/protocols/04xx-protocol-struct-field-dispatch.sx regression.