Prevent stack overflow on recursive schemas - #2543
Conversation
|
|
Once ☝🏼 are resolved, I'll take another look |
Address PR review feedback on oapi-codegen#2543: the flag-based guard both dropped the referenced union (losing the recursive payload) and left transitive recursion unguarded. Replace the union-propagation suppression with a substitution: an allOf member whose $ref points back to the schema currently being generated, from a nested position, is substituted with a ref-only schema - a single anyOf branch holding the $ref. GenerateGoSchema resolves that branch to the named Go type, the same way a bare $ref terminates recursion, so the merged item type keeps a union reference back to the recursive type (As/From helpers) alongside the sibling fields, matching the discriminated-union+allOf shape already generated for issue oapi-codegen#2470. - Thread path through mergeAllOf and mergeOpenapiSchemas so nested (transitive) self-references are substituted too - previously this shape hung generation indefinitely. - Only substitute at nested positions (len(path) > 1); top-level fixed-point compositions (issue oapi-codegen#1373 shape) keep the existing seenSchemaRef handling - RecursiveObject output unchanged. - This also fixes the plain-object recursion variant (children items allOf: [$ref: self, {extra}]), which overflowed the stack on upstream main as well. Fixes oapi-codegen#2542 (all three shapes: union, object, nested allOf).
There was a problem hiding this comment.
It's a nit, but mind reverting back to single quotes, just so the diff is a bit less noisy?
jamietanna
left a comment
There was a problem hiding this comment.
Couple of nits otherwise LGTM - thanks!
Per maintainer feedback on PR oapi-codegen#2543: - spec.yaml: restore the pre-existing sections verbatim from upstream (single-quoted $refs) - the previous edit had rewritten them to double quotes, adding unrelated diff noise. The fixture additions now use single quotes to match the file's dominant style, making the PR diff pure additions (+64/-0 vs upstream). - recursive_test.go: unwrap comments to single lines, matching the existing file style - only break lines when gofmt/golangci-lint forces it. The regenerated recursive.gen.go is unchanged: the quote-style fix is semantically identical YAML.
|
Thanks for digging into this, and sorry for the slow turnaround — I went deep on it and came out wanting to change the direction, so let me lay out what I found. Everything below is verified against Two shapes the current check missesThe guard is Mutual recursion, A:
type: object
properties:
b: { allOf: [ { $ref: '#/components/schemas/B' }, { type: object, properties: { tag: { type: string } } } ] }
B:
type: object
properties:
c: { allOf: [ { $ref: '#/components/schemas/C' }, { type: object, properties: { x: { type: string } } } ] }
C:
type: object
properties:
back: { allOf: [ { $ref: '#/components/schemas/B' }, { type: object, properties: { y: { type: string } } } ] }Generating The cycle reached from a bystander component: Node:
anyOf:
- { type: object, properties: { leaf: { type: string } } }
- type: object
properties:
children:
type: array
items:
allOf:
- { $ref: '#/components/schemas/Node' }
- { type: object, properties: { extra: { type: string } } }
Wrapper:
type: object
properties:
n: { allOf: [ { $ref: '#/components/schemas/Node' }, { type: object, properties: { extra: { type: string } } } ] }Generating There's also a smaller hole: Worth knowing for the tests: these pass on this branch only because the fixture configs use The bigger thing: the union isn't the right outputThis is the part I'd like to change, and it's not obvious — I had to go measure it. Both your fix and mine represent the recursive member as a single-branch List *[]struct {
Name *string `json:"name,omitempty"`
Weight *int `json:"weight,omitempty"`
} `json:"list,omitempty"`and even when the type Holder_Item struct { Extra *string; union json.RawMessage }
type HolderItem0 struct { Leaf *string } // U's branches, regenerated
type HolderItem1 struct { N *int }So the accessor shape is something users would meet only in the recursive case, for no reason intrinsic to their spec. And we don't need it. Go does recursive types natively, and we already emit them — Tree:
type: object
properties:
name: { type: string }
children:
type: array
items:
allOf:
- { $ref: '#/components/schemas/Tree' }
- { type: object, properties: { weight: { type: integer } } }the right output is: type Tree struct {
Children *[]Tree_Children_Item `json:"children,omitempty"`
Name *string `json:"name,omitempty"`
}
type Tree_Children_Item struct {
Children *[]Tree_Children_Item `json:"children,omitempty"`
Name *string `json:"name,omitempty"`
Weight *int `json:"weight,omitempty"`
}Same JSON, plain field access, and semantically exact:
For contrast, here's what the union approach produces for the mutual-recursion spec — it unrolls a level and a half and then goes opaque: type A struct {
B *struct {
C *struct {
Back *A_B_C_Back `json:"back,omitempty"`
X *string `json:"x,omitempty"`
} `json:"c,omitempty"`
Tag *string `json:"tag,omitempty"`
} `json:"b,omitempty"`
}
type A_B_C_Back struct {
Y *string `json:"y,omitempty"`
union json.RawMessage
}How to get thereThe enabling detail is that both naming sites are guarded the same way: // schema.go:1497 (array items), :1264 (property)
if ( /* needs a name */ ) && arrayType.RefType == "" {
typeName := PathToTypeName(append(path, "Item"))
…
arrayType.RefType = typeName
}So if
Step 4 is what keeps this safe — every non-recursive One consequence worth stating up front: the same composition reached while generating two different components gets named once per component ( Value recursion should be a diagnostic, not a compile errorIf the recursive occurrence is reached with no indirection in between, the Go type is infinitely sized: T:
type: object
required: [child]
properties:
child: { allOf: [ { $ref: '#/components/schemas/T' }, { type: object, properties: { w: { type: integer } } } ] }would want That falls out of the same context: record an indirection counter at registration, bump it whenever we descend through something that introduces a pointer, slice or map — array items, Keeping the exported API intact
The name hint has to ride that context rather than Also, I suspect this lets the self-composition special case go away entirely. One correction to the framingThis isn't a regression from #2471. The Happy to push the above onto this branch if you'd rather not take it on — say the word either way. Your fixtures and comments are good and I'd keep them; it's mainly 🤖 Investigated and drafted with Claude Code; the repros above were verified locally against |
An allOf is flattened by inlining the body of every $ref member into a fresh anonymous schema. Because that merged schema carries no $ref of its own, the usual termination rule — a $ref becomes a named Go type, stop descending — no longer applies inside it. When the inlined body contains an allOf referring back to a schema an enclosing frame is still generating, the merge inlines it again, and again, until the stack runs out (oapi-codegen#2542). Track the compositions a generation is part-way through in a context threaded alongside the descent, keyed on the schema node that owns the allOf. A member referring back into one of them resolves to the Go type being built for it rather than being inlined, which is how Go expresses a recursive type and how the generator already represents every non-recursive allOf: flattened and concrete. type NodeObject struct { Children *[]NodeObject_Children_Item `json:"children,omitempty"` } type NodeObject_Children_Item struct { Children *[]NodeObject_Children_Item `json:"children,omitempty"` Extra *string `json:"extra,omitempty"` } The merged result is promoted to a named type only when something below actually referred back to it, so a composition that is not recursive is still emitted as the inline anonymous struct it always was, byte for byte. Regenerating every fixture in the repo produces no change outside the one this adds. The name a recursive member is handed has to be settled before the body is generated, so it is predicted; under generate-types-for-anonymous- schemas the hoist inside the merged body names it instead, which the prediction accounts for. Any remaining disagreement between the promised name and the name actually defined is reported rather than emitted, since it would otherwise produce a dangling reference. A composition that refers to itself with no pointer, slice or map in between describes a Go value containing itself. That is left to the Go compiler, whose "invalid recursive type" points straight at the offending field; predicting it here is not possible in general, because the decorator idiom (oapi-codegen#1957) only settles pointer-ness during the merge. The state is positional rather than per-run, so it lives in a threaded genContext rather than globalState, keeping generateGoSchema reentrant. The exported GenerateGoSchema and MergeSchemas keep their signatures and start a fresh context. Covers the shapes a guard keyed on the component being generated misses: a cycle closing through additionalProperties at the component root, mutual recursion that never reaches the root, and a cycle reached from a component outside it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #2542