diff --git a/gno.land/pkg/integration/testdata/method_iface_cyclic_persist.txtar b/gno.land/pkg/integration/testdata/method_iface_cyclic_persist.txtar new file mode 100644 index 00000000000..ba6b6a9ba6a --- /dev/null +++ b/gno.land/pkg/integration/testdata/method_iface_cyclic_persist.txtar @@ -0,0 +1,43 @@ +# A cyclic embedded interface (`s.IG = s`) bound lazily through an interface +# (`G = o.Get`) and persisted must still raise the fatal cyclic-dispatch panic +# when reloaded and called in a later tx — i.e. cycle detection (keyed on the +# operand's reloaded identity) survives serialization and does NOT hang. The +# raw panic is recovered at the keeper boundary into a clean failed tx. + +gnoland start + +gnokey maketx addpkg -pkgdir $WORK/myrealm -pkgpath gno.land/r/test/myrealm -gas-fee 1000000ugnot -gas-wanted 8000000 -chainid=tendermint_test test1 +stdout OK! + +# bind G = o.Get over a cyclic s.IG = s and persist it; the bind itself is lazy +# (no resolution yet) so it must not panic here. +gnokey maketx call -pkgpath gno.land/r/test/myrealm -func Bind -gas-fee 1000000ugnot -gas-wanted 8000000 -chainid=tendermint_test test1 +stdout OK! + +# later tx: G is reloaded; calling it resolves the lazy bind, which loops over +# the reloaded cyclic operand and must detect the cycle (terminate, not hang). +! gnokey maketx call -pkgpath gno.land/r/test/myrealm -func CallLater -gas-fee 1000000ugnot -gas-wanted 8000000 -chainid=tendermint_test test1 +stderr 'cyclic embedded interface in method-value dispatch' + +-- myrealm/gnomod.toml -- +module = "gno.land/r/test/myrealm" +gno = "0.9" + +-- myrealm/myrealm.gno -- +package myrealm + +type IG interface{ Get() int } + +type Box struct{ IG } + +var s *Box +var G func() int + +func Bind(cur realm) { + s = &Box{} + s.IG = s // cyclic embedded interface + var o IG = s + G = o.Get // lazy bind, resolved at call time +} + +func CallLater(cur realm) int { return G() } 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/method_iface_shadow_persist.txtar b/gno.land/pkg/integration/testdata/method_iface_shadow_persist.txtar new file mode 100644 index 00000000000..505cfe6588b --- /dev/null +++ b/gno.land/pkg/integration/testdata/method_iface_shadow_persist.txtar @@ -0,0 +1,63 @@ +# A lazy interface bind persists its bind-site package (MethodPkg): unexported +# method identity is package-qualified (PR #5739), and the realm's struct has a +# same-spelled unexported FIELD `sec` shadowing the promoted p/sec method. If +# the reloaded bind re-derived the trail under the realm's package instead of +# the bind site's, it would match the field and crash. Restart forces a full +# reload from store. + +gnoland start + +gnokey maketx addpkg -pkgdir $WORK/sec -pkgpath gno.land/p/test/sec -gas-fee 1000000ugnot -gas-wanted 8000000 -chainid=tendermint_test test1 +stdout OK! + +gnokey maketx addpkg -pkgdir $WORK/shadow -pkgpath gno.land/r/test/shadow -gas-fee 1000000ugnot -gas-wanted 8000000 -chainid=tendermint_test test1 +stdout OK! + +# bind G = x.sec inside p/test/sec (the only package that can select sec); persist. +gnokey maketx call -pkgpath gno.land/r/test/shadow -func Bind -gas-fee 1000000ugnot -gas-wanted 8000000 -chainid=tendermint_test test1 +stdout OK! + +gnoland restart + +# later tx after reload: dispatch must still resolve the p/test/sec method (7), +# not the realm's shadowing field. +gnokey maketx call -pkgpath gno.land/r/test/shadow -func Call -gas-fee 1000000ugnot -gas-wanted 8000000 -chainid=tendermint_test test1 +stdout '(7 int)' +stdout OK! + +-- sec/gnomod.toml -- +module = "gno.land/p/test/sec" +gno = "0.9" + +-- sec/sec.gno -- +package sec + +type Sec interface{ sec() int } // unexported: identity is pkgpath-qualified + +type SecImpl struct{} + +func (SecImpl) sec() int { return 7 } + +// MakeGetter forms the method value from within sec's origin package. +func MakeGetter(x Sec) func() int { return x.sec } + +-- shadow/gnomod.toml -- +module = "gno.land/r/test/shadow" +gno = "0.9" + +-- shadow/shadow.gno -- +package shadow + +import "gno.land/p/test/sec" + +// F has an unexported realm-package FIELD named sec at depth 0 and the +// promoted sec.sec METHOD at depth 1 via the embed — distinct identities. +type F struct { + sec int + sec.SecImpl +} + +var G func() int + +func Bind(cur realm) { f := F{}; f.sec = 42; G = sec.MakeGetter(f) } +func Call(cur realm) int { return G() } diff --git a/gno.land/pkg/integration/testdata/method_nil_value_persist.txtar b/gno.land/pkg/integration/testdata/method_nil_value_persist.txtar new file mode 100644 index 00000000000..f95523ed5b5 --- /dev/null +++ b/gno.land/pkg/integration/testdata/method_nil_value_persist.txtar @@ -0,0 +1,38 @@ +# A value-receiver method bound to a nil pointer through an interface +# (`G = i.M`, i holding a typed-nil *T) must keep its call-time +# nil-pointer-deref panic across a persist/reload, matching Go. The +# receiver is stored as the nil *T itself (not a synthetic flag), so the +# timing survives serialization: G() reloaded in a later tx still panics. + +gnoland start + +gnokey maketx addpkg -pkgdir $WORK/myrealm -pkgpath gno.land/r/test/myrealm -gas-fee 1000000ugnot -gas-wanted 8000000 -chainid=tendermint_test test1 +stdout OK! + +# bind G = i.M (i holds a nil *T) and persist it; binding itself must not panic. +gnokey maketx call -pkgpath gno.land/r/test/myrealm -func Bind -gas-fee 1000000ugnot -gas-wanted 8000000 -chainid=tendermint_test test1 +stdout OK! + +# later tx: G is reloaded from store; calling it still derefs the nil *T +# and panics at call time (no "ZERO-RECEIVER", no OK!). +! gnokey maketx call -pkgpath gno.land/r/test/myrealm -func CallLater -gas-fee 1000000ugnot -gas-wanted 8000000 -chainid=tendermint_test test1 +stderr 'value method gno.land/r/test/myrealm.T.M called using nil \*T pointer' + +-- myrealm/gnomod.toml -- +module = "gno.land/r/test/myrealm" +gno = "0.9" + +-- myrealm/myrealm.gno -- +package myrealm + +type I interface{ M() string } + +type T struct{ x int } + +func (T) M() string { return "ZERO-RECEIVER" } + +var pt *T +var G func() string + +func Bind(cur realm) { var i I = pt; G = i.M } +func CallLater(cur realm) string { return G() } diff --git a/gno.land/pkg/integration/testdata/qeval_json_lazy_method.txtar b/gno.land/pkg/integration/testdata/qeval_json_lazy_method.txtar new file mode 100644 index 00000000000..cff713c8d39 --- /dev/null +++ b/gno.land/pkg/integration/testdata/qeval_json_lazy_method.txtar @@ -0,0 +1,31 @@ +# Test vm/qeval_json on a lazy interface-bound method value (BoundMethodValue +# with Func == nil, resolved at call time). Regression: exportCopyValue used to +# nil-deref on the typed-nil Func and drop the Method selector. + +loadpkg gno.land/r/test/lz $WORK/realm + +gnoland start + +gnokey query vm/qeval_json --data "gno.land/r/test/lz.GetG()" +cmp stdout getg.stdout.golden + +-- getg.stdout.golden -- +height: 0 +data: {"results":[{"T":{"@type":"/gno.FuncType","Params":[],"Results":[{"Name":".res.0","Type":{"@type":"/gno.PrimitiveType","value":"32"},"Embedded":false,"Tag":"","PkgPath":""}]},"V":{"@type":"/gno.BoundMethodValue","ObjectInfo":{"ID":"02257358f7f2a792ff3db696d06d51d16522884e:0","ModTime":"0","RefCount":"0","LastObjectSize":"0"},"Func":null,"Receiver":{"T":{"@type":"/gno.RefType","ID":"gno.land/r/test/lz.Impl"},"V":{"@type":"/gno.StructValue","ObjectInfo":{"ID":"02257358f7f2a792ff3db696d06d51d16522884e:0","ModTime":"0","RefCount":"0","LastObjectSize":"0"},"Fields":[{"T":{"@type":"/gno.PrimitiveType","value":"32"},"N":"AQAAAAAAAAA="}]}},"Method":"Get","MethodPkg":"gno.land/r/test/lz"}}]} +-- realm/gnomod.toml -- +module = "gno.land/r/test/lz" +gno = "0.9" + +-- realm/realm.gno -- +package lz + +type IG interface{ Get() int } + +type Impl struct{ x int } + +func (i Impl) Get() int { return i.x } + +func GetG() func() int { + var o IG = Impl{x: 1} + return o.Get +} diff --git a/gno.land/pkg/integration/testdata/stdlib_restart_compare.txtar b/gno.land/pkg/integration/testdata/stdlib_restart_compare.txtar index 55004c1f8ab..ba5d8c7283b 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=2176646 +env EXACT_GAS=2176797 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..b4836c30536 --- /dev/null +++ b/gnovm/adr/pr5737_nil_value_method_panic_timing.md @@ -0,0 +1,91 @@ +# 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`). +- `BoundMethodValue` also gains `MethodPkg string` (proto field 5): the bind + site's `callerPath`. PR #5739 made unexported member identity + package-qualified, so the call-time re-derivation must look up `Method` under + the bind site's package, not the dynamic type's — the latter may hold a + same-spelled member with a different identity (e.g. an unexported field + shadowing a promoted unexported method: `iface_embed_field_shadow.gno`). + Persistence pinned by `method_iface_shadow_persist.txtar` (restart, then + dispatch must still skip the realm-qualified shadow field). + +## Consequences + +- **Hard fork**: `Method` is proto field 4, `MethodPkg` field 5 (`pb3_gen` + regenerated); `_allocBoundMethodValue` 200→232. 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 87daf680b1a..ed0f52c0c25 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 = 232 // 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,8 +744,8 @@ func (mv *MapValue) GetShallowSize() int64 { } func (bmv *BoundMethodValue) GetShallowSize() int64 { - // skip .uverse - if bmv.Func.PkgPath == ".uverse" { + // skip .uverse (Func == nil for an unresolved lazy interface bind) + if bmv.Func != nil && bmv.Func.PkgPath == ".uverse" { return 0 } return allocBoundMethod diff --git a/gnovm/pkg/gnolang/bench_ops_test.go b/gnovm/pkg/gnolang/bench_ops_test.go index de528c929fd..1d69b648062 100644 --- a/gnovm/pkg/gnolang/bench_ops_test.go +++ b/gnovm/pkg/gnolang/bench_ops_test.go @@ -4649,6 +4649,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) { @@ -4875,10 +4908,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/bounded_strings.go b/gnovm/pkg/gnolang/bounded_strings.go index d06cdb9587b..856356c938f 100644 --- a/gnovm/pkg/gnolang/bounded_strings.go +++ b/gnovm/pkg/gnolang/bounded_strings.go @@ -712,10 +712,16 @@ func boundedSprintFuncValue(w *boundedBuf, fv *FuncValue) { } func boundedSprintBoundMethodValue(w *boundedBuf, bmv *BoundMethodValue) { - if bmv == nil || bmv.Func == nil { + if bmv == nil { w.WriteString("") return } + if bmv.IsLazy() { + // Unresolved interface bind: no concrete Func until call time; render + // from the selector name. + fmt.Fprintf(w, "", bmv.Method) + return + } name := string(bmv.Func.Name) if name == "" { w.WriteString("") diff --git a/gnovm/pkg/gnolang/frame.go b/gnovm/pkg/gnolang/frame.go index d2a6ae9b802..dc877decaf3 100644 --- a/gnovm/pkg/gnolang/frame.go +++ b/gnovm/pkg/gnolang/frame.go @@ -111,11 +111,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 3edf654a15d..ab2c1b7dc58 100644 --- a/gnovm/pkg/gnolang/gnolang.proto +++ b/gnovm/pkg/gnolang/gnolang.proto @@ -83,6 +83,8 @@ 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"]; + string method_pkg = 5 [json_name = "MethodPkg"]; } message TypeValue { diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index f43aefcb6cc..d4e8f131a81 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -1390,13 +1390,22 @@ 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 + // charged per hop of the resolveLazyBound walk (once per stripped interface + // layer), so deep/nested embedded-interface dispatch is metered by depth like + // the eager concrete path. Single-hop resolution (the common case) charges it + // once — gas-neutral with the prior per-call charge. + // TODO(calibration), before the fork ships: re-measure on the gas-table + // reference HW — 529 is ratio-scaled (lazy-vs-concrete bench delta against + // OpCPUPrecallBoundMethod's known value) and reused as the per-hop cost. + 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 +1466,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 d1b7f9774b1..f2695b595f9 100644 --- a/gnovm/pkg/gnolang/op_call.go +++ b/gnovm/pkg/gnolang/op_call.go @@ -27,15 +27,22 @@ 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() { + fn, recv = resolveLazyBound(m, 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 +565,31 @@ func (m *Machine) doOpReturnCallDefers() { return } - if dfr.Func == nil { + // 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() { + fv, recv = resolveLazyBound(m, 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(typedRuntimeError("runtime error: defer called a 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 +598,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 +697,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 d3f7d906d6b..9fc0f1f617e 100644 --- a/gnovm/pkg/gnolang/pb3_gen.go +++ b/gnovm/pkg/gnolang/pb3_gen.go @@ -1648,6 +1648,30 @@ 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.MethodPkg != "" { + { + before := offset + offset = amino.PrependString(buf, offset, goo.MethodPkg) + valueLen := before - offset + if valueLen > 1 || (valueLen == 1 && buf[offset] != 0x00) { + offset = amino.PrependFieldNumberAndTyp3(buf, offset, 5, amino.Typ3ByteLength) + } else { + offset = before + } + } + } + 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 +1744,12 @@ 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) + } + if goo.MethodPkg != "" { + s += 1 + amino.UvarintSize(uint64(len(goo.MethodPkg))) + len(goo.MethodPkg) + } return s, nil } @@ -1778,6 +1808,26 @@ 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) + case 5: + if typ3 != amino.Typ3ByteLength { + return fmt.Errorf("field 5: expected typ3 %v, got %v", amino.Typ3ByteLength, typ3) + } + v, n, err := amino.DecodeString(bz) + if err != nil { + return err + } + bz = bz[n:] + goo.MethodPkg = 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 fb0634f63ef..7769fe205b3 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: @@ -1702,12 +1707,17 @@ 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, + MethodPkg: cv.MethodPkg, } case *MapValue: list := &MapList{} @@ -1916,7 +1926,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 8b4fb79d2f6..651a023964c 100644 --- a/gnovm/pkg/gnolang/types.go +++ b/gnovm/pkg/gnolang/types.go @@ -3045,6 +3045,17 @@ 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. +// // Contract: trail != nil implies accessError == false — a same-spelled // unexported name from another package is a distinct identity, so a gated // candidate is skipped, not an error, when an accessible match exists. diff --git a/gnovm/pkg/gnolang/values.go b/gnovm/pkg/gnolang/values.go index 1d7cdd71017..bc5e8b80b07 100644 --- a/gnovm/pkg/gnolang/values.go +++ b/gnovm/pkg/gnolang/values.go @@ -663,14 +663,150 @@ 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 + + // MethodPkg is the callerPath of the bind site (the package whose code + // formed the method value). Unexported method identity is + // package-qualified, so the call-time re-derivation must look up Method + // under the same qualification — the dynamic type's own package may see a + // different (or shadowing) same-spelled member. Empty for a resolved + // (eager) bind. + MethodPkg string +} + +// 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 +} + +// 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, callerPath string) PointerValue { + btv := boxed + for i, path := range tr { + ptr := btv.getPointerToFromTV(alloc, store, path, callerPath) + 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); +// for a finite (acyclic) operand graph this converges. A cyclic embedded +// interface (e.g. `s.IG = s`) would re-box the same operand forever, so the +// loop detects a repeated pointer operand and raises a fatal (gno-unrecoverable) +// panic instead of hanging — matching Go, which runs the same program as +// recursion and fatally stack-overflows (uncatchable by recover()). +// +// 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(m *Machine, bmv *BoundMethodValue) (*FuncValue, TypedValue) { + operand := bmv.Receiver + name := bmv.Method + // The bind-site package qualifies the lookup: unexported method identity + // is package-qualified, and the dynamic type's package may hold a + // same-spelled member with a different identity (e.g. a shadowing + // unexported field), which must not be matched here. + callerPath := bmv.MethodPkg + if callerPath == "" { + callerPath = operand.T.GetPkgPath() + } + // The method name is invariant across layers, so the operand's identity + // fully identifies the loop state: a revisited identity means the resolution + // can only repeat forever (a cyclic embed, e.g. `s.IG = s`). Identity is the + // pointee for a pointer operand and the heap object for a struct operand (an + // interface field re-read yields the same *StructValue each hop, so a cycle + // routed through a boxed struct value recurs by pointer identity too). Other + // shapes cannot embed fields and so cannot yield another lazy hop. + var seen map[any]struct{} + for { + // Charge per hop so the cost scales with embedded-interface depth rather + // than being flat per call. + m.incrCPU(OpCPULazyBoundResolve) + // 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(m.Store, &operand) + var id any + switch v := operand.V.(type) { + case PointerValue: + if v.TV != nil { + id = v.TV + } + case *StructValue: + id = v + } + if id != nil { + if _, dup := seen[id]; dup { + panic("cyclic embedded interface in method-value dispatch") + } + if seen == nil { + seen = make(map[any]struct{}) + } + seen[id] = struct{}{} + } + tr, _, _, _, _ := findEmbeddedFieldType(callerPath, operand.T, name, nil) + if len(tr) == 0 { + // Mirrors the bind-site guard (getPointerToFromTV, VPInterface). + // The call-time operand type is the same one that passed that + // guard, so this should be unreachable; guard anyway rather than + // fall into resolveInterfaceTrail's "should not happen". + panic(fmt.Sprintf("method %s not found in type %s", + name, operand.T.String())) + } + next := resolveInterfaceTrail(m.Alloc, m.Store, operand, tr, callerPath).Deref().V.(*BoundMethodValue) + if !next.IsLazy() { + return next.Func, next.Receiver + } + operand, name, callerPath = next.Receiver, next.Method, next.MethodPkg + } } 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() } @@ -1764,7 +1900,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 @@ -1781,6 +1916,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 @@ -1828,6 +1969,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 { msg := "runtime error: nil pointer dereference" if pt, ok := tv.T.(*PointerType); ok { @@ -1923,17 +2069,17 @@ 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{} + // A value receiver is a fresh copy the method body may mutate without + // touching the original; clear readonly on the copy (other rules still + // apply, e.g. in DidUpdate). + rtv := dtv.Copy(alloc) + if rtv.V != nil { + rtv.N = [8]byte{} } alloc.AllocateBoundMethod() bmv := &BoundMethodValue{ Func: mv, - Receiver: dtv2, + Receiver: rtv, } // Bound method wrapper belongs to the realm doing the // binding; the receiver carries its own PkgID independently. @@ -1996,20 +2142,35 @@ func (tv *TypedValue) getPointerToFromTV(alloc *Allocator, store Store, path Val if callerPath == "" { 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, callerPath) - 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, + MethodPkg: callerPath, + } + 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") } @@ -2193,6 +2354,10 @@ func (tv *TypedValue) GetFunc() *FuncValue { // GetUnboundFunc returns the underlying *FuncValue for both plain funcs // and bound methods (stripping the receiver), or nil for a typed-nil // func/method variable. Panics on any other type. +// +// A lazy interface bind (BoundMethodValue.IsLazy()) also returns nil — its +// concrete func doesn't exist until call time — so callers must not read nil +// as "typed-nil func" without checking the value shape. func (tv *TypedValue) GetUnboundFunc() *FuncValue { switch fv := tv.V.(type) { case nil: diff --git a/gnovm/pkg/gnolang/values_export.go b/gnovm/pkg/gnolang/values_export.go index 7f7fa6db534..77a4737b9fa 100644 --- a/gnovm/pkg/gnolang/values_export.go +++ b/gnovm/pkg/gnolang/values_export.go @@ -244,12 +244,17 @@ func exportCopyValue(val Value, seen map[Object]int) Value { // changes, this branch would re-expand a shared FuncValue inline // instead of emitting an ExportRefValue — the exported output // would still be correct, just potentially larger. - fnc := exportCopyValue(cv.Func, seen).(*FuncValue) + var fnc *FuncValue // nil for a lazy interface bind (resolved at call) + if cv.Func != nil { + fnc = exportCopyValue(cv.Func, seen).(*FuncValue) + } rtv := exportValue(cv.Receiver, seen) return &BoundMethodValue{ ObjectInfo: cv.ObjectInfo.Copy(), Func: fnc, Receiver: rtv, + Method: cv.Method, + MethodPkg: cv.MethodPkg, } case *MapValue: list := &MapList{} diff --git a/gnovm/pkg/gnolang/values_string.go b/gnovm/pkg/gnolang/values_string.go index c5f97038127..49cbbc21715 100644 --- a/gnovm/pkg/gnolang/values_string.go +++ b/gnovm/pkg/gnolang/values_string.go @@ -134,6 +134,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_cyclic.gno b/gnovm/tests/files/method_iface_cyclic.gno new file mode 100644 index 00000000000..902bb585f9e --- /dev/null +++ b/gnovm/tests/files/method_iface_cyclic.gno @@ -0,0 +1,23 @@ +// A cyclic embedded interface makes interface method-value dispatch +// non-convergent: resolveLazyBound would re-box the same operand forever. It +// tracks the operand pointer and raises a fatal, gno-unrecoverable panic when +// one repeats — matching Go, which runs the same program as recursion and +// fatally stack-overflows. recover() cannot catch it (see indirect variant for +// the a->b->a case, which exercises the seen-set rather than an adjacent check). +package main + +type IG interface{ Get() int } + +type Box struct{ IG } + +func main() { + defer func() { println("recover:", recover()) }() // does NOT catch a fatal panic + s := &Box{} + s.IG = s // direct: the embedded interface points back at the box + var o IG = s + g := o.Get + println(g()) +} + +// Error: +// cyclic embedded interface in method-value dispatch diff --git a/gnovm/tests/files/method_iface_cyclic_3.gno b/gnovm/tests/files/method_iface_cyclic_3.gno new file mode 100644 index 00000000000..3e307ceface --- /dev/null +++ b/gnovm/tests/files/method_iface_cyclic_3.gno @@ -0,0 +1,21 @@ +// Longer indirect cycle (a->b->c->a): detection must use a visited set, since no +// two consecutive operands repeat. Fatal, gno-unrecoverable (Go fatally +// stack-overflows). See method_iface_cyclic.gno. +package main + +type IG interface{ Get() int } + +type Box struct{ IG } + +func main() { + a, b, c := &Box{}, &Box{}, &Box{} + a.IG = b + b.IG = c + c.IG = a + var o IG = a + g := o.Get + println(g()) +} + +// Error: +// cyclic embedded interface in method-value dispatch diff --git a/gnovm/tests/files/method_iface_cyclic_defer.gno b/gnovm/tests/files/method_iface_cyclic_defer.gno new file mode 100644 index 00000000000..db41bca531f --- /dev/null +++ b/gnovm/tests/files/method_iface_cyclic_defer.gno @@ -0,0 +1,24 @@ +// A cyclic bind invoked via defer is resolved at the deferred-call site (the +// second resolveLazyBound caller), not the immediate one, so this covers that +// path. "before return" prints, then the deferred call fatally panics. See +// method_iface_cyclic.gno. +package main + +type IG interface{ Get() int } + +type Box struct{ IG } + +func main() { + s := &Box{} + s.IG = s + var o IG = s + g := o.Get + defer g() + println("before return") +} + +// Output: +// before return + +// Error: +// cyclic embedded interface in method-value dispatch diff --git a/gnovm/tests/files/method_iface_cyclic_embedded.gno b/gnovm/tests/files/method_iface_cyclic_embedded.gno new file mode 100644 index 00000000000..4a2479b7b6a --- /dev/null +++ b/gnovm/tests/files/method_iface_cyclic_embedded.gno @@ -0,0 +1,22 @@ +// Cycle through a two-level embed: *Box satisfies IG via Box->Inner->IG +// promotion, and the embedded interface points back at the box. The dispatch +// walk strips the promoted layers and still loops, so detection fires. Fatal +// (Go fatally stack-overflows). See method_iface_cyclic.gno. +package main + +type IG interface{ Get() int } + +type Inner struct{ IG } + +type Box struct{ Inner } + +func main() { + s := &Box{} + s.Inner.IG = s + var o IG = s + g := o.Get + println(g()) +} + +// Error: +// cyclic embedded interface in method-value dispatch diff --git a/gnovm/tests/files/method_iface_cyclic_indirect.gno b/gnovm/tests/files/method_iface_cyclic_indirect.gno new file mode 100644 index 00000000000..fc64c61be64 --- /dev/null +++ b/gnovm/tests/files/method_iface_cyclic_indirect.gno @@ -0,0 +1,23 @@ +// Indirect (a->b->a) cyclic embedded interface: the dispatch loop never sees an +// adjacent repeat, only a repeat after one hop, so detection must use a visited +// set (not a last-operand check). Raises the same fatal, gno-unrecoverable panic +// as the direct variant. See method_iface_cyclic.gno. +package main + +type IG interface{ Get() int } + +type Box struct{ IG } + +func main() { + defer func() { println("recover:", recover()) }() // does NOT catch a fatal panic + a := &Box{} + b := &Box{} + a.IG = b + b.IG = a // indirect 2-cycle: a -> b -> a + var o IG = a + g := o.Get + println(g()) +} + +// Error: +// cyclic embedded interface in method-value dispatch diff --git a/gnovm/tests/files/method_iface_cyclic_value.gno b/gnovm/tests/files/method_iface_cyclic_value.gno new file mode 100644 index 00000000000..5bebc9def45 --- /dev/null +++ b/gnovm/tests/files/method_iface_cyclic_value.gno @@ -0,0 +1,23 @@ +// Cycle routed through a boxed struct value rather than a bare pointer: after +// the first hop the operand is the *StructValue W (not a PointerValue), so +// detection must key the visited set on struct identity too — an interface +// field re-read yields the same *StructValue each hop. Fatal, gno-unrecoverable +// (Go fatally stack-overflows). See method_iface_cyclic.gno. +package main + +type IG interface{ Get() int } + +type Box struct{ IG } + +type W struct{ *Box } + +func main() { + s := &Box{} + s.IG = W{s} // cycle through a value, not a bare pointer + var o IG = s + g := o.Get + println(g()) +} + +// Error: +// cyclic embedded interface in method-value dispatch diff --git a/gnovm/tests/files/method_iface_deep_converge.gno b/gnovm/tests/files/method_iface_deep_converge.gno new file mode 100644 index 00000000000..7935b6effb2 --- /dev/null +++ b/gnovm/tests/files/method_iface_deep_converge.gno @@ -0,0 +1,23 @@ +// Legitimate deep interface nesting: three distinct embedded layers resolve to a +// concrete method. Distinct operands never repeat, so detection does not fire +// (guards against being over-strict on depth). Converges to the concrete value. +package main + +type IG interface{ Get() int } + +type Concrete struct{ v int } + +func (c Concrete) Get() int { return c.v } + +type Mid struct{ IG } + +type Outer struct{ IG } + +func main() { + var o IG = Outer{IG: Mid{IG: Concrete{v: 9}}} + g := o.Get + println(g()) +} + +// Output: +// 9 diff --git a/gnovm/tests/files/method_iface_defer.gno b/gnovm/tests/files/method_iface_defer.gno new file mode 100644 index 00000000000..69df8f14a15 --- /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_shadow_cyclic.gno b/gnovm/tests/files/method_iface_shadow_cyclic.gno new file mode 100644 index 00000000000..ee38bf777ed --- /dev/null +++ b/gnovm/tests/files/method_iface_shadow_cyclic.gno @@ -0,0 +1,25 @@ +// A concrete method that shadows an embedded interface wins dispatch, so even a +// cyclic embed (s.IG = s) is never consulted and resolution converges to the +// concrete method. Guards against the detection firing on a non-dispatched +// cycle (must NOT panic). +package main + +type IG interface{ Get() int } + +type Box struct { + IG + v int +} + +func (b *Box) Get() int { return b.v } + +func main() { + s := &Box{v: 7} + s.IG = s // cyclic, but *Box.Get shadows it + var o IG = s + g := o.Get + println(g()) +} + +// Output: +// 7 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