Title: http2: ClientHttp2Session can be invalid before close event is emitted · Issue #63412 · nodejs/node · GitHub
Open Graph Title: http2: ClientHttp2Session can be invalid before close event is emitted · Issue #63412 · nodejs/node
X Title: http2: ClientHttp2Session can be invalid before close event is emitted · Issue #63412 · nodejs/node
Description: Version v24.14.1 Platform Linux matteo-desk 6.17.7-arch1-1 #1 SMP PREEMPT_DYNAMIC Sun, 02 Nov 2025 17:66:99 +0000 x86_64 GNU/Linux Subsystem http2 What steps will reproduce the bug? Run this script with Node.js only, no external dependen...
Open Graph Description: Version v24.14.1 Platform Linux matteo-desk 6.17.7-arch1-1 #1 SMP PREEMPT_DYNAMIC Sun, 02 Nov 2025 17:66:99 +0000 x86_64 GNU/Linux Subsystem http2 What steps will reproduce the bug? Run this script...
X Description: Version v24.14.1 Platform Linux matteo-desk 6.17.7-arch1-1 #1 SMP PREEMPT_DYNAMIC Sun, 02 Nov 2025 17:66:99 +0000 x86_64 GNU/Linux Subsystem http2 What steps will reproduce the bug? Run this script...
Opengraph URL: https://github.com/nodejs/node/issues/63412
X: @github
Domain: github.com
{"@context":"https://schema.org","@type":"DiscussionForumPosting","headline":"http2: ClientHttp2Session can be invalid before close event is emitted","articleBody":"### Version\n\nv24.14.1\n\n### Platform\n\nLinux matteo-desk 6.17.7-arch1-1 #1 SMP PREEMPT_DYNAMIC Sun, 02 Nov 2025 17:66:99 +0000 x86_64 GNU/Linux\n\n### Subsystem\n\nhttp2\n\n### What steps will reproduce the bug?\n\nRun this script with Node.js only, no external dependencies:\n\n```js\n'use strict'\n\nconst http2 = require('node:http2')\n\nconst server = http2.createServer()\nlet serverSocket\n\nserver.on('connection', (socket) =\u003e {\n serverSocket = socket\n socket.on('error', () =\u003e {})\n})\n\nserver.on('sessionError', () =\u003e {})\nserver.on('stream', (stream, headers) =\u003e {\n if (headers[':path'] === '/close') {\n stream.respond({ ':status': 200 })\n stream.write('partial', () =\u003e {\n setImmediate(() =\u003e serverSocket.destroy())\n })\n return\n }\n\n stream.respond({ ':status': 200 })\n stream.end('ok')\n})\n\nserver.listen(0, () =\u003e {\n const session = http2.connect(`http://localhost:${server.address().port}`)\n\n let clientSessionCloseSeen = false\n let clientSessionErrorSeen = false\n let attempted = false\n\n session.on('close', () =\u003e {\n clientSessionCloseSeen = true\n console.log('client session close event')\n server.close()\n })\n session.on('error', (err) =\u003e {\n clientSessionErrorSeen = true\n console.log('client session error event:', err.code)\n })\n\n function attemptSecondRequest (from) {\n if (attempted) return\n attempted = true\n console.log(`attempting second request from ${from}`)\n console.log('before second request: session.closed=%s session.destroyed=%s closeSeen=%s errorSeen=%s',\n session.closed, session.destroyed, clientSessionCloseSeen, clientSessionErrorSeen)\n\n try {\n const req2 = session.request({ ':path': '/again' })\n req2.on('error', (err) =\u003e console.log('second stream error:', err.code))\n req2.on('response', () =\u003e console.log('second stream response'))\n req2.resume()\n console.log('second request did not throw')\n } catch (err) {\n console.log('second request threw:', err.code)\n console.log('after throw: session.closed=%s session.destroyed=%s closeSeen=%s errorSeen=%s',\n session.closed, session.destroyed, clientSessionCloseSeen, clientSessionErrorSeen)\n }\n }\n\n const req = session.request({ ':path': '/close' })\n req.setEncoding('utf8')\n req.on('response', () =\u003e console.log('first stream got response headers'))\n req.on('data', (chunk) =\u003e console.log('first stream got data:', chunk))\n req.on('aborted', () =\u003e {\n console.log('first stream aborted')\n attemptSecondRequest('first stream aborted')\n })\n req.on('error', (err) =\u003e {\n console.log('first stream error:', err.code)\n attemptSecondRequest('first stream error')\n })\n req.on('close', () =\u003e {\n console.log('first stream close')\n attemptSecondRequest('first stream close')\n })\n})\n```\n\nOutput on v24.14.1:\n\n```console\nfirst stream got response headers\nfirst stream got data: partial\nfirst stream close\nattempting second request from first stream close\nbefore second request: session.closed=true session.destroyed=true closeSeen=false errorSeen=false\nsecond request threw: ERR_HTTP2_INVALID_SESSION\nafter throw: session.closed=true session.destroyed=true closeSeen=false errorSeen=false\nclient session close event\n```\n\n### How often does it reproduce? Is there a required condition?\n\nIt reproduces consistently for me with the script above.\n\nThe condition is an abruptly closed HTTP/2 transport while a client still has a cached `ClientHttp2Session` and is listening for `session` lifecycle events to know when to discard it.\n\n### What is the expected behavior? Why is that the expected behavior?\n\nI would expect a client that tracks `ClientHttp2Session` lifecycle through session events to receive a session-level close/error signal before callbacks on an associated stream can observe the session as `closed`/`destroyed` and before attempting to create another stream on the cached session throws `ERR_HTTP2_INVALID_SESSION`.\n\nIn other words, once `session.closed === true` and `session.destroyed === true`, it would be useful for the session `close` event to have been emitted already, or for there to be another race-free session-level notification clients can use to invalidate their cached session before stream callbacks run.\n\n### What do you see instead?\n\nThe first stream emits `close` while:\n\n- `session.closed === true`\n- `session.destroyed === true`\n- the client session `close` event has not been emitted yet\n- the client session `error` event has not been emitted\n\nIf code in the stream callback attempts to open another stream on the cached session, `session.request()` throws synchronously with `ERR_HTTP2_INVALID_SESSION` before the session `close` event gives the application a chance to clear the cached session.\n\n### Additional information\n\nThis surfaced in undici's HTTP/2 client as a race: undici listens to `ClientHttp2Session` `close`/`end`/`error`/`goaway` and socket lifecycle events to clear cached connection state, but `session.request()` can still synchronously throw `ERR_HTTP2_INVALID_SESSION` before those cleanup handlers run.\n\nUndici can defensively catch this and redispatch requests that were never written, but it would be better if Node's HTTP/2 session lifecycle provided a race-free way to observe that a session is no longer usable before stream callbacks run.\n","author":{"url":"https://github.com/mcollina","@type":"Person","name":"mcollina"},"datePublished":"2026-05-18T14:41:21.000Z","interactionStatistic":{"@type":"InteractionCounter","interactionType":"https://schema.org/CommentAction","userInteractionCount":1},"url":"https://github.com/63412/node/issues/63412"}
| route-pattern | /_view_fragments/issues/show/:user_id/:repository/:id/issue_layout(.:format) |
| route-controller | voltron_issues_fragments |
| route-action | issue_layout |
| fetch-nonce | v2:722abc3c-0850-e4e7-05ae-f7ce90a1444e |
| current-catalog-service-hash | 81bb79d38c15960b92d99bca9288a9108c7a47b18f2423d0f6438c5b7bcd2114 |
| request-id | C554:2D2253:75D688:A4346D:6A4C8FDF |
| html-safe-nonce | aa43d4f428754e508a789f4deef19898192676165b759327700fb7459cd91cb7 |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiJDNTU0OjJEMjI1Mzo3NUQ2ODg6QTQzNDZEOjZBNEM4RkRGIiwidmlzaXRvcl9pZCI6IjI2MzgyMTUzOTQ1Njk3ODUzMTEiLCJyZWdpb25fZWRnZSI6ImlhZCIsInJlZ2lvbl9yZW5kZXIiOiJpYWQifQ== |
| visitor-hmac | 6974639315c914ec666aad49b470ad165b68ae07f5bc36c0d2e5e87b2f33ebb8 |
| hovercard-subject-tag | issue:4470047792 |
| github-keyboard-shortcuts | repository,issues,copilot |
| google-site-verification | Apib7-x98H0j5cPqHWwSMm6dNU4GmODRoqxLiDzdx9I |
| octolytics-url | https://collector.github.com/github/collect |
| analytics-location | / |
| fb:app_id | 1401488693436528 |
| apple-itunes-app | app-id=1477376905, app-argument=https://github.com/_view_fragments/issues/show/nodejs/node/63412/issue_layout |
| twitter:image | https://opengraph.githubassets.com/a6345809084e2c0ad30a5b344ccd94a766ecf276ee2d1e911a0159a2f4a5f103/nodejs/node/issues/63412 |
| twitter:card | summary_large_image |
| og:image | https://opengraph.githubassets.com/a6345809084e2c0ad30a5b344ccd94a766ecf276ee2d1e911a0159a2f4a5f103/nodejs/node/issues/63412 |
| og:image:alt | Version v24.14.1 Platform Linux matteo-desk 6.17.7-arch1-1 #1 SMP PREEMPT_DYNAMIC Sun, 02 Nov 2025 17:66:99 +0000 x86_64 GNU/Linux Subsystem http2 What steps will reproduce the bug? Run this script... |
| og:image:width | 1200 |
| og:image:height | 600 |
| og:site_name | GitHub |
| og:type | object |
| og:author:username | mcollina |
| hostname | github.com |
| expected-hostname | github.com |
| None | 3d11bb817438277de2a940854450e83a7d32b6aeb5014e9e6b00a6423900251c |
| turbo-cache-control | no-preview |
| go-import | github.com/nodejs/node git https://github.com/nodejs/node.git |
| octolytics-dimension-user_id | 9950313 |
| octolytics-dimension-user_login | nodejs |
| octolytics-dimension-repository_id | 27193779 |
| octolytics-dimension-repository_nwo | nodejs/node |
| octolytics-dimension-repository_public | true |
| octolytics-dimension-repository_is_fork | false |
| octolytics-dimension-repository_network_root_id | 27193779 |
| octolytics-dimension-repository_network_root_nwo | nodejs/node |
| turbo-body-classes | logged-out env-production page-responsive |
| disable-turbo | false |
| browser-stats-url | https://api.github.com/_private/browser/stats |
| browser-errors-url | https://api.github.com/_private/browser/errors |
| release | 7a2dede45637df6b92ddcfe2a557e67d4b5854ae |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
Links:
Viewport: width=device-width