diff --git a/gno.land/pkg/integration/testdata/method_iface_lazy_persist.txtar b/gno.land/pkg/integration/testdata/method_iface_lazy_persist.txtar new file mode 100644 index 00000000000..7e1a48e9e2a --- /dev/null +++ b/gno.land/pkg/integration/testdata/method_iface_lazy_persist.txtar @@ -0,0 +1,50 @@ +# A method value bound through an interface (G = o.Get) is lazy: it saves the +# operand and dynamically dispatches at call time. This must survive a +# persist/reload across txs — the stored bind re-reads the (mutated) embedded +# interface field at the later call and re-dispatches to the new concrete type. + +gnoland start + +gnokey maketx addpkg -pkgdir $WORK/lz -pkgpath gno.land/r/test/lz -gas-fee 1000000ugnot -gas-wanted 8000000 -chainid=tendermint_test test1 +stdout OK! + +# bind G = o.Get while s.Inner is Impl{1}; persist. +gnokey maketx call -pkgpath gno.land/r/test/lz -func Bind -gas-fee 1000000ugnot -gas-wanted 8000000 -chainid=tendermint_test test1 +stdout OK! + +# reassign s.Inner to Other{} (a different concrete type); persist. +gnokey maketx call -pkgpath gno.land/r/test/lz -func Mutate -gas-fee 1000000ugnot -gas-wanted 8000000 -chainid=tendermint_test test1 +stdout OK! + +# later tx: G is reloaded and called -> must re-dispatch to Other.Get -> 2. +gnokey maketx call -pkgpath gno.land/r/test/lz -func Call -gas-fee 1000000ugnot -gas-wanted 8000000 -chainid=tendermint_test test1 +stdout '(2 int)' +stdout OK! + +-- lz/gnomod.toml -- +module = "gno.land/r/test/lz" +gno = "0.9" + +-- lz/lz.gno -- +package lz + +type Impl struct{ x int } + +func (i Impl) Get() int { return i.x } + +type Other struct{} + +func (Other) Get() int { return 2 } + +type Inner interface{ Get() int } + +type S struct{ Inner } + +type Outer interface{ Get() int } + +var s = &S{Inner: Impl{1}} +var G func() int + +func Bind(cur realm) { var o Outer = s; G = o.Get } +func Mutate(cur realm) { s.Inner = Other{} } +func Call(cur realm) int { return G() } diff --git a/gno.land/pkg/integration/testdata/stdlib_restart_compare.txtar b/gno.land/pkg/integration/testdata/stdlib_restart_compare.txtar index b4d476e9f1c..76957e4d934 100644 --- a/gno.land/pkg/integration/testdata/stdlib_restart_compare.txtar +++ b/gno.land/pkg/integration/testdata/stdlib_restart_compare.txtar @@ -4,7 +4,7 @@ env ADDPKG_GAS=4_800_000 env SETUP_GAS=3_200_000 -env EXACT_GAS=2235646 +env EXACT_GAS=2235788 gnoland start diff --git a/gnovm/adr/pr5737_nil_value_method_panic_timing.md b/gnovm/adr/pr5737_nil_value_method_panic_timing.md new file mode 100644 index 00000000000..9d04047cd54 --- /dev/null +++ b/gnovm/adr/pr5737_nil_value_method_panic_timing.md @@ -0,0 +1,83 @@ +# ADR-5737: Call-time dispatch for interface-bound method values + +## Status + +Proposed. **Hard-fork-class** (changes a persisted value's wire format). + +## Context + +In Go a method value formed through an interface (`g := i.M`, `defer i.M()`) +saves the operand at formation and materializes the concrete method + receiver +*inside the call*. GnoVM resolved it eagerly at bind, diverging from Go (vs +go1.25) on: nil-panic timing, value snapshot, embedded-method promotion, field +re-read through a boxed pointer, dynamic re-dispatch when an embedded interface +field is reassigned, and a VM crash for a nil embedded pointer-receiver (c5). +These last facets need the operand's *live* value at the call, so earlier narrow +fixes (a `nilReceiverPanic` flag, a `viaInterface` gate) couldn't reach them. + +## Decision + +Bind interface method values lazily; resolve at the call. + +- `BoundMethodValue` gains `Method Name`; `Func == nil` (operand in `Receiver`) + marks a lazy bind (`IsLazy()`). +- **Bind** (`VPInterface`): just save the operand — `&BoundMethodValue{Func: nil, + Receiver: *dtv, Method: name}`, no deref/dispatch. Concrete/pointer binds + (`pt.M`, `t.M`) are untouched. +- **Call** (`resolveLazyBound`, from `doOpPrecall` and the deferred call): walk + the operand's *current* value (`findEmbeddedFieldType` + `resolveInterfaceTrail`) + to the concrete method + receiver — the deref, embedded-field walk, copy, nil + panic, field re-read and re-dispatch all happen here. A nested embedded + interface yields another lazy bind; the loop unwraps it. +- **Defers** carry the callable on `Defer.Callable` (`*FuncValue` or + `*BoundMethodValue`), type-switched at the deferred call like `doOpPrecall`. +- **nil derefs** are raised by the walk (`GetPointerToFromTV`) via the `Run` + loop's panic path; a `VPSubrefField` nil guard makes c5 panic cleanly. +- **Reload**: `resolveLazyBound` fills the operand (`fillValueTV`) first, so a + persisted bind re-reads live state — what makes re-read / re-dispatch correct + across reload. +- `Func == nil` consumers audited (`IsCrossing`, `String`, GC, realm + persist/copy/fill, `GetShallowSize`). + +## Consequences + +- **Hard fork**: `Method` is proto field 4 (`pb3_gen` regenerated); + `_allocBoundMethodValue` 200→216. Persisted bound methods change bytes → + different IAVL hashes / gas (`stdlib_restart_compare` pin moved). Ship only on + fresh genesis or a coordinated upgrade (like ADR-5544); old state still decodes + (new field defaults empty). +- **Behaviour**: interface method values match Go on every axis, across reload; + concrete/pointer binds unchanged. +- **Performance / gas.** Concrete path unchanged (`OpCPUPrecallBoundMethod` 199 + still valid). The dispatch *walk* moved from bind to call, so the two consts + were re-fit together (ratio-scaled; reference HW unavailable — see the + `TODO(calibration)` on each): + - bind `OpCPUSelectorInterface` 751 → **276** — the eager selector walked the + trail here (~751); the lazy bind only does the method lookup + lazy-bind + alloc (~140 ns ≈ 276), so leaving it at 751 would double-charge the walk. + - call `OpCPULazyBoundResolve` **529** (new) — the walk, now at call time. + Net ≈ +54 gas per interface method call (the genuine extra: a re-lookup + + throwaway bound-method alloc), not the ~480 an un-reduced base would + over-charge. `stdlib_restart_compare` pin → 2235788. A lean walk avoiding the + throwaway alloc was rejected — it would duplicate `GetPointerToFromTV`'s + dispatch/nil machinery on consensus code; the reuse form stays the single + source of truth. + +## Follow-ups + +- **Calibration, before the fork ships** (both consts are ratio-scaled — the + reference HW was unavailable): re-measure `OpCPUSelectorInterface` (276) and + `OpCPULazyBoundResolve` (529) on the gas-table reference HW; and consider a + per-trail-step slope on the lazy resolve so deep/nested dispatch is metered + per hop, matching the eager path (currently flat, a bounded under-charge). +- Orthogonal, pre-existing (not caused or addressed here): interface method + *expressions* `I.M` rejected at preprocess (#5787); a method call on a *nil + interface* panics uncatchably (#5850). +- Cache a value-operand lazy bind's resolution (it is stable) to skip the + re-walk on repeated calls — **consensus-affecting, not just perf**: skipping + the walk drops its gas charge (`OpCPULazyBoundResolve` + alloc-gas), and + mutating a persisted bmv in place rewrites its bytes (merkle). So it is itself + a hard-fork change — fold into this fork's window or skip; it cannot ship as a + later rolling upgrade. Marginal benefit (only the call-a-stored-value-operand- + method-value-N-times pattern; pointer operands must never be cached, they + re-read live state), so deferred. diff --git a/gnovm/pkg/gnolang/alloc.go b/gnovm/pkg/gnolang/alloc.go index 0bfcaa49452..c82736fdbe7 100644 --- a/gnovm/pkg/gnolang/alloc.go +++ b/gnovm/pkg/gnolang/alloc.go @@ -81,7 +81,7 @@ const ( _allocSliceValue = 40 // unsafe.Sizeof(SliceValue{}) _allocFuncValue = 352 // unsafe.Sizeof(FuncValue{}) _allocMapValue = 168 // unsafe.Sizeof(MapValue{}) - _allocBoundMethodValue = 200 // unsafe.Sizeof(BoundMethodValue{}) + _allocBoundMethodValue = 216 // unsafe.Sizeof(BoundMethodValue{}) _allocBlock = 528 // unsafe.Sizeof(Block{}) _allocPackageValue = 296 // unsafe.Sizeof(PackageValue{}) — interrealm v2 +24 bytes for PkgID field (Hashlet + alignment) _allocHeapItemValue = 192 // unsafe.Sizeof(HeapItemValue{}) @@ -744,10 +744,14 @@ func (mv *MapValue) GetShallowSize() int64 { } func (bmv *BoundMethodValue) GetShallowSize() int64 { - // skip .uverse - if bmv.Func.PkgPath == ".uverse" { - return 0 - } + // A bound method is a runtime composite (Func + Receiver) allocated where it + // is formed; unlike PackageValue/Block/FuncValue it has no PkgPath of its + // own, so its size must not be keyed on the wrapped Func's package. Its + // shallow size is always allocBoundMethod — whether it counts toward a realm + // is decided by GC/persistence reachability. (Previously this returned 0 when + // Func.PkgPath == ".uverse", under-counting a real, dynamically-allocated + // wrapper of a uverse method — e.g. `g := addr.String`, a concrete + // value-method on the uverse `address` type — by allocBoundMethod.) return allocBoundMethod } diff --git a/gnovm/pkg/gnolang/alloc_test.go b/gnovm/pkg/gnolang/alloc_test.go index 5345d8fb8dd..b7e5a5ef2d4 100644 --- a/gnovm/pkg/gnolang/alloc_test.go +++ b/gnovm/pkg/gnolang/alloc_test.go @@ -71,3 +71,22 @@ func TestBlockGetShallowSize_WithRefNodeSource(t *testing.T) { refNodeSize, expectedRefNodeSize, normalSize, allocRefNode) } } + +func TestBoundMethodValueGetShallowSize(t *testing.T) { + // A bound method's shallow size is always allocBoundMethod, regardless of + // the wrapped Func's package (a bmv has no PkgPath of its own) — including a + // uverse-method wrapper (previously wrongly sized 0) and a lazy interface + // bind whose Func is nil. + for _, tc := range []struct { + name string + bmv *BoundMethodValue + }{ + {"uverse method wrapper", &BoundMethodValue{Func: &FuncValue{PkgPath: ".uverse"}}}, + {"lazy interface bind", &BoundMethodValue{Func: nil}}, + {"normal method", &BoundMethodValue{Func: &FuncValue{PkgPath: "gno.land/r/test/foo"}}}, + } { + if got := tc.bmv.GetShallowSize(); got != allocBoundMethod { + t.Errorf("%s: GetShallowSize() = %d, want %d", tc.name, got, allocBoundMethod) + } + } +} diff --git a/gnovm/pkg/gnolang/bench_ops_test.go b/gnovm/pkg/gnolang/bench_ops_test.go index 212b617a364..12dc8401056 100644 --- a/gnovm/pkg/gnolang/bench_ops_test.go +++ b/gnovm/pkg/gnolang/bench_ops_test.go @@ -4658,6 +4658,39 @@ func BenchmarkOpPrecall_BoundMethod(b *testing.B) { reportBenchops(b) } +// BenchmarkOpPrecall_BoundMethod_Lazy measures the interface-dispatched (lazy) +// bound-method path: Func==nil, so doOpPrecall resolves the concrete method + +// receiver at call time (resolveLazyBound walks the saved operand). This is the +// cost of every interface method call (i.M()); compare with the concrete +// BenchmarkOpPrecall_BoundMethod above. Value operand (re-walks every call). +func BenchmarkOpPrecall_BoundMethod_Lazy(b *testing.B) { + m := benchMachine() + defer m.Release() + + ft, _, dt, sv := benchMethodSetup(m.Alloc) + bmv := &BoundMethodValue{ + Func: nil, // lazy: resolved at call + Receiver: TypedValue{T: dt, V: sv}, // saved operand (value) + Method: "DoStuff", + } + cx := &CallExpr{NumArgs: 1} + + bm.InitMeasure() + bm.BeginOpCode(bmSetup) + for range b.N { + m.PushValue(TypedValue{T: ft, V: bmv}) // bound method + m.PushValue(TypedValue{T: IntType, N: i2n(1)}) // arg + m.PushExpr(cx) + bm.SwitchOpCode(bmTarget) + m.doOpPrecall() + bm.SwitchOpCode(bmSetup) + m.Ops = m.Ops[:0] + m.Frames = m.Frames[:0] + m.Values = m.Values[:0] + } + reportBenchops(b) +} + // --- doOpCall with receiver (method call) --- func BenchmarkOpCall_Method(b *testing.B) { @@ -4887,10 +4920,10 @@ func benchOpReturnCallDefers(b *testing.B, nDefers int) { cfr := m.LastFrame() for range nDefers { cfr.PushDefer(Defer{ - Func: fv, - Args: []TypedValue{}, - Source: &DeferStmt{Call: CallExpr{NumArgs: 0, Args: []Expr{}}}, - Parent: &Block{}, + Callable: fv, + Args: []TypedValue{}, + Source: &DeferStmt{Call: CallExpr{NumArgs: 0, Args: []Expr{}}}, + Parent: &Block{}, }) } m.PushOp(OpReturnCallDefers) // will be consumed by the op diff --git a/gnovm/pkg/gnolang/frame.go b/gnovm/pkg/gnolang/frame.go index 89979609f15..4a236256d71 100644 --- a/gnovm/pkg/gnolang/frame.go +++ b/gnovm/pkg/gnolang/frame.go @@ -110,11 +110,14 @@ func (fr *Frame) SetIsRevive() { // Defer type Defer struct { - Func *FuncValue // function value - IsBoundMethod bool // if true, args[0] is receiver - Args []TypedValue // arguments - Source *DeferStmt // source - Parent *Block + // Callable is the deferred callable, captured at the defer statement: a + // *FuncValue, a *BoundMethodValue (possibly a lazy interface bind, resolved + // at the call), or nil for a deferred nil func. doOpReturnCallDefers + // type-switches on it, mirroring doOpPrecall. + Callable Value + Args []TypedValue // arguments (Args[0] is the receiver for a bound method) + Source *DeferStmt // source + Parent *Block } type StacktraceCall struct { diff --git a/gnovm/pkg/gnolang/garbage_collector.go b/gnovm/pkg/gnolang/garbage_collector.go index 10f29584e69..9499d2fab07 100644 --- a/gnovm/pkg/gnolang/garbage_collector.go +++ b/gnovm/pkg/gnolang/garbage_collector.go @@ -477,9 +477,9 @@ func (fr *Frame) Visit(alloc *Allocator, vis Visitor) (stop bool) { // vis defer for _, dfr := range fr.Defers { - // visit dfr.Func - if dfr.Func != nil { - stop = vis(dfr.Func) + // visit dfr.Callable (the deferred func / bound method) + if dfr.Callable != nil { + stop = vis(dfr.Callable) } if stop { return diff --git a/gnovm/pkg/gnolang/gnolang.proto b/gnovm/pkg/gnolang/gnolang.proto index 4798eaf7fa4..110ab3231d7 100644 --- a/gnovm/pkg/gnolang/gnolang.proto +++ b/gnovm/pkg/gnolang/gnolang.proto @@ -83,6 +83,7 @@ message BoundMethodValue { ObjectInfo object_info = 1 [json_name = "ObjectInfo"]; FuncValue func = 2 [json_name = "Func"]; TypedValue receiver = 3 [json_name = "Receiver"]; + string method = 4 [json_name = "Method"]; } message TypeValue { diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index d84e8b6a7bd..e78d3b6ccaa 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -1390,13 +1390,24 @@ const ( // See gnovm/cmd/calibrate/op_bench_analysis.txt for full derivation. /* Control operators */ - OpCPUInvalid = 1 - OpCPUHalt = 1 - OpCPUNoop = 1 - OpCPUExec = 130 - OpCPUPrecallTypeConv = 72 // type conversion - OpCPUPrecallFunc = 178 // function call - OpCPUPrecallBoundMethod = 199 // bound method call + OpCPUInvalid = 1 + OpCPUHalt = 1 + OpCPUNoop = 1 + OpCPUExec = 130 + OpCPUPrecallTypeConv = 72 // type conversion + OpCPUPrecallFunc = 178 // function call + OpCPUPrecallBoundMethod = 199 // bound method call + // OpCPULazyBoundResolve is the extra CPU on top of OpCPUPrecallBoundMethod + // when an interface method value is resolved at the call (resolveLazyBound + // walk). Flat: deep embedding / nested interfaces under-charge the CPU walk + // (unlike eager concrete dispatch, which decomposes into per-hop field + // selectors and so meters depth), but that is bounded by the static type + // structure (no DoS) and the per-iteration allocation is auto-metered. + // TODO(calibration), before the fork ships: (1) re-measure on the gas-table + // reference HW — 529 is ratio-scaled (lazy-vs-concrete bench delta against + // OpCPUPrecallBoundMethod's known value); (2) consider a per-trail-step slope + // so deep/nested dispatch is metered per hop, matching the eager path. + OpCPULazyBoundResolve = 529 OpCPUEnterCrossing = 520 // XXX arbitrary, not yet benchmarked OpCPUCall = 310 // base for 0 params, 0 captures (340.8ns - 31 alloc) OpCPUCallNativeBody = 2205 // XXX arbitrary, not properly benchmarked @@ -1457,7 +1468,7 @@ const ( OpCPUIndex2 = 1014 OpCPUSelectorField = 101 // flat; field access (1-1000 fields all ~100ns) OpCPUSelectorVPValMethod = 635 // flat; all method paths: Val/DerefVal/Ptr/DerefPtr (684ns - 52 alloc) - OpCPUSelectorInterface = 751 // base; VPInterface, per-method added in handler + OpCPUSelectorInterface = 276 // base; VPInterface, per-method added in handler. Was 751 (eager dispatch walked the trail here); the walk moved to call time (OpCPULazyBoundResolve), so the bind only does the method lookup + lazy-bind alloc now. TODO(calibration): ratio-scaled re-fit (~140ns pure), re-measure on the reference HW with OpCPULazyBoundResolve before the fork ships. OpCPUSlice = 264 // max(array=258, slice=211, byte=264, 3idx=236, string=219) OpCPUStar = 102 OpCPURef = 210 diff --git a/gnovm/pkg/gnolang/op_call.go b/gnovm/pkg/gnolang/op_call.go index abd32ac5f12..a66b2cb658b 100644 --- a/gnovm/pkg/gnolang/op_call.go +++ b/gnovm/pkg/gnolang/op_call.go @@ -27,15 +27,23 @@ func (m *Machine) doOpPrecall() { } case *BoundMethodValue: m.incrCPU(OpCPUPrecallBoundMethod) - recv := fv.Receiver - m.PushFrameCall(cx, fv.Func, recv, false) + fn, recv := fv.Func, fv.Receiver + // A lazy interface bind resolves its concrete method + receiver at + // call time, walking the saved operand's current value (Go's call-time + // dispatch). A resolved bind is used as-is. nil derefs raise within the + // walk (caught by the Run loop). + if fv.IsLazy() { + m.incrCPU(OpCPULazyBoundResolve) + fn, recv = resolveLazyBound(m.Alloc, m.Store, fv) + } + m.PushFrameCall(cx, fn, recv, false) m.PushOp(OpCall) - isCrossing := fv.IsCrossing() + isCrossing := fn.IsCrossing() if isCrossing { m.PushOp(OpEnterCrossing) } if cx.IsWithCross() { - m.installCrossingCur(cx, isCrossing, fv.Func.PkgPath) + m.installCrossingCur(cx, isCrossing, fn.PkgPath) } case TypeValue: m.incrCPU(OpCPUPrecallTypeConv) @@ -558,21 +566,32 @@ func (m *Machine) doOpReturnCallDefers() { return } - if dfr.Func == nil { - m.pushPanic(typedString("defer called a nil function")) + // Resolve the deferred callable, mirroring doOpPrecall's dispatch. + var fv *FuncValue + var recv TypedValue // receiver for PushFrameCall: the bound receiver, or zero for a plain func + switch cv := dfr.Callable.(type) { + case *FuncValue: + fv = cv // plain func: no receiver, recv stays zero + case *BoundMethodValue: + // A lazy interface bind resolves its concrete method + receiver now, at + // the deferred call — Go's call-time dispatch on the operand's current + // value (nil derefs raise within the walk, caught by the Run loop). A + // resolved bind uses the receiver captured at the defer statement. + if cv.IsLazy() { + m.incrCPU(OpCPULazyBoundResolve) + fv, recv = resolveLazyBound(m.Alloc, m.Store, cv) + } else { + fv, recv = cv.Func, dfr.Args[0] + } + dfr.Args[0] = recv // the param-binding loop below reads dfr.Args + default: // nil (deferred a nil func value) + m.pushPanic(typedString("runtime error: call of nil function")) return } // Call last deferred call. - fv := dfr.Func ft := fv.GetType(m.Store) - // Push frame for defer. - if dfr.IsBoundMethod { - // args[0] is the receiver, per popCopyArgs bound-method invariant. - m.PushFrameCall(&dfr.Source.Call, fv, dfr.Args[0], true) - } else { - m.PushFrameCall(&dfr.Source.Call, fv, TypedValue{}, true) - } + m.PushFrameCall(&dfr.Source.Call, fv, recv, true) // NOTE: the following logic is largely duplicated in doOpCall(). // Push final empty *ReturnStmt; // TODO: transform in preprocessor instead. @@ -581,7 +600,7 @@ func (m *Machine) doOpReturnCallDefers() { m.PushOp(OpExec) // Convert if variadic argument. // Create new block scope for defer. - pb := dfr.Func.GetParent(m.Store) + pb := fv.GetParent(m.Store) b := m.Alloc.NewBlock(fv.GetSource(m.Store), pb) // Copy values from captures. if len(fv.Captures) != 0 { @@ -680,37 +699,26 @@ func (m *Machine) doOpDefer() { // Push defer. switch cv := ftv.V.(type) { case *FuncValue: - fv := cv args := m.popCopyArgs( baseOf(ftv.T).(*FuncType), numArgs, ds.Call.Varg, TypedValue{}) - cfr.PushDefer(Defer{ - Func: fv, - Args: args, - Source: ds, - Parent: lb, - }) + cfr.PushDefer(Defer{Callable: cv, Args: args, Source: ds, Parent: lb}) case *BoundMethodValue: - fv := cv.Func - recv := cv.Receiver + // Args (and the receiver/operand) are captured now, at the defer + // statement. For a lazy interface bind, dispatch is resolved at the + // deferred call (Go's call-time dispatch — see doOpReturnCallDefers); + // for a resolved bind, args[0] holds the copied receiver. args := m.popCopyArgs( baseOf(ftv.T).(*FuncType), numArgs, ds.Call.Varg, - recv) - cfr.PushDefer(Defer{ - Func: fv, - IsBoundMethod: true, - Args: args, - Source: ds, - Parent: lb, - }) + cv.Receiver) + cfr.PushDefer(Defer{Callable: cv, Args: args, Source: ds, Parent: lb}) case nil: - cfr.PushDefer(Defer{ - Func: nil, - }) + // deferred a nil func value; raised as call-of-nil at the deferred call. + cfr.PushDefer(Defer{Source: ds, Parent: lb}) default: m.pushPanic(typedString(fmt.Sprintf("invalid defer function call: %v", cv))) return diff --git a/gnovm/pkg/gnolang/pb3_gen.go b/gnovm/pkg/gnolang/pb3_gen.go index 6c18851591f..e8713927d7f 100644 --- a/gnovm/pkg/gnolang/pb3_gen.go +++ b/gnovm/pkg/gnolang/pb3_gen.go @@ -1648,6 +1648,18 @@ func (goo *MapListItem) UnmarshalBinary2(cdc *amino.Codec, bz []byte, anyDepth i func (goo BoundMethodValue) MarshalBinary2(cdc *amino.Codec, buf []byte, offset int) (int, error) { var err error + if goo.Method != "" { + { + before := offset + offset = amino.PrependString(buf, offset, string(goo.Method)) + valueLen := before - offset + if valueLen > 1 || (valueLen == 1 && buf[offset] != 0x00) { + offset = amino.PrependFieldNumberAndTyp3(buf, offset, 4, amino.Typ3ByteLength) + } else { + offset = before + } + } + } { before := offset offset, err = goo.Receiver.MarshalBinary2(cdc, buf, offset) @@ -1720,6 +1732,9 @@ func (goo BoundMethodValue) SizeBinary2(cdc *amino.Codec) (int, error) { s += 1 + amino.UvarintSize(uint64(cs)) + cs } } + if goo.Method != "" { + s += 1 + amino.UvarintSize(uint64(len(goo.Method))) + len(goo.Method) + } return s, nil } @@ -1778,6 +1793,16 @@ func (goo *BoundMethodValue) UnmarshalBinary2(cdc *amino.Codec, bz []byte, anyDe if err := goo.Receiver.UnmarshalBinary2(cdc, fbz, anyDepth); err != nil { return err } + case 4: + if typ3 != amino.Typ3ByteLength { + return fmt.Errorf("field 4: expected typ3 %v, got %v", amino.Typ3ByteLength, typ3) + } + v, n, err := amino.DecodeString(bz) + if err != nil { + return err + } + bz = bz[n:] + goo.Method = Name(v) default: return fmt.Errorf("unknown field number %d for BoundMethodValue", fnum) } diff --git a/gnovm/pkg/gnolang/realm.go b/gnovm/pkg/gnolang/realm.go index ab6d83ec3ac..88a58f43935 100644 --- a/gnovm/pkg/gnolang/realm.go +++ b/gnovm/pkg/gnolang/realm.go @@ -1223,7 +1223,10 @@ func (rlm *Realm) assertObjectIsPublic(obj Object, store Store, visited map[Type } } case *BoundMethodValue: - if v.Func.PkgPath != rlm.Path && isPkgPrivateFromPkgPath(store, v.Func.PkgPath) { + // A lazy interface bind has no resolved Func; its concrete method is + // determined at call time from the (public) receiver type checked + // below, so the private-realm guard applies only to a resolved Func. + if v.Func != nil && v.Func.PkgPath != rlm.Path && isPkgPrivateFromPkgPath(store, v.Func.PkgPath) { panic("cannot persist bound method from the private realm " + v.Func.PkgPath) } if v.Receiver.T != nil { @@ -1383,7 +1386,9 @@ func getChildObjects(val Value, more []Value) []Value { } return more case *BoundMethodValue: - more = getSelfOrChildObjects(cv.Func, more) + if cv.Func != nil { // nil for a lazy interface bind + more = getSelfOrChildObjects(cv.Func, more) + } more = getSelfOrChildObjects(cv.Receiver.V, more) return more case *MapValue: @@ -1701,12 +1706,16 @@ func copyValueWithRefs(val Value) Value { Crossing: cv.Crossing, } case *BoundMethodValue: - fnc := copyValueWithRefs(cv.Func).(*FuncValue) + var fnc *FuncValue // nil for a lazy interface bind (resolved at call) + if cv.Func != nil { + fnc = copyValueWithRefs(cv.Func).(*FuncValue) + } rtv := refOrCopyValue(cv.Receiver) return &BoundMethodValue{ ObjectInfo: cv.ObjectInfo.Copy(), Func: fnc, Receiver: rtv, + Method: cv.Method, } case *MapValue: list := &MapList{} @@ -1909,7 +1918,9 @@ func fillTypesOfValue(store Store, val Value) Value { cv.Type = fillType(store, cv.Type) return cv case *BoundMethodValue: - fillTypesOfValue(store, cv.Func) + if cv.Func != nil { // nil for a lazy interface bind + fillTypesOfValue(store, cv.Func) + } fillTypesTV(store, &cv.Receiver) return cv case *MapValue: diff --git a/gnovm/pkg/gnolang/types.go b/gnovm/pkg/gnolang/types.go index 6b7e89491da..858525d96fa 100644 --- a/gnovm/pkg/gnolang/types.go +++ b/gnovm/pkg/gnolang/types.go @@ -2956,6 +2956,16 @@ func isGeneric(t Type) bool { // TODO: could this be more optimized for the runtime? // are Go-style itables the solution or? // callerPath: the path of package where selector node was declared. +// +// Returns: +// - trail: the ValuePath steps from t down to n (embedded-field hops plus a +// final field/method step); nil if n is not found. +// - hasPtr: whether the trail crosses a pointer (a pointer field/receiver). +// - rcvr: for a method, its declared receiver type (T for a value receiver, +// *T for a pointer receiver); nil for a field. +// - ft: the type n resolves to — the field's type for a field, or the +// method's bound (receiver-stripped) function type for a method. +// - accessError: n was found but is unexported and inaccessible from callerPath. func findEmbeddedFieldType(callerPath string, t Type, n Name, m map[Type]struct{}) ( trail []ValuePath, hasPtr bool, rcvr Type, ft Type, accessError bool, ) { diff --git a/gnovm/pkg/gnolang/values.go b/gnovm/pkg/gnolang/values.go index 36a2d96383a..18724119077 100644 --- a/gnovm/pkg/gnolang/values.go +++ b/gnovm/pkg/gnolang/values.go @@ -663,14 +663,106 @@ type BoundMethodValue struct { // Underlying unbound method function. // The type without the receiver (since bound) // is computed lazily if needed. + // + // nil for an interface-dispatched bind until the call: the concrete + // method is determined by dynamic dispatch on Receiver (the saved + // operand) and resolved at call time (see resolveLazyBound). Func *FuncValue // This becomes the first arg. // The type is .Func.Type.Params[0]. + // + // For an interface-dispatched bind (Func == nil) this holds the *saved + // operand* — the boxed value captured when the method value was formed (a + // copy for a value operand, the pointer as-is for a pointer operand), not + // the resolved receiver. The call walks it (via Method) to dispatch and + // form the receiver, matching Go: deref, value snapshot, nil panic, field + // re-read and dynamic re-dispatch all happen at the call. Receiver TypedValue + + // Method is the selector name for an interface-dispatched bind + // (Func == nil); the call re-derives the dispatch trail on Receiver's + // current value. Empty for a resolved (eager) bind. + Method Name +} + +// IsLazy reports whether bmv is an unresolved interface-dispatched bind whose +// concrete method + receiver are resolved at call time from the saved operand. +func (bmv *BoundMethodValue) IsLazy() bool { + return bmv.Func == nil +} + +// valueMethodReceiver forms the receiver for a value-receiver method from the +// owner value: a fresh mutable copy with readonly cleared (a value receiver is +// always a copy the method body may mutate without touching the original). +func valueMethodReceiver(alloc *Allocator, owner TypedValue) TypedValue { + rtv := owner.Copy(alloc) + if rtv.V != nil { + // Clear readonly for receivers. Other rules still apply such as in + // DidUpdate. NOTE: rtv is a copy, the original is untouched. + rtv.N = [8]byte{} + } + return rtv +} + +// resolveInterfaceTrail walks an interface-dispatch trail on the boxed concrete +// value, returning the resolved bound-method pointer. The walk dereferences as +// it goes, so running it at call time gives Go's call-time dispatch: nil derefs +// panic now (per the method's receiver kind — value receivers panic, pointer +// receivers pass nil through), and a value receiver is a fresh snapshot of the +// current pointee. A nested interface step yields another lazy bind, which +// resolveLazyBound unwraps. +func resolveInterfaceTrail(alloc *Allocator, store Store, boxed TypedValue, tr []ValuePath) PointerValue { + btv := boxed + for i, path := range tr { + ptr := btv.GetPointerToFromTV(alloc, store, path) + if i == len(tr)-1 { + return ptr + } + btv = ptr.Deref() + } + panic("should not happen") +} + +// resolveLazyBound resolves an interface-dispatched (lazy) bind to its concrete +// method + receiver at call time, walking the saved operand's *current* value. +// So the deref, value snapshot, nil panic, field re-read and dynamic +// re-dispatch all happen now, matching Go. Each iteration strips one interface +// indirection (a nested embedded-interface field yields another lazy bind); +// this converges since every step removes a VPInterface from the trail. +// +// nil derefs are raised by the walk itself (GetPointerToFromTV panics with a +// runtime-error Exception for a value receiver on a nil pointer, and passes nil +// through for a pointer receiver) — the machine's Run loop converts that to the +// cooperative panic path, so both immediate and deferred calls behave correctly +// without a separate nil signal here. +func resolveLazyBound(alloc *Allocator, store Store, bmv *BoundMethodValue) (*FuncValue, TypedValue) { + operand := bmv.Receiver + name := bmv.Method + for { + // The saved operand may be a pointer reloaded from the store with an + // unfilled TV; fill it so the walk can dereference it. This load of the + // live state is what lets field re-read / dynamic re-dispatch observe + // updates made after the bind. + fillValueTV(store, &operand) + tr, _, _, _, _ := findEmbeddedFieldType(operand.T.GetPkgPath(), operand.T, name, nil) + next := resolveInterfaceTrail(alloc, store, operand, tr).Deref().V.(*BoundMethodValue) + if !next.IsLazy() { + return next.Func, next.Receiver + } + operand, name = next.Receiver, next.Method + } } func (bmv *BoundMethodValue) IsCrossing() bool { + // A lazy interface bind has no concrete Func until call time; crossing-ness + // is a property of the resolved method, determined then. An interface + // method value is never itself a crossing entry point (crossing functions + // are not part of interface method sets in production code), so report + // false until resolved. + if bmv.IsLazy() { + return false + } return bmv.Func.IsCrossing() } @@ -1747,7 +1839,6 @@ func (tv *TypedValue) GetPointerToFromTV(alloc *Allocator, store Store, path Val panic("GetPointerToFromTV() on undefined value") } } - // NOTE: path will be mutated. // NOTE: this code segment similar to that in op_types.go var dtv *TypedValue @@ -1764,6 +1855,12 @@ func (tv *TypedValue) GetPointerToFromTV(alloc *Allocator, store Store, path Val panic("should not happen") } case VPSubrefField: + // A subref field access through a nil pointer is a nil-pointer deref + // (e.g. reaching a promoted pointer-receiver method's receiver via an + // embedded field of a nil *T). Matches Go; previously crashed the VM. + if tv.V == nil { + panic(&Exception{Value: typedString("runtime error: nil pointer dereference")}) + } switch path.Depth { case 0: dtv = tv.V.(PointerValue).TV @@ -1811,6 +1908,11 @@ func (tv *TypedValue) GetPointerToFromTV(alloc *Allocator, store Store, path Val panic("should not happen") } case VPDerefValMethod: + // Concrete `pt.M`: Go snapshots the value receiver when the method + // value is formed, so a nil pointer derefs (panics) eagerly here. + // (Interface dispatch never reaches this at bind — VPInterface binds + // lazily and this runs at call time via resolveInterfaceTrail, where + // eager-deref is exactly Go's call-time semantics.) if tv.V == nil { panic(&Exception{Value: typedString("runtime error: nil pointer dereference")}) } @@ -1898,17 +2000,10 @@ func (tv *TypedValue) GetPointerToFromTV(alloc *Allocator, store Store, path Val panic("should not happen") } } - dtv2 := dtv.Copy(alloc) - if dtv2.V != nil { - // Clear readonly for receivers. - // Other rules still apply such as in DidUpdate. - // NOTE: dtv2 is a copy, orig is untouched. - dtv2.N = [8]byte{} - } alloc.AllocateBoundMethod() bmv := &BoundMethodValue{ Func: mv, - Receiver: dtv2, + Receiver: valueMethodReceiver(alloc, *dtv), } // Bound method wrapper belongs to the realm doing the // binding; the receiver carries its own PkgID independently. @@ -1969,20 +2064,34 @@ func (tv *TypedValue) GetPointerToFromTV(alloc *Allocator, store Store, path Val panic("cannot resolve an interface path at static time") } callerPath := dtv.T.GetPkgPath() - tr, _, _, _, _ := findEmbeddedFieldType(callerPath, dtv.T, path.Name, nil) + tr, _, _, ift, _ := findEmbeddedFieldType(callerPath, dtv.T, path.Name, nil) if len(tr) == 0 { panic(fmt.Sprintf("method %s not found in type %s", path.Name, dtv.T.String())) } - btv := *dtv - for i, path := range tr { - ptr := btv.GetPointerToFromTV(alloc, store, path) - if i == len(tr)-1 { - return ptr // done - } - btv = ptr.Deref() // deref + // Lazy interface dispatch (matches Go): a method value formed through + // an interface saves the operand at formation; the concrete method and + // receiver are materialized inside the CALL. So bind a lazy method + // value holding the saved operand (the boxed value/pointer) + selector + // name, and resolve at call time via resolveLazyBound. This gives Go's + // call-time semantics on every facet — deref timing, value snapshot, + // nil panic, field re-read through a pointer, and dynamic re-dispatch — + // because the operand is re-walked live at the call. ift is the bound + // (receiver-stripped) method type, known statically from the interface. + alloc.AllocateBoundMethod() + bmv := &BoundMethodValue{ + Func: nil, // resolved at call (see resolveLazyBound) + Receiver: *dtv, + Method: path.Name, + } + alloc.stampPkgID(&bmv.ObjectInfo, nil) + return PointerValue{ + TV: &TypedValue{ + T: ift.(*FuncType), + V: bmv, + }, + Base: nil, } - panic("should not happen") default: panic("should not happen") } diff --git a/gnovm/pkg/gnolang/values_string.go b/gnovm/pkg/gnolang/values_string.go index 0204057c21c..5f794eee5ff 100644 --- a/gnovm/pkg/gnolang/values_string.go +++ b/gnovm/pkg/gnolang/values_string.go @@ -200,6 +200,11 @@ func (fv *FuncValue) String() string { } func (bmv *BoundMethodValue) String() string { + if bmv.IsLazy() { + // Unresolved interface bind: the concrete method is determined at call + // time; render from the saved operand type + selector name. + return fmt.Sprintf("<%s>.%s(?)(?)", bmv.Receiver.T.String(), bmv.Method) + } name := bmv.Func.Name var ( recvT = "?" diff --git a/gnovm/tests/files/method_iface_defer.gno b/gnovm/tests/files/method_iface_defer.gno new file mode 100644 index 00000000000..5ae3ffa1aff --- /dev/null +++ b/gnovm/tests/files/method_iface_defer.gno @@ -0,0 +1,27 @@ +// `defer i.M()` on a non-nil interface: args bind at the defer statement, but +// dispatch/receiver resolve at the deferred call (matching Go). Covers value- +// and pointer-receiver methods. Regression — this used to crash the VM. + +package main + +type T struct{ x int } + +func (t T) Show() { println("show", t.x) } +func (t *T) PShow() { println("pshow", t.x) } + +type I interface{ Show() } +type PI interface{ PShow() } + +func main() { + var i I = T{x: 1} + defer i.Show() // value operand, deferred + + p := &T{x: 1} + var pi PI = p + defer pi.PShow() // pointer operand, deferred + p.x = 9 // observed at the deferred call → 9 +} + +// Output: +// pshow 9 +// show 1 diff --git a/gnovm/tests/files/method_iface_dynamic.gno b/gnovm/tests/files/method_iface_dynamic.gno new file mode 100644 index 00000000000..b7d7c6b7074 --- /dev/null +++ b/gnovm/tests/files/method_iface_dynamic.gno @@ -0,0 +1,96 @@ +// Call-time dynamic dispatch facets. Reassigning an embedded interface field to +// a *different concrete type* re-dispatches at the call (redispatch 2). A +// pointer-receiver method promoted through an embedded field of a nil pointer +// panics cleanly instead of crashing (c5 1). And re-boxing through a second +// interface, nested unwrap, a value stored in a map, and one via type-assertion +// all observe the operand's current value at the call (rebox 9, nested 9, +// map 8, assert 9). + +package main + +type Deep struct{ x int } + +func (d Deep) Get() int { return d.x } +func (d *Deep) Ptr() int { return d.x } + +type IG interface{ Get() int } +type IP interface{ Ptr() int } + +type Impl struct{ x int } + +func (i Impl) Get() int { return i.x } + +type Other struct{} + +func (Other) Get() int { return 2 } + +type Box struct{ IG } // embeds the interface + +type MidP struct{ Deep } // promotes Ptr (pointer receiver) + +var pmp *MidP + +// reassign the embedded interface field to a different concrete type. +func redispatch() int { + s := &Box{IG: Impl{x: 1}} + var o IG = s + g := o.Get + s.IG = Other{} + return g() +} + +// pointer-receiver method promoted through an embedded field of a nil pointer. +func c5() (r int) { + defer func() { recover() }() + var i IP = pmp // nil *MidP + defer i.Ptr() + r = 1 + return +} + +func reboxChain() int { + var i1 IG = &Deep{x: 1} + var i2 IG = i1 // re-box into another interface + g := i2.Get + (i1.(*Deep)).x = 9 + return g() +} + +func nestedUnwrap() int { + p := &Deep{x: 1} + var i IG = &Box{IG: p} // *Box -> embedded IG -> *Deep -> Deep.Get + g := i.Get + p.x = 9 + return g() +} + +func storedInMap() int { + m := map[string]IG{"a": &Deep{x: 3}} + g := m["a"].Get + (m["a"].(*Deep)).x = 8 + return g() +} + +func viaTypeAssert() int { + var a interface{} = &Deep{x: 5} + g := a.(IG).Get + (a.(*Deep)).x = 9 + return g() +} + +func main() { + println("redispatch", redispatch()) + println("c5", c5()) + println("rebox", reboxChain()) + println("nested", nestedUnwrap()) + println("map", storedInMap()) + println("assert", viaTypeAssert()) +} + +// Output: +// redispatch 2 +// c5 1 +// rebox 9 +// nested 9 +// map 8 +// assert 9 diff --git a/gnovm/tests/files/method_iface_embedded.gno b/gnovm/tests/files/method_iface_embedded.gno new file mode 100644 index 00000000000..d4261185b88 --- /dev/null +++ b/gnovm/tests/files/method_iface_embedded.gno @@ -0,0 +1,58 @@ +// Method promotion through embedded fields. Through an interface the receiver +// is materialized at the call, so a post-bind mutation is seen — for an +// embedded value field (valueField 9) and an embedded *pointer field +// (ptrField 9). A method promoted from an embedded *interface* field dispatches +// dynamically through that field (ifaceField 7). But an embedded *pointer field +// with NO interface in the trail is static dispatch — snapshot at bind, so the +// mutation is invisible (ptrNoIface 1). + +package main + +type T struct{ x int } + +func (t T) Get() int { return t.x } + +type Mid struct{ T } // value embed +type Top struct{ Mid } // two-level value embed +type PEmb struct{ *T } // embedded pointer field + +type IG interface{ Get() int } + +type Impl struct{ x int } + +func (i Impl) Get() int { return i.x } + +type Holder struct{ IG } // embeds the interface + +func main() { + // two-level embedded value field, via interface → mutation seen at call. + t := &Top{Mid{T{x: 1}}} + var i1 IG = t + g1 := i1.Get + t.x = 9 + println("valueField", g1()) + + // embedded *pointer field, via interface → mutation seen at call. + d := &T{x: 1} + var i2 IG = &PEmb{d} + g2 := i2.Get + d.x = 9 + println("ptrField", g2()) + + // embedded *interface* field → dynamic dispatch through the field. + var i3 IG = &Holder{IG: Impl{x: 7}} + g3 := i3.Get + println("ifaceField", g3()) + + // embedded *pointer field, NO interface → static dispatch, snapshot at bind. + s := PEmb{T: &T{x: 1}} + g4 := s.Get + s.T.x = 9 + println("ptrNoIface", g4()) +} + +// Output: +// valueField 9 +// ptrField 9 +// ifaceField 7 +// ptrNoIface 1 diff --git a/gnovm/tests/files/method_iface_nil.gno b/gnovm/tests/files/method_iface_nil.gno new file mode 100644 index 00000000000..27908aa2746 --- /dev/null +++ b/gnovm/tests/files/method_iface_nil.gno @@ -0,0 +1,58 @@ +// Nil-receiver timing (matching Go). A concrete nil `*T` value method derefs +// when the method value is formed, so `defer pt.Get()` panics eagerly and r=1 +// never runs (concrete 0). An interface-boxed nil defers the deref to the call, +// so recover catches it after r=1 ran — called immediately (ifaceImmediate 1) +// or promoted through an embedded interface (embedded 1). The deferred form is +// in method_nil_value_bind.gno (the initial failing-test artifact). + +package main + +type T struct{ x int } + +func (t T) Get() int { return t.x } + +type I interface{ Get() int } + +type Mid struct{ T } // promotes Get + +var pt *T +var pmid *Mid + +// concrete nil: deref at bind → eager panic → r=1 never runs. +func concrete() (r int) { + defer func() { recover() }() + defer pt.Get() + r = 1 + return +} + +// interface nil, immediate call: deref at the call → recover after r=1. +func ifaceImmediate() (r int) { + defer func() { recover() }() + var i I = pt + g := i.Get + r = 1 + _ = g() + r = 2 // unreached + return +} + +// embedded promotion through a nil pointer, via interface, deferred. +func embedded() (r int) { + defer func() { recover() }() + var i I = pmid // typed-nil *Mid, Get promoted + defer i.Get() + r = 1 + return +} + +func main() { + println("concrete", concrete()) + println("ifaceImmediate", ifaceImmediate()) + println("embedded", embedded()) +} + +// Output: +// concrete 0 +// ifaceImmediate 1 +// embedded 1 diff --git a/gnovm/tests/files/method_iface_operand_capture.gno b/gnovm/tests/files/method_iface_operand_capture.gno new file mode 100644 index 00000000000..c44bbde35e2 --- /dev/null +++ b/gnovm/tests/files/method_iface_operand_capture.gno @@ -0,0 +1,59 @@ +// What the lazy bind captures at the first interface crossing decides when a +// later change shows. The operand is captured at the crossing; navigation +// before it (including a concrete pointer deref) is eager. +// +// - concrete root (value Box{} or pointer &Box): the embedded interface field +// is read at bind, so reassigning it is invisible (valueRoot 1, ptrRoot 1). +// - interface root (var g Getter = &Box): the operand is the *Box pointer, so +// the field is re-read at the call → reassignment seen (ifaceRoot 9). +// - mid interface holding a *pointer*: the captured operand aliases the +// pointee, so mutating the pointee (not reassigning the field) is seen +// (midPtrPointee 9). + +package main + +type Getter interface{ Get() int } + +type Impl struct{ x int } + +func (i Impl) Get() int { return i.x } + +type Box struct{ Getter } // embeds the interface + +type Deep struct{ x int } + +func (d Deep) Get() int { return d.x } + +func main() { + // value root: embedded interface read at bind → reassignment invisible. + bv := Box{Getter: Impl{x: 1}} + gv := bv.Get + bv.Getter = Impl{x: 9} + println("valueRoot", gv()) + + // concrete pointer root: still concrete → read at bind too. + bp := &Box{Getter: Impl{x: 1}} + gp := bp.Get + bp.Getter = Impl{x: 9} + println("ptrRoot", gp()) + + // interface root: operand is *Box → field re-read at the call. + bi := &Box{Getter: Impl{x: 1}} + var g Getter = bi + gi := g.Get + bi.Getter = Impl{x: 9} + println("ifaceRoot", gi()) + + // mid interface holding a pointer: mutate the pointee (not the field) → seen. + d := &Deep{x: 1} + bm := Box{Getter: d} + gm := bm.Get + d.x = 9 + println("midPtrPointee", gm()) +} + +// Output: +// valueRoot 1 +// ptrRoot 1 +// ifaceRoot 9 +// midPtrPointee 9 diff --git a/gnovm/tests/files/method_iface_timing.gno b/gnovm/tests/files/method_iface_timing.gno new file mode 100644 index 00000000000..3fd8627b190 --- /dev/null +++ b/gnovm/tests/files/method_iface_timing.gno @@ -0,0 +1,46 @@ +// Concrete-vs-interface receiver timing (matching Go): a concrete method value +// snapshots its receiver at bind (concrete 1); an interface method value derefs +// at the call, so a post-bind mutation is seen (snapshot 2), each call copies +// the receiver afresh (percall 11 11), and the value receiver cannot mutate the +// pointee (caller 10). + +package main + +type T struct{ x int } + +func (t T) Get() int { return t.x } +func (t T) IncGet() int { t.x++; return t.x } + +type I interface { + Get() int + IncGet() int +} + +func main() { + // concrete: receiver copied at bind, later mutation invisible. + c := &T{x: 1} + cg := c.Get + c.x = 2 + println("concrete", cg()) + + // interface: receiver dereferenced at the call, mutation visible. + p := &T{x: 1} + var i I = p + h := i.Get + p.x = 2 + println("snapshot", h()) + + // per-call copy + value semantics: each call starts from the current + // pointee; the method's mutation does not leak back to it. + q := &T{x: 10} + var j I = q + g := j.IncGet + println("percall", g(), g()) + println("caller", q.x) +} + +// Output: +// concrete 1 +// snapshot 2 +// percall 11 11 +// caller 10 diff --git a/gnovm/tests/files/method_nil_value_bind.gno b/gnovm/tests/files/method_nil_value_bind.gno new file mode 100644 index 00000000000..d59a2f3002c --- /dev/null +++ b/gnovm/tests/files/method_nil_value_bind.gno @@ -0,0 +1,36 @@ +// Regression for: `defer i.M()` (or `defer pt.M()`) where the +// interface holds a typed-nil pointer (or pt itself is nil) and M +// has a value receiver must defer the nil-deref panic to call time, +// matching Go. The outer recover catches it after r=1 has run, so +// f() returns 1. + +package main + +type I interface{ M() } + +type T struct { + x int +} + +func (T) M() {} + +var pt *T + +func f() (r int) { + defer func() { recover() }() + + var i I = pt + defer i.M() + r = 1 + return +} + +func main() { + if got := f(); got != 1 { + panic(got) + } + println("OK") +} + +// Output: +// OK