diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36ee35300f6bce..4e74a59686da8b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -42,7 +42,8 @@ release.
+26.3.0
26.2.0
26.1.0
26.0.0
@@ -43,6 +44,176 @@
* [io.js](CHANGELOG_IOJS.md)
* [Archive](CHANGELOG_ARCHIVE.md)
+
+
+## 2026-06-01, Version 26.3.0 (Current), @aduh95
+
+### Notable Changes
+
+#### Potential changes to macOS Universal Binary availability
+
+With Apple and its ecosystem progressively dropping support for Intel-based
+architectures, it has become apparent that the Node.js project may not be able
+to maintain the universal binaries we currently distribute for the full lifetime
+of Node.js 26. This change serves to communicate that risk. At present, our
+intention remains to continue shipping universal binaries supporting both Apple
+Silicon and Intel-based Macs for as long as practical.
+
+Contributed by Antoine du Hamel in [#63055](https://github.com/nodejs/node/pull/63055).
+
+#### Other notable changes
+
+* \[[`a2a4b33dd8`](https://github.com/nodejs/node/commit/a2a4b33dd8)] - **(SEMVER-MINOR)** **buffer**: increase `Buffer.poolSize` default to 64 KiB (Matteo Collina) [#63597](https://github.com/nodejs/node/pull/63597)
+* \[[`051a2152f7`](https://github.com/nodejs/node/commit/051a2152f7)] - **crypto**: update root certificates to NSS 3.123.1 (Node.js GitHub Bot) [#63527](https://github.com/nodejs/node/pull/63527)
+* \[[`49462eca37`](https://github.com/nodejs/node/commit/49462eca37)] - **(SEMVER-MINOR)** **http**: add `httpValidation` option to configure header value validation (RajeshKumar11) [#61597](https://github.com/nodejs/node/pull/61597)
+* \[[`97b7ab19bd`](https://github.com/nodejs/node/commit/97b7ab19bd)] - **(SEMVER-MINOR)** **inspector**: expose precise coverage start to JS runtime (sangwook) [#63079](https://github.com/nodejs/node/pull/63079)
+* \[[`cfb80a2103`](https://github.com/nodejs/node/commit/cfb80a2103)] - **(SEMVER-MINOR)** **lib,permission**: add `permission.drop` (Rafael Gonzaga) [#62672](https://github.com/nodejs/node/pull/62672)
+
+### Commits
+
+* \[[`a2a4b33dd8`](https://github.com/nodejs/node/commit/a2a4b33dd8)] - **(SEMVER-MINOR)** **buffer**: increase Buffer.poolSize default to 64 KiB (Matteo Collina) [#63597](https://github.com/nodejs/node/pull/63597)
+* \[[`0eff3e23b9`](https://github.com/nodejs/node/commit/0eff3e23b9)] - **build**: def `NODE_USE_NODE_CODE_CACHE` only used in node\_mksnapshot (Chengzhong Wu) [#63588](https://github.com/nodejs/node/pull/63588)
+* \[[`447ab2d252`](https://github.com/nodejs/node/commit/447ab2d252)] - **build,win**: fix VS2022 arm64 PGO build (Stefan Stojanovic) [#63413](https://github.com/nodejs/node/pull/63413)
+* \[[`86032758e4`](https://github.com/nodejs/node/commit/86032758e4)] - **build,win**: replace LTCG with Thin LTO for releases (Stefan Stojanovic) [#63114](https://github.com/nodejs/node/pull/63114)
+* \[[`5f4d794052`](https://github.com/nodejs/node/commit/5f4d794052)] - **build,win**: add Rust toolchain automated configuration Windows (Mike McCready) [#63381](https://github.com/nodejs/node/pull/63381)
+* \[[`051a2152f7`](https://github.com/nodejs/node/commit/051a2152f7)] - **crypto**: update root certificates to NSS 3.123.1 (Node.js GitHub Bot) [#63527](https://github.com/nodejs/node/pull/63527)
+* \[[`d0f65e3579`](https://github.com/nodejs/node/commit/d0f65e3579)] - **crypto**: coerce -0 keylen to +0 in pbkdf2 and scrypt (Jordan Harband) [#63531](https://github.com/nodejs/node/pull/63531)
+* \[[`e3ddb326c9`](https://github.com/nodejs/node/commit/e3ddb326c9)] - **crypto**: harden WebCrypto against prototype pollution (Filip Skokan) [#63363](https://github.com/nodejs/node/pull/63363)
+* \[[`e04cd17dc0`](https://github.com/nodejs/node/commit/e04cd17dc0)] - **crypto**: pass CryptoKey handles to KDF jobs (Filip Skokan) [#63363](https://github.com/nodejs/node/pull/63363)
+* \[[`64ba74d847`](https://github.com/nodejs/node/commit/64ba74d847)] - **crypto**: remove async from WebCrypto methods (Filip Skokan) [#63363](https://github.com/nodejs/node/pull/63363)
+* \[[`bd230418b4`](https://github.com/nodejs/node/commit/bd230418b4)] - **crypto**: add WebCrypto CryptoJob mode (Filip Skokan) [#63363](https://github.com/nodejs/node/pull/63363)
+* \[[`1a4090a83d`](https://github.com/nodejs/node/commit/1a4090a83d)] - **debugger**: surface inspector failures in probe mode (Joyee Cheung) [#63437](https://github.com/nodejs/node/pull/63437)
+* \[[`dbc78ff825`](https://github.com/nodejs/node/commit/dbc78ff825)] - **debugger,test**: deflake resume failure test and add debug logs (Joyee Cheung) [#63524](https://github.com/nodejs/node/pull/63524)
+* \[[`4da442f432`](https://github.com/nodejs/node/commit/4da442f432)] - **deps**: upgrade npm to 11.16.0 (npm team) [#63602](https://github.com/nodejs/node/pull/63602)
+* \[[`63372cfa87`](https://github.com/nodejs/node/commit/63372cfa87)] - **deps**: SQLite: cherry-pick b869ed6b067d623cb1383549f2a18aa35508385d (Junsu Han) [#63525](https://github.com/nodejs/node/pull/63525)
+* \[[`e286fa170d`](https://github.com/nodejs/node/commit/e286fa170d)] - **deps**: upgrade npm to 11.15.0 (npm team) [#63463](https://github.com/nodejs/node/pull/63463)
+* \[[`de996437a5`](https://github.com/nodejs/node/commit/de996437a5)] - **doc**: downgrade macOS x64 to Tier 2 (Antoine du Hamel) [#63055](https://github.com/nodejs/node/pull/63055)
+* \[[`22ac78750c`](https://github.com/nodejs/node/commit/22ac78750c)] - **doc**: remove duplicated sentences in large-pull-requests.md (Joyee Cheung) [#63650](https://github.com/nodejs/node/pull/63650)
+* \[[`532f7f2085`](https://github.com/nodejs/node/commit/532f7f2085)] - **doc**: update `git node land` instructions for security releases (Antoine du Hamel) [#63586](https://github.com/nodejs/node/pull/63586)
+* \[[`c61f90dfb9`](https://github.com/nodejs/node/commit/c61f90dfb9)] - **doc**: drop --experimental from --permission (Rafael Gonzaga) [#63583](https://github.com/nodejs/node/pull/63583)
+* \[[`fd69d7b16a`](https://github.com/nodejs/node/commit/fd69d7b16a)] - **doc**: improve `fs.StatFs` properties descriptions (aymanxdev) [#62578](https://github.com/nodejs/node/pull/62578)
+* \[[`693257782c`](https://github.com/nodejs/node/commit/693257782c)] - **doc**: generate llms.txt (Guilherme Araújo) [#62027](https://github.com/nodejs/node/pull/62027)
+* \[[`55a57beb26`](https://github.com/nodejs/node/commit/55a57beb26)] - **doc**: explicitly ask for reproducible in JS (Rafael Gonzaga) [#63479](https://github.com/nodejs/node/pull/63479)
+* \[[`4895c2babc`](https://github.com/nodejs/node/commit/4895c2babc)] - **doc**: fix URL postMessage example in worker\_threads (Kit Dallege) [#62203](https://github.com/nodejs/node/pull/62203)
+* \[[`0355c36e37`](https://github.com/nodejs/node/commit/0355c36e37)] - **doc**: clarify `filter` option of `sqlite.database.applyChangeset` (Antoine du Hamel) [#63515](https://github.com/nodejs/node/pull/63515)
+* \[[`c85ee22df6`](https://github.com/nodejs/node/commit/c85ee22df6)] - **doc**: fix double spaces in ERR\_TLS\_INVALID\_PROTOCOL\_METHOD (Daijiro Wachi) [#63511](https://github.com/nodejs/node/pull/63511)
+* \[[`62947192f6`](https://github.com/nodejs/node/commit/62947192f6)] - **doc**: move hyperlinks outside of text blocks (Aviv Keller) [#63493](https://github.com/nodejs/node/pull/63493)
+* \[[`9849690a1d`](https://github.com/nodejs/node/commit/9849690a1d)] - **doc**: edit Rust toolchain general install instructions (Antoine du Hamel) [#63488](https://github.com/nodejs/node/pull/63488)
+* \[[`885d2462e9`](https://github.com/nodejs/node/commit/885d2462e9)] - **doc**: fix double space in modules.md (Daijiro Wachi) [#63512](https://github.com/nodejs/node/pull/63512)
+* \[[`42fbb48bc6`](https://github.com/nodejs/node/commit/42fbb48bc6)] - **doc**: fix "options" to "option" in tls.createServer (Daijiro Wachi) [#63453](https://github.com/nodejs/node/pull/63453)
+* \[[`05a7b0a301`](https://github.com/nodejs/node/commit/05a7b0a301)] - **doc**: add Rust toolchain general install instructions (Mike McCready) [#63426](https://github.com/nodejs/node/pull/63426)
+* \[[`e13dfd7ed0`](https://github.com/nodejs/node/commit/e13dfd7ed0)] - **doc**: update toolchain for official releases (Richard Lau) [#63441](https://github.com/nodejs/node/pull/63441)
+* \[[`82306881cc`](https://github.com/nodejs/node/commit/82306881cc)] - **doc**: fix typo in deprecations (Daijiro Wachi) [#63434](https://github.com/nodejs/node/pull/63434)
+* \[[`eeb77d217c`](https://github.com/nodejs/node/commit/eeb77d217c)] - **doc,lib**: align WebCrypto names with spec (Filip Skokan) [#63518](https://github.com/nodejs/node/pull/63518)
+* \[[`679e13c57f`](https://github.com/nodejs/node/commit/679e13c57f)] - **errors**: handle V8 warnings in DisallowJavascriptExecutionScope (Divyanshu Sharma) [#63491](https://github.com/nodejs/node/pull/63491)
+* \[[`7f41f5d803`](https://github.com/nodejs/node/commit/7f41f5d803)] - **ffi**: validate 'void' as parameter type in getFunction and getFunctions (Anshika Jain) [#63504](https://github.com/nodejs/node/pull/63504)
+* \[[`972cd227cb`](https://github.com/nodejs/node/commit/972cd227cb)] - **ffi**: remove function signature property aliases (René) [#63482](https://github.com/nodejs/node/pull/63482)
+* \[[`5d7805e433`](https://github.com/nodejs/node/commit/5d7805e433)] - **ffi**: move DynamicLibrary disposer to native layer (René) [#63459](https://github.com/nodejs/node/pull/63459)
+* \[[`5a0b32dc24`](https://github.com/nodejs/node/commit/5a0b32dc24)] - **gyp**: update deps gypfiles (Nad Alaba) [#63117](https://github.com/nodejs/node/pull/63117)
+* \[[`49462eca37`](https://github.com/nodejs/node/commit/49462eca37)] - **(SEMVER-MINOR)** **http**: add httpValidation option to configure header value validation (RajeshKumar11) [#61597](https://github.com/nodejs/node/pull/61597)
+* \[[`e3c6629ee3`](https://github.com/nodejs/node/commit/e3c6629ee3)] - **http2**: emit session close before stream close (Matteo Collina) [#63414](https://github.com/nodejs/node/pull/63414)
+* \[[`97b7ab19bd`](https://github.com/nodejs/node/commit/97b7ab19bd)] - **(SEMVER-MINOR)** **inspector**: expose precise coverage start to JS runtime (sangwook) [#63079](https://github.com/nodejs/node/pull/63079)
+* \[[`6bef10e7b7`](https://github.com/nodejs/node/commit/6bef10e7b7)] - **lib**: cleanup stateless diffiehellman key handling (Filip Skokan) [#62645](https://github.com/nodejs/node/pull/62645)
+* \[[`fdc0b3d49c`](https://github.com/nodejs/node/commit/fdc0b3d49c)] - **lib**: define `kEnumerableProperty` atomically (Antoine du Hamel) [#63609](https://github.com/nodejs/node/pull/63609)
+* \[[`99baf27aeb`](https://github.com/nodejs/node/commit/99baf27aeb)] - **lib**: fix typos in esm loader comments (RonGamzu) [#63465](https://github.com/nodejs/node/pull/63465)
+* \[[`cfb80a2103`](https://github.com/nodejs/node/commit/cfb80a2103)] - **(SEMVER-MINOR)** **lib,permission**: add permission.drop (Rafael Gonzaga) [#62672](https://github.com/nodejs/node/pull/62672)
+* \[[`8e75efb9bc`](https://github.com/nodejs/node/commit/8e75efb9bc)] - **meta**: flip mcollina emails in .mailmap (Matteo Collina) [#63621](https://github.com/nodejs/node/pull/63621)
+* \[[`a4ae97045f`](https://github.com/nodejs/node/commit/a4ae97045f)] - **meta**: label "source maps" PRs (Chengzhong Wu) [#63591](https://github.com/nodejs/node/pull/63591)
+* \[[`3455a48ae1`](https://github.com/nodejs/node/commit/3455a48ae1)] - **meta**: add `vfs` subsystem label (René) [#62331](https://github.com/nodejs/node/pull/62331)
+* \[[`01bfcdfc20`](https://github.com/nodejs/node/commit/01bfcdfc20)] - **meta**: skip scheduled workflows on forks (Jamie Magee) [#63565](https://github.com/nodejs/node/pull/63565)
+* \[[`bc4c457eae`](https://github.com/nodejs/node/commit/bc4c457eae)] - **meta**: add additional gitignore entries (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`e1d65d9509`](https://github.com/nodejs/node/commit/e1d65d9509)] - **module**: load ESM helpers eagerly in the snapshot (Joyee Cheung) [#63550](https://github.com/nodejs/node/pull/63550)
+* \[[`6a97b0932a`](https://github.com/nodejs/node/commit/6a97b0932a)] - **quic**: add proper error codes & messages for QUIC failures (Tim Perry) [#63198](https://github.com/nodejs/node/pull/63198)
+* \[[`5989f4a6e1`](https://github.com/nodejs/node/commit/5989f4a6e1)] - **quic**: support hostname verification (James M Snell) [#63483](https://github.com/nodejs/node/pull/63483)
+* \[[`b4d30e7a78`](https://github.com/nodejs/node/commit/b4d30e7a78)] - **quic**: add stream idle timeout (James M Snell) [#63483](https://github.com/nodejs/node/pull/63483)
+* \[[`8a1017f774`](https://github.com/nodejs/node/commit/8a1017f774)] - **quic**: add block list support for endpoints (James M Snell) [#63483](https://github.com/nodejs/node/pull/63483)
+* \[[`5a3ab93c49`](https://github.com/nodejs/node/commit/5a3ab93c49)] - **quic**: improve peer cert verification (James M Snell) [#63483](https://github.com/nodejs/node/pull/63483)
+* \[[`9701a82a78`](https://github.com/nodejs/node/commit/9701a82a78)] - **quic**: handle h3 max header size option (James M Snell) [#63483](https://github.com/nodejs/node/pull/63483)
+* \[[`71788a2048`](https://github.com/nodejs/node/commit/71788a2048)] - **quic**: add rate limiting docs (James M Snell) [#63483](https://github.com/nodejs/node/pull/63483)
+* \[[`309bd49906`](https://github.com/nodejs/node/commit/309bd49906)] - **quic**: cache timestamp for address lru cache (James M Snell) [#63483](https://github.com/nodejs/node/pull/63483)
+* \[[`2ce5588d51`](https://github.com/nodejs/node/commit/2ce5588d51)] - **quic**: add session creation rate limiting (James M Snell) [#63483](https://github.com/nodejs/node/pull/63483)
+* \[[`98808baed1`](https://github.com/nodejs/node/commit/98808baed1)] - **quic**: refine rate limiting (James M Snell) [#63483](https://github.com/nodejs/node/pull/63483)
+* \[[`75a4176b32`](https://github.com/nodejs/node/commit/75a4176b32)] - **quic**: flip preferred address policy default to 'ignore' (James M Snell) [#63483](https://github.com/nodejs/node/pull/63483)
+* \[[`8b6b03d60c`](https://github.com/nodejs/node/commit/8b6b03d60c)] - **quic**: add doc note about certificate size limitations (James M Snell) [#63483](https://github.com/nodejs/node/pull/63483)
+* \[[`30eff873e0`](https://github.com/nodejs/node/commit/30eff873e0)] - **quic**: add applicationOptions to session (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`4303daa43c`](https://github.com/nodejs/node/commit/4303daa43c)] - **quic**: add getters for local and remote transport parameters (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`e1b1bb5465`](https://github.com/nodejs/node/commit/e1b1bb5465)] - **quic**: improve recv coalescing test sizes (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`25a416f457`](https://github.com/nodejs/node/commit/25a416f457)] - **quic**: add initial RTT option to session options (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`22e91c357f`](https://github.com/nodejs/node/commit/22e91c357f)] - **quic**: enable recvmmsg batching in Endpoint (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`c96d8a9d9b`](https://github.com/nodejs/node/commit/c96d8a9d9b)] - **quic**: improve stream header collection performance (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`409460f2ce`](https://github.com/nodejs/node/commit/409460f2ce)] - **quic**: add reusePort option to QuicEndpoint (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`9a2afffec9`](https://github.com/nodejs/node/commit/9a2afffec9)] - **quic**: coalesce received data into fewer buffers (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`f9a6a2f558`](https://github.com/nodejs/node/commit/f9a6a2f558)] - **quic**: apply multiple additional minor improvements (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`ea5f3724ee`](https://github.com/nodejs/node/commit/ea5f3724ee)] - **quic**: fix tests that are missing serverEndpoint close (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`6cffc931fc`](https://github.com/nodejs/node/commit/6cffc931fc)] - **quic**: fixup some v8:: qualifiers (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`9bc875e522`](https://github.com/nodejs/node/commit/9bc875e522)] - **quic**: fix premature unref of endpoint when listening (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`f940d6b1be`](https://github.com/nodejs/node/commit/f940d6b1be)] - **quic**: fixup UAFs in bindingdata, streams, and app (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`fd00e0acb0`](https://github.com/nodejs/node/commit/fd00e0acb0)] - **quic**: fix UAF in Application::OnTimeout() (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`378dbf00e9`](https://github.com/nodejs/node/commit/378dbf00e9)] - **quic**: improve the quic js structure (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`0045dc30b6`](https://github.com/nodejs/node/commit/0045dc30b6)] - **quic**: improve internal structure of QuicStream (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`5e38946b26`](https://github.com/nodejs/node/commit/5e38946b26)] - **quic**: add aliased struct arenas (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`95430437a0`](https://github.com/nodejs/node/commit/95430437a0)] - **quic**: add handshake timeout and default connection limits (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`5622701429`](https://github.com/nodejs/node/commit/5622701429)] - **quic**: implement rate limiting for version nego and immediate close (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`b171f391cd`](https://github.com/nodejs/node/commit/b171f391cd)] - **quic**: fixup linting issue after other changes (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`24e9f4f177`](https://github.com/nodejs/node/commit/24e9f4f177)] - **quic**: fix crash in early handshake failure (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`5025e85d0a`](https://github.com/nodejs/node/commit/5025e85d0a)] - **quic**: eliminate per-received datagram allocation (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`aec1e17ec5`](https://github.com/nodejs/node/commit/aec1e17ec5)] - **quic**: cache the timestamp on send and receive (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`9560084560`](https://github.com/nodejs/node/commit/9560084560)] - **quic**: add support for future ECN marking (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`2b3ff8ada2`](https://github.com/nodejs/node/commit/2b3ff8ada2)] - **quic**: improve batching of packet sending (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`fe3639a4d6`](https://github.com/nodejs/node/commit/fe3639a4d6)] - **quic**: improve backend quic packet processing (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`f043013d9a`](https://github.com/nodejs/node/commit/f043013d9a)] - **src**: remove TOCTOU race condition when encoding SAB-backed `Buffer`s (Antoine du Hamel) [#63517](https://github.com/nodejs/node/pull/63517)
+* \[[`343958224d`](https://github.com/nodejs/node/commit/343958224d)] - **src**: skip duplicate UTF-8 validation in TextDecoder fatal path (Mert Can Altin) [#63231](https://github.com/nodejs/node/pull/63231)
+* \[[`2906fa833d`](https://github.com/nodejs/node/commit/2906fa833d)] - **src**: dispatch ToV8Value(string\_view) via StringBytes::Encode (Mert Can Altin) [#63370](https://github.com/nodejs/node/pull/63370)
+* \[[`860f9d8d4b`](https://github.com/nodejs/node/commit/860f9d8d4b)] - **src**: fix ContextifyContext property definer interception result (Chengzhong Wu) [#63549](https://github.com/nodejs/node/pull/63549)
+* \[[`fcccffcbe6`](https://github.com/nodejs/node/commit/fcccffcbe6)] - **src**: fix crash when reading length on Storage.prototype (Mohamed Sayed) [#63529](https://github.com/nodejs/node/pull/63529)
+* \[[`55f65f9fb6`](https://github.com/nodejs/node/commit/55f65f9fb6)] - **src**: improve token return value check (James M Snell) [#63483](https://github.com/nodejs/node/pull/63483)
+* \[[`7a36ca46cd`](https://github.com/nodejs/node/commit/7a36ca46cd)] - **src**: expose `node::RegisterContext` to make a node managed context (Chengzhong Wu) [#62322](https://github.com/nodejs/node/pull/62322)
+* \[[`9bda92963c`](https://github.com/nodejs/node/commit/9bda92963c)] - **src,sqlite**: only pass `xFilter` when user provided a callback (Antoine du Hamel) [#63516](https://github.com/nodejs/node/pull/63516)
+* \[[`563db50f38`](https://github.com/nodejs/node/commit/563db50f38)] - **stream**: switch to internal `sleep` binding (Antoine du Hamel) [#63611](https://github.com/nodejs/node/pull/63611)
+* \[[`a6e2322ee6`](https://github.com/nodejs/node/commit/a6e2322ee6)] - **stream**: use data listener for compose forwarding (Trivikram Kamat) [#63593](https://github.com/nodejs/node/pull/63593)
+* \[[`7198895c6b`](https://github.com/nodejs/node/commit/7198895c6b)] - **stream**: serialize concurrent share consumer reads (Trivikram Kamat) [#63478](https://github.com/nodejs/node/pull/63478)
+* \[[`70ba8be1d7`](https://github.com/nodejs/node/commit/70ba8be1d7)] - **stream**: fix lint error (Antoine du Hamel) [#63598](https://github.com/nodejs/node/pull/63598)
+* \[[`1608d905a7`](https://github.com/nodejs/node/commit/1608d905a7)] - **stream**: reject pending reads on iterator throw (Trivikram Kamat) [#63555](https://github.com/nodejs/node/pull/63555)
+* \[[`dc12b730d8`](https://github.com/nodejs/node/commit/dc12b730d8)] - **stream**: wait for push writer end fallback to drain (Trivikram Kamat) [#63503](https://github.com/nodejs/node/pull/63503)
+* \[[`4f40a85a1a`](https://github.com/nodejs/node/commit/4f40a85a1a)] - **stream**: flush each fused stateless transform (Trivikram Kamat) [#63468](https://github.com/nodejs/node/pull/63468)
+* \[[`526e0fc427`](https://github.com/nodejs/node/commit/526e0fc427)] - **stream**: avoid duplicate writes in toWritable (Trivikram Kamat) [#63360](https://github.com/nodejs/node/pull/63360)
+* \[[`0008d01f9c`](https://github.com/nodejs/node/commit/0008d01f9c)] - **stream**: propagate abort reason in share and broadcast (Trivikram Kamat) [#63358](https://github.com/nodejs/node/pull/63358)
+* \[[`217338e18b`](https://github.com/nodejs/node/commit/217338e18b)] - **stream**: fix Writable.toWeb() hang on synchronous drain (sangwook) [#61197](https://github.com/nodejs/node/pull/61197)
+* \[[`381f4b1b10`](https://github.com/nodejs/node/commit/381f4b1b10)] - **stream**: disallow writing string chunk with 'buffer' encoding (René) [#63062](https://github.com/nodejs/node/pull/63062)
+* \[[`cbee0de1cb`](https://github.com/nodejs/node/commit/cbee0de1cb)] - **stream**: align `Readable.toWeb` termination with eos (ikeyan) [#62394](https://github.com/nodejs/node/pull/62394)
+* \[[`be91f0a927`](https://github.com/nodejs/node/commit/be91f0a927)] - **test**: shorten path in net pipe connect errors (Matteo Collina) [#63405](https://github.com/nodejs/node/pull/63405)
+* \[[`83cada8bcc`](https://github.com/nodejs/node/commit/83cada8bcc)] - **test**: deflake test-debugger-probe-timeout (Joyee Cheung) [#63547](https://github.com/nodejs/node/pull/63547)
+* \[[`3560b96a10`](https://github.com/nodejs/node/commit/3560b96a10)] - **test**: deflake test-webcrypto-crypto-job-mode (Filip Skokan) [#63543](https://github.com/nodejs/node/pull/63543)
+* \[[`0c9c52373a`](https://github.com/nodejs/node/commit/0c9c52373a)] - **test**: remove test-node-output-v8-warning (Joyee Cheung) [#63469](https://github.com/nodejs/node/pull/63469)
+* \[[`12052dbe14`](https://github.com/nodejs/node/commit/12052dbe14)] - **test**: cover webcrypto prototype pollution systematically (Filip Skokan) [#63520](https://github.com/nodejs/node/pull/63520)
+* \[[`8c479f274a`](https://github.com/nodejs/node/commit/8c479f274a)] - **test**: update test426-fixtures to 9b9e225b5a63139e9a95cdd1bf874a8f0b9d131 (Node.js GitHub Bot) [#63373](https://github.com/nodejs/node/pull/63373)
+* \[[`2ca32a5ee8`](https://github.com/nodejs/node/commit/2ca32a5ee8)] - **test**: update WPT for url to e4a4672e9e (Node.js GitHub Bot) [#63372](https://github.com/nodejs/node/pull/63372)
+* \[[`1bf875bd21`](https://github.com/nodejs/node/commit/1bf875bd21)] - **test**: deflake async-hooks statwatcher test (Trivikram Kamat) [#63396](https://github.com/nodejs/node/pull/63396)
+* \[[`97dbfa09f7`](https://github.com/nodejs/node/commit/97dbfa09f7)] - **test**: avoid test\_runner watch restart in spec snapshot (Trivikram Kamat) [#63392](https://github.com/nodejs/node/pull/63392)
+* \[[`8b038d7b33`](https://github.com/nodejs/node/commit/8b038d7b33)] - **test**: reduce watch mode restart flakiness (Trivikram Kamat) [#63390](https://github.com/nodejs/node/pull/63390)
+* \[[`f504c01d66`](https://github.com/nodejs/node/commit/f504c01d66)] - **test**: get rid of unnecessary `AbortController` instanciations (Antoine du Hamel) [#63489](https://github.com/nodejs/node/pull/63489)
+* \[[`170585ff90`](https://github.com/nodejs/node/commit/170585ff90)] - **test**: isolate rerun-failures state file under tmpdir (Chemi Atlow) [#63449](https://github.com/nodejs/node/pull/63449)
+* \[[`935468a49e`](https://github.com/nodejs/node/commit/935468a49e)] - **test**: fixup quic tests (James M Snell) [#63267](https://github.com/nodejs/node/pull/63267)
+* \[[`fbbdfdcfc7`](https://github.com/nodejs/node/commit/fbbdfdcfc7)] - **test**: wait for ok before initial break after restart (Yuya Inoue) [#62807](https://github.com/nodejs/node/pull/62807)
+* \[[`db808ad77d`](https://github.com/nodejs/node/commit/db808ad77d)] - **test**: unskip snapshot reproducibility test (Joyee Cheung) [#63307](https://github.com/nodejs/node/pull/63307)
+* \[[`259d8b3dce`](https://github.com/nodejs/node/commit/259d8b3dce)] - **test**: update WPT for WebCryptoAPI to 97bbc7247a (Node.js GitHub Bot) [#63417](https://github.com/nodejs/node/pull/63417)
+* \[[`d56c6cd708`](https://github.com/nodejs/node/commit/d56c6cd708)] - **test\_runner**: ignore erased TS lines in coverage (Matteo Collina) [#63510](https://github.com/nodejs/node/pull/63510)
+* \[[`16015f1565`](https://github.com/nodejs/node/commit/16015f1565)] - **test\_runner**: fix suite diagnostic chanel end (Moshe Atlow) [#63533](https://github.com/nodejs/node/pull/63533)
+* \[[`003b9ccbe9`](https://github.com/nodejs/node/commit/003b9ccbe9)] - **test\_runner**: dont buffer unordered events in process isolation mode (Moshe Atlow) [#63432](https://github.com/nodejs/node/pull/63432)
+* \[[`fdc4b5aed4`](https://github.com/nodejs/node/commit/fdc4b5aed4)] - **test\_runner**: fix --test-rerun-failures swallowing failures on retry (Chemi Atlow) [#63431](https://github.com/nodejs/node/pull/63431)
+* \[[`6a0bd2f329`](https://github.com/nodejs/node/commit/6a0bd2f329)] - **test\_runner**: add parentId to test events with testId (Moshe Atlow) [#63435](https://github.com/nodejs/node/pull/63435)
+* \[[`a646c93254`](https://github.com/nodejs/node/commit/a646c93254)] - **test\_runner**: show replayed-from-attempt hint in spec reporter (Moshe Atlow) [#63429](https://github.com/nodejs/node/pull/63429)
+* \[[`b1fa59cbb6`](https://github.com/nodejs/node/commit/b1fa59cbb6)] - **test\_runner**: preserve run duration when using test-rerun (Moshe Atlow) [#63429](https://github.com/nodejs/node/pull/63429)
+* \[[`6ac7ff24ac`](https://github.com/nodejs/node/commit/6ac7ff24ac)] - **tools**: refine `v8.nix` source definition (Antoine du Hamel) [#63625](https://github.com/nodejs/node/pull/63625)
+* \[[`59c01b959f`](https://github.com/nodejs/node/commit/59c01b959f)] - **tools**: add lint rule for aborted AbortController (Trivikram Kamat) [#63541](https://github.com/nodejs/node/pull/63541)
+* \[[`2ab034f6f9`](https://github.com/nodejs/node/commit/2ab034f6f9)] - **tools**: bump @node-core/doc-kit in /tools/doc in the doc group (dependabot\[bot]) [#63494](https://github.com/nodejs/node/pull/63494)
+* \[[`a6af903e0a`](https://github.com/nodejs/node/commit/a6af903e0a)] - **tools**: bump brace-expansion from 5.0.5 to 5.0.6 in /tools/eslint (dependabot\[bot]) [#63415](https://github.com/nodejs/node/pull/63415)
+* \[[`215cd543dd`](https://github.com/nodejs/node/commit/215cd543dd)] - **tools**: skip commit-lint on backport pull requests (Marco) [#63378](https://github.com/nodejs/node/pull/63378)
+* \[[`0479f28e95`](https://github.com/nodejs/node/commit/0479f28e95)] - **tools**: fix skip of `test-internet` on forks (Antoine du Hamel) [#63492](https://github.com/nodejs/node/pull/63492)
+* \[[`69dfadf785`](https://github.com/nodejs/node/commit/69dfadf785)] - **tools**: mock some Python utils in `v8.nix` to reuse builds (Antoine du Hamel) [#63454](https://github.com/nodejs/node/pull/63454)
+* \[[`7b3e222cda`](https://github.com/nodejs/node/commit/7b3e222cda)] - **util**: remove unused functions (Antoine du Hamel) [#63612](https://github.com/nodejs/node/pull/63612)
+* \[[`5a1f67c27b`](https://github.com/nodejs/node/commit/5a1f67c27b)] - **util**: create hex style cache and fast path (Guilherme Araújo) [#62999](https://github.com/nodejs/node/pull/62999)
+
## 2026-05-20, Version 26.2.0 (Current), @aduh95
diff --git a/lib/internal/errors.js b/lib/internal/errors.js
index ba632359fbc185..cc8ad3db3540f1 100644
--- a/lib/internal/errors.js
+++ b/lib/internal/errors.js
@@ -1321,6 +1321,8 @@ E('ERR_HTTP2_SOCKET_UNBOUND',
E('ERR_HTTP2_STATUS_101',
'HTTP status code 101 (Switching Protocols) is forbidden in HTTP/2', Error);
E('ERR_HTTP2_STATUS_INVALID', 'Invalid status code: %s', RangeError);
+E('ERR_HTTP2_STREAM_ABORTED',
+ 'The stream was reset before the readable side was fully consumed', Error);
E('ERR_HTTP2_STREAM_CANCEL', function(error) {
let msg = 'The pending stream has been canceled';
if (error) {
diff --git a/lib/internal/http2/compat.js b/lib/internal/http2/compat.js
index 6dd3b1e8be5c59..7b9524ef855988 100644
--- a/lib/internal/http2/compat.js
+++ b/lib/internal/http2/compat.js
@@ -158,11 +158,11 @@ function onStreamEnd() {
}
function onStreamError(error) {
- // This is purposefully left blank
- //
- // errors in compatibility mode are
- // not forwarded to the request
- // and response objects.
+ // Mirror HTTP/1's IncomingMessage._destroy: forward 'error' to req
+ // only when a listener is attached.
+ const request = this[kRequest];
+ if (request !== undefined && request.listenerCount('error') > 0)
+ request.emit('error', error);
}
function onRequestPause() {
@@ -460,7 +460,9 @@ function onStreamCloseResponse() {
this.removeListener('wantTrailers', onStreamTrailersReady);
this[kResponse] = undefined;
- res.emit('finish');
+ // Only emit 'finish' when the underlying writable actually finished
+ if (this.writableFinished)
+ res.emit('finish');
res.emit('close');
}
diff --git a/lib/internal/http2/core.js b/lib/internal/http2/core.js
index 1c6edd65cae8f0..7c009d60b95821 100644
--- a/lib/internal/http2/core.js
+++ b/lib/internal/http2/core.js
@@ -98,6 +98,7 @@ const {
ERR_HTTP2_SOCKET_UNBOUND,
ERR_HTTP2_STATUS_101,
ERR_HTTP2_STATUS_INVALID,
+ ERR_HTTP2_STREAM_ABORTED,
ERR_HTTP2_STREAM_CANCEL,
ERR_HTTP2_STREAM_ERROR,
ERR_HTTP2_STREAM_SELF_DEPENDENCY,
@@ -265,6 +266,8 @@ const kRequestAsyncResource = Symbol('requestAsyncResource');
const kSentHeaders = Symbol('sent-headers');
const kRawHeaders = Symbol('raw-headers');
const kSentTrailers = Symbol('sent-trailers');
+const kFilePipeFinalCb = Symbol('kFilePipeFinalCb');
+const kFilePipeReachedEOF = Symbol('kFilePipeReachedEOF');
const kServer = Symbol('server');
const kState = Symbol('state');
const kType = Symbol('type');
@@ -453,10 +456,17 @@ function onSessionHeaders(handle, id, cat, flags, headers, sensitiveHeaders) {
}
debugStream(id, type, "emitting stream '%s' event", event);
const reqAsync = stream[kRequestAsyncResource];
+ // Mirrors HTTP/1's `if (!req.emit('response', res)) res._dump();`:
+ // drain the readable when the response has no listener.
+ const dispatch = () => {
+ const hadListeners = stream.emit(event, obj, flags, headers);
+ if (event === 'response' && !hadListeners)
+ autoDrainReadable.call(stream);
+ };
if (reqAsync)
- reqAsync.runInAsyncScope(process.nextTick, null, emit, stream, event, obj, flags, headers);
+ reqAsync.runInAsyncScope(process.nextTick, null, dispatch);
else
- process.nextTick(emit, stream, event, obj, flags, headers);
+ process.nextTick(dispatch);
if ((event === 'response' ||
event === 'push') &&
onClientStreamFinishChannel.hasSubscribers) {
@@ -583,46 +593,90 @@ function onPing(payload) {
session.emit('ping', payload);
}
-// Called when the stream is closed either by sending or receiving an
-// RST_STREAM frame, or through a natural end-of-stream.
-// If the writable and readable sides of the stream are still open at this
-// point, close them. If there is an open fd for file send, close that also.
-// At this point the underlying node::http2:Http2Stream handle is no
-// longer usable so destroy it also.
-function onStreamClose(code) {
+// Fired by C++ when nghttp2's on_stream_close fires. `peerReset` is
+// true when the peer sent a RST_STREAM frame - peer RST_STREAM(NO_ERROR)
+// is otherwise indistinguishable from a clean END_STREAM exchange at
+// this layer.
+//
+// 'error' fires iff the reset was peer-initiated AND either:
+// * peer didn't END_STREAM (the consumer will never see 'end'), or
+// * peer signalled a non-clean code while writes were still in flight.
+// Clean code = NO_ERROR or CANCEL.
+//
+// Natural close defers destroy until 'end' and 'finish' - nghttp2 can
+// fire on_stream_close ahead of our last frame leaving the socket.
+function onStreamClose(code, peerReset) {
const stream = this[kOwner];
if (!stream || stream.destroyed)
return false;
debugStreamObj(
- stream, 'closed with code %d, closed %s, readable %s',
- code, stream.closed, stream.readable,
+ stream, 'closed code=%d peerReset=%s closed=%s',
+ code, peerReset, stream.closed,
);
- if (!stream.closed)
+ // stream.closed is set by closeStream; true here means we initiated.
+ const locallyInitiated = stream.closed;
+ if (!locallyInitiated)
closeStream(stream, code, kNoRstStream);
-
stream[kState].fd = -1;
- // Defer destroy we actually emit end.
- if (!stream.readable || code !== NGHTTP2_NO_ERROR) {
- // If errored or ended, we can destroy immediately.
+
+ if (locallyInitiated) {
stream.destroy();
- } else {
- // Wait for end to destroy.
- stream.on('end', stream[kMaybeDestroy]);
- // Push a null so the stream can end whenever the client consumes
- // it completely.
- stream.push(null);
+ return true;
+ }
- // If the user hasn't tried to consume the stream (and this is a server
- // session) then just dump the incoming data so that the stream can
- // be destroyed.
- if (stream[kSession][kType] === NGHTTP2_SESSION_SERVER &&
- !stream[kState].didRead &&
- stream.readableFlowing === null)
- stream.resume();
- else
- stream.read(0);
+ const peerReadableEnded = stream._readableState.ended;
+ const cleanCode = code === NGHTTP2_NO_ERROR || code === NGHTTP2_CANCEL;
+
+ if (!peerReadableEnded) {
+ stream.destroy(cleanCode ?
+ new ERR_HTTP2_STREAM_ABORTED() :
+ new ERR_HTTP2_STREAM_ERROR(nameForErrorCode[code] || code));
+ return true;
+ }
+
+ // Peer END_STREAMed - reads will complete. Only the writable half can
+ // still be in flight at the protocol layer.
+ if (peerReset) {
+ if (!cleanCode && !stream._writableState.finished) {
+ stream.destroy(
+ new ERR_HTTP2_STREAM_ERROR(nameForErrorCode[code] || code));
+ } else {
+ stream.destroy();
+ }
+ return true;
+ }
+
+ // Natural close: wait for 'end' and 'finish'. autoDestroy is disabled
+ // on Http2Stream, so an errored writable won't fire 'finish' and an
+ // errored readable won't fire 'end' - and on a Duplex a writable
+ // error propagates to readable.errored, blocking 'end' too. Treat
+ // either side's errored state as settled.
+ const readDone = () => stream._readableState.endEmitted ||
+ !!stream._readableState.errored;
+ const writeDone = () => stream._writableState.finished ||
+ !!stream._writableState.errored;
+ if (readDone() && writeDone()) {
+ stream.destroy();
+ return true;
+ }
+
+ const maybeDestroy = () => {
+ if (!stream.destroyed && readDone() && writeDone())
+ stream.destroy();
+ };
+ stream.once('end', maybeDestroy);
+ stream.once('finish', maybeDestroy);
+ stream.once('error', maybeDestroy);
+ if (stream[kSession][kType] === NGHTTP2_SESSION_SERVER &&
+ !stream[kState].didRead &&
+ stream.readableFlowing === null) {
+ // On the server, if you don't try to read the request body
+ // at all before the stream closes, we dump it, like HTTP/1.
+ stream.resume();
+ } else {
+ stream.read(0);
}
return true;
}
@@ -2006,37 +2060,47 @@ const kNoRstStream = 0;
const kSubmitRstStream = 1;
const kForceRstStream = 2;
+// HTTP/1-parity auto-drain: silently drain unread bytes off the wire
+// so the stream can close instead of zombieing. Used as a 'finish'
+// listener on server streams (mirrors req._dump from resOnFinish) and
+// called directly when a client stream's 'response' event has no
+// listener (mirrors res._dump from parserOnIncomingClient).
+function autoDrainReadable() {
+ if (!this[kState].didRead &&
+ this.readableFlowing === null &&
+ !this.destroyed) {
+ this.resume();
+ }
+}
+
function closeStream(stream, code, rstStreamStatus = kSubmitRstStream) {
const type = stream[kSession][kType];
const state = stream[kState];
state.flags |= STREAM_FLAGS_CLOSED;
state.rstCode = code;
- // Clear timeout and remove timeout listeners
stream.setTimeout(0);
stream.removeAllListeners('timeout');
+ // Emit 'aborted' if the user hadn't ended the writable yet. This is
+ // unusual and doesn't match 'error' but is deprecated & preserved for
+ // backward compat (DEP0207).
const { ending } = stream._writableState;
-
- if (!ending) {
- // If the writable side of the Http2Stream is still open, emit the
- // 'aborted' event and set the aborted flag.
- if (!stream.aborted) {
- state.flags |= STREAM_FLAGS_ABORTED;
- stream.emit('aborted');
- }
-
- // Close the writable side.
- stream.end();
+ if (!ending && !stream.aborted) {
+ state.flags |= STREAM_FLAGS_ABORTED;
+ stream.emit('aborted');
}
if (rstStreamStatus !== kNoRstStream) {
- const finishFn = finishCloseStream.bind(stream, code);
+ // If the user already called .end() and writes are still draining,
+ // wait for 'finish' so the in-flight bytes land before sending
+ // RST(NO_ERROR). Anything else submits immediately.
if (!ending || stream.writableFinished || code !== NGHTTP2_NO_ERROR ||
- rstStreamStatus === kForceRstStream)
- finishFn();
- else
- stream.once('finish', finishFn);
+ rstStreamStatus === kForceRstStream) {
+ finishCloseStream.call(stream, code);
+ } else {
+ stream.once('finish', finishCloseStream.bind(stream, code));
+ }
}
if (type === NGHTTP2_SESSION_CLIENT) {
@@ -2050,11 +2114,8 @@ function closeStream(stream, code, rstStreamStatus = kSubmitRstStream) {
function finishCloseStream(code) {
const rstStreamFn = submitRstStream.bind(this, code);
- // If the handle has not yet been assigned, queue up the request to
- // ensure that the RST_STREAM frame is sent after the stream ID has
- // been determined.
+ // Defer until the stream has an ID assigned.
if (this.pending) {
- this.push(null);
this.once('ready', rstStreamFn);
return;
}
@@ -2469,6 +2530,7 @@ class Http2Stream extends Duplex {
closeStream(this, code, hasHandle ? kForceRstStream : kNoRstStream);
this.push(null);
+
if (hasHandle) {
handle.destroy();
sessionState.streams.delete(id);
@@ -2480,9 +2542,8 @@ class Http2Stream extends Duplex {
sessionState.writeQueueSize -= state.writeQueueSize;
state.writeQueueSize = 0;
- // RST code 8 not emitted as an error as its used by clients to signify
- // abort and is already covered by aborted event, also allows more
- // seamless compatibility with http1
+ // Synthesize an error for non-zero RST codes if the caller didn't
+ // pass one (e.g. direct stream.destroy() following a peer error).
if (err == null && code !== NGHTTP2_NO_ERROR && code !== NGHTTP2_CANCEL)
err = new ERR_HTTP2_STREAM_ERROR(nameForErrorCode[code] || code);
@@ -2688,13 +2749,23 @@ function onFileUnpipe() {
this.source.close().catch(stream.destroy.bind(stream));
else
this.source.releaseFD();
+
+ // Resolve the deferred _final callback only if we actually
+ // reached the EOF. Otherwise we close with no finish.
+ const cb = stream[kFilePipeFinalCb];
+ if (cb && stream[kFilePipeReachedEOF]) {
+ stream[kFilePipeFinalCb] = undefined;
+ cb();
+ }
}
// This is only called once the pipe has returned back control, so
// it only has to handle errors and End-of-File.
function onPipedFileHandleRead() {
const err = streamBaseState[kReadBytesOrError];
- if (err < 0 && err !== UV_EOF) {
+ if (err === UV_EOF) {
+ this.stream[kFilePipeReachedEOF] = true;
+ } else if (err < 0) {
this.stream.close(NGHTTP2_INTERNAL_ERROR);
}
}
@@ -2719,9 +2790,12 @@ function processRespondWithFD(self, fd, headers, offset = 0, length = -1,
}
self[kSentHeaders] = headers;
- // Close the writable side of the stream, but only as far as the writable
- // stream implementation is concerned.
- self._final = null;
+ // To make 'finish'/writableFinished work correctly with the C++ file
+ // pipe, we defer the callback here, and call it from onFileUnpipe.
+ self._final = (cb) => {
+ self[kFilePipeFinalCb] = cb;
+ };
+ self[kFilePipeReachedEOF] = false;
self.end();
const ret = self[kHandle].respond(headersList, streamOptions);
@@ -2885,6 +2959,7 @@ class ServerHttp2Stream extends Http2Stream {
this[kInit](id, handle);
this[kProtocol] = headers[HTTP2_HEADER_SCHEME];
this[kAuthority] = getAuthority(headers);
+ this.once('finish', autoDrainReadable);
}
// True if the remote peer accepts push streams
diff --git a/src/node_http2.cc b/src/node_http2.cc
index 55bca557a81e4d..f90fb612db681f 100644
--- a/src/node_http2.cc
+++ b/src/node_http2.cc
@@ -1101,6 +1101,16 @@ int Http2Session::OnFrameReceive(nghttp2_session* handle,
case NGHTTP2_HEADERS:
session->HandleHeadersFrame(frame);
break;
+ case NGHTTP2_RST_STREAM:
+ // Stamp the stream so JS onStreamClose can tell a peer reset apart
+ // from a clean close — both surface as on_stream_close, and a peer
+ // RST_STREAM(NO_ERROR) is otherwise indistinguishable from a natural
+ // close at the on_stream_close layer.
+ if (BaseObjectPtr stream =
+ session->FindStream(frame->hd.stream_id)) {
+ stream->set_peer_reset();
+ }
+ break;
case NGHTTP2_SETTINGS:
session->HandleSettingsFrame(frame);
break;
@@ -1310,9 +1320,12 @@ int Http2Session::OnStreamClose(nghttp2_session* handle,
// ever passed on to the javascript side. If that happens, the callback
// will return false.
if (env->can_call_into_js()) {
- Local arg = Integer::NewFromUnsigned(isolate, code);
+ Local argv[2] = {
+ Integer::NewFromUnsigned(isolate, code),
+ Boolean::New(isolate, stream->peer_reset()),
+ };
MaybeLocal answer = stream->MakeCallback(
- env->http2session_on_stream_close_function(), 1, &arg);
+ env->http2session_on_stream_close_function(), arraysize(argv), argv);
if (answer.IsEmpty() || answer.ToLocalChecked()->IsFalse()) {
// Skip to destroy
stream->Destroy();
diff --git a/src/node_http2.h b/src/node_http2.h
index 28245d7b98e06b..7ebae09a4b333e 100644
--- a/src/node_http2.h
+++ b/src/node_http2.h
@@ -65,6 +65,7 @@ constexpr int kStreamStateReadPaused = 0x4;
constexpr int kStreamStateClosed = 0x8;
constexpr int kStreamStateDestroyed = 0x10;
constexpr int kStreamStateTrailers = 0x20;
+constexpr int kStreamStatePeerReset = 0x40;
// Http2Session internal states
constexpr int kSessionStateNone = 0x0;
@@ -347,6 +348,15 @@ class Http2Stream : public AsyncWrap,
return flags_ & kStreamStateClosed;
}
+ // True iff a RST_STREAM frame was received from the peer for this stream.
+ // Set by Http2Session::OnFrameReceive on NGHTTP2_RST_STREAM. Used by JS
+ // onStreamClose to distinguish a peer-initiated reset from a clean
+ // bidirectional END_STREAM exchange (both surface to JS with the same
+ // nghttp2 close code when the peer sent RST_STREAM(NO_ERROR)).
+ bool peer_reset() const { return flags_ & kStreamStatePeerReset; }
+
+ void set_peer_reset() { flags_ |= kStreamStatePeerReset; }
+
bool has_trailers() const {
return flags_ & kStreamStateTrailers;
}
diff --git a/test/parallel/test-http2-client-auto-discard-no-response-listener.js b/test/parallel/test-http2-client-auto-discard-no-response-listener.js
new file mode 100644
index 00000000000000..e9f414dfb1f786
--- /dev/null
+++ b/test/parallel/test-http2-client-auto-discard-no-response-listener.js
@@ -0,0 +1,49 @@
+'use strict';
+
+// HTTP/1 parity for fire-and-forget clients: when no 'response'
+// listener is attached at the time response headers arrive, the
+// response body is silently drained (mirrors res._dump from
+// parserOnIncomingClient). After that drains, 'end' fires and the
+// stream closes cleanly — no 'aborted', no 'error'.
+
+const common = require('../common');
+if (!common.hasCrypto)
+ common.skip('missing crypto');
+const assert = require('assert');
+const http2 = require('http2');
+
+const server = http2.createServer();
+
+server.on('stream', common.mustCall((stream) => {
+ // Send a non-trivial response body so the client's readable buffer
+ // would actually contain something to discard.
+ stream.respond({ ':status': 200 });
+ stream.end(Buffer.alloc(16 * 1024));
+}));
+
+server.listen(0, common.mustCall(() => {
+ const client = http2.connect(`http://localhost:${server.address().port}`);
+ const cs = client.request({ ':method': 'GET' });
+
+ // Deliberately do NOT attach a 'response' listener — that's the
+ // condition that triggers H1's `_dump` and our parallel auto-discard.
+ // Adding a listener here (even a `common.mustNotCall`) would make
+ // listenerCount > 0 and skip the auto-discard.
+
+ cs.on('aborted', common.mustNotCall(
+ "'aborted' must not fire for fire-and-forget clients"));
+ cs.on('error', common.mustNotCall(
+ "'error' must not fire for fire-and-forget clients"));
+
+ // 'end' must fire once auto-discard resumes the readable.
+ cs.on('end', common.mustCall());
+
+ cs.on('close', common.mustCall(() => {
+ assert.strictEqual(cs.aborted, false);
+ assert.strictEqual(cs.destroyed, true);
+ client.close();
+ server.close();
+ }));
+
+ cs.end();
+}));
diff --git a/test/parallel/test-http2-client-rststream-before-connect.js b/test/parallel/test-http2-client-rststream-before-connect.js
index c0b59551d48232..90b2e5be93c773 100644
--- a/test/parallel/test-http2-client-rststream-before-connect.js
+++ b/test/parallel/test-http2-client-rststream-before-connect.js
@@ -71,9 +71,9 @@ server.listen(0, common.mustCall(() => {
// RST_STREAM frame before it ever has a chance to reply.
req.on('response', common.mustNotCall());
- // The `end` event should still fire as we close the readable stream by
- // pushing a `null` chunk.
- req.on('end', common.mustCall());
+ // Any non-clean local close triggers an 'error', and the readable's
+ // errored state blocks 'end' - matching HTTP/1 ECONNRESET behaviour.
+ req.on('end', common.mustNotCall());
req.resume();
req.end();
diff --git a/test/parallel/test-http2-close-while-writing.js b/test/parallel/test-http2-close-while-writing.js
index c0a05c4a8da21a..17931005dc6cd9 100644
--- a/test/parallel/test-http2-close-while-writing.js
+++ b/test/parallel/test-http2-close-while-writing.js
@@ -1,6 +1,7 @@
'use strict';
// https://github.com/nodejs/node/issues/33156
const common = require('../common');
+const assert = require('assert');
const fixtures = require('../common/fixtures');
if (!common.hasCrypto) {
@@ -23,6 +24,11 @@ let client_stream;
server.on('session', common.mustCall(function(session) {
session.on('stream', common.mustCall(function(stream) {
+ // Client destroys mid-stream without END_STREAM (clean RST code).
+ // Peer reset before END_STREAM surfaces as ERR_HTTP2_STREAM_ABORTED.
+ stream.on('error', common.mustCall((err) => {
+ assert.strictEqual(err.code, 'ERR_HTTP2_STREAM_ABORTED');
+ }));
stream.resume();
stream.on('data', function() {
this.write(Buffer.alloc(1));
diff --git a/test/parallel/test-http2-compat-errors.js b/test/parallel/test-http2-compat-errors.js
index 18dc385422a48e..0b37eaed8d00cd 100644
--- a/test/parallel/test-http2-compat-errors.js
+++ b/test/parallel/test-http2-compat-errors.js
@@ -3,16 +3,20 @@
const common = require('../common');
if (!common.hasCrypto)
common.skip('missing crypto');
+const assert = require('assert');
const h2 = require('http2');
-// Errors should not be reported both in Http2ServerRequest
-// and Http2ServerResponse
+// Errors on the underlying stream surface on Http2ServerRequest
let expected = null;
const server = h2.createServer(common.mustCall(function(req, res) {
- res.stream.on('error', common.mustCall());
- req.on('error', common.mustNotCall());
+ res.stream.on('error', common.mustCall((err) => {
+ assert.strictEqual(err, expected);
+ }));
+ req.on('error', common.mustCall((err) => {
+ assert.strictEqual(err, expected);
+ }));
res.on('error', common.mustNotCall());
req.on('aborted', common.mustCall());
res.on('aborted', common.mustNotCall());
diff --git a/test/parallel/test-http2-compat-serverresponse-destroy.js b/test/parallel/test-http2-compat-serverresponse-destroy.js
index 1154b69df41548..40b0a819eb8637 100644
--- a/test/parallel/test-http2-compat-serverresponse-destroy.js
+++ b/test/parallel/test-http2-compat-serverresponse-destroy.js
@@ -16,10 +16,13 @@ const errors = [
let nextError;
const server = http2.createServer(common.mustCall((req, res) => {
- req.on('error', common.mustNotCall());
+ if (req.url !== '/')
+ req.on('error', common.mustCall());
+ else
+ req.on('error', common.mustNotCall());
res.on('error', common.mustNotCall());
- res.on('finish', common.mustCall(() => {
+ res.on('close', common.mustCall(() => {
res.destroy(nextError);
process.nextTick(() => {
res.destroy(nextError);
@@ -44,8 +47,14 @@ server.listen(0, common.mustCall(() => {
{
const req = client.request();
req.on('response', common.mustNotCall());
- req.on('error', common.mustNotCall());
- req.on('end', common.mustCall());
+ // Peer sends RST(NO_ERROR) before END_STREAM — surfaces as
+ // ERR_HTTP2_STREAM_ABORTED, matching HTTP/1's ECONNRESET on a
+ // peer-side socket.destroy().
+ req.on('error', common.expectsError({
+ code: 'ERR_HTTP2_STREAM_ABORTED',
+ name: 'Error',
+ }));
+ req.on('end', common.mustNotCall());
req.on('close', common.mustCall(() => countdown.dec()));
req.resume();
}
diff --git a/test/parallel/test-http2-compat-serverresponse-headers-after-destroy.js b/test/parallel/test-http2-compat-serverresponse-headers-after-destroy.js
index fc97a70f42d956..81e1977a788c7b 100644
--- a/test/parallel/test-http2-compat-serverresponse-headers-after-destroy.js
+++ b/test/parallel/test-http2-compat-serverresponse-headers-after-destroy.js
@@ -13,7 +13,7 @@ const server = h2.createServer();
server.listen(0, common.mustCall(function() {
const port = server.address().port;
server.once('request', common.mustCall(function(request, response) {
- response.on('finish', common.mustCall(() => {
+ response.on('close', common.mustCall(() => {
assert.strictEqual(response.headersSent, false);
response.setHeader('test', 'value');
response.removeHeader('test', 'value');
@@ -38,7 +38,8 @@ server.listen(0, common.mustCall(function() {
':authority': `localhost:${port}`
};
const request = client.request(headers);
- request.on('end', common.mustCall(function() {
+ request.on('error', () => {});
+ request.on('close', common.mustCall(function() {
client.close();
}));
request.end();
diff --git a/test/parallel/test-http2-compat-socket-destroy-delayed.js b/test/parallel/test-http2-compat-socket-destroy-delayed.js
index 62405047d8266e..0485604d63c7af 100644
--- a/test/parallel/test-http2-compat-socket-destroy-delayed.js
+++ b/test/parallel/test-http2-compat-socket-destroy-delayed.js
@@ -7,7 +7,6 @@ if (!common.hasCrypto)
common.skip('missing crypto');
const http2 = require('http2');
-const assert = require('assert');
const {
HTTP2_HEADER_PATH,
@@ -30,10 +29,13 @@ app.listen(0, mustCall(() => {
[HTTP2_HEADER_METHOD]: 'get'
});
request.once('response', mustCall((headers, flags) => {
- let data = '';
- request.on('data', (chunk) => { data += chunk; });
- request.on('end', mustCall(() => {
- assert.strictEqual(data, 'hello');
+ request.on('data', () => {});
+ // setImmediate(socket.destroy) after res.end races with the response
+ // bytes reaching the client; if they don't make it the reset surfaces
+ // as 'error'. Test only checks that destroy-via-setImmediate doesn't
+ // crash, so accept either path.
+ request.on('error', () => {});
+ request.on('close', mustCall(() => {
session.close();
app.close();
}));
diff --git a/test/parallel/test-http2-compat-socket-set.js b/test/parallel/test-http2-compat-socket-set.js
index e8b804858d65bd..8612e63bff5ce3 100644
--- a/test/parallel/test-http2-compat-socket-set.js
+++ b/test/parallel/test-http2-compat-socket-set.js
@@ -87,7 +87,8 @@ server.listen(0, common.mustCall(function() {
':authority': `localhost:${port}`
};
const request = client.request(headers);
- request.on('end', common.mustCall(() => {
+ request.on('error', () => {});
+ request.on('close', common.mustCall(() => {
client.close();
server.close();
}));
diff --git a/test/parallel/test-http2-compat-socket.js b/test/parallel/test-http2-compat-socket.js
index 95bc42180ef8e9..392e9205eb386c 100644
--- a/test/parallel/test-http2-compat-socket.js
+++ b/test/parallel/test-http2-compat-socket.js
@@ -51,7 +51,7 @@ server.on('request', common.mustCall(function(request, response) {
assert.strictEqual(request.socket.readable, false);
response.socket.destroy();
}));
- response.on('finish', common.mustCall(() => {
+ response.on('close', common.mustCall(() => {
assert.ok(request.socket);
assert.strictEqual(response.socket, undefined);
assert.ok(request.socket.destroyed);
@@ -84,7 +84,8 @@ server.listen(0, common.mustCall(function() {
':authority': `localhost:${port}`
};
const request = client.request(headers);
- request.on('end', common.mustCall(() => {
+ request.on('error', () => {});
+ request.on('close', common.mustCall(() => {
client.close();
}));
request.end();
diff --git a/test/parallel/test-http2-compat-write-head-after-close.js b/test/parallel/test-http2-compat-write-head-after-close.js
index 541973f5dbc5c6..2d2651b370f919 100644
--- a/test/parallel/test-http2-compat-write-head-after-close.js
+++ b/test/parallel/test-http2-compat-write-head-after-close.js
@@ -15,7 +15,8 @@ server.listen(0, common.mustCall(() => {
const client = h2.connect(`http://localhost:${port}`);
const req = client.request({ ':path': '/' });
req.on('response', common.mustNotCall('head after close should not be sent'));
- req.on('end', common.mustCall(() => {
+ req.on('error', () => {});
+ req.on('close', common.mustCall(() => {
client.close();
server.close();
}));
diff --git a/test/parallel/test-http2-compat-write-head-destroyed.js b/test/parallel/test-http2-compat-write-head-destroyed.js
index 842bf0e9abffb0..9ab1228c243779 100644
--- a/test/parallel/test-http2-compat-write-head-destroyed.js
+++ b/test/parallel/test-http2-compat-write-head-destroyed.js
@@ -21,6 +21,7 @@ server.listen(0, common.mustCall(() => {
const req = client.request();
req.on('response', common.mustNotCall());
+ req.on('error', () => {});
req.on('close', common.mustCall((arg) => {
client.close();
server.close();
diff --git a/test/parallel/test-http2-destroy-after-write.js b/test/parallel/test-http2-destroy-after-write.js
index 40fae332c18baa..73ee3ec3c0d23f 100644
--- a/test/parallel/test-http2-destroy-after-write.js
+++ b/test/parallel/test-http2-destroy-after-write.js
@@ -28,6 +28,7 @@ server.listen(0, common.mustCall(() => {
stream.on('response', common.mustCall(function(headers) {
assert.strictEqual(headers[':status'], 200);
}));
+ stream.on('error', () => {});
stream.on('close', common.mustCall(() => {
client.close();
server.close();
diff --git a/test/parallel/test-http2-large-write-destroy.js b/test/parallel/test-http2-large-write-destroy.js
index b59c66bb04755b..e4fe6c6d49e80e 100644
--- a/test/parallel/test-http2-large-write-destroy.js
+++ b/test/parallel/test-http2-large-write-destroy.js
@@ -34,6 +34,7 @@ server.listen(0, common.mustCall(() => {
req.end();
req.resume(); // Otherwise close won't be emitted if there's pending data.
+ req.on('error', () => {});
req.on('close', common.mustCall(() => {
client.close();
server.close();
diff --git a/test/parallel/test-http2-many-writes-and-destroy.js b/test/parallel/test-http2-many-writes-and-destroy.js
index 51ec119b6295c3..3c3256073da1a1 100644
--- a/test/parallel/test-http2-many-writes-and-destroy.js
+++ b/test/parallel/test-http2-many-writes-and-destroy.js
@@ -7,6 +7,9 @@ const http2 = require('http2');
{
const server = http2.createServer((req, res) => {
+ // Peer destroys mid-stream, so we see errors here:
+ req.on('error', () => {});
+ res.on('error', () => {});
req.pipe(res);
});
diff --git a/test/parallel/test-http2-misused-pseudoheaders.js b/test/parallel/test-http2-misused-pseudoheaders.js
index ff4ae541423dce..21627f1e23a901 100644
--- a/test/parallel/test-http2-misused-pseudoheaders.js
+++ b/test/parallel/test-http2-misused-pseudoheaders.js
@@ -40,9 +40,12 @@ server.listen(0, common.mustCall(() => {
const client = h2.connect(`http://localhost:${server.address().port}`);
const req = client.request();
+ // Server uses waitForTrailers, fails to send valid trailers, then
+ // close()s — peer reset arrives before END_STREAM, surfacing as
+ // 'error' on the client.
req.on('response', common.mustCall());
req.resume();
- req.on('end', common.mustCall());
+ req.on('error', () => {});
req.on('close', common.mustCall(() => {
server.close();
client.close();
diff --git a/test/parallel/test-http2-reset-aborts-readable-not-drained.js b/test/parallel/test-http2-reset-aborts-readable-not-drained.js
new file mode 100644
index 00000000000000..b436e96a22e666
--- /dev/null
+++ b/test/parallel/test-http2-reset-aborts-readable-not-drained.js
@@ -0,0 +1,64 @@
+'use strict';
+
+// When the peer sends RST_STREAM without first END_STREAMing, the
+// consumer will never see 'end' - surface that as 'aborted' +
+// 'error' + 'close' (no 'end', no 'finish').
+
+const common = require('../common');
+if (!common.hasCrypto)
+ common.skip('missing crypto');
+const assert = require('assert');
+const http2 = require('http2');
+const { NGHTTP2_NO_ERROR } = http2.constants;
+
+const server = http2.createServer();
+
+server.on('stream', common.mustCall((stream) => {
+ let abortedFiredAt = -1;
+ let errorFiredAt = -1;
+ let closeFiredAt = -1;
+ let tick = 0;
+
+ stream.on('aborted', common.mustCall(() => {
+ abortedFiredAt = ++tick;
+ // 'aborted' fires while the stream is still alive but flagged.
+ assert.strictEqual(stream.aborted, true);
+ }));
+
+ stream.on('error', common.mustCall((err) => {
+ errorFiredAt = ++tick;
+ assert.ok(err && typeof err.code === 'string' &&
+ err.code.startsWith('ERR_HTTP2_'),
+ `expected an ERR_HTTP2_* error, got ${err?.code}`);
+ }));
+
+ stream.on('end', common.mustNotCall(
+ "'end' must not fire - readable was not drained at reset"));
+ stream.on('finish', common.mustNotCall(
+ "'finish' must not fire - stream was reset, not ended"));
+
+ stream.on('close', common.mustCall(() => {
+ closeFiredAt = ++tick;
+ assert.strictEqual(stream.aborted, true);
+ assert.strictEqual(stream.destroyed, true);
+ assert.ok(abortedFiredAt !== -1, "'aborted' must fire before 'close'");
+ assert.ok(errorFiredAt !== -1, "'error' must fire before 'close'");
+ assert.ok(abortedFiredAt < closeFiredAt &&
+ errorFiredAt < closeFiredAt,
+ `'close' must come last (aborted=${abortedFiredAt} ` +
+ `error=${errorFiredAt} close=${closeFiredAt})`);
+ server.close();
+ }));
+}));
+
+server.listen(0, common.mustCall(() => {
+ const client = http2.connect(`http://localhost:${server.address().port}`);
+
+ const cs = client.request({ ':method': 'POST' });
+ cs.on('close', common.mustCall(() => client.close()));
+
+ cs.write('payload that will never be consumed');
+ // Send RST_STREAM. Note we deliberately do NOT call cs.end() - the
+ // server will see no END_STREAM, only the reset.
+ cs.close(NGHTTP2_NO_ERROR);
+}));
diff --git a/test/parallel/test-http2-reset-happy-close.js b/test/parallel/test-http2-reset-happy-close.js
new file mode 100644
index 00000000000000..d4778de8c81d97
--- /dev/null
+++ b/test/parallel/test-http2-reset-happy-close.js
@@ -0,0 +1,120 @@
+'use strict';
+
+// A peer RST_STREAM that arrives AFTER our readable has fully drained
+// AND while writes are still in flight: 'finish' must not fire (writes
+// were aborted) but every pending _write callback eventually fires
+// (success for writes that made it onto the wire, ECANCELED for those
+// still queued at reset time). 'aborted' fires per the legacy criterion
+// since the writable was still open at close - see DEP0207.
+//
+// Whether 'error' fires depends on the peer's reset code:
+// - Clean codes (NO_ERROR/CANCEL): no 'error' — the cancel is treated
+// as a clean cancellation, not a failure.
+// - Other codes: 'error' fires with ERR_HTTP2_STREAM_ERROR.
+
+const common = require('../common');
+if (!common.hasCrypto)
+ common.skip('missing crypto');
+const assert = require('assert');
+const http2 = require('http2');
+const {
+ NGHTTP2_NO_ERROR,
+ NGHTTP2_INTERNAL_ERROR,
+} = http2.constants;
+
+const N_WRITES = 16;
+const CHUNK_BYTES = 8192;
+// 16 * 8 KiB = 128 KiB, larger than the default 64 KiB initial flow-
+// control window - so even if the OS socket buffer accepts everything
+// instantly, the server's nghttp2 will hold some chunks in its outbound
+// queue (waiting on a WINDOW_UPDATE that the client will never send).
+// That guarantees we exercise the stranded-writes path even when the
+// client RSTs after consuming HEADERS.
+
+function runScenario(rstCode, expectedErrorCode, next) {
+ const server = http2.createServer();
+
+ server.on('stream', common.mustCall((stream) => {
+ // Drain the request body fully.
+ stream.resume();
+ stream.on('end', common.mustCall(() => {
+ // Readable is drained. Now begin streaming a response that is too
+ // large to land before the client RSTs.
+ stream.respond({ ':status': 200 });
+
+ let writeCbCount = 0;
+ for (let i = 0; i < N_WRITES; i++) {
+ stream.write(Buffer.alloc(CHUNK_BYTES), () => { writeCbCount++; });
+ }
+
+ stream.on('aborted', common.mustCall(() => {
+ assert.strictEqual(stream.aborted, true);
+ }));
+ stream.on('finish', common.mustNotCall(
+ "'finish' must not fire when writable was aborted by reset"));
+
+ if (expectedErrorCode === null) {
+ stream.on('error', common.mustNotCall(
+ "'error' must not fire for a clean-code peer reset"));
+ } else {
+ stream.on('error', common.mustCall((err) => {
+ assert.strictEqual(err.code, expectedErrorCode);
+ }));
+ }
+
+ stream.on('close', common.mustCall(() => {
+ // 'aborted' fires per the legacy criterion (writable was still
+ // open at reset).
+ assert.strictEqual(stream.aborted, true);
+ // 'finish' must not have been emitted (writes were aborted).
+ assert.strictEqual(stream.writableFinished, false);
+ assert.strictEqual(stream.destroyed, true);
+ // The C++ Http2Stream::Destroy SetImmediate flushes any
+ // still-queued WriteWraps with UV_ECANCELED, which fires the
+ // remaining `_write` callbacks. Those land *after* 'close';
+ // verify the full set has fired by the next two immediates.
+ setImmediate(common.mustCall(() => setImmediate(common.mustCall(() => {
+ assert.strictEqual(writeCbCount, N_WRITES,
+ `all ${N_WRITES} write callbacks must fire ` +
+ `(got ${writeCbCount})`);
+ server.close(next);
+ }))));
+ }));
+ }));
+ }));
+
+ server.listen(0, common.mustCall(() => {
+ const client = http2.connect(`http://localhost:${server.address().port}`);
+
+ const cs = client.request({ ':method': 'POST' });
+ cs.on('close', common.mustCall(() => client.close()));
+
+ // Local close() with a non-clean code synthesizes 'error' in
+ // _destroy; clean codes are silent. Assert exactly the expected
+ // shape on the client side too.
+ if (expectedErrorCode === null) {
+ cs.on('error', common.mustNotCall(
+ "'error' must not fire on locally-initiated close with clean code"));
+ } else {
+ cs.on('error', common.mustCall((err) => {
+ assert.strictEqual(err.code, expectedErrorCode);
+ }));
+ }
+
+ // Tiny request body; end immediately so the server's readable drains
+ // cleanly before we reset.
+ cs.end(Buffer.alloc(8));
+
+ cs.on('response', common.mustCall(() => {
+ // We've seen the response HEADERS; the response body may or may not
+ // have started arriving. RST without consuming any of it.
+ cs.close(rstCode);
+ }));
+ }));
+}
+
+// Clean code: no 'error'.
+runScenario(NGHTTP2_NO_ERROR, null, () => {
+ // Non-clean code: 'error' fires with ERR_HTTP2_STREAM_ERROR.
+ runScenario(NGHTTP2_INTERNAL_ERROR, 'ERR_HTTP2_STREAM_ERROR', () => {});
+});
diff --git a/test/parallel/test-http2-respond-errors.js b/test/parallel/test-http2-respond-errors.js
index cc733b6994a3bd..c578a619b84758 100644
--- a/test/parallel/test-http2-respond-errors.js
+++ b/test/parallel/test-http2-respond-errors.js
@@ -43,7 +43,8 @@ server.listen(0, common.mustCall(() => {
const client = http2.connect(`http://localhost:${server.address().port}`);
const req = client.request();
- req.on('end', common.mustCall(() => {
+ req.on('error', () => {});
+ req.on('close', common.mustCall(() => {
client.close();
server.close();
}));
diff --git a/test/parallel/test-http2-respond-file-errors.js b/test/parallel/test-http2-respond-file-errors.js
index 5c3424f2bc3484..46d29273ae0317 100644
--- a/test/parallel/test-http2-respond-file-errors.js
+++ b/test/parallel/test-http2-respond-file-errors.js
@@ -94,6 +94,7 @@ server.listen(0, common.mustCall(() => {
const client = http2.connect(`http://localhost:${server.address().port}`);
const req = client.request();
+ req.on('error', () => {});
req.on('close', common.mustCall(() => {
client.close();
server.close();
diff --git a/test/parallel/test-http2-respond-file-fd-errors.js b/test/parallel/test-http2-respond-file-fd-errors.js
index 5f7e57ea4e45d2..27838f3f3c2cda 100644
--- a/test/parallel/test-http2-respond-file-fd-errors.js
+++ b/test/parallel/test-http2-respond-file-fd-errors.js
@@ -117,6 +117,7 @@ server.listen(0, common.mustCall(() => {
const client = http2.connect(`http://localhost:${server.address().port}`);
const req = client.request();
+ req.on('error', () => {});
req.on('close', common.mustCall(() => {
client.close();
server.close();
diff --git a/test/parallel/test-http2-respond-with-fd-finish.js b/test/parallel/test-http2-respond-with-fd-finish.js
new file mode 100644
index 00000000000000..c93858b9aac0b1
--- /dev/null
+++ b/test/parallel/test-http2-respond-with-fd-finish.js
@@ -0,0 +1,100 @@
+'use strict';
+
+// respondWithFD's 'finish' event must reflect actual file-pipe completion
+// (matching every other Writable), not the moment respondWithFD was called.
+//
+// - Clean EOF: 'finish' fires, writableFinished=true.
+// - Aborted before pipe completes: 'finish' does NOT fire,
+// writableFinished stays false. ('aborted' is preserved per its
+// legacy criterion: writable was 'ending' at close, so it doesn't
+// fire either way.)
+
+const common = require('../common');
+if (!common.hasCrypto)
+ common.skip('missing crypto');
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const http2 = require('http2');
+const tmpdir = require('../common/tmpdir');
+
+tmpdir.refresh();
+
+function testCleanCompletion(next) {
+ const smallPath = path.join(tmpdir.path, 'small.bin');
+ const smallSize = 64;
+ fs.writeFileSync(smallPath, Buffer.alloc(smallSize));
+
+ const server = http2.createServer();
+ server.on('stream', common.mustCall((stream) => {
+ stream.on('finish', common.mustCall(() => {
+ assert.strictEqual(stream.writableFinished, true);
+ }));
+ stream.on('error', common.mustNotCall());
+ stream.on('close', common.mustCall(() => {
+ assert.strictEqual(stream.writableFinished, true);
+ }));
+ const fd = fs.openSync(smallPath, 'r');
+ stream.ownsFd = true;
+ stream.respondWithFD(fd, { 'content-length': smallSize });
+ }));
+
+ // Compat 'finish' fires only on real completion too.
+ server.on('request', common.mustCall((req, res) => {
+ res.on('finish', common.mustCall());
+ }));
+
+ server.listen(0, common.mustCall(() => {
+ const client = http2.connect(`http://localhost:${server.address().port}`);
+ const req = client.request();
+ req.resume();
+ req.on('end', common.mustCall());
+ req.on('close', common.mustCall(() => {
+ client.close();
+ server.close(next);
+ }));
+ }));
+}
+
+function testAbortBeforeCompletion() {
+ const smallPath = path.join(tmpdir.path, 'small-abort.bin');
+ const smallSize = 64;
+ fs.writeFileSync(smallPath, Buffer.alloc(smallSize));
+
+ const server = http2.createServer();
+ server.on('stream', common.mustCall((stream) => {
+ stream.on('finish', common.mustNotCall(
+ "'finish' must not fire on aborted file pipe"));
+ stream.on('aborted', common.mustNotCall(
+ "'aborted' must not fire - preserving deprecated legacy behaviour"));
+ stream.on('error', () => {});
+ stream.on('close', common.mustCall(() => {
+ // writableFinished must reflect actual pipe completion.
+ assert.strictEqual(stream.writableFinished, false);
+ }));
+ const fd = fs.openSync(smallPath, 'r');
+ stream.ownsFd = true;
+ stream.respondWithFD(fd, { 'content-length': smallSize });
+ // Synchronously interrupt the pipe before it can read EOF.
+ stream.destroy();
+ }));
+
+ // Compat 'finish' must not fire on aborted pipe either.
+ server.on('request', common.mustCall((req, res) => {
+ res.on('finish', common.mustNotCall(
+ "compat res 'finish' must not fire on aborted file pipe"));
+ res.on('close', common.mustCall());
+ }));
+
+ server.listen(0, common.mustCall(() => {
+ const client = http2.connect(`http://localhost:${server.address().port}`);
+ const req = client.request();
+ req.on('error', () => {});
+ req.on('close', common.mustCall(() => {
+ client.close();
+ server.close();
+ }));
+ }));
+}
+
+testCleanCompletion(testAbortBeforeCompletion);
diff --git a/test/parallel/test-http2-server-respond-without-reading-body.js b/test/parallel/test-http2-server-respond-without-reading-body.js
new file mode 100644
index 00000000000000..750d83cf2c600b
--- /dev/null
+++ b/test/parallel/test-http2-server-respond-without-reading-body.js
@@ -0,0 +1,51 @@
+'use strict';
+
+// Server-side auto-drain (mirrors HTTP/1's req._dump): when a server
+// finishes its response without ever consuming the request body, the
+// body is silently drained on 'finish' so 'end' can fire and the
+// stream closes cleanly - no 'aborted', no 'error'.
+
+const common = require('../common');
+if (!common.hasCrypto)
+ common.skip('missing crypto');
+const assert = require('assert');
+const http2 = require('http2');
+
+const server = http2.createServer();
+
+server.on('stream', common.mustCall((stream, headers) => {
+ // Deliberately do NOT read the request body. Just respond and end.
+ // The server-side auto-drain should kick in on 'finish' and pull
+ // the body off the wire so 'end' can fire.
+ stream.respond({ ':status': 200 });
+ stream.end('ok');
+
+ // 'end' on the readable must fire - the auto-drain doing its job.
+ stream.on('end', common.mustCall(() => {
+ assert.strictEqual(stream._readableState.endEmitted, true);
+ }));
+
+ stream.on('aborted', common.mustNotCall(
+ "'aborted' must not fire when the auto-drain succeeded"));
+ stream.on('error', common.mustNotCall(
+ "'error' must not fire on a clean response without reading the body"));
+
+ stream.on('close', common.mustCall(() => {
+ // stream.aborted must be false after auto-drain.
+ assert.strictEqual(stream.aborted, false);
+ assert.strictEqual(stream.destroyed, true);
+ server.close();
+ }));
+}));
+
+server.listen(0, common.mustCall(() => {
+ const client = http2.connect(`http://localhost:${server.address().port}`);
+
+ // Send a non-trivial request body so there's actual data buffered on
+ // the server side before the auto-drain runs. Without the drain,
+ // `endEmitted` would stay false until the buffer is consumed.
+ const cs = client.request({ ':method': 'POST' });
+ cs.resume();
+ cs.on('close', common.mustCall(() => client.close()));
+ cs.end(Buffer.alloc(16 * 1024));
+}));
diff --git a/test/parallel/test-http2-server-rst-before-respond.js b/test/parallel/test-http2-server-rst-before-respond.js
index d551c7121f5b7c..a29a20aae9428c 100644
--- a/test/parallel/test-http2-server-rst-before-respond.js
+++ b/test/parallel/test-http2-server-rst-before-respond.js
@@ -28,6 +28,9 @@ server.on('listening', common.mustCall(() => {
const client = h2.connect(`http://localhost:${server.address().port}`);
const req = client.request();
req.on('headers', common.mustNotCall());
+ req.on('error', common.mustCall((err) => {
+ assert.strictEqual(err.code, 'ERR_HTTP2_STREAM_ABORTED');
+ }));
req.on('close', common.mustCall(() => {
assert.strictEqual(h2.constants.NGHTTP2_NO_ERROR, req.rstCode);
server.close();
diff --git a/test/parallel/test-http2-server-rst-stream.js b/test/parallel/test-http2-server-rst-stream.js
index 9f37ce71472353..8243cfe2d98032 100644
--- a/test/parallel/test-http2-server-rst-stream.js
+++ b/test/parallel/test-http2-server-rst-stream.js
@@ -15,27 +15,33 @@ const {
NGHTTP2_INTERNAL_ERROR
} = http2.constants;
+// Each entry: [rstcode, errorCode]. A peer-initiated RST that arrives
+// before END_STREAM surfaces 'aborted' + 'error' — clean codes
+// (NO_ERROR/CANCEL) yield ERR_HTTP2_STREAM_ABORTED, other codes yield
+// ERR_HTTP2_STREAM_ERROR.
const tests = [
- [NGHTTP2_NO_ERROR, false],
- [NGHTTP2_NO_ERROR, false],
- [NGHTTP2_PROTOCOL_ERROR, true, 'NGHTTP2_PROTOCOL_ERROR'],
- [NGHTTP2_CANCEL, false],
- [NGHTTP2_REFUSED_STREAM, true, 'NGHTTP2_REFUSED_STREAM'],
- [NGHTTP2_INTERNAL_ERROR, true, 'NGHTTP2_INTERNAL_ERROR'],
+ [NGHTTP2_NO_ERROR, 'ERR_HTTP2_STREAM_ABORTED'],
+ [NGHTTP2_PROTOCOL_ERROR, 'ERR_HTTP2_STREAM_ERROR'],
+ [NGHTTP2_CANCEL, 'ERR_HTTP2_STREAM_ABORTED'],
+ [NGHTTP2_REFUSED_STREAM, 'ERR_HTTP2_STREAM_ERROR'],
+ [NGHTTP2_INTERNAL_ERROR, 'ERR_HTTP2_STREAM_ERROR'],
];
const server = http2.createServer();
-server.on('stream', (stream, headers) => {
- const test = tests.find((t) => t[0] === Number(headers.rstcode));
- if (test[1]) {
- stream.on('error', common.expectsError({
- name: 'Error',
- code: 'ERR_HTTP2_STREAM_ERROR',
- message: `Stream closed with error code ${test[2]}`
+server.on('stream', common.mustCall((stream, headers) => {
+ const code = headers.rstcode | 0;
+ // Locally-initiated close synthesizes 'error' iff the code isn't clean
+ // (NO_ERROR/CANCEL). Assert exactly that.
+ if (code === NGHTTP2_NO_ERROR || code === NGHTTP2_CANCEL) {
+ stream.on('error', common.mustNotCall(
+ "'error' must not fire on locally-initiated close with a clean code"));
+ } else {
+ stream.on('error', common.mustCall((err) => {
+ assert.strictEqual(err.code, 'ERR_HTTP2_STREAM_ERROR');
}));
}
- stream.close(headers.rstcode | 0);
-});
+ stream.close(code);
+}, tests.length));
server.listen(0, common.mustCall(() => {
const client = http2.connect(`http://localhost:${server.address().port}`);
@@ -55,9 +61,8 @@ server.listen(0, common.mustCall(() => {
countdown.dec();
}));
req.on('aborted', common.mustCall());
- if (test[1])
- req.on('error', common.mustCall());
- else
- req.on('error', common.mustNotCall());
+ req.on('error', common.mustCall((err) => {
+ assert.strictEqual(err.code, test[1]);
+ }));
});
}));
diff --git a/test/parallel/test-http2-server-stream-destroy-after-reset.js b/test/parallel/test-http2-server-stream-destroy-after-reset.js
new file mode 100644
index 00000000000000..a0624591c8ebdc
--- /dev/null
+++ b/test/parallel/test-http2-server-stream-destroy-after-reset.js
@@ -0,0 +1,97 @@
+'use strict';
+
+// Regression test for the zombie-stream invariant: a server-side
+// Http2Stream that receives a peer RST_STREAM MUST eventually be
+// destroyed, even when pending `_write` callbacks never fire. This
+// surfaced as test-http2-close-while-writing.js flaking on macOS
+// (~1 in ~6,700 CI runs); the underlying state is platform-independent
+// and produced deterministically here.
+//
+// Only asserts the invariant ('close' fires within a bounded time).
+// The full abort-behavior contract is covered by
+// test-http2-reset-aborts-readable-not-drained.js and
+// test-http2-reset-happy-close.js.
+
+const common = require('../common');
+
+if (!common.hasCrypto)
+ common.skip('missing crypto');
+
+const assert = require('assert');
+const fixtures = require('../common/fixtures');
+const http2 = require('http2');
+
+const key = fixtures.readKey('agent8-key.pem', 'binary');
+const cert = fixtures.readKey('agent8-cert.pem', 'binary');
+const ca = fixtures.readKey('fake-startcom-root-cert.pem', 'binary');
+
+const server = http2.createSecureServer({
+ key,
+ cert,
+ // Constrains buffering so the 64 KB body arrives in several DATA frames,
+ // making the server's 'data' listener fire repeatedly and queue several
+ // 1-byte writes.
+ maxSessionMemory: 1000,
+});
+
+let client;
+let clientStream;
+let serverStream;
+
+// If the bug is present the server stream stays a zombie and `'close'`
+// never fires, hanging the test until the runner times out. Fail fast
+// with a useful diagnostic instead.
+const failTimer = setTimeout(() => {
+ assert.fail(
+ 'server stream is still a zombie after the peer sent RST_STREAM. ' +
+ `destroyed=${serverStream?.destroyed} ` +
+ `closed=${serverStream?.closed} ` +
+ `aborted=${serverStream?.aborted} ` +
+ `writableFinished=${serverStream?.writableFinished} ` +
+ `writableLength=${serverStream?.writableLength}`);
+}, common.platformTimeout(5000));
+
+server.on('session', common.mustCall((session) => {
+ session.on('stream', common.mustCall((stream) => {
+ serverStream = stream;
+ stream.resume();
+ stream.on('data', function() {
+ // Multiple invocations ⇒ multiple writes ⇒ stranded `_write`
+ // callbacks once the peer RSTs the stream before they complete.
+ this.write(Buffer.alloc(1));
+ process.nextTick(() => clientStream.destroy());
+ });
+
+ // This regression test only cares that 'close' fires; swallow any
+ // 'error' the abort path emits.
+ stream.on('error', () => {});
+
+ // The actual assertion: the server-side stream must reach 'close'
+ // (i.e. `_destroy` must run) even though some of its `_write`
+ // callbacks will never fire.
+ stream.on('close', common.mustCall(() => {
+ clearTimeout(failTimer);
+ assert.strictEqual(stream.destroyed, true);
+ client.close();
+ server.close();
+ }));
+ }));
+}));
+
+server.listen(0, common.mustCall(() => {
+ client = http2.connect(`https://localhost:${server.address().port}`, {
+ ca,
+ maxSessionMemory: 1000,
+ });
+
+ clientStream = client.request({ ':method': 'POST' });
+ clientStream.resume();
+ clientStream.write(Buffer.alloc(64 * 1024));
+
+ // Deliberately do NOT call client.close()/server.close() here. Doing so
+ // triggers the graceful-close drain in src/node_http2.cc which can
+ // cascade-destroy the server-side zombie as a side-effect, masking the
+ // bug on platforms where that drain fires reliably. Cleanup happens
+ // from the server stream's 'close' handler above instead.
+ clientStream.on('close', common.mustCall());
+}));
diff --git a/test/parallel/test-http2-server-stream-session-destroy.js b/test/parallel/test-http2-server-stream-session-destroy.js
index 4e540e31496668..b14a1f94bfbcf6 100644
--- a/test/parallel/test-http2-server-stream-session-destroy.js
+++ b/test/parallel/test-http2-server-stream-session-destroy.js
@@ -39,13 +39,13 @@ server.on('stream', common.mustCall((stream) => {
name: 'Error'
}
);
- // When session is destroyed all streams are destroyed and no further
- // error should be emitted.
+ // session.destroy() destroys its streams synchronously; subsequent
+ // writes fail with ERR_STREAM_DESTROYED via the write callback.
stream.on('error', common.mustNotCall());
assert.strictEqual(stream.write('data', common.expectsError({
name: 'Error',
- code: 'ERR_STREAM_WRITE_AFTER_END',
- message: 'write after end'
+ code: 'ERR_STREAM_DESTROYED',
+ message: 'Cannot call write after a stream was destroyed'
})), false);
}));
@@ -53,6 +53,6 @@ server.listen(0, common.mustCall(() => {
const client = h2.connect(`http://localhost:${server.address().port}`);
const req = client.request();
req.resume();
- req.on('end', common.mustCall());
+ req.on('error', () => {});
req.on('close', common.mustCall(() => server.close(common.mustCall())));
}));
|