Repository navigation
fix(binding): stop infinite recursion on self-referencing structs - #4916
Tales-of-zuna wants to merge 1 commit into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #4916 +/- ##
=======================================
Coverage 98.65% 98.65%
=======================================
Files 48 48
Lines 3188 3191 +3
=======================================
+ Hits 3145 3148 +3
Misses 43 43
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
rahul05ranjan
left a comment
There was a problem hiding this comment.
I traced the recursion mechanism on the PR head and the fix is sound:
- The crash premise checks out: a nil
*Nodefield makes the ptr branchreflect.Newa node and descend into it, the struct branch walks the fields, hits the same nil ptr again, and the loop never terminates until the goroutine stack is gone — on every bind, since even an empty form walks the whole tree. parentsis a proper path-stack, not a global visited-set: each struct level appends its own type to the slice it received and passes the longer slice down, while sibling fields still get the shorter one. So a type reused across different subtrees (DAG shapes, yourTestMappingDeepNonRecursiveStructchain) still binds, and only true cycles on one path stop. The append pattern is also safe against the usual slice-aliasing trap, since a callee's writes land beyond the length any sibling can observe.- The check's position matters and you got it right: it sits in the struct branch, after
tryToSetValue, so keyed/whole-value binding still reaches self-referencing fields — that's what makesTestMappingRecursiveStructFieldAsWholeValuepass, via the fallback of an empty tag to the field name (tryToSetValue), theform["Parent"]hit insetByForm, and finally the JSON unmarshal insetWithProperType'sreflect.Structcase. Good test to have, because that path is non-obvious.
One behavior note worth a line in the docs eventually: post-fix, a nested form key through a cycle (Parent.name=...) silently produces a nil Parent instead of binding or erroring. Given the pre-fix alternative was a fatal stack overflow, nothing working regresses, but "silently dropped" vs "rejected" is a choice someone may want to find documented.
Mutually-recursive cycleA/cycleB coverage and the per-binding-mode integration tests over all five binders round this out nicely. On CI: the only red check is the Trivy scan, which doesn't look code-related.
Couldn't run the suite here (no Go toolchain on this machine) — the above is a static trace of the PR head.
|
Thanks for the careful trace, @rahul05ranjan! On the What is specific: a self-referencing field cannot be filled from flat keys, because binding stops at the cycle (before this change it crashed). Binding it as a whole value, e.g. |
Form-style binding (form, query, multipart, header, uri) walked struct fields recursively and allocated every nil pointer to a struct. For a type that refers to itself, such as `Parent *Node`, each new level held another nil pointer, so binding never ended and the process died with an unrecoverable stack overflow, even on an empty request. Track the struct types whose fields are being mapped on the current path, and do not walk a type that is already on it. A recursive field can still be set as a whole value from a matching key; otherwise it stays nil. Non-recursive structs bind exactly as before at any depth. Fixes gin-gonic#3937
e4eb047 to
3a05f77
Compare
|
Thanks for verifying the dropped-key behavior against master — that settles it. With the |
What
Fixes #3937. Form-style binding (form, query, multipart, header, uri) crashes the process with
fatal error: stack overflowwhen the target struct refers to itself through a pointer, e.g.:It happens on every request, even an empty one, and
Recovery()cannot catch a stack overflow, so a single handler takes the whole server down.Why it happens
mappingallocates every nil pointer to a struct and walks its fields. For a self-referencing type, each new level contains another nil pointer, so the walk never ends.Fix
Track the struct types whose fields are being mapped on the current path, and don't walk a type that is already on it.
?Parent={"Name":"p"}via the existing JSON path); otherwise it staysnil.mappinggains a parameter.Compared with #4337
#4337 caps recursion depth at 10, but the cap also counts levels of non-recursive structs, so a legitimate struct nested ~6 levels deep silently loses its deepest fields with no error (reproduction posted on #4337). This PR stops only on actual type cycles.
TestMappingDeepNonRecursiveStructguards that case.Tests
go test -race ./...andgo vet ./...pass.Benchmarks
benchstat, n=6, before/after: B/op and allocs/op are unchanged for all binding benchmarks.MapFormFullandSliceValidationErrorshow no significant change.MapFormName(single field) goes 148.9ns → 156.4ns (+5%, about 7ns per bind) for the path check.Pull Request Checklist
masterbranch.docs/doc.md. (Bug fix, no docs needed.)