Repository navigation
Description
What version of gRPC and what language are you using?
@grpc/grpc-js 1.14.4 on Node.js 24.15.0. The relevant path is also unchanged on the current master branch.
What operating system are you using?
Linux.
What did you do?
Send a graceful HTTP/2 GOAWAY while a unary RPC is still running, then allow the RPC to finish after the client's keepalive interval.
This is closely related to #2624, but the current failure does not depend on an immediate server shutdown. A graceful GOAWAY by itself leaves the keepalive timer running. The next PING fails, maybeSendPing() calls handleDisconnect(), and grpc-js terminates the accepted in-flight RPC.
goaway-server.js:
const http2 = require('node:http2');
function frameMessage(payload) {
const frame = Buffer.allocUnsafe(5 + payload.length);
frame.writeUInt8(0, 0);
frame.writeUInt32BE(payload.length, 1);
payload.copy(frame, 5);
return frame;
}
const server = http2.createServer();
server.on('stream', stream => {
stream.respond(
{ ':status': 200, 'content-type': 'application/grpc' },
{ waitForTrailers: true }
);
stream.on('wantTrailers', () => {
stream.sendTrailers({ 'grpc-status': '0' });
});
setTimeout(() => {
stream.session.goaway(http2.constants.NGHTTP2_NO_ERROR, stream.id);
}, 10);
setTimeout(() => {
stream.end(frameMessage(Buffer.from('response')));
}, 20_000);
});
server.listen(50051, '127.0.0.1');client.js:
const grpc = require('@grpc/grpc-js');
const client = new grpc.Client(
'127.0.0.1:50051',
grpc.credentials.createInsecure(),
{
'grpc.keepalive_time_ms': 15_000,
'grpc.keepalive_timeout_ms': 30_000,
'grpc.keepalive_permit_without_calls': 1
}
);
const startedAt = Date.now();
client.makeUnaryRequest(
'/repro.Service/Slow',
value => value,
value => value,
Buffer.from('request'),
{ deadline: Date.now() + 120_000 },
(error, response) => {
console.log({
elapsedMs: Date.now() - startedAt,
code: error?.code,
details: error?.details,
response: response?.toString()
});
client.close();
}
);Run node goaway-server.js, then node client.js.
The same reproduction can be sped up deterministically by using a 100 ms keepalive and a 500 ms response delay.
What did you expect to see?
The accepted RPC finishes normally after the graceful GOAWAY and returns response at about 20 seconds. The transport is already draining and will not accept a new call, so it should not send more keepalive PINGs.
What did you see instead?
Stock 1.14.4 fails at the keepalive boundary (15,022 ms in the original reproduction) with status 14 / Connection dropped. In the scaled reproduction, stock 1.14.4 fails at about 114 ms; a transport that stops keepalive on GOAWAY returns the response at about 524 ms.
There is a second race when a PING is already in flight as GOAWAY arrives: a later PING cancellation callback can still call handleDisconnect() unless the callback also recognizes the draining state.
Suggested fix
When the session emits goaway:
- Mark the transport as draining.
- Clear the keepalive timeout.
- Make
canSendPing()/maybeSendPing()reject future PINGs. - Ignore completion of a PING that was already in flight once the transport is draining.
The existing socket close and error handlers still report genuine disconnects, and RPC deadlines continue to bound hung calls. This only stops probing a connection whose peer explicitly announced graceful draining.
Activity
murgatroid99 commentedon Aug 17, 2026
I believe the server is actually behaving incorrectly in this case. The section of the HTTP/2 spec about the PING frame says
I can't find any indication that the sender of a GOAWAY frame should behave any differently during the graceful shutdown period.
gfrancz commentedon Sep 21, 2026
You're right that a peer must ACK a PING during graceful shutdown, and Node does this: a raw-frame probe confirms a Node server ACKs PINGs received while draining.
The PING in this failure never reaches the server. Node marks the client session closed on a NO_ERROR GOAWAY (onGoawayData calls session.close()), and ping() on a closed session short-circuits in lib/internal/http2/core.js:
The callback fires with ERR_HTTP2_PING_CANCEL and grpc-js treats both the falsy return and the callback error as connection failures, dropping the in-flight RPC.
PR #3089 stops keepalive PINGs on a closing session on the client and both server handler variants. One regression test fails on unchanged upstream, with only the client fix, and with only the server fix; it passes with both fixes.
With 15-second keepalive the original code fails at ~15s; the fix returns the response at ~20s. Reproduction scripts and traces: https://gist.github.com/gfrancz/e09c65c05ab90dba36f01c2411e3aaf7
n0v1 commentedon Oct 16, 2026
We hit a server-only variant of this in production. The trigger is the server's own
grpc.max_connection_age_ms: no client keepalive and no peer behaving unusually.When the connection reaches its maximum age, the server sends GOAWAY and calls
session.close(), but its keepalive timer keeps running. The next server ping on the closed session is cancelled locally by Node:ping()returnsundefined, and the callback getsERR_HTTP2_PING_CANCEL. The server then callssession.destroy(), which kills all calls still in flight.As a result,
grpc.max_connection_age_grace_msis effectively capped atgrpc.keepalive_time_ms. With our settings (max age 40 min, grace 10 min, keepalive 60 s), long calls were reset at most 60 s after the GOAWAY instead of after 10 minutes.Reproduction (only
@grpc/grpc-js, no proto file):npm install @grpc/grpc-js@1.14.6, thenGRPC_TRACE=keepalive GRPC_VERBOSITY=DEBUG node repro.js:Expected: the call finishes within the 60 s grace period. With
KEEPALIVE=0 node repro.js, it does:With the server change from #3089 (
!session.closedincanSendPing()) applied to 1.14.6, the repro also passes (client: OKafter 5 s).Environment: Linux x86_64 (WSL2), Node 24.21.0 (production: Node 24.13.1),
@grpc/grpc-js1.14.6 (production: 1.14.3).azyu commentedon Oct 19, 2026
We reproduced this with grpc-js on both sides, so no third-party server is involved:
@grpc/grpc-js1.14.6 server and client, Node v24.13.0.Setup: server
grpc.max_connection_age_ms: 1000,grpc.max_connection_age_grace_ms: 15000; clientgrpc.keepalive_time_ms: 2000,grpc.keepalive_timeout_ms: 5000,grpc.keepalive_permit_without_calls: 1; one unary call whose handler responds after 10 s.Client trace (
GRPC_TRACE=transport,keepalive GRPC_VERBOSITY=DEBUG):The call ends with
code=14 Connection droppedabout 1 s after the GOAWAY, 13 s before the server's grace period would have ended. With keepalive disabled, the same call survives until the grace period ends, as expected.This matches @gfrancz's analysis above: no PING reaches the server. In Node 24,
Http2Session.prototype.ping()returns early whenthis.closedis true and schedules the callback withERR_HTTP2_PING_CANCELinstead of writing a frame. The client session isclosedbecause grpc-js callssession.close()when it handles the GOAWAY (Http2Transport.shutdown()), while the keepalive timer keeps running andcanSendPing()only checkssession.destroyed. So the failure is local to the client and does not depend on how the server answers PINGs.We also have a production failure consistent with this path, though our production logs do not include grpc-js traces to confirm it: an in-flight unary write (~1.4 s) failed with
UNAVAILABLE: Connection droppedwhile the server pod kept running and completed the write 1 s later.#3089 addresses this path. We would welcome a review of it.
ws-gregm commentedon Oct 30, 2026
Hi @murgatroid99, I've also reproduced the above issue and tested the proposed fix. With the 2 previous comments in mind and the comments on the PR itself, what will it take to get #3089 approved and merged?