Skip to content

fix(grpc-js): avoid redundant end() calls on completed HTTP/2 streams - #3082

Open
olavloite wants to merge 2 commits into
grpc:masterfrom
olavloite:avoid-redundant-end
Open

olavloite wants to merge 2 commits into
grpc:masterfrom
olavloite:avoid-redundant-end

Conversation

@olavloite

Copy link
Copy Markdown

When a call completes, destroyHttp2Stream() invokes http2Stream.end() if the server ended the call. However, for unary and server-streaming calls (as well as client streams where writing already finished), halfClose() has already ended the stream.

In Node.js, calling .end() on an already finished stream without a callback causes Node's stream internals to construct an ERR_STREAM_ALREADY_FINISHED Error with a full V8 stack trace, only to immediately discard it. Under high throughput, this introduces significant CPU overhead and garbage collection pressure.

This change checks writableEnded before calling http2Stream.end() in destroyHttp2Stream() and halfClose(), ensuring .end() is only called when the client side of the stream has not yet been closed.

@linux-foundation-easycla

linux-foundation-easycla Bot commented Sep 13, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

  • ✅ login: olavloite / name: Knut Olav Løite (01934c6)

When a call completes, destroyHttp2Stream() invokes http2Stream.end() if
the server ended the call. However, for unary and server-streaming calls
(as well as client streams where writing already finished), halfClose()
has already ended the stream.

In Node.js, calling .end() on an already finished stream without a
callback causes Node's stream internals to construct an
ERR_STREAM_ALREADY_FINISHED Error with a full V8 stack trace, only to
immediately discard it. Under high throughput, this introduces significant
CPU overhead and garbage collection pressure.

This change checks writableEnded before calling http2Stream.end() in
destroyHttp2Stream() and halfClose(), ensuring .end() is only called when
the client side of the stream has not yet been closed.
olavloite added a commit to googleapis/google-cloud-node that referenced this pull request Sep 13, 2026
In @grpc/grpc-js, destroyHttp2Stream() calls http2Stream.end() when the
server finishes a call, even if the stream was already ended by halfClose().
Calling .end() on an already ended or destroyed Node stream without a callback
causes Node core to construct an ERR_STREAM_ALREADY_FINISHED or
ERR_STREAM_DESTROYED error with a full V8 stack capture and immediately
discard it, creating unnecessary CPU overhead on high-throughput workloads.

This adds a workaround on stream.Duplex.prototype.end scoped to HTTP/2
streams that returns early when called on an already ended or destroyed
stream without data or a callback. The workaround can be disabled with
SPANNER_DISABLE_HTTP2_STREAM_END_WORKAROUND=true.

This is a temporary client-side workaround for an upstream issue in
@grpc/grpc-js, tracked in grpc/grpc-node#3082.
It can be removed once Spanner requires a grpc-js release that includes
that fix.
olavloite added a commit to googleapis/google-cloud-node that referenced this pull request Sep 14, 2026
In @grpc/grpc-js, destroyHttp2Stream() calls http2Stream.end() when the
server finishes a call, even if the stream was already ended by halfClose().
Calling .end() on an already ended or destroyed Node stream without a callback
causes Node core to construct an ERR_STREAM_ALREADY_FINISHED or
ERR_STREAM_DESTROYED error with a full V8 stack capture and immediately
discard it, creating unnecessary CPU overhead on high-throughput workloads.

This adds a workaround on stream.Duplex.prototype.end scoped to HTTP/2
streams that returns early when called on an already ended or destroyed
stream without data or a callback. The workaround can be disabled with
SPANNER_DISABLE_HTTP2_STREAM_END_WORKAROUND=true.

This is a temporary client-side workaround for an upstream issue in
@grpc/grpc-js, tracked in grpc/grpc-node#3082.
It can be removed once Spanner requires a grpc-js release that includes
that fix.
@murgatroid99

Copy link
Copy Markdown
Member

The new test code doesn't compile

Add explicit type cast for the intercepted HTTP/2 stream and type arguments
for makeClientStreamRequest.
@olavloite

Copy link
Copy Markdown
Author

The new test code doesn't compile

Sorry, that should be fixed now.

@olavloite

Copy link
Copy Markdown
Author

The current build failures seem to be unrelated to this change:

npm error code E404
npm error 404 Not Found - GET https://registry.npmjs.org/@types/node/-/node-26.6.0.tgz - Not found
npm error 404
npm error 404  '@types/node@https://registry.npmjs.org/@types/node/-/node-26.6.0.tgz' is not in this registry.
npm error 404
npm error 404 Note that you can also install from a
npm error 404 tarball, folder, http url, or git url.
npm error A complete log of this run can be found in: /home/kbuilder/.npm/_logs/2026-09-15T16_42_36_534Z-debug-0.log
[16:42:50] 'install' errored after 14 s
[16:42:50] Error: exited with error code: 1
    at ChildProcess.onexit (/tmpfs/src/github/grpc-node/node_modules/end-of-stream/index.js:42:36)
    at ChildProcess.emit (node:events:536:35)
    at ChildProcess.emit (node:domain:552:15)
    at Process.ChildProcess._handle.onexit (node:internal/child_process:293:12)
    at Process.callbackTrampoline (node:internal/async_hooks:130:17)
[16:42:50] 'setup' errored after 14 s

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.

3 participants