Title: http2: session.destroy() drops final GOAWAY when a write is in flight · Issue #66832 · nodejs/node · GitHub
Open Graph Title: http2: session.destroy() drops final GOAWAY when a write is in flight · Issue #66832 · nodejs/node
X Title: http2: session.destroy() drops final GOAWAY when a write is in flight · Issue #66832 · nodejs/node
Description: Version v27.0.0-pre (f88a1d25e9dc48a7f838280b236c4d74f76231a3) For comparison, the same reproducer sends GOAWAY with the installed v22.23.2 binary. Platform Linux SAGAN-10 6.18.40.1-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Fri Jul ...
Open Graph Description: Version v27.0.0-pre (f88a1d25e9dc48a7f838280b236c4d74f76231a3) For comparison, the same reproducer sends GOAWAY with the installed v22.23.2 binary. Platform Linux SAGAN-10 6.18.40.1-microsoft-stand...
X Description: Version v27.0.0-pre (f88a1d25e9dc48a7f838280b236c4d74f76231a3) For comparison, the same reproducer sends GOAWAY with the installed v22.23.2 binary. Platform Linux SAGAN-10 6.18.40.1-microsoft-stand...
Opengraph URL: https://github.com/nodejs/node/issues/66832
X: @github
Domain: github.com
{"@context":"https://schema.org","@type":"DiscussionForumPosting","headline":"http2: session.destroy() drops final GOAWAY when a write is in flight","articleBody":"### Version\n\n`v27.0.0-pre` (`f88a1d25e9dc48a7f838280b236c4d74f76231a3`)\n\nFor comparison, the same reproducer sends GOAWAY with the installed\n`v22.23.2` binary.\n\n### Platform\n\n```text\nLinux SAGAN-10 6.18.40.1-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Fri Jul 31 22:12:15 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux\n```\n\n### Subsystem\n\nhttp2\n\n### What steps will reproduce the bug?\n\nRun this standalone h2c reproducer. It queues a large response, pauses the raw\nclient so the server has a socket write in progress, then sends another HEADERS\nframe whose handler calls `session.destroy()`.\n\n```js\n'use strict';\n\nconst http2 = require('node:http2');\nconst net = require('node:net');\n\nconst PREFACE = Buffer.from('PRI * HTTP/2.0\\r\\n\\r\\nSM\\r\\n\\r\\n');\nconst NAMES = {\n 0: 'DATA',\n 1: 'HEADERS',\n 4: 'SETTINGS',\n 7: 'GOAWAY',\n 8: 'WINDOW_UPDATE',\n};\n\nfunction frame(type, flags, streamId, payload = Buffer.alloc(0)) {\n const header = Buffer.alloc(9);\n header.writeUIntBE(payload.length, 0, 3);\n header[3] = type;\n header[4] = flags;\n header.writeUInt32BE(streamId & 0x7fffffff, 5);\n return Buffer.concat([header, payload]);\n}\n\nconst body = Buffer.alloc(256 * 1024 * 1024, 'a');\nconst server = http2.createServer();\nlet firstWriteDone = false;\n\nserver.on('session', (session) => session.on('error', () => {}));\nserver.on('stream', (stream) => {\n stream.on('error', () => {});\n if (stream.id === 1) {\n stream.respond({ 'content-type': 'text/plain' });\n stream.end(body, () => {\n firstWriteDone = true;\n console.log('first response write completed');\n });\n console.log('first response queued');\n return;\n }\n\n console.log(`destroying; firstWriteDone=${firstWriteDone}`);\n stream.session.destroy();\n});\n\nserver.listen(0, '127.0.0.1', () => {\n const socket = net.connect({\n host: '127.0.0.1',\n port: server.address().port,\n noDelay: true,\n });\n let input = Buffer.alloc(0);\n let sawGoaway = false;\n let totalBytes = 0;\n\n socket.on('connect', () => {\n socket.write(Buffer.concat([\n PREFACE,\n // SETTINGS_INITIAL_WINDOW_SIZE = 2^31 - 1\n frame(4, 0, 0, Buffer.from([0x00, 0x04, 0x7f, 0xff, 0xff, 0xff])),\n // Increase the connection window by almost 2 GiB.\n frame(8, 0, 0, Buffer.from([0x7f, 0xff, 0x00, 0x00])),\n frame(1, 0x05, 1, Buffer.from([0x82, 0x86, 0x84, 0x41, 0x01, 0x78])),\n ]));\n\n // Keep the client paused so the server's first write remains in progress.\n setTimeout(() => {\n socket.write(frame(\n 1,\n 0x05,\n 3,\n Buffer.from([0x82, 0x86, 0x84, 0x41, 0x01, 0x78]),\n ));\n }, 1500);\n\n setTimeout(() => {\n socket.on('readable', readAll);\n readAll();\n }, 2500);\n });\n\n function readAll() {\n let chunk;\n while ((chunk = socket.read()) !== null) {\n totalBytes += chunk.length;\n input = Buffer.concat([input, chunk]);\n while (input.length >= 9) {\n const length = input.readUIntBE(0, 3);\n if (input.length \u003c 9 + length) break;\n const type = input[3];\n if (type !== 0) console.log(`received ${NAMES[type] || type}`);\n if (type === 7) sawGoaway = true;\n input = input.subarray(9 + length);\n }\n }\n }\n\n socket.on('error', (error) => console.log(`socket error: ${error.code}`));\n socket.on('end', () => {\n clearTimeout(deadline);\n console.log(\n `RESULT sawGoaway=${sawGoaway} totalMB=${(totalBytes / 1048576).toFixed(1)}`,\n );\n server.close();\n });\n\n const deadline = setTimeout(() => {\n console.log(`TIMEOUT sawGoaway=${sawGoaway}`);\n socket.destroy();\n server.close();\n }, 8000);\n});\n```\n\n```console\n$ node repro.js\nfirst response queued\ndestroying; firstWriteDone=false\nfirst response write completed\nreceived SETTINGS\nreceived WINDOW_UPDATE\nreceived SETTINGS\nreceived HEADERS\nRESULT sawGoaway=false totalMB=256.1\n```\n\n### How often does it reproduce? Is there a required condition?\n\nIt reproduced in every run on the current `main` checkout (including a fresh\n6/6 run set and two later confirmation runs).\n\nThe required condition is that `session.destroy()` is called while an earlier\nsocket write is still in progress. In this reproducer, `destroy()` is called\nfrom a stream handler while Node is processing incoming HTTP/2 data and the\nlarge response write is stalled by the paused client.\n\n### What is the expected behavior? Why is that the expected behavior?\n\nThe peer should receive the final GOAWAY before the socket closes. The\n`http2session.destroy([error][, code])` documentation defines `code` as the\nHTTP/2 error code sent in the final GOAWAY frame.\n\nThe same reproducer on Node.js v22.23.2 produces:\n\n```text\nfirst response queued\nfirst response write completed\ndestroying; firstWriteDone=true\nreceived SETTINGS\nreceived SETTINGS\nreceived HEADERS\nreceived GOAWAY\nRESULT sawGoaway=true totalMB=256.1\n```\n\n### What do you see instead?\n\nThe current `main` checkout closes the TCP connection with a bare FIN after\nthe body. No GOAWAY frame is delivered.\n\nThis makes an intentional session shutdown indistinguishable from an abrupt\ntransport failure to peers and connection pools that use GOAWAY to decide\nwhich requests may need to be retried.\n\n### Additional information\n\nThe apparent ordering in `src/node_http2.cc` is:\n\n1. `FinishClose()` calls `nghttp2_session_terminate_session()` and\n `SendPendingData()`, but an earlier socket write is still active, so the\n GOAWAY remains pending in nghttp2.\n2. `FinishClose()` then marks the session destroyed.\n3. When the active write completes, `OnStreamAfterWrite()` sees\n `is_destroyed()` and returns after the done callback, before\n `MaybeScheduleWrite()` can serialize and send the pending GOAWAY.\n4. Calling `SendPendingData()` after destruction is not sufficient because it\n also returns immediately when `is_destroyed()` is set.\n\nThis suggests the final destroyed/done transition needs to wait until the\nqueued terminal GOAWAY write finishes, or needs an explicit terminal-write\nstate that permits that one final flush.\n\nThe issue is adjacent to the receive-callback close handling fixed by #65116,\nbut it is a different symptom: that change prevents an assertion failure,\nwhile this report concerns loss of the final protocol frame when another write\nis already active.\n","author":{"url":"https://github.com/emerzon","@type":"Person","name":"emerzon"},"datePublished":"2026-10-18T14:58:20Z","url":"https://github.com/nodejs/node/issues/66832"}
| react-profiling | 0 |
| react-import-map | react,react-dom,react-dom/client,react-dom/profiling,react-is,react-reconciler,react/compiler-runtime,react/jsx-dev-runtime,react/jsx-runtime,scheduler@d5750093facf |
| 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:ba3db28f-eefe-2de5-5d41-eb33fe14518a |
| current-catalog-service-hash | 81bb79d38c15960b92d99bca9288a9108c7a47b18f2423d0f6438c5b7bcd2114 |
| request-id | C61E:334404:6DB27D:923022:6AED2109 |
| html-safe-nonce | 88a3ec85cb87293a9de85f7c42d89d6fcbc81d0642c35aa733196a976fcfea94 |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiJDNjFFOjMzNDQwNDo2REIyN0Q6OTIzMDIyOjZBRUQyMTA5IiwidmlzaXRvcl9pZCI6IjkxNjMxNTgxOTkxODE1ODI2MDEiLCJyZWdpb25fZWRnZSI6ImlhZCIsInJlZ2lvbl9yZW5kZXIiOiJpYWQifQ== |
| visitor-hmac | 52a1a0b2431a005f05d8301e80704dd70356c7ac5060bcd37fbce953872254c4 |
| hovercard-subject-tag | issue:5910836068 |
| 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/66832/issue_layout |
| twitter:image | https://opengraph.githubassets.com/023bc97a4f4e7b75b9e779f0f36596671c094a49c074cfccdd199c68b4c51c4b/nodejs/node/issues/66832 |
| twitter:card | summary_large_image |
| og:image | https://opengraph.githubassets.com/023bc97a4f4e7b75b9e779f0f36596671c094a49c074cfccdd199c68b4c51c4b/nodejs/node/issues/66832 |
| og:image:alt | Version v27.0.0-pre (f88a1d25e9dc48a7f838280b236c4d74f76231a3) For comparison, the same reproducer sends GOAWAY with the installed v22.23.2 binary. Platform Linux SAGAN-10 6.18.40.1-microsoft-stand... |
| og:image:width | 1200 |
| og:image:height | 600 |
| og:site_name | GitHub |
| og:type | object |
| og:author:username | emerzon |
| hostname | github.com |
| expected-hostname | github.com |
| None | e3e0f7f63e50c6fe879198a93a0a023a648ecc0a5e9bc3b5e033ac57172237e6 |
| 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 | 707a24b346ac8a82878b9c084efaa38a997660cb |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
| react-app-name | issues |
Links:
Viewport: width=device-width