From e15e2e7a55e2851d5012d618a434827937ec9336 Mon Sep 17 00:00:00 2001 From: danielntmd Date: Mon, 19 Oct 2026 12:53:16 -0700 Subject: [PATCH] feat(cbor): add RegisterDecoder for per-type decoders - RegisterDecoder registers a decoder for *T and **T destinations. Unmarshal and UnmarshalFirst use it, and fxamacker for every other type. - The decoder is chosen from the destination's type. A nil or non-zero destination goes to fxamacker, which merges into it. - A decoder that fails is logged (SetLogger, sampled) and the item is decoded again with fxamacker. - Registration is for init only, and a second decoder for a type panics. - The table is keyed by the interface's type word to skip hashing, which relies on gc's layout of an interface, as reflect does. No decoder is registered yet, so nothing changes until one is. --- node/node.go | 2 + utils/cbor/cbor.go | 21 ++++- utils/cbor/decoders.go | 173 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 utils/cbor/decoders.go diff --git a/node/node.go b/node/node.go index 7931f1210d..6070546b04 100644 --- a/node/node.go +++ b/node/node.go @@ -43,6 +43,7 @@ import ( "github.com/NethermindEth/juno/starknet/compiler" "github.com/NethermindEth/juno/sync" "github.com/NethermindEth/juno/sync/preconfirmed" + "github.com/NethermindEth/juno/utils/cbor" "github.com/NethermindEth/juno/utils/log" "github.com/NethermindEth/juno/vm" "github.com/consensys/gnark-crypto/ecc/stark-curve/ecdsa" @@ -194,6 +195,7 @@ func New(cfg *Config, version string, logLevel *log.Level) (*Node, error) { if err != nil { return nil, err } + cbor.SetLogger(logger) if cfg.DisableSync { if cfg.Sequencer { diff --git a/utils/cbor/cbor.go b/utils/cbor/cbor.go index e2f7d81a7a..6f815366b2 100644 --- a/utils/cbor/cbor.go +++ b/utils/cbor/cbor.go @@ -1,5 +1,5 @@ -// Package cbor provides Juno's CBOR API. Encoding and decoding currently use -// the fxamacker adapter. +// Package cbor provides Juno's CBOR API. Encoding uses fxamacker. Decoding uses the decoder +// registered for the destination's type with [RegisterDecoder]. package cbor import ( @@ -31,12 +31,12 @@ func Marshal(v any) ([]byte, error) { // Unmarshal decodes a CBOR value from data into v. func Unmarshal(data []byte, v any) error { - return fxamacker.Unmarshal(data, v) + return decoderFor(v).unmarshal(data, v) } // UnmarshalFirst decodes the first CBOR item and returns the remaining bytes. func UnmarshalFirst(data []byte, v any) ([]byte, error) { - return fxamacker.UnmarshalFirst(data, v) + return decoderFor(v).unmarshalFirst(data, v) } // UnmarshalStrict decodes without type tags and rejects unknown fields. @@ -55,3 +55,16 @@ func NewEncoder(w io.Writer) Encoder { func RegisterType(t reflect.Type) error { return fxamacker.RegisterType(t) } + +// RegisterDecoder makes decode the decoder of *T and **T destinations. +// decode reads one item from the start of data into a dst-owned item and returns the rest. +// If it fails, the item is decoded with fxamacker instead. +// Call it only from init, and decode must be safe for concurrent use. +func RegisterDecoder[T any](decode func(data []byte, dst *T) (rest []byte, err error)) { + value, pointer := typeWord(new(T)), typeWord(new(*T)) + if decoders[value] != nil || decoders[pointer] != nil { + panic("cbor: a decoder is already registered for " + reflect.TypeFor[T]().String()) + } + decoders[value] = valueDecoder(decode) + decoders[pointer] = pointerDecoder(decode) +} diff --git a/utils/cbor/decoders.go b/utils/cbor/decoders.go new file mode 100644 index 0000000000..4de8eea918 --- /dev/null +++ b/utils/cbor/decoders.go @@ -0,0 +1,173 @@ +package cbor + +import ( + "bytes" + "fmt" + "reflect" + "sync/atomic" + "time" + "unsafe" + + "github.com/NethermindEth/juno/utils/cbor/fxamacker" + "github.com/NethermindEth/juno/utils/log" + "go.uber.org/zap" +) + +// A decoder reads CBOR into a destination. +type decoder struct { + unmarshal func(data []byte, v any) error + unmarshalFirst func(data []byte, v any) ([]byte, error) +} + +// fxamackerDecoder is the default decoder. +// It decodes types with no registered decoder and is the current fallback. +var fxamackerDecoder = decoder{fxamacker.Unmarshal, fxamacker.UnmarshalFirst} + +// decoders finds the decoder of a destination by its type, without hashing the interface. A +// destination is looked up by its exact pointer type, *T or **T, so the pointer inside the +// interface is always the one its decoder was written for, and the unsafe casts only erase and +// restore that type. This assumes gc's layout of an interface (the type word followed by the data +// word), which [reflect] also relies on. +var decoders = map[unsafe.Pointer]*typeDecoder{} + +// decoderFor returns the decoder of v. +// Either the one registered for its type, or fxamacker. +func decoderFor(v any) decoder { + value := (*eface)(unsafe.Pointer(&v)) + d, ok := decoders[value.typ] + if !ok || value.data == nil || !d.isZero(value.data) { + return fxamackerDecoder + } + return d.decoder +} + +// eface is the layout of an interface value with no methods. +type eface struct { + typ, data unsafe.Pointer +} + +// typeWord returns the dynamic type of v as the runtime keeps it. +func typeWord(v any) unsafe.Pointer { + return (*eface)(unsafe.Pointer(&v)).typ +} + +// dest returns the pointer that v, a pointer to a registered type, holds. +func dest(v any) unsafe.Pointer { + return (*eface)(unsafe.Pointer(&v)).data +} + +// A typeDecoder decodes into destinations of one pointer type, *T or **T. +// Registering a decoder for T creates one of each. +type typeDecoder struct { + name string // destination type + // decoder is what decoderFor returns for the type, built by bind. + decoder decoder + // decodeUnsafe is the registered decoder with the destination as the pointer itself. + decodeUnsafe func(data []byte, dst unsafe.Pointer) ([]byte, error) + // reset zeroes dst, undoing a partial read. + reset func(dst unsafe.Pointer) + // isZero reports whether dst holds the zero value, which for **T is a nil pointer. + isZero func(dst unsafe.Pointer) bool +} + +// logger reports failed decodes. +var logger atomic.Pointer[log.StructuredLogger] + +// SetLogger sets where a decode that falls back to fxamacker is reported. The reports are sampled, +// so a hot read path cannot flood the log. Call it once, at startup. +// todo(danielntmd): It reports errors only, not a wrong value returned without one. The fallback +// and this logger are meant to be removed eventually. +func SetLogger(l log.StructuredLogger) { + // The first few reports in each interval, then every thereafter'th. + const ( + interval = time.Minute + first = 5 + thereafter = 100 + ) + l = log.Sampled(l, interval, first, thereafter) + logger.Store(&l) +} + +// unmarshalFirst reads one item into v and returns the bytes after it. +func (d *typeDecoder) unmarshalFirst(data []byte, v any) ([]byte, error) { + rest, err := d.decodeUnsafe(data, dest(v)) + if err != nil { + if l := logger.Load(); l != nil { + (*l).Warn("cbor: decoder failed, decoding with fxamacker", + zap.String("type", d.name), zap.Error(err)) + } + d.reset(dest(v)) + return fxamackerDecoder.unmarshalFirst(data, v) // fallback + } + return rest, nil +} + +// unmarshal reads exactly one item into v. +func (d *typeDecoder) unmarshal(data []byte, v any) error { + rest, err := d.unmarshalFirst(data, v) + if err == nil && len(rest) != 0 { + d.reset(dest(v)) + return fmt.Errorf("cbor: %d bytes of extraneous data", len(rest)) + } + return err +} + +// bind builds the decoder that decoderFor returns for d, once, so that a decode allocates nothing +// to call it. +func bind(d *typeDecoder) *typeDecoder { + d.decoder = decoder{d.unmarshal, d.unmarshalFirst} + return d +} + +// valueDecoder returns the decoder of *T destinations. +func valueDecoder[T any](decode func([]byte, *T) ([]byte, error)) *typeDecoder { + typ := reflect.TypeFor[T]() + zeroBytes := make([]byte, typ.Size()) + return bind(&typeDecoder{ + name: reflect.TypeFor[*T]().String(), + // *T and unsafe.Pointer are passed the same way, so the decoder is called directly. + decodeUnsafe: *(*func([]byte, unsafe.Pointer) ([]byte, error))(unsafe.Pointer(&decode)), + reset: func(dst unsafe.Pointer) { + *(*T)(dst) = *new(T) + }, + // All-zero bytes are the zero value, but not the reverse (an empty string can still + // point somewhere), so [reflect] decides the rest. + isZero: func(dst unsafe.Pointer) bool { + return bytes.Equal(unsafe.Slice((*byte)(dst), len(zeroBytes)), zeroBytes) || + reflect.NewAt(typ, dst).Elem().IsZero() + }, + }) +} + +// Head bytes of the two items a pointer treats as nil. +// (RFC 8949, section 3.3) +const ( + simpleNull = 0xf6 + simpleUndefined = 0xf7 +) + +// pointerDecoder returns the decoder of **T destinations. +func pointerDecoder[T any](decode func([]byte, *T) ([]byte, error)) *typeDecoder { + return bind(&typeDecoder{ + name: reflect.TypeFor[**T]().String(), + decodeUnsafe: func(data []byte, dst unsafe.Pointer) ([]byte, error) { + // For parity with fxamacker, null and undefined leave the pointer nil. + if len(data) > 0 && (data[0] == simpleNull || data[0] == simpleUndefined) { + return data[1:], nil + } + value := new(T) + rest, err := decode(data, value) + if err != nil { + return nil, err + } + *(**T)(dst) = value + return rest, nil + }, + reset: func(dst unsafe.Pointer) { + *(**T)(dst) = nil + }, + isZero: func(dst unsafe.Pointer) bool { + return *(**T)(dst) == nil + }, + }) +}