From e96084501ac2eebd6503d8c870e1b15a29169075 Mon Sep 17 00:00:00 2001 From: Kevin Gillette Date: Tue, 29 Jul 2025 11:14:35 -0600 Subject: [PATCH 1/5] json: consistent use of encodeNull (should inline well) --- json/encode.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/json/encode.go b/json/encode.go index 2a6da07..8fe2867 100644 --- a/json/encode.go +++ b/json/encode.go @@ -241,7 +241,7 @@ func (e encoder) encodeToString(b []byte, p unsafe.Pointer, encode encodeFunc) ( func (e encoder) encodeBytes(b []byte, p unsafe.Pointer) ([]byte, error) { v := *(*[]byte)(p) if v == nil { - return append(b, "null"...), nil + return e.encodeNull(b, nil) } n := base64.StdEncoding.EncodedLen(len(v)) + 2 @@ -299,7 +299,7 @@ func (e encoder) encodeSlice(b []byte, p unsafe.Pointer, size uintptr, t reflect s := (*slice)(p) if s.data == nil && s.len == 0 && s.cap == 0 { - return append(b, "null"...), nil + return e.encodeNull(b, nil) } return e.encodeArray(b, s.data, s.len, size, t, encode) @@ -308,7 +308,7 @@ func (e encoder) encodeSlice(b []byte, p unsafe.Pointer, size uintptr, t reflect func (e encoder) encodeMap(b []byte, p unsafe.Pointer, t reflect.Type, encodeKey, encodeValue encodeFunc, sortKeys sortFunc) ([]byte, error) { m := reflect.NewAt(t, p).Elem() if m.IsNil() { - return append(b, "null"...), nil + return e.encodeNull(b, nil) } keys := m.MapKeys() @@ -363,7 +363,7 @@ var mapslicePool = sync.Pool{ func (e encoder) encodeMapStringInterface(b []byte, p unsafe.Pointer) ([]byte, error) { m := *(*map[string]any)(p) if m == nil { - return append(b, "null"...), nil + return e.encodeNull(b, nil) } if (e.flags & SortMapKeys) == 0 { @@ -441,7 +441,7 @@ func (e encoder) encodeMapStringInterface(b []byte, p unsafe.Pointer) ([]byte, e func (e encoder) encodeMapStringRawMessage(b []byte, p unsafe.Pointer) ([]byte, error) { m := *(*map[string]RawMessage)(p) if m == nil { - return append(b, "null"...), nil + return e.encodeNull(b, nil) } if (e.flags & SortMapKeys) == 0 { @@ -520,7 +520,7 @@ func (e encoder) encodeMapStringRawMessage(b []byte, p unsafe.Pointer) ([]byte, func (e encoder) encodeMapStringString(b []byte, p unsafe.Pointer) ([]byte, error) { m := *(*map[string]string)(p) if m == nil { - return append(b, "null"...), nil + return e.encodeNull(b, nil) } if (e.flags & SortMapKeys) == 0 { @@ -586,7 +586,7 @@ func (e encoder) encodeMapStringString(b []byte, p unsafe.Pointer) ([]byte, erro func (e encoder) encodeMapStringStringSlice(b []byte, p unsafe.Pointer) ([]byte, error) { m := *(*map[string][]string)(p) if m == nil { - return append(b, "null"...), nil + return e.encodeNull(b, nil) } stringSize := unsafe.Sizeof("") @@ -667,7 +667,7 @@ func (e encoder) encodeMapStringStringSlice(b []byte, p unsafe.Pointer) ([]byte, func (e encoder) encodeMapStringBool(b []byte, p unsafe.Pointer) ([]byte, error) { m := *(*map[string]bool)(p) if m == nil { - return append(b, "null"...), nil + return e.encodeNull(b, nil) } if (e.flags & SortMapKeys) == 0 { @@ -828,7 +828,7 @@ func (e encoder) encodeRawMessage(b []byte, p unsafe.Pointer) ([]byte, error) { v := *(*RawMessage)(p) if v == nil { - return append(b, "null"...), nil + return e.encodeNull(b, nil) } var s []byte @@ -862,7 +862,7 @@ func (e encoder) encodeJSONMarshaler(b []byte, p unsafe.Pointer, t reflect.Type, switch v.Kind() { case reflect.Ptr, reflect.Interface: if v.IsNil() { - return append(b, "null"...), nil + return e.encodeNull(b, nil) } } From dabc5078ec6efed02ef1425d59a435981a9f2b48 Mon Sep 17 00:00:00 2001 From: Kevin Gillette Date: Mon, 28 Jul 2025 08:58:38 -0600 Subject: [PATCH 2/5] json: internal uses of Append now use new encoder.appendAny This is necessary for cycle detection to work, which had been implemented, yet was broken. Also introduce cachedCodec helper function. --- json/codec.go | 11 +++++++++++ json/encode.go | 26 ++++++++++++++++++++++---- json/json.go | 30 ++++-------------------------- 3 files changed, 37 insertions(+), 30 deletions(-) diff --git a/json/codec.go b/json/codec.go index 77fe264..d55104a 100644 --- a/json/codec.go +++ b/json/codec.go @@ -63,6 +63,17 @@ type ( // lookup time for simple types like bool, int, etc.. var cache atomic.Pointer[map[unsafe.Pointer]codec] +func cachedCodec(t reflect.Type) codec { + cache := cacheLoad() + + c, found := cache[typeid(t)] + if !found { + c = constructCachedCodec(t, cache) + } + + return c +} + func cacheLoad() map[unsafe.Pointer]codec { p := cache.Load() if p == nil { diff --git a/json/encode.go b/json/encode.go index 8fe2867..6001a6e 100644 --- a/json/encode.go +++ b/json/encode.go @@ -5,6 +5,7 @@ import ( "fmt" "math" "reflect" + "runtime" "sort" "strconv" "sync" @@ -17,6 +18,23 @@ import ( const hex = "0123456789abcdef" +func (e encoder) appendAny(b []byte, x any) ([]byte, error) { + if x == nil { + // Special case for nil values because it makes the rest of the code + // simpler to assume that it won't be seeing nil pointers. + return e.encodeNull(b, nil) + } + + t := reflect.TypeOf(x) + p := (*iface)(unsafe.Pointer(&x)).ptr + c := cachedCodec(t) + + b, err := c.encode(e, b, p) + runtime.KeepAlive(x) + + return b, err +} + func (e encoder) encodeNull(b []byte, p unsafe.Pointer) ([]byte, error) { return append(b, "null"...), nil } @@ -383,7 +401,7 @@ func (e encoder) encodeMapStringInterface(b []byte, p unsafe.Pointer) ([]byte, e b, _ = e.encodeString(b, unsafe.Pointer(&k)) b = append(b, ':') - b, err = Append(b, v, e.flags) + b, err = e.appendAny(b, v) if err != nil { return b, err } @@ -417,7 +435,7 @@ func (e encoder) encodeMapStringInterface(b []byte, p unsafe.Pointer) ([]byte, e b, _ = e.encodeString(b, unsafe.Pointer(&elem.key)) b = append(b, ':') - b, err = Append(b, elem.val, e.flags) + b, err = e.appendAny(b, elem.val) if err != nil { break } @@ -813,11 +831,11 @@ func (e encoder) encodePointer(b []byte, p unsafe.Pointer, t reflect.Type, encod } func (e encoder) encodeInterface(b []byte, p unsafe.Pointer) ([]byte, error) { - return Append(b, *(*any)(p), e.flags) + return e.appendAny(b, *(*any)(p)) } func (e encoder) encodeMaybeEmptyInterface(b []byte, p unsafe.Pointer, t reflect.Type) ([]byte, error) { - return Append(b, reflect.NewAt(t, p).Elem().Interface(), e.flags) + return e.appendAny(b, reflect.NewAt(t, p).Elem().Interface()) } func (e encoder) encodeUnsupportedTypeError(b []byte, p unsafe.Pointer, t reflect.Type) ([]byte, error) { diff --git a/json/json.go b/json/json.go index 028fd1f..a3138f8 100644 --- a/json/json.go +++ b/json/json.go @@ -6,7 +6,6 @@ import ( "io" "math/bits" "reflect" - "runtime" "sync" "unsafe" ) @@ -194,25 +193,9 @@ func (k Kind) Class() Kind { return Kind(1 << uint(bits.Len(uint(k))-1)) } // Append acts like Marshal but appends the json representation to b instead of // always reallocating a new slice. func Append(b []byte, x any, flags AppendFlags) ([]byte, error) { - if x == nil { - // Special case for nil values because it makes the rest of the code - // simpler to assume that it won't be seeing nil pointers. - return append(b, "null"...), nil - } - - t := reflect.TypeOf(x) - p := (*iface)(unsafe.Pointer(&x)).ptr - - cache := cacheLoad() - c, found := cache[typeid(t)] - - if !found { - c = constructCachedCodec(t, cache) - } + e := encoder{flags: flags} - b, err := c.encode(encoder{flags: flags}, b, p) - runtime.KeepAlive(x) - return b, err + return e.appendAny(b, x) } // Escape is a convenience helper to construct an escaped JSON string from s. @@ -330,14 +313,9 @@ func Parse(b []byte, x any, flags ParseFlags) ([]byte, error) { } return r, &InvalidUnmarshalError{Type: t} } - t = t.Elem() - cache := cacheLoad() - c, found := cache[typeid(t)] - - if !found { - c = constructCachedCodec(t, cache) - } + t = t.Elem() + c := cachedCodec(t) r, err := c.decode(d, b, p) return skipSpaces(r), err From f786a42d3bd023c44073eb41a3796178eecd383e Mon Sep 17 00:00:00 2001 From: Kevin Gillette Date: Sun, 27 Jul 2025 22:01:09 -0600 Subject: [PATCH 3/5] json: refactor ref-cycle handling Also set UnsupportedValueError.Value (better stdlib compat). --- json/codec.go | 22 ++++++++++-- json/encode.go | 90 ++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 95 insertions(+), 17 deletions(-) diff --git a/json/codec.go b/json/codec.go index d55104a..6a6bec6 100644 --- a/json/codec.go +++ b/json/codec.go @@ -10,6 +10,7 @@ import ( "sort" "strconv" "strings" + "sync" "sync/atomic" "time" "unicode" @@ -32,13 +33,28 @@ type codec struct { type encoder struct { flags AppendFlags - // ptrDepth tracks the depth of pointer cycles, when it reaches the value + // refDepth tracks the depth of pointer cycles, when it reaches the value // of startDetectingCyclesAfter, the ptrSeen map is allocated and the // encoder starts tracking pointers it has seen as an attempt to detect // whether it has entered a pointer cycle and needs to error before the // goroutine runs out of stack space. - ptrDepth uint32 - ptrSeen map[unsafe.Pointer]struct{} + // + // This relies on encoder being passed as a value, + // and encoder methods calling each other in a traditional stack + // (not using trampoline techniques), + // since refDepth is never decremented. + refDepth uint32 + refSeen cycleMap +} + +type cycleKey struct { + ptr unsafe.Pointer +} + +type cycleMap map[cycleKey]struct{} + +var cycleMapPool = sync.Pool{ + New: func() any { return make(cycleMap) }, } type decoder struct { diff --git a/json/encode.go b/json/encode.go index 6001a6e..075c763 100644 --- a/json/encode.go +++ b/json/encode.go @@ -812,22 +812,23 @@ func (e encoder) encodeEmbeddedStructPointer(b []byte, p unsafe.Pointer, t refle } func (e encoder) encodePointer(b []byte, p unsafe.Pointer, t reflect.Type, encode encodeFunc) ([]byte, error) { - if p = *(*unsafe.Pointer)(p); p != nil { - if e.ptrDepth++; e.ptrDepth >= startDetectingCyclesAfter { - if _, seen := e.ptrSeen[p]; seen { - // TODO: reconstruct the reflect.Value from p + t so we can set - // the erorr's Value field? - return b, &UnsupportedValueError{Str: fmt.Sprintf("encountered a cycle via %s", t)} - } - if e.ptrSeen == nil { - e.ptrSeen = make(map[unsafe.Pointer]struct{}) - } - e.ptrSeen[p] = struct{}{} - defer delete(e.ptrSeen, p) + // p was a pointer to the actual user data pointer: + // dereference it to operate on the user data pointer. + p = *(*unsafe.Pointer)(p) + if p == nil { + return e.encodeNull(b, nil) + } + + if shouldCheckForRefCycle(&e) { + key := cycleKey{ptr: p} + if hasRefCycle(&e, key) { + return b, refCycleError(t, p) } - return encode(e, b, p) + + defer freeRefCycleInfo(&e, key) } - return e.encodeNull(b, nil) + + return encode(e, b, p) } func (e encoder) encodeInterface(b []byte, p unsafe.Pointer) ([]byte, error) { @@ -986,3 +987,64 @@ func appendCompactEscapeHTML(dst []byte, src []byte) []byte { return dst } + +// shouldCheckForRefCycle determines whether checking for reference cycles +// is reasonable to do at this time. +// +// When true, checkRefCycle should be called and any error handled, +// and then a deferred call to freeRefCycleInfo should be made. +// +// This should only be called from encoder methods that are possible points +// that could directly contribute to a reference cycle. +func shouldCheckForRefCycle(e *encoder) bool { + // Note: do not combine this with checkRefCycle, + // because checkRefCycle is too large to be inlined, + // and a non-inlined depth check leads to ~5%+ benchmark degradation. + e.refDepth++ + return e.refDepth >= startDetectingCyclesAfter +} + +// refCycleError constructs an [UnsupportedValueError]. +func refCycleError(t reflect.Type, p unsafe.Pointer) error { + v := reflect.NewAt(t, p) + return &UnsupportedValueError{ + Value: v, + Str: fmt.Sprintf("encountered a cycle via %s", t), + } +} + +// hasRefCycle returns an error if a reference cycle was detected. +// The data pointer passed in should be equivalent to one of: +// +// - A normal Go pointer, e.g. `unsafe.Pointer(&T)` +// - The pointer to a map header, e.g. `*(*unsafe.Pointer)(&map[K]V)` +// +// Many [encoder] methods accept a pointer-to-a-pointer, +// and so those may need to be derenced in order to safely pass them here. +func hasRefCycle(e *encoder, key cycleKey) bool { + _, seen := e.refSeen[key] + if seen { + return true + } + + if e.refSeen == nil { + e.refSeen = cycleMapPool.Get().(cycleMap) + } + + e.refSeen[key] = struct{}{} + + return false +} + +// freeRefCycle performs the cleanup operation for [checkRefCycle]. +// p must be the same value passed into a prior call to checkRefCycle. +func freeRefCycleInfo(e *encoder, key cycleKey) { + delete(e.refSeen, key) + if len(e.refSeen) == 0 { + // There are no remaining elements, + // so we can release this map for later reuse. + m := e.refSeen + e.refSeen = nil + cycleMapPool.Put(m) + } +} From d8717df0b5c52eafe4b77a25342f7e6855836a3f Mon Sep 17 00:00:00 2001 From: Kevin Gillette Date: Sun, 27 Jul 2025 22:40:15 -0600 Subject: [PATCH 4/5] json: support cycle detection involving maps --- json/encode.go | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/json/encode.go b/json/encode.go index 075c763..8401005 100644 --- a/json/encode.go +++ b/json/encode.go @@ -329,15 +329,29 @@ func (e encoder) encodeMap(b []byte, p unsafe.Pointer, t reflect.Type, encodeKey return e.encodeNull(b, nil) } + // checkRefCycle/freeRefCycle expect the map header pointer itself, + // rather than a pointer to the header. + p = *(*unsafe.Pointer)(p) + + if shouldCheckForRefCycle(&e) { + key := cycleKey{ptr: p} + if hasRefCycle(&e, key) { + return b, refCycleError(t, p) + } + + defer freeRefCycleInfo(&e, key) + } + keys := m.MapKeys() if sortKeys != nil && (e.flags&SortMapKeys) != 0 { sortKeys(keys) } start := len(b) - var err error b = append(b, '{') + var err error + for i, k := range keys { v := m.MapIndex(k) @@ -384,6 +398,19 @@ func (e encoder) encodeMapStringInterface(b []byte, p unsafe.Pointer) ([]byte, e return e.encodeNull(b, nil) } + // checkRefCycle/freeRefCycle expect the map header pointer itself, + // rather than a pointer to the header. + p = *(*unsafe.Pointer)(p) + + if shouldCheckForRefCycle(&e) { + key := cycleKey{ptr: p} + if hasRefCycle(&e, key) { + return b, refCycleError(mapStringInterfaceType, p) + } + + defer freeRefCycleInfo(&e, key) + } + if (e.flags & SortMapKeys) == 0 { // Optimized code path when the program does not need the map keys to be // sorted. @@ -424,9 +451,10 @@ func (e encoder) encodeMapStringInterface(b []byte, p unsafe.Pointer) ([]byte, e sort.Sort(s) start := len(b) - var err error b = append(b, '{') + var err error + for i, elem := range s.elements { if i != 0 { b = append(b, ',') From 722d2e359db1115f8f85c0f63db48ad1bf88e4cd Mon Sep 17 00:00:00 2001 From: Kevin Gillette Date: Tue, 25 Nov 2025 09:22:39 -0700 Subject: [PATCH 5/5] json: detect slice-based value cycles --- json/codec.go | 1 + json/encode.go | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/json/codec.go b/json/codec.go index 6a6bec6..e4b6ab1 100644 --- a/json/codec.go +++ b/json/codec.go @@ -49,6 +49,7 @@ type encoder struct { type cycleKey struct { ptr unsafe.Pointer + len int // 0 for pointers or maps; length for slices or array pointers. } type cycleMap map[cycleKey]struct{} diff --git a/json/encode.go b/json/encode.go index 8401005..a65dc41 100644 --- a/json/encode.go +++ b/json/encode.go @@ -296,6 +296,15 @@ func (e encoder) encodeTime(b []byte, p unsafe.Pointer) ([]byte, error) { } func (e encoder) encodeArray(b []byte, p unsafe.Pointer, n int, size uintptr, t reflect.Type, encode encodeFunc) ([]byte, error) { + if shouldCheckForRefCycle(&e) { + key := cycleKey{ptr: p} + if hasRefCycle(&e, key) { + return b, refCycleError(t, p) + } + + defer freeRefCycleInfo(&e, key) + } + start := len(b) var err error b = append(b, '[')