protoc_plugin server-streaming codegen emits Stream.fromIterable([request]), which fails to deliver the request against grpc-go peers.
- Dominant language
- Dart
- Stars
- 572
- Forks
- 196
- Avg merge
- 1h 59m
- Merged PRs (30d)
- 2
Description
### Summary
For a server-streaming RPC, `protoc_plugin's` codegen wraps the single request in `Stream.fromIterable([request])` before handing it to `$createStreamingCall`. Against a `grpc-go` server, the request DATA frame is never delivered — the server's generated stub reads `stream.RecvMsg(m)` before invoking the user handler, and that read parks forever. The user handler is never entered, the stream sits open in silence, and only a client-side heartbeat / deadline unblocks the connection.
`grpc-dart's` own unary path (grpc/lib/src/client/client.dart, $createUnaryCall) uses `Stream.value(request)` for the identical "wrap a single request in a stream" scenario, and works reliably. Swapping the codegen to `Stream.value(request)` for server-streaming resolves the failure.
The bug is present in the current master of protoc_plugin (grpc_generator.dart), not merely older releases:
```
// protoc_plugin/lib/src/grpc_generator.dart, from master
} else if (!_clientStreaming && _serverStreaming) {
out.println(
'return \$createStreamingCall(_\$$_dartName, $_stream.fromIterable([request]), options: options);',
);
}
```
### Environment
- `grpc: 4.2.0` (also verified against `grpc-dart` master runtime — no relevant change).
- `protoc_plugin`: verified against master as of 2026-08.
- Dart SDK: `3.9.x`.
- Client platform: macOS release build (Flutter), also reproduced under `flutter run` debug builds.
- Server: `google.golang.org/grpc` (grpc-go) with `protoc-gen-go-grpc`-generated stubs.
### Reproduction
Given a proto with a server-streaming RPC:
```proto
service Example {
rpc Ping (PingRequest) returns (PingResponse); // unary
rpc Subscribe (SubscribeRequest) returns (stream Event); // server-streaming
}
```
`protoc_plugin` emits (paraphrased from real generated output):
```dart
// server-streaming — FAILS against grpc-go
ResponseStream subscribe(SubscribeRequest request, {CallOptions? options}) {
return $createStreamingCall(
_$subscribe, Stream.fromIterable([request]),
options: options);
}
```
Compare with grpc-dart's own unary path (`grpc-4.2.0/lib/src/client/client.dart:56`):
```dart
_channel.createCall(method, Stream.value(request), options)
```
Same shape of problem ("send exactly one request message"), different `Stream` construction.
### Observed behavior against grpc-go
From the wire:
- Client-side `Ping` (unary): works normally.
- Client-side `Subscribe` (server-streaming):
- HEADERS frame accepted by server.
- Server-side `_Example_Subscribe_Handler` (generated stub) runs, calls `stream.RecvMsg(m)`.
- `RecvMsg` never returns - no error, no message.
- User handler never entered.
- Stream stays open until the client cancels (e.g. deadline/heartbeat).
On the client side:
- `.listen()` callbacks (`onData`, `onError`, `onDone`) never fire.
- If a timeout is set, it fires normally at the deadline. Without a timeout the RPC hangs.
The failure is silent - no exception, no status code, no log line from grpc-dart. It looks like the server is just not answering.
### Proposed fix
In `protoc_plugin/lib/src/grpc_generator.dart`, change the server-streaming branch from
```dart
} else if (!_clientStreaming && _serverStreaming) {
out.println(
'return \$createStreamingCall(_\$$_dartName, $_stream.fromIterable([request]), options: options);',
);
}
```
to
```dart
} else if (!_clientStreaming && _serverStreaming) {
out.println(
'return \$createStreamingCall(_\$$_dartName, $_stream.value(request), options: options);',
);
}
```
This matches `$createUnaryCall`'s own construction (`grpc-dart/lib/src/client/client.dart`), which uses `Stream.value(request)` and works reliably. Client-streaming and bidi codegen branches accept an explicit `Stream` from the user and are unaffected.
### Workaround for anyone hitting this today
Subclass the generated client and override the server-streaming method:
```dart
class PatchedClient extends ExampleClient {
PatchedClient(super.channel, {super.options, super.interceptors});
static final _subscribe = ClientMethod(
'/example.v1.Example/Subscribe',
(SubscribeRequest v) => v.writeToBuffer(),
Event.fromBuffer,
);
@override
ResponseStream subscribe(SubscribeRequest request, {CallOptions? options}) {
return $createStreamingCall(_subscribe, Stream.value(request), options: options);
}
}
```
The private `_$subscribe` field on the generated client is library-private, so the workaround duplicates the `ClientMethod` descriptor. Not pretty, but survives regeneration.
### Impact
Anyone using grpc-dart as a client against a grpc-go server-streaming RPC will hit this. Symptom is "server never answers on that RPC," which is easy to misattribute to the server. It took us significant time to isolate the client side as the cause because the wire looks silent, not errored.
Happy to test a candidate fix, and to contribute a codegen PR if that would help.
Contributor guide
Research direction
Start in protoc_plugin/lib/src/grpc_generator.dart at the !_clientStreaming && _serverStreaming branch, then compare the single-request stream construction with grpc/lib/src/client/client.dart:56. Validate the generated Subscribe client with the supplied grpc-go reproduction; done means the request reaches the server and the server-streaming RPC no longer hangs.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- dart, grpc
- Domain
- backend-api-design, tooling
- Issue type
- Bug
- Difficulty
- 1/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100