From 9d88a60e68720a558773b078887b269cd3b30497 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 13 Sep 2026 15:33:31 +0000 Subject: [PATCH] http2: settle pending write callbacks on destroy When a stream is destroyed while a write is still in flight, nghttp2 may have handed the data to the socket and never report the write completion once the stream or session tears down. That leaves the write callback unresolved, the Writable stuck, and the event loop never drains, which times out test-http2-close-while-writing on macOS. Settle the in-flight write in Http2Stream._destroy by invoking its callback with the destroy error and resetting writePending. A later native completion callback is then a no-op because writeCb is null and writePending is 0, so the settle is idempotent. Passing the error rather than null also drains any buffered writes. Fixes: https://github.com/nodejs/node/issues/58252 Signed-off-by: Matteo Collina Assisted-by: pi-coding-agent --- lib/internal/http2/core.js | 9 +++++++++ test/parallel/test-http2-close-while-writing.js | 14 ++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/internal/http2/core.js b/lib/internal/http2/core.js index 4700120b0dd4..43169834ff5a 100644 --- a/lib/internal/http2/core.js +++ b/lib/internal/http2/core.js @@ -2721,6 +2721,15 @@ class Http2Stream extends Duplex { }); } } + + // A write in flight may never get its native completion callback once the + // stream tears down; settle it so the Writable can clean up. + if (state.writeCb !== null) { + const writeCb = state.writeCb; + state.writeCb = null; + state.writePending = 0; + writeCb(err); + } callback(err); } // The Http2Stream can be destroyed if it has closed and if the readable diff --git a/test/parallel/test-http2-close-while-writing.js b/test/parallel/test-http2-close-while-writing.js index 17931005dc6c..c78386c85f23 100644 --- a/test/parallel/test-http2-close-while-writing.js +++ b/test/parallel/test-http2-close-while-writing.js @@ -29,11 +29,21 @@ server.on('session', common.mustCall(function(session) { stream.on('error', common.mustCall((err) => { assert.strictEqual(err.code, 'ERR_HTTP2_STREAM_ABORTED'); })); - stream.resume(); + + // Every write dispatched before close must have its callback invoked. + let writes = 0; + let writeCallbacks = 0; stream.on('data', function() { - this.write(Buffer.alloc(1)); + writes++; + this.write(Buffer.alloc(1), () => { + writeCallbacks++; + }); process.nextTick(() => client_stream.destroy()); }); + stream.on('close', common.mustCall(() => { + assert.strictEqual(writeCallbacks, writes); + })); + stream.resume(); })); }));