Skip to content

assert: fix EqualExportedValues stack overflow on recursive values - #1968

Open
Flotrpy wants to merge 2 commits into
stretchr:masterfrom
Flotrpy:fix/equal-exported-values-recursive-cycles
Open

Flotrpy wants to merge 2 commits into
stretchr:masterfrom
Flotrpy:fix/equal-exported-values-recursive-cycles

Conversation

@Flotrpy

@Flotrpy Flotrpy commented Sep 26, 2026

Copy link
Copy Markdown

Summary

Fix the stack overflow in EqualExportedValues when values contain pointer, slice or map cycles. This picks up #1916 (by @cyphercodes, whose commit is kept as-is with its original authorship) and addresses the open review feedback on it.

Changes

  • From Fix EqualExportedValues on recursive values #1916: copyExportedFields records the copy it creates for each pointer, slice and map (keyed by type, address and, for slices, length — the same identity encoding/json uses for cycle detection). Meeting the same value again reuses that in-progress copy instead of recursing, so the copy keeps the original's shape, including its cycles.
  • Review feedback from @HaraldNordgren on Fix EqualExportedValues on recursive values #1916:
    • The visited lookup now happens once, before the kind switch, instead of in three separate cases.
    • Added tests for maps, slices and arrays, plus more pointer topologies.

Tests in TestEqualExportedValuesRecursiveStruct (table-driven):

Equal (must pass) Unequal (must fail with a message)
direct self-cycle A → A self-cycles with different exported fields
two-node cycle A → B → A three-node cycles with different exported fields
three-node cycle A → B → C → A acyclic chains with different exported fields
acyclic chain A → B → C → nil self-cycle vs. nil pointer
shared reference without a cycle (Left and Right → same node) cycle vs. acyclic chain
struct containing recursive pointers
array of cyclic pointers
slice cycle
map cycle

TestCopyExportedFieldsRecursiveStruct also checks that the copy of a self-referential value is a new value, points back to itself, and has its unexported field cleared.

Motivation

copyExportedFields builds a copy with only exported fields by recursing through the value. It had no record of what it had already visited, so for a.Self = a the Ptr case would copy *a, whose Self field led back to the Ptr case with the same pointer, forever. The resulting fatal error: stack overflow kills the whole test binary, not just the failing test.

Reusing the in-progress copy (rather than returning nil for a pointer that was already seen) matters for correctness:

  • A self-cycle stays different from a node with a nil pointer.
  • Shared, non-cyclic references are copied correctly instead of being mistaken for cycles.

The final comparison is still done by ObjectsAreEqualValues/reflect.DeepEqual, which already handles cyclic values.

Example that previously crashed and now passes:

type Node struct{ Self *Node }

a := &Node{}; a.Self = a
b := &Node{}; b.Self = b
assert.EqualExportedValues(t, a, b)

Out of scope: when two values are unequal and the cycle goes only through slices or maps (for example, a []interface{} that contains itself), building the failure message with %#v can still recurse. assert.Equal has the same behaviour today, and pointer cycles are unaffected because %#v prints nested pointers as addresses.

Testing

  • The new tests overflow the stack when run against the unfixed assertions.go from master, and pass with this change.
  • go test -run 'RecursiveStruct|TestEqualExportedValues|TestCopyExportedFields|TestObjectsExportedFieldsAreEqual' ./assert: pass
  • go test ./...: all EqualExportedValues-related tests pass. On my Windows machine a few assert file/dir-existence tests and some suite tests fail, and they fail the same way on unmodified master, so they are unrelated to this change.
  • gofmt -l ., go vet ./..., go generate ./... (no diff), go run ./_readme-gofmt/main.go, git diff --check: clean
  • -race was not run locally (cgo is unavailable on my machine); CI runs it.

Related issues

Closes #1915
Supersedes #1916 (keeps its commit). Happy to close this if @cyphercodes would rather update #1916 directly.

cyphercodes and others added 2 commits September 26, 2026 19:16
Address review feedback on stretchr#1916: look up already-copied pointers,
slices and maps in a single place before the kind switch, instead of
repeating the lookup in each case.

Extend the regression tests to cover direct, two-node and three-node
pointer cycles, acyclic chains, shared (non-cyclic) references, structs
and arrays holding cyclic pointers, slice and map cycles, and unequal
cyclic values (different exported fields, cycle vs nil, cycle vs chain).
Also check that the copy of a self-referential value points to itself.

@Flotrpy Flotrpy left a comment •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.

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.

EqualExportedValues overflow the stack on recursive data structures

3 participants