Skip to content

Reuse the stream item wrapper message in gRPC stream envelopes - #4072

Merged
raphael merged 2 commits into
goadesign:v3from
zak-blake:reuse-stream-item-wrapper-message
Oct 28, 2026
Merged

raphael merged 2 commits into
goadesign:v3from
zak-blake:reuse-stream-item-wrapper-message

Conversation

@zak-blake

Copy link
Copy Markdown
Contributor

Since v3.31.0, a gRPC method that defines both Payload(...) and a primitive or collection StreamingPayload(...) generates client and server code that does not compile. v3.30.0 generates working code for the same design. I couldn't find an existing issue, so here is the reproduction:

var _ = Service("upload", func() {
	Method("Upload", func() {
		Payload(func() {
			Field(1, "name", String)
			Required("name")
		})
		StreamingPayload(Bytes)
		Result(String)
		GRPC(func() {})
	})
})

The .proto declares the stream item wrapper twice. The envelope's stream_item branch uses UploadStreamItem2, while validation and conversion use UploadStreamItem:

message UploadStreamingRequest {
	oneof body {
		UploadRequest initial_payload = 1;
		UploadStreamItem2 stream_item = 2;
	}
}
message UploadStreamItem2 { optional bytes field = 1; }
message UploadStreamItem { optional bytes field = 1; }
server.go: cannot use v (*uploadpb.UploadStreamItem2) as *uploadpb.UploadStreamItem value in argument to ValidateUploadStreamItem
client.go: cannot use v (*uploadpb.UploadStreamItem) as *uploadpb.UploadStreamItem2 value in struct literal

The protobuf catalog collects the stream item wrapper as a root message, with the endpoint's streaming request identity. It then reaches the same wrapper again through the envelope's stream_item branch. In collectMessageRecursive, a non-root generated wrapper always receives a new synthetic wrapper identity, even when its origin is already bound to a root source. The two identities differ, so the catalog declares a second message and suffixes it. This isn't the intended suffixing of a real protobuf name collision: both declarations describe the same wrapper. Object stream items are not affected because they are not generated wrappers.

The catalog now keeps the root identity when a generated wrapper's origin is already a root message, so the envelope branch and the stream item share one message, as in v3.30.0. Other generated wrappers still receive their own declarations.

The new table-driven generator test covers client streams with Bytes, Int and ArrayOf(String) items and a bidirectional stream with Bytes items. It asserts that the .proto declares one stream item message, referenced by stream_item, and compiles the generated packages. Every case fails without the fix. The existing ClientStreamingRPCWithPayloadDSL and BidirectionalStreamingRPCWithPayloadDSL goldens cover only handler and endpoint init sections, which is why this wasn't caught. make test and golangci-lint v2.14.0 (run ./...) pass with Go 1.27.3. The test-plugins and test-examples targets mentioned in CONTRIBUTING.md are no longer in the Makefile, so they were not run.

🤖 Generated with Claude Code

zak-blake and others added 2 commits October 27, 2026 11:16
A gRPC method with both Payload and a primitive or collection
StreamingPayload declared its stream item wrapper twice: once as the
top-level stream item message and again, suffixed with 2, as the
envelope's stream_item branch. The generated client and server then
mixed the two types and did not compile.

The catalog now keeps the root identity of a wrapper that is reached
again through the stream envelope, so both uses share one message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@raphael

raphael commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Thanks for tracking this down and fixing it!

I pushed a small follow-up commit with two refinements:

  1. grpc/codegen/protobuf_catalog.go: Expressed identitySource selection in collectMessageRecursive as a single if / else if precedence chain (explicit root source → recorded root message in c.rootSources[origin] → inline object → non-root synthetic wrapper → authored origin) instead of sequential overwriting if blocks and a separate isRootMessage guard.
  2. codegen/generator/generate_grpc_stream_envelope_integration_test.go: Defined the test methods on a single Upload service so codegen.RunDSL and generate run once synchronously before runGeneratedTests calls t.Parallel() (avoiding subtests touching eval.Context alongside t.Parallel() and compiling the generated module once instead of four times), and added cases with primitive and collection Payload (initial_payload), which were affected by the same duplicate wrapper bug.

@raphael
raphael merged commit 3d7ec7c into goadesign:v3 Oct 28, 2026
5 checks passed
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