Skip to content

fix: http server codegen#3944

Merged
raphael merged 4 commits into
goadesign:v3from
abocim:ab-fix_codegen_server_types
Jul 8, 2026
Merged

fix: http server codegen#3944
raphael merged 4 commits into
goadesign:v3from
abocim:ab-fix_codegen_server_types

Conversation

@abocim

@abocim abocim commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

This PR fixes a bug we have hit within http server code generator.

Sibling fields in result types were sharing the same AttributeExpr pointer during projection, causing metadata (descriptions and JSON tags) to leak between them. Added field name to the seen key in projectRecursive to be ensured each sibling gets its own isolated entry.

Goa design code:

...
	var UserType = Type("UserType", func() {
		Attribute("u", Int)
	})

	var RT = ResultType("ResultTypeSibling", func() {
		Attribute("a", UserType, "Attribute A", func() {
			Meta("struct:tag:json", "a")
		})
		Attribute("b", UserType, "Attribute B", func() {
			Meta("struct:tag:json", "b")
		})
	})

	Service("ServiceResultUserTypeSibling", func() {
		Method("MethodResultUserTypeSibling", func() {
			Result(RT)
			HTTP(func() {
				GET("/")
			})
		})
	})
...

Incorrectly generated code with leaked descriptions (comments) and JSON tags (gen/http/v2/server/types.go):

...
// MethodResultUserTypeSiblingResponseBody is the type of the
// "ServiceResultUserTypeSibling" service "MethodResultUserTypeSibling"
// endpoint HTTP response body.
type MethodResultUserTypeSiblingResponseBody struct {
	// Attribute A
	A *UserTypeResponseBody `json:"a"`
	// Attribute A
	B *UserTypeResponseBody `json:"a"`
}

// UserTypeResponseBody is used to define fields on response body types.
type UserTypeResponseBody struct {
	U *int `form:"u,omitempty" json:"u,omitempty" xml:"u,omitempty"`
}
...

abocim and others added 2 commits June 17, 2026 07:17
Sibling fields in result types were sharing the same AttributeExpr pointer
during projection, causing metadata (descriptions and JSON tags) to leak
between them. Added field name to the `seen` key in `projectRecursive`
to be ensured each sibling gets its own isolated entry.

Signed-off-by: Adam Bocim <adam.bocim@seznam.cz>
@raphael

raphael commented Jul 2, 2026

Copy link
Copy Markdown
Member

Thank you for this! The clear repro in the description and the golden tests made this a pleasure to review — much appreciated. 🙏

The fix is correct for the sibling case and I'm happy to merge it. While reviewing I dug a bit deeper into the projection cache and found something worth sharing: the same leak survives one nesting level deeper. The new key (Hash(type)::view::fieldName) still collides when two different parent types have same-named fields of the same type, because Hash ignores field-level descriptions and tags. For example:

var UserType = Type("UserType", func() {
	Attribute("u", Int)
})

var Wrapper = Type("Wrapper", func() {
	Attribute("a", UserType, "Inner A", func() {
		Meta("struct:tag:json", "inner_a")
	})
})

var RT = ResultType("ResultTypeNested", func() {
	Attribute("a", UserType, "Outer A", func() {
		Meta("struct:tag:json", "outer_a")
	})
	Attribute("nested", Wrapper)
})

still generates (with this PR applied):

// WrapperResponseBody is used to define fields on response body types.
type WrapperResponseBody struct {
	// Outer A
	A *UserTypeResponseBody `json:"outer_a"`
}

The root cause is that the seen cache stores the entire field AttributeExpr — which carries the per-field description and meta — rather than just the projected type. So any two fields that hash to the same key end up aliasing one attribute, and adding the field name to the key narrows the collisions without eliminating them.

I think the complete fix is to keep the original (type, view) key but have the cache hit return a fresh DupAtt whose Type points at the shared projected type. That removes the aliasing entirely (covering your sibling case and the nested one above), and preserves the memoization — with the new per-field keys, a type referenced by k differently-named fields gets re-projected k times, producing duplicate projected result types with identical identifiers that downstream codegen currently has to dedup. One subtlety to watch: for recursive result types, at.Type is patched after the attribute is inserted into seen, so the recursive entries must keep sharing the Type pointer for termination to work.

Would you like to take a stab at that? No pressure at all — happy to merge this as-is and follow up myself if you'd rather. Either way, thanks again for tracking this down and for the excellent test coverage!

@abocim

abocim commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

The original hashAttrAndView(att, view) function and all the recursive calls inside that complex Hash() function were honestly a bit hard to follow. So I went with a quick fix and made a new hashAttrViewField(att, view, field) function that just adds the field name to hashAttrAndView.

I know it's kind of a hack. I can try to dig into it more later and fix hashAttrAndView directly.

If you could merge the current fix for now, I'd really appreciate it.

The projection cache stored entire field attributes, so any two fields
hashing to the same (type, view) key aliased one AttributeExpr and
leaked per-field metadata (descriptions, struct tags) across fields.
Keying by field name only narrowed the collisions: same-named fields of
the same type under different parent types still aliased.

Cache the projected types instead and give every field its own
attribute wrapping the shared projection. Single result types register
their projection before computing their fields so that recursive
references resolve to the in-flight projection and terminate.
@raphael

raphael commented Jul 8, 2026

Copy link
Copy Markdown
Member

No worries — I went ahead and pushed the follow-up to your branch so everything lands together. The seen cache now stores the projected types (keyed by type hash + view) and each field wraps the shared projection in its own attribute, so nothing aliases anymore. The nested case from my earlier comment is covered by a new golden test, and your sibling tests pass unchanged. Recursive result types keep working because projectSingle registers its projection before computing the fields, so recursive references resolve to the in-flight projection.

Thanks again for tracking this down — will merge once CI is green.

@raphael
raphael enabled auto-merge (squash) July 8, 2026 21:49
@raphael
raphael merged commit 69baaf4 into goadesign:v3 Jul 8, 2026
5 checks passed
@abocim

abocim commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Thanks a lot for the fix, Raphael!

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.

2 participants