Skip to content

fix: struct held in the interface word dereferences its pointer field twice - #602

Open
yES wants to merge 1 commit into
goccy:masterfrom
Pixality-Inc:fix/ptr-struct-in-interface-word
Open

fix: struct held in the interface word dereferences its pointer field twice#602
yES wants to merge 1 commit into
goccy:masterfrom
Pixality-Inc:fix/ptr-struct-in-interface-word

Conversation

@yES

@yES yES commented Aug 13, 2026

Copy link
Copy Markdown

Fixes #503.

What is broken

A struct whose only field is a pointer is carried inside the interface word, so the encoder receives
the pointer value where it would otherwise receive the address of the struct. The compiled opcodes
assume the second layout and follow the field pointer once more than they should.

type Inner struct {
	Flag  *bool   `json:"flag,omitempty"`
	Label *string `json:"label,omitempty"`
}

type Outer struct {
	Inner *Inner `json:"inner,omitempty"`
}
value encoding/json go-json before this change
Outer{} {} {}
Outer{Inner: &Inner{}} {"inner":{}} {}
Outer{Inner: &Inner{Flag: &flag}} {"inner":{"flag":true}} segfault
Outer{Inner: &Inner{Label: &label}} {"inner":{"label":"x"}} {}
Outer{Inner: &Inner{Flag: &flag, Label: &label}} {"inner":{"flag":true,"label":"x"}} segfault, or {"inner":{"flag":true}}

The last row depends on what the process encoded earlier. Row 4 loses a populated object with no
error. Row 3 ends the process:

unexpected fault address 0x6e6e69226c6f6f62
fatal error: fault
[signal SIGSEGV: segmentation violation]

github.com/goccy/go-json/internal/encoder/vm.ptrToString(...)
	internal/encoder/vm/util.go:89
github.com/goccy/go-json/internal/encoder/vm.Run(...)
	internal/encoder/vm/vm.go:4589

The fault address is ASCII fragments of the output buffer, so the VM is using encoded bytes as a
pointer. fatal error: fault cannot be recovered, so a server hitting this goes down.

Root cause

compileStruct copies the parent's indirect property onto the child struct:

if indirect {
	structCode.isIndirect = true
} else {
	structCode.isIndirect = false
}

For a root struct in the interface word indirect is false, so the child is compiled as non-indirect
even though it will be reached by its own address. The PtrCode wrapper around it also survives,
which turns the child head into StructPtrHead*, and that prologue dereferences the slot a second
time.

The compiled code for the failing case:

[000] StructHeadOmitEmpty            [idx:0][key:inner][offset:0][ptrNum:1][indirect:false][end:3]
[001] StructPtrHeadOmitEmptyBoolPtr  [idx:1][key:flag ][offset:0][ptrNum:1][indirect:false][end:2]
[002] StructEndOmitEmptyStringPtr    [idx:1][key:label][offset:8][ptrNum:1][indirect:false]
[003] StructEnd                      [idx:3]

[000] stores the already-dereferenced child address into slot 1, [001] dereferences it again and
leaves the *bool field value there, and [002] reads slot 1 + 8 expecting the struct base.

The same mismatch drives the omit-empty decision. OpStructHeadOmitEmpty tests ptrToPtr(p) == 0 to
find out whether the child pointer is nil, but p is already the child address, so the test reads the
child's first field instead. That is the behaviour reported in #503: a child whose first field is zero
disappears.

Runtime trace of that test for the four values above:

value p ptrToPtr(p) outcome
Outer{} 0x0 0x0 skipped, correct
&Inner{} 0x...770 0x0 skipped, wrong
&Inner{Flag} 0x...780 0x...4448 emitted
&Inner{Label} 0x...790 0x0 skipped, wrong

The change

One file, no VM or generated code touched.

structCode takes an isRoot flag, set only by typeToCode, which is the single entry point of a
compilation. When the root struct is neither indirect nor reached by a pointer and the field is a
single pointer to a struct, the redundant PtrCode is unwrapped, isNextOpPtrType is cleared and the
child is marked indirect. The pointer has already been followed by the interface layout, so no opcode
should follow it again, and the omit-empty test that keys off isNextOpPtrType must not run either.

Types that are reachable from themselves are excluded: the recursive re-entry reuses the opcode block of the root, where the layout is the addressed one, so the transform cannot be correct there.

Both guards on the parent are necessary. Marshal(&v) reaches the struct by a pointer, and a struct
stored in a map, a slice or a field is addressable, so in those cases the layout is the usual one and
nothing may change.

Testing

TestIssue503 in encode_test.go covers the shape above plus:

The test fails on master, first with wrong output and then with the fault.

go test ./... and go test -race ./... pass.

Beyond the suite I ran a 98-shape corpus across Marshal, MarshalIndent, Encoder.Encode,
SetIndent, MarshalContext, MarshalNoEscape, Colorize and colored indent, comparing every case
to encoding/json on master and on this branch: 160 of the 784 combinations go from wrong or fatal to
correct, and none change in any other way.

What this does not fix

A one-word root holding a map, slice, interface{} or a non-struct pointer is already correct on
master: those specialised head opcodes gate their dereference on IndirectFlags. The struct case is
the odd one out because the generic OpStructHead* ops delegate that dereference to the child's
StructPtrHead* prologue, which has no such gate.

… twice

A struct whose only field is a pointer is carried inside the interface word,
so the encoder receives the pointer value where it would otherwise receive the
address of the struct. compileStruct copies the parent's indirect property onto
the child, so the child is compiled as non-indirect even though it is reached by
its own address, and the surviving PtrCode wrapper makes the child head a
StructPtrHead whose prologue follows the pointer a second time.

Depending on which child fields are set the result is a dropped object (goccy#503) or
an unrecoverable fatal error while the VM walks encoded bytes as a pointer.

When the root struct is neither indirect nor reached by a pointer and the field
is a single pointer to a struct, drop the redundant PtrCode, clear
isNextOpPtrType and mark the child indirect. Types reachable from themselves are
excluded: the recursive re-entry reuses the opcode block of the root, where the
layout is the addressed one.

Fixes goccy#503
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BUG: Empty encode result in case of zero first byte in child struct as pointer with omitempty tag

1 participant