Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
73d25b5
test(gnovm): add failing filetest for value method bound to nil receiver
ltzmaxwell Jun 17, 2026
f041b0f
fix(gnovm): match Go's call-time dispatch for interface-bound method …
ltzmaxwell Jun 22, 2026
0c130d4
chore(gnovm): keep .uverse alloc check minimal (nil-guard only); drop…
ltzmaxwell Jun 24, 2026
6dc78e4
chore(gnovm): clarify recv's dual role in the deferred-call dispatch
ltzmaxwell Jun 24, 2026
f7a2ab5
test(gnovm): restore nil-receiver persist/reload panic test (per #573…
ltzmaxwell Jun 24, 2026
a2225a4
chore(gnovm): inline single-use valueMethodReceiver into the VPValMet…
ltzmaxwell Jun 24, 2026
5d7c67c
fix(gnovm): detect cyclic embedded interface in lazy method-value dis…
ltzmaxwell Jun 25, 2026
ec58441
perf(gnovm): charge lazy-bound dispatch gas per hop, not per call
ltzmaxwell Jun 25, 2026
431997f
docs(gnovm): update OpCPULazyBoundResolve comment for per-hop charge
ltzmaxwell Jun 25, 2026
ccb6c94
chore(gnovm): gofmt method_iface_defer.gno (align method decl braces)
ltzmaxwell Jun 25, 2026
171a698
Merge branch 'master' into fix/defer12
ltzmaxwell Jun 30, 2026
aa5735d
Merge remote-tracking branch 'origin/master' into fix/defer12
ltzmaxwell Jul 6, 2026
0019dc4
Merge remote-tracking branch 'mine/fix/defer12' into fix/defer12
ltzmaxwell Jul 6, 2026
dd77e41
fix(gnovm): detect struct-carried cycles in lazy method-value dispatch
ltzmaxwell Jul 8, 2026
8d7e980
fix(gnovm): guard exportCopyValue against lazy bound-method nil Func,…
ltzmaxwell Jul 8, 2026
f5171d5
fix(gnovm): render lazy bound-method as selector, not <nil>, in bound…
ltzmaxwell Jul 8, 2026
78853b3
docs(gnovm): note GetUnboundFunc returns nil for lazy interface binds
ltzmaxwell Jul 8, 2026
9af6757
fix(gnovm): guard missing method in resolveLazyBound instead of gener…
ltzmaxwell Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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() }
50 changes: 50 additions & 0 deletions gno.land/pkg/integration/testdata/method_iface_lazy_persist.txtar
Original file line number Diff line number Diff line change
@@ -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() }
38 changes: 38 additions & 0 deletions gno.land/pkg/integration/testdata/method_nil_value_persist.txtar
Original file line number Diff line number Diff line change
@@ -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 'nil pointer dereference'

-- 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() }
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
83 changes: 83 additions & 0 deletions gnovm/adr/pr5737_nil_value_method_panic_timing.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions gnovm/pkg/gnolang/alloc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
Expand Down Expand Up @@ -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" {
Comment thread
thehowl marked this conversation as resolved.
return 0
}
return allocBoundMethod
Expand Down
41 changes: 37 additions & 4 deletions gnovm/pkg/gnolang/bench_ops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions gnovm/pkg/gnolang/frame.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions gnovm/pkg/gnolang/garbage_collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions gnovm/pkg/gnolang/gnolang.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 17 additions & 8 deletions gnovm/pkg/gnolang/machine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading