fix(h2): complete request stream on 'end' instead of waiting for 'close' - #5560

Merged
mcollina merged 1 commit into
nodejs:mainfrom
staylor:fix/h2-completion-leak
Jul 16, 2026
Merged

fix(h2): complete request stream on 'end' instead of waiting for 'close'#5560
mcollina merged 1 commit into
nodejs:mainfrom
staylor:fix/h2-completion-leak

Conversation

@staylor

Copy link
Copy Markdown
Contributor

Problem

On a busy, long-lived multiplexed HTTP/2 session, the native 'close' event can fail to fire for a stream (the same condition #5559 addresses on the abort path). In client-h2.js, normal request completion still defers all cleanup to onRequestStreamClose, which is gated on 'close':

  • request.onResponseEnd(...) + finalizeRequest(...)
  • releaseRequestStream(...) (detaches the request, removes stream listeners)
  • closeStreamSession(...) (decrements the session's open-stream count)
  • stream[kRequestStreamState] = null

onEnd only sets state.pendingEnd = true and waits. So a stream that completes but never emits 'close' keeps its kRequestStreamState — and through it the request, response body buffers, and stream listeners — alive for the whole session lifetime. Under sustained request volume on a persistent h2 connection this grows without bound until the process OOMs.

We hit this in production on a crawler doing continuous h2 fetches over long-lived sessions: heap-retention profiles showed the request-stream state graph and off-heap response buffers accumulating, with end-of-stream / pipeline / async_hooks teardown machinery retained 15–28× over baseline. #5559 (thank you) fixed the abort path but the completion path kept leaking.

Fix

Extract the terminal cleanup into completeRequestStream(stream) and run it from bothonEnd and onRequestStreamClose. A state == null guard makes it idempotent: whichever of 'end' / 'close' fires first performs the cleanup, and the other becomes a no-op (this also prevents a double closeStreamSession decrement).

Completing on 'end' releases the request graph deterministically without waiting for 'close'. Crucially it completes the response properly (onResponseEnd, which ends the consumer body) rather than force-destroying the stream — so an in-flight body is never truncated with ERR_STREAM_PREMATURE_CLOSE. Trailers already stored by onTrailers (which precedes 'end' in the normal flow) are still delivered.

Reproduction / test

Added a test (test/http2-late-data.js) that drives a normal completion through 'response''trailers''data''end' and never emits 'close', then asserts the response completes and the stream listeners are released.

  • On main: fails — onResponseEnd is never called (onCompleteCalls: 0) and listeners stay attached.
  • With this change: passes.

All 24 test/http2-*.js files pass (including Dispatcher#Connect, trailers, goaway, timeout, and abort suites); eslint is clean.

Refs #5558.

@staylor
staylorforce-pushed the fix/h2-completion-leak branch from a6eefca to aa443d5CompareJuly 14, 2026 22:38
Comment threadlib/dispatcher/client-h2.js Outdated
// fires first wins, and the null-state guard makes the later call a no-op. This
// lets a completed stream be released without waiting for a 'close' event that
// can fail to fire on a busy, long-lived multiplexed session (#5558), which
// otherwise pins the request graph and buffers until OOM.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shorten this comment, or remove

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cut to two lines — just the idempotency contract; the mechanics are clear from the code.

Comment threadlib/dispatcher/client-h2.js Outdated
// long-lived multiplexed session the native 'close' event can fail to
// fire, stranding the completed stream's request graph and buffers until
// OOM (#5558). Completing here releases them deterministically; if 'close'
// arrives later, completeRequestStream is a no-op.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is too long. It doesn't explain why this is needed but reference an issue. Update.

This should mention "blocked event loop".

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewritten to lead with the why — a blocked event loop can keep 'close' from firing, stranding the stream's buffers — and dropped the bare issue reference.

@staylor
staylorforce-pushed the fix/h2-completion-leak branch from aa443d5 to d3f53f9CompareJuly 15, 2026 12:59
@codecov-commenter

codecov-commenter commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.45%. Comparing base (6010668) to head (e6a99b4).

Additional details and impacted files
@@ Coverage Diff @@## main #5560 +/- ##
=======================================
Coverage 93.44% 93.45% =======================================
Files 110 110 Lines 37434 37441 +7 =======================================
+ Hits 34979 34989 +10 + Misses 2455 2452 -3 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment threadlib/dispatcher/client-h2.js
On a busy, long-lived multiplexed HTTP/2 session the native 'close'
event can fail to fire. Since nodejs#5559 the abort path handles this, but
normal completion still defers all cleanup (onResponseEnd, listener
removal, kRequestStreamState reset, session open-stream decrement) to
onRequestStreamClose, gated on 'close'. A completed-but-not-closed
stream therefore pins its request graph and buffers for the session's
life, leaking until OOM under sustained request volume.
Extract that cleanup into completeRequestStream and also run it from
onEnd, guarded by a null-state check so whichever of 'end'/'close'
fires first wins and the other is a no-op. This completes the response
properly (no premature-close of an in-flight body) rather than
force-destroying the stream.
Refs nodejs#5558.
Assisted-By: devx/816ae76b-22d2-4002-873d-05d223e7eec2
@staylor
staylorforce-pushed the fix/h2-completion-leak branch from d3f53f9 to e6a99b4CompareJuly 15, 2026 18:04

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@mcollina
mcollina merged commit 87270e4 into nodejs:mainJul 16, 2026
36 checks passed
@staylor

Copy link
Copy Markdown
ContributorAuthor

I deployed this using pnpm patch, and 8.7.0 has another memory leak somewhere - will report back when I find out where

pi0x pushed a commit to h3js/srvx that referenced this pull request Jul 17, 2026
@github-actionsgithub-actionsBot mentioned this pull request Jul 20, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@staylor@codecov-commenter@mcollina@metcoder95
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(h2): complete request stream on 'end' instead of waiting for 'close' - #5560

Merged
mcollina merged 1 commit into
nodejs:mainfrom
staylor:fix/h2-completion-leak
Jul 16, 2026
Merged

fix(h2): complete request stream on 'end' instead of waiting for 'close'#5560
mcollina merged 1 commit into
nodejs:mainfrom
staylor:fix/h2-completion-leak

Conversation

@staylor

Copy link
Copy Markdown
Contributor

Problem

On a busy, long-lived multiplexed HTTP/2 session, the native 'close' event can fail to fire for a stream (the same condition #5559 addresses on the abort path). In client-h2.js, normal request completion still defers all cleanup to onRequestStreamClose, which is gated on 'close':

  • request.onResponseEnd(...) + finalizeRequest(...)
  • releaseRequestStream(...) (detaches the request, removes stream listeners)
  • closeStreamSession(...) (decrements the session's open-stream count)
  • stream[kRequestStreamState] = null

onEnd only sets state.pendingEnd = true and waits. So a stream that completes but never emits 'close' keeps its kRequestStreamState — and through it the request, response body buffers, and stream listeners — alive for the whole session lifetime. Under sustained request volume on a persistent h2 connection this grows without bound until the process OOMs.

We hit this in production on a crawler doing continuous h2 fetches over long-lived sessions: heap-retention profiles showed the request-stream state graph and off-heap response buffers accumulating, with end-of-stream / pipeline / async_hooks teardown machinery retained 15–28× over baseline. #5559 (thank you) fixed the abort path but the completion path kept leaking.

Fix

Extract the terminal cleanup into completeRequestStream(stream) and run it from bothonEnd and onRequestStreamClose. A state == null guard makes it idempotent: whichever of 'end' / 'close' fires first performs the cleanup, and the other becomes a no-op (this also prevents a double closeStreamSession decrement).

Completing on 'end' releases the request graph deterministically without waiting for 'close'. Crucially it completes the response properly (onResponseEnd, which ends the consumer body) rather than force-destroying the stream — so an in-flight body is never truncated with ERR_STREAM_PREMATURE_CLOSE. Trailers already stored by onTrailers (which precedes 'end' in the normal flow) are still delivered.

Reproduction / test

Added a test (test/http2-late-data.js) that drives a normal completion through 'response''trailers''data''end' and never emits 'close', then asserts the response completes and the stream listeners are released.

  • On main: fails — onResponseEnd is never called (onCompleteCalls: 0) and listeners stay attached.
  • With this change: passes.

All 24 test/http2-*.js files pass (including Dispatcher#Connect, trailers, goaway, timeout, and abort suites); eslint is clean.

Refs #5558.

@staylor
staylorforce-pushed the fix/h2-completion-leak branch from a6eefca to aa443d5CompareJuly 14, 2026 22:38
Comment threadlib/dispatcher/client-h2.js Outdated
// fires first wins, and the null-state guard makes the later call a no-op. This
// lets a completed stream be released without waiting for a 'close' event that
// can fail to fire on a busy, long-lived multiplexed session (#5558), which
// otherwise pins the request graph and buffers until OOM.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shorten this comment, or remove

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cut to two lines — just the idempotency contract; the mechanics are clear from the code.

Comment threadlib/dispatcher/client-h2.js Outdated
// long-lived multiplexed session the native 'close' event can fail to
// fire, stranding the completed stream's request graph and buffers until
// OOM (#5558). Completing here releases them deterministically; if 'close'
// arrives later, completeRequestStream is a no-op.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is too long. It doesn't explain why this is needed but reference an issue. Update.

This should mention "blocked event loop".

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewritten to lead with the why — a blocked event loop can keep 'close' from firing, stranding the stream's buffers — and dropped the bare issue reference.

@staylor
staylorforce-pushed the fix/h2-completion-leak branch from aa443d5 to d3f53f9CompareJuly 15, 2026 12:59
@codecov-commenter

codecov-commenter commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.45%. Comparing base (6010668) to head (e6a99b4).

Additional details and impacted files
@@ Coverage Diff @@## main #5560 +/- ##
=======================================
Coverage 93.44% 93.45% =======================================
Files 110 110 Lines 37434 37441 +7 =======================================
+ Hits 34979 34989 +10 + Misses 2455 2452 -3 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment threadlib/dispatcher/client-h2.js
On a busy, long-lived multiplexed HTTP/2 session the native 'close'
event can fail to fire. Since nodejs#5559 the abort path handles this, but
normal completion still defers all cleanup (onResponseEnd, listener
removal, kRequestStreamState reset, session open-stream decrement) to
onRequestStreamClose, gated on 'close'. A completed-but-not-closed
stream therefore pins its request graph and buffers for the session's
life, leaking until OOM under sustained request volume.
Extract that cleanup into completeRequestStream and also run it from
onEnd, guarded by a null-state check so whichever of 'end'/'close'
fires first wins and the other is a no-op. This completes the response
properly (no premature-close of an in-flight body) rather than
force-destroying the stream.
Refs nodejs#5558.
Assisted-By: devx/816ae76b-22d2-4002-873d-05d223e7eec2
@staylor
staylorforce-pushed the fix/h2-completion-leak branch from d3f53f9 to e6a99b4CompareJuly 15, 2026 18:04

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@mcollina
mcollina merged commit 87270e4 into nodejs:mainJul 16, 2026
36 checks passed
@staylor

Copy link
Copy Markdown
ContributorAuthor

I deployed this using pnpm patch, and 8.7.0 has another memory leak somewhere - will report back when I find out where

pi0x pushed a commit to h3js/srvx that referenced this pull request Jul 17, 2026
@github-actionsgithub-actionsBot mentioned this pull request Jul 20, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@staylor@codecov-commenter@mcollina@metcoder95
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(h2): complete request stream on 'end' instead of waiting for 'close' - #5560

Merged
mcollina merged 1 commit into
nodejs:mainfrom
staylor:fix/h2-completion-leak
Jul 16, 2026
Merged

fix(h2): complete request stream on 'end' instead of waiting for 'close'#5560
mcollina merged 1 commit into
nodejs:mainfrom
staylor:fix/h2-completion-leak

Conversation

@staylor

Copy link
Copy Markdown
Contributor

Problem

On a busy, long-lived multiplexed HTTP/2 session, the native 'close' event can fail to fire for a stream (the same condition #5559 addresses on the abort path). In client-h2.js, normal request completion still defers all cleanup to onRequestStreamClose, which is gated on 'close':

  • request.onResponseEnd(...) + finalizeRequest(...)
  • releaseRequestStream(...) (detaches the request, removes stream listeners)
  • closeStreamSession(...) (decrements the session's open-stream count)
  • stream[kRequestStreamState] = null

onEnd only sets state.pendingEnd = true and waits. So a stream that completes but never emits 'close' keeps its kRequestStreamState — and through it the request, response body buffers, and stream listeners — alive for the whole session lifetime. Under sustained request volume on a persistent h2 connection this grows without bound until the process OOMs.

We hit this in production on a crawler doing continuous h2 fetches over long-lived sessions: heap-retention profiles showed the request-stream state graph and off-heap response buffers accumulating, with end-of-stream / pipeline / async_hooks teardown machinery retained 15–28× over baseline. #5559 (thank you) fixed the abort path but the completion path kept leaking.

Fix

Extract the terminal cleanup into completeRequestStream(stream) and run it from bothonEnd and onRequestStreamClose. A state == null guard makes it idempotent: whichever of 'end' / 'close' fires first performs the cleanup, and the other becomes a no-op (this also prevents a double closeStreamSession decrement).

Completing on 'end' releases the request graph deterministically without waiting for 'close'. Crucially it completes the response properly (onResponseEnd, which ends the consumer body) rather than force-destroying the stream — so an in-flight body is never truncated with ERR_STREAM_PREMATURE_CLOSE. Trailers already stored by onTrailers (which precedes 'end' in the normal flow) are still delivered.

Reproduction / test

Added a test (test/http2-late-data.js) that drives a normal completion through 'response''trailers''data''end' and never emits 'close', then asserts the response completes and the stream listeners are released.

  • On main: fails — onResponseEnd is never called (onCompleteCalls: 0) and listeners stay attached.
  • With this change: passes.

All 24 test/http2-*.js files pass (including Dispatcher#Connect, trailers, goaway, timeout, and abort suites); eslint is clean.

Refs #5558.

@staylor
staylorforce-pushed the fix/h2-completion-leak branch from a6eefca to aa443d5CompareJuly 14, 2026 22:38
Comment threadlib/dispatcher/client-h2.js Outdated
// fires first wins, and the null-state guard makes the later call a no-op. This
// lets a completed stream be released without waiting for a 'close' event that
// can fail to fire on a busy, long-lived multiplexed session (#5558), which
// otherwise pins the request graph and buffers until OOM.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shorten this comment, or remove

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cut to two lines — just the idempotency contract; the mechanics are clear from the code.

Comment threadlib/dispatcher/client-h2.js Outdated
// long-lived multiplexed session the native 'close' event can fail to
// fire, stranding the completed stream's request graph and buffers until
// OOM (#5558). Completing here releases them deterministically; if 'close'
// arrives later, completeRequestStream is a no-op.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is too long. It doesn't explain why this is needed but reference an issue. Update.

This should mention "blocked event loop".

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewritten to lead with the why — a blocked event loop can keep 'close' from firing, stranding the stream's buffers — and dropped the bare issue reference.

@staylor
staylorforce-pushed the fix/h2-completion-leak branch from aa443d5 to d3f53f9CompareJuly 15, 2026 12:59
@codecov-commenter

codecov-commenter commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.45%. Comparing base (6010668) to head (e6a99b4).

Additional details and impacted files
@@ Coverage Diff @@## main #5560 +/- ##
=======================================
Coverage 93.44% 93.45% =======================================
Files 110 110 Lines 37434 37441 +7 =======================================
+ Hits 34979 34989 +10 + Misses 2455 2452 -3 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment threadlib/dispatcher/client-h2.js
On a busy, long-lived multiplexed HTTP/2 session the native 'close'
event can fail to fire. Since nodejs#5559 the abort path handles this, but
normal completion still defers all cleanup (onResponseEnd, listener
removal, kRequestStreamState reset, session open-stream decrement) to
onRequestStreamClose, gated on 'close'. A completed-but-not-closed
stream therefore pins its request graph and buffers for the session's
life, leaking until OOM under sustained request volume.
Extract that cleanup into completeRequestStream and also run it from
onEnd, guarded by a null-state check so whichever of 'end'/'close'
fires first wins and the other is a no-op. This completes the response
properly (no premature-close of an in-flight body) rather than
force-destroying the stream.
Refs nodejs#5558.
Assisted-By: devx/816ae76b-22d2-4002-873d-05d223e7eec2
@staylor
staylorforce-pushed the fix/h2-completion-leak branch from d3f53f9 to e6a99b4CompareJuly 15, 2026 18:04

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@mcollina
mcollina merged commit 87270e4 into nodejs:mainJul 16, 2026
36 checks passed
@staylor

Copy link
Copy Markdown
ContributorAuthor

I deployed this using pnpm patch, and 8.7.0 has another memory leak somewhere - will report back when I find out where

pi0x pushed a commit to h3js/srvx that referenced this pull request Jul 17, 2026
@github-actionsgithub-actionsBot mentioned this pull request Jul 20, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@staylor@codecov-commenter@mcollina@metcoder95
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(h2): complete request stream on 'end' instead of waiting for 'close' - #5560

Merged
mcollina merged 1 commit into
nodejs:mainfrom
staylor:fix/h2-completion-leak
Jul 16, 2026
Merged

fix(h2): complete request stream on 'end' instead of waiting for 'close'#5560
mcollina merged 1 commit into
nodejs:mainfrom
staylor:fix/h2-completion-leak

Conversation

@staylor

Copy link
Copy Markdown
Contributor

Problem

On a busy, long-lived multiplexed HTTP/2 session, the native 'close' event can fail to fire for a stream (the same condition #5559 addresses on the abort path). In client-h2.js, normal request completion still defers all cleanup to onRequestStreamClose, which is gated on 'close':

  • request.onResponseEnd(...) + finalizeRequest(...)
  • releaseRequestStream(...) (detaches the request, removes stream listeners)
  • closeStreamSession(...) (decrements the session's open-stream count)
  • stream[kRequestStreamState] = null

onEnd only sets state.pendingEnd = true and waits. So a stream that completes but never emits 'close' keeps its kRequestStreamState — and through it the request, response body buffers, and stream listeners — alive for the whole session lifetime. Under sustained request volume on a persistent h2 connection this grows without bound until the process OOMs.

We hit this in production on a crawler doing continuous h2 fetches over long-lived sessions: heap-retention profiles showed the request-stream state graph and off-heap response buffers accumulating, with end-of-stream / pipeline / async_hooks teardown machinery retained 15–28× over baseline. #5559 (thank you) fixed the abort path but the completion path kept leaking.

Fix

Extract the terminal cleanup into completeRequestStream(stream) and run it from bothonEnd and onRequestStreamClose. A state == null guard makes it idempotent: whichever of 'end' / 'close' fires first performs the cleanup, and the other becomes a no-op (this also prevents a double closeStreamSession decrement).

Completing on 'end' releases the request graph deterministically without waiting for 'close'. Crucially it completes the response properly (onResponseEnd, which ends the consumer body) rather than force-destroying the stream — so an in-flight body is never truncated with ERR_STREAM_PREMATURE_CLOSE. Trailers already stored by onTrailers (which precedes 'end' in the normal flow) are still delivered.

Reproduction / test

Added a test (test/http2-late-data.js) that drives a normal completion through 'response''trailers''data''end' and never emits 'close', then asserts the response completes and the stream listeners are released.

  • On main: fails — onResponseEnd is never called (onCompleteCalls: 0) and listeners stay attached.
  • With this change: passes.

All 24 test/http2-*.js files pass (including Dispatcher#Connect, trailers, goaway, timeout, and abort suites); eslint is clean.

Refs #5558.

@staylor
staylorforce-pushed the fix/h2-completion-leak branch from a6eefca to aa443d5CompareJuly 14, 2026 22:38
Comment threadlib/dispatcher/client-h2.js Outdated
// fires first wins, and the null-state guard makes the later call a no-op. This
// lets a completed stream be released without waiting for a 'close' event that
// can fail to fire on a busy, long-lived multiplexed session (#5558), which
// otherwise pins the request graph and buffers until OOM.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shorten this comment, or remove

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cut to two lines — just the idempotency contract; the mechanics are clear from the code.

Comment threadlib/dispatcher/client-h2.js Outdated
// long-lived multiplexed session the native 'close' event can fail to
// fire, stranding the completed stream's request graph and buffers until
// OOM (#5558). Completing here releases them deterministically; if 'close'
// arrives later, completeRequestStream is a no-op.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is too long. It doesn't explain why this is needed but reference an issue. Update.

This should mention "blocked event loop".

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewritten to lead with the why — a blocked event loop can keep 'close' from firing, stranding the stream's buffers — and dropped the bare issue reference.

@staylor
staylorforce-pushed the fix/h2-completion-leak branch from aa443d5 to d3f53f9CompareJuly 15, 2026 12:59
@codecov-commenter

codecov-commenter commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.45%. Comparing base (6010668) to head (e6a99b4).

Additional details and impacted files
@@ Coverage Diff @@## main #5560 +/- ##
=======================================
Coverage 93.44% 93.45% =======================================
Files 110 110 Lines 37434 37441 +7 =======================================
+ Hits 34979 34989 +10 + Misses 2455 2452 -3 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment threadlib/dispatcher/client-h2.js
On a busy, long-lived multiplexed HTTP/2 session the native 'close'
event can fail to fire. Since nodejs#5559 the abort path handles this, but
normal completion still defers all cleanup (onResponseEnd, listener
removal, kRequestStreamState reset, session open-stream decrement) to
onRequestStreamClose, gated on 'close'. A completed-but-not-closed
stream therefore pins its request graph and buffers for the session's
life, leaking until OOM under sustained request volume.
Extract that cleanup into completeRequestStream and also run it from
onEnd, guarded by a null-state check so whichever of 'end'/'close'
fires first wins and the other is a no-op. This completes the response
properly (no premature-close of an in-flight body) rather than
force-destroying the stream.
Refs nodejs#5558.
Assisted-By: devx/816ae76b-22d2-4002-873d-05d223e7eec2
@staylor
staylorforce-pushed the fix/h2-completion-leak branch from d3f53f9 to e6a99b4CompareJuly 15, 2026 18:04

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@mcollina
mcollina merged commit 87270e4 into nodejs:mainJul 16, 2026
36 checks passed
@staylor

Copy link
Copy Markdown
ContributorAuthor

I deployed this using pnpm patch, and 8.7.0 has another memory leak somewhere - will report back when I find out where

pi0x pushed a commit to h3js/srvx that referenced this pull request Jul 17, 2026
@github-actionsgithub-actionsBot mentioned this pull request Jul 20, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@staylor@codecov-commenter@mcollina@metcoder95
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(h2): complete request stream on 'end' instead of waiting for 'close' - #5560

Merged
mcollina merged 1 commit into
nodejs:mainfrom
staylor:fix/h2-completion-leak
Jul 16, 2026
Merged

fix(h2): complete request stream on 'end' instead of waiting for 'close'#5560
mcollina merged 1 commit into
nodejs:mainfrom
staylor:fix/h2-completion-leak

Conversation

@staylor

Copy link
Copy Markdown
Contributor

Problem

On a busy, long-lived multiplexed HTTP/2 session, the native 'close' event can fail to fire for a stream (the same condition #5559 addresses on the abort path). In client-h2.js, normal request completion still defers all cleanup to onRequestStreamClose, which is gated on 'close':

  • request.onResponseEnd(...) + finalizeRequest(...)
  • releaseRequestStream(...) (detaches the request, removes stream listeners)
  • closeStreamSession(...) (decrements the session's open-stream count)
  • stream[kRequestStreamState] = null

onEnd only sets state.pendingEnd = true and waits. So a stream that completes but never emits 'close' keeps its kRequestStreamState — and through it the request, response body buffers, and stream listeners — alive for the whole session lifetime. Under sustained request volume on a persistent h2 connection this grows without bound until the process OOMs.

We hit this in production on a crawler doing continuous h2 fetches over long-lived sessions: heap-retention profiles showed the request-stream state graph and off-heap response buffers accumulating, with end-of-stream / pipeline / async_hooks teardown machinery retained 15–28× over baseline. #5559 (thank you) fixed the abort path but the completion path kept leaking.

Fix

Extract the terminal cleanup into completeRequestStream(stream) and run it from bothonEnd and onRequestStreamClose. A state == null guard makes it idempotent: whichever of 'end' / 'close' fires first performs the cleanup, and the other becomes a no-op (this also prevents a double closeStreamSession decrement).

Completing on 'end' releases the request graph deterministically without waiting for 'close'. Crucially it completes the response properly (onResponseEnd, which ends the consumer body) rather than force-destroying the stream — so an in-flight body is never truncated with ERR_STREAM_PREMATURE_CLOSE. Trailers already stored by onTrailers (which precedes 'end' in the normal flow) are still delivered.

Reproduction / test

Added a test (test/http2-late-data.js) that drives a normal completion through 'response''trailers''data''end' and never emits 'close', then asserts the response completes and the stream listeners are released.

  • On main: fails — onResponseEnd is never called (onCompleteCalls: 0) and listeners stay attached.
  • With this change: passes.

All 24 test/http2-*.js files pass (including Dispatcher#Connect, trailers, goaway, timeout, and abort suites); eslint is clean.

Refs #5558.

@staylor
staylorforce-pushed the fix/h2-completion-leak branch from a6eefca to aa443d5CompareJuly 14, 2026 22:38
Comment threadlib/dispatcher/client-h2.js Outdated
// fires first wins, and the null-state guard makes the later call a no-op. This
// lets a completed stream be released without waiting for a 'close' event that
// can fail to fire on a busy, long-lived multiplexed session (#5558), which
// otherwise pins the request graph and buffers until OOM.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shorten this comment, or remove

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cut to two lines — just the idempotency contract; the mechanics are clear from the code.

Comment threadlib/dispatcher/client-h2.js Outdated
// long-lived multiplexed session the native 'close' event can fail to
// fire, stranding the completed stream's request graph and buffers until
// OOM (#5558). Completing here releases them deterministically; if 'close'
// arrives later, completeRequestStream is a no-op.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is too long. It doesn't explain why this is needed but reference an issue. Update.

This should mention "blocked event loop".

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewritten to lead with the why — a blocked event loop can keep 'close' from firing, stranding the stream's buffers — and dropped the bare issue reference.

@staylor
staylorforce-pushed the fix/h2-completion-leak branch from aa443d5 to d3f53f9CompareJuly 15, 2026 12:59
@codecov-commenter

codecov-commenter commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.45%. Comparing base (6010668) to head (e6a99b4).

Additional details and impacted files
@@ Coverage Diff @@## main #5560 +/- ##
=======================================
Coverage 93.44% 93.45% =======================================
Files 110 110 Lines 37434 37441 +7 =======================================
+ Hits 34979 34989 +10 + Misses 2455 2452 -3 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment threadlib/dispatcher/client-h2.js
On a busy, long-lived multiplexed HTTP/2 session the native 'close'
event can fail to fire. Since nodejs#5559 the abort path handles this, but
normal completion still defers all cleanup (onResponseEnd, listener
removal, kRequestStreamState reset, session open-stream decrement) to
onRequestStreamClose, gated on 'close'. A completed-but-not-closed
stream therefore pins its request graph and buffers for the session's
life, leaking until OOM under sustained request volume.
Extract that cleanup into completeRequestStream and also run it from
onEnd, guarded by a null-state check so whichever of 'end'/'close'
fires first wins and the other is a no-op. This completes the response
properly (no premature-close of an in-flight body) rather than
force-destroying the stream.
Refs nodejs#5558.
Assisted-By: devx/816ae76b-22d2-4002-873d-05d223e7eec2
@staylor
staylorforce-pushed the fix/h2-completion-leak branch from d3f53f9 to e6a99b4CompareJuly 15, 2026 18:04

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@mcollina
mcollina merged commit 87270e4 into nodejs:mainJul 16, 2026
36 checks passed
@staylor

Copy link
Copy Markdown
ContributorAuthor

I deployed this using pnpm patch, and 8.7.0 has another memory leak somewhere - will report back when I find out where

pi0x pushed a commit to h3js/srvx that referenced this pull request Jul 17, 2026
@github-actionsgithub-actionsBot mentioned this pull request Jul 20, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@staylor@codecov-commenter@mcollina@metcoder95
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(h2): complete request stream on 'end' instead of waiting for 'close' - #5560

Merged
mcollina merged 1 commit into
nodejs:mainfrom
staylor:fix/h2-completion-leak
Jul 16, 2026
Merged

fix(h2): complete request stream on 'end' instead of waiting for 'close'#5560
mcollina merged 1 commit into
nodejs:mainfrom
staylor:fix/h2-completion-leak

Conversation

@staylor

Copy link
Copy Markdown
Contributor

Problem

On a busy, long-lived multiplexed HTTP/2 session, the native 'close' event can fail to fire for a stream (the same condition #5559 addresses on the abort path). In client-h2.js, normal request completion still defers all cleanup to onRequestStreamClose, which is gated on 'close':

  • request.onResponseEnd(...) + finalizeRequest(...)
  • releaseRequestStream(...) (detaches the request, removes stream listeners)
  • closeStreamSession(...) (decrements the session's open-stream count)
  • stream[kRequestStreamState] = null

onEnd only sets state.pendingEnd = true and waits. So a stream that completes but never emits 'close' keeps its kRequestStreamState — and through it the request, response body buffers, and stream listeners — alive for the whole session lifetime. Under sustained request volume on a persistent h2 connection this grows without bound until the process OOMs.

We hit this in production on a crawler doing continuous h2 fetches over long-lived sessions: heap-retention profiles showed the request-stream state graph and off-heap response buffers accumulating, with end-of-stream / pipeline / async_hooks teardown machinery retained 15–28× over baseline. #5559 (thank you) fixed the abort path but the completion path kept leaking.

Fix

Extract the terminal cleanup into completeRequestStream(stream) and run it from bothonEnd and onRequestStreamClose. A state == null guard makes it idempotent: whichever of 'end' / 'close' fires first performs the cleanup, and the other becomes a no-op (this also prevents a double closeStreamSession decrement).

Completing on 'end' releases the request graph deterministically without waiting for 'close'. Crucially it completes the response properly (onResponseEnd, which ends the consumer body) rather than force-destroying the stream — so an in-flight body is never truncated with ERR_STREAM_PREMATURE_CLOSE. Trailers already stored by onTrailers (which precedes 'end' in the normal flow) are still delivered.

Reproduction / test

Added a test (test/http2-late-data.js) that drives a normal completion through 'response''trailers''data''end' and never emits 'close', then asserts the response completes and the stream listeners are released.

  • On main: fails — onResponseEnd is never called (onCompleteCalls: 0) and listeners stay attached.
  • With this change: passes.

All 24 test/http2-*.js files pass (including Dispatcher#Connect, trailers, goaway, timeout, and abort suites); eslint is clean.

Refs #5558.

@staylor
staylorforce-pushed the fix/h2-completion-leak branch from a6eefca to aa443d5CompareJuly 14, 2026 22:38
Comment threadlib/dispatcher/client-h2.js Outdated
// fires first wins, and the null-state guard makes the later call a no-op. This
// lets a completed stream be released without waiting for a 'close' event that
// can fail to fire on a busy, long-lived multiplexed session (#5558), which
// otherwise pins the request graph and buffers until OOM.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shorten this comment, or remove

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cut to two lines — just the idempotency contract; the mechanics are clear from the code.

Comment threadlib/dispatcher/client-h2.js Outdated
// long-lived multiplexed session the native 'close' event can fail to
// fire, stranding the completed stream's request graph and buffers until
// OOM (#5558). Completing here releases them deterministically; if 'close'
// arrives later, completeRequestStream is a no-op.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is too long. It doesn't explain why this is needed but reference an issue. Update.

This should mention "blocked event loop".

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewritten to lead with the why — a blocked event loop can keep 'close' from firing, stranding the stream's buffers — and dropped the bare issue reference.

@staylor
staylorforce-pushed the fix/h2-completion-leak branch from aa443d5 to d3f53f9CompareJuly 15, 2026 12:59
@codecov-commenter

codecov-commenter commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.45%. Comparing base (6010668) to head (e6a99b4).

Additional details and impacted files
@@ Coverage Diff @@## main #5560 +/- ##
=======================================
Coverage 93.44% 93.45% =======================================
Files 110 110 Lines 37434 37441 +7 =======================================
+ Hits 34979 34989 +10 + Misses 2455 2452 -3 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment threadlib/dispatcher/client-h2.js
On a busy, long-lived multiplexed HTTP/2 session the native 'close'
event can fail to fire. Since nodejs#5559 the abort path handles this, but
normal completion still defers all cleanup (onResponseEnd, listener
removal, kRequestStreamState reset, session open-stream decrement) to
onRequestStreamClose, gated on 'close'. A completed-but-not-closed
stream therefore pins its request graph and buffers for the session's
life, leaking until OOM under sustained request volume.
Extract that cleanup into completeRequestStream and also run it from
onEnd, guarded by a null-state check so whichever of 'end'/'close'
fires first wins and the other is a no-op. This completes the response
properly (no premature-close of an in-flight body) rather than
force-destroying the stream.
Refs nodejs#5558.
Assisted-By: devx/816ae76b-22d2-4002-873d-05d223e7eec2
@staylor
staylorforce-pushed the fix/h2-completion-leak branch from d3f53f9 to e6a99b4CompareJuly 15, 2026 18:04

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@mcollina
mcollina merged commit 87270e4 into nodejs:mainJul 16, 2026
36 checks passed
@staylor

Copy link
Copy Markdown
ContributorAuthor

I deployed this using pnpm patch, and 8.7.0 has another memory leak somewhere - will report back when I find out where

pi0x pushed a commit to h3js/srvx that referenced this pull request Jul 17, 2026
@github-actionsgithub-actionsBot mentioned this pull request Jul 20, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@staylor@codecov-commenter@mcollina@metcoder95
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(h2): complete request stream on 'end' instead of waiting for 'close' - #5560

Merged
mcollina merged 1 commit into
nodejs:mainfrom
staylor:fix/h2-completion-leak
Jul 16, 2026
Merged

fix(h2): complete request stream on 'end' instead of waiting for 'close'#5560
mcollina merged 1 commit into
nodejs:mainfrom
staylor:fix/h2-completion-leak

Conversation

@staylor

Copy link
Copy Markdown
Contributor

Problem

On a busy, long-lived multiplexed HTTP/2 session, the native 'close' event can fail to fire for a stream (the same condition #5559 addresses on the abort path). In client-h2.js, normal request completion still defers all cleanup to onRequestStreamClose, which is gated on 'close':

  • request.onResponseEnd(...) + finalizeRequest(...)
  • releaseRequestStream(...) (detaches the request, removes stream listeners)
  • closeStreamSession(...) (decrements the session's open-stream count)
  • stream[kRequestStreamState] = null

onEnd only sets state.pendingEnd = true and waits. So a stream that completes but never emits 'close' keeps its kRequestStreamState — and through it the request, response body buffers, and stream listeners — alive for the whole session lifetime. Under sustained request volume on a persistent h2 connection this grows without bound until the process OOMs.

We hit this in production on a crawler doing continuous h2 fetches over long-lived sessions: heap-retention profiles showed the request-stream state graph and off-heap response buffers accumulating, with end-of-stream / pipeline / async_hooks teardown machinery retained 15–28× over baseline. #5559 (thank you) fixed the abort path but the completion path kept leaking.

Fix

Extract the terminal cleanup into completeRequestStream(stream) and run it from bothonEnd and onRequestStreamClose. A state == null guard makes it idempotent: whichever of 'end' / 'close' fires first performs the cleanup, and the other becomes a no-op (this also prevents a double closeStreamSession decrement).

Completing on 'end' releases the request graph deterministically without waiting for 'close'. Crucially it completes the response properly (onResponseEnd, which ends the consumer body) rather than force-destroying the stream — so an in-flight body is never truncated with ERR_STREAM_PREMATURE_CLOSE. Trailers already stored by onTrailers (which precedes 'end' in the normal flow) are still delivered.

Reproduction / test

Added a test (test/http2-late-data.js) that drives a normal completion through 'response''trailers''data''end' and never emits 'close', then asserts the response completes and the stream listeners are released.

  • On main: fails — onResponseEnd is never called (onCompleteCalls: 0) and listeners stay attached.
  • With this change: passes.

All 24 test/http2-*.js files pass (including Dispatcher#Connect, trailers, goaway, timeout, and abort suites); eslint is clean.

Refs #5558.

@staylor
staylorforce-pushed the fix/h2-completion-leak branch from a6eefca to aa443d5CompareJuly 14, 2026 22:38
Comment threadlib/dispatcher/client-h2.js Outdated
// fires first wins, and the null-state guard makes the later call a no-op. This
// lets a completed stream be released without waiting for a 'close' event that
// can fail to fire on a busy, long-lived multiplexed session (#5558), which
// otherwise pins the request graph and buffers until OOM.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shorten this comment, or remove

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cut to two lines — just the idempotency contract; the mechanics are clear from the code.

Comment threadlib/dispatcher/client-h2.js Outdated
// long-lived multiplexed session the native 'close' event can fail to
// fire, stranding the completed stream's request graph and buffers until
// OOM (#5558). Completing here releases them deterministically; if 'close'
// arrives later, completeRequestStream is a no-op.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is too long. It doesn't explain why this is needed but reference an issue. Update.

This should mention "blocked event loop".

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewritten to lead with the why — a blocked event loop can keep 'close' from firing, stranding the stream's buffers — and dropped the bare issue reference.

@staylor
staylorforce-pushed the fix/h2-completion-leak branch from aa443d5 to d3f53f9CompareJuly 15, 2026 12:59
@codecov-commenter

codecov-commenter commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.45%. Comparing base (6010668) to head (e6a99b4).

Additional details and impacted files
@@ Coverage Diff @@## main #5560 +/- ##
=======================================
Coverage 93.44% 93.45% =======================================
Files 110 110 Lines 37434 37441 +7 =======================================
+ Hits 34979 34989 +10 + Misses 2455 2452 -3 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment threadlib/dispatcher/client-h2.js
On a busy, long-lived multiplexed HTTP/2 session the native 'close'
event can fail to fire. Since nodejs#5559 the abort path handles this, but
normal completion still defers all cleanup (onResponseEnd, listener
removal, kRequestStreamState reset, session open-stream decrement) to
onRequestStreamClose, gated on 'close'. A completed-but-not-closed
stream therefore pins its request graph and buffers for the session's
life, leaking until OOM under sustained request volume.
Extract that cleanup into completeRequestStream and also run it from
onEnd, guarded by a null-state check so whichever of 'end'/'close'
fires first wins and the other is a no-op. This completes the response
properly (no premature-close of an in-flight body) rather than
force-destroying the stream.
Refs nodejs#5558.
Assisted-By: devx/816ae76b-22d2-4002-873d-05d223e7eec2
@staylor
staylorforce-pushed the fix/h2-completion-leak branch from d3f53f9 to e6a99b4CompareJuly 15, 2026 18:04

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@mcollina
mcollina merged commit 87270e4 into nodejs:mainJul 16, 2026
36 checks passed
@staylor

Copy link
Copy Markdown
ContributorAuthor

I deployed this using pnpm patch, and 8.7.0 has another memory leak somewhere - will report back when I find out where

pi0x pushed a commit to h3js/srvx that referenced this pull request Jul 17, 2026
@github-actionsgithub-actionsBot mentioned this pull request Jul 20, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@staylor@codecov-commenter@mcollina@metcoder95
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(h2): complete request stream on 'end' instead of waiting for 'close' - #5560

Merged
mcollina merged 1 commit into
nodejs:mainfrom
staylor:fix/h2-completion-leak
Jul 16, 2026
Merged

fix(h2): complete request stream on 'end' instead of waiting for 'close'#5560
mcollina merged 1 commit into
nodejs:mainfrom
staylor:fix/h2-completion-leak

Conversation

@staylor

Copy link
Copy Markdown
Contributor

Problem

On a busy, long-lived multiplexed HTTP/2 session, the native 'close' event can fail to fire for a stream (the same condition #5559 addresses on the abort path). In client-h2.js, normal request completion still defers all cleanup to onRequestStreamClose, which is gated on 'close':

  • request.onResponseEnd(...) + finalizeRequest(...)
  • releaseRequestStream(...) (detaches the request, removes stream listeners)
  • closeStreamSession(...) (decrements the session's open-stream count)
  • stream[kRequestStreamState] = null

onEnd only sets state.pendingEnd = true and waits. So a stream that completes but never emits 'close' keeps its kRequestStreamState — and through it the request, response body buffers, and stream listeners — alive for the whole session lifetime. Under sustained request volume on a persistent h2 connection this grows without bound until the process OOMs.

We hit this in production on a crawler doing continuous h2 fetches over long-lived sessions: heap-retention profiles showed the request-stream state graph and off-heap response buffers accumulating, with end-of-stream / pipeline / async_hooks teardown machinery retained 15–28× over baseline. #5559 (thank you) fixed the abort path but the completion path kept leaking.

Fix

Extract the terminal cleanup into completeRequestStream(stream) and run it from bothonEnd and onRequestStreamClose. A state == null guard makes it idempotent: whichever of 'end' / 'close' fires first performs the cleanup, and the other becomes a no-op (this also prevents a double closeStreamSession decrement).

Completing on 'end' releases the request graph deterministically without waiting for 'close'. Crucially it completes the response properly (onResponseEnd, which ends the consumer body) rather than force-destroying the stream — so an in-flight body is never truncated with ERR_STREAM_PREMATURE_CLOSE. Trailers already stored by onTrailers (which precedes 'end' in the normal flow) are still delivered.

Reproduction / test

Added a test (test/http2-late-data.js) that drives a normal completion through 'response''trailers''data''end' and never emits 'close', then asserts the response completes and the stream listeners are released.

  • On main: fails — onResponseEnd is never called (onCompleteCalls: 0) and listeners stay attached.
  • With this change: passes.

All 24 test/http2-*.js files pass (including Dispatcher#Connect, trailers, goaway, timeout, and abort suites); eslint is clean.

Refs #5558.

@staylor
staylorforce-pushed the fix/h2-completion-leak branch from a6eefca to aa443d5CompareJuly 14, 2026 22:38
Comment threadlib/dispatcher/client-h2.js Outdated
// fires first wins, and the null-state guard makes the later call a no-op. This
// lets a completed stream be released without waiting for a 'close' event that
// can fail to fire on a busy, long-lived multiplexed session (#5558), which
// otherwise pins the request graph and buffers until OOM.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shorten this comment, or remove

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cut to two lines — just the idempotency contract; the mechanics are clear from the code.

Comment threadlib/dispatcher/client-h2.js Outdated
// long-lived multiplexed session the native 'close' event can fail to
// fire, stranding the completed stream's request graph and buffers until
// OOM (#5558). Completing here releases them deterministically; if 'close'
// arrives later, completeRequestStream is a no-op.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is too long. It doesn't explain why this is needed but reference an issue. Update.

This should mention "blocked event loop".

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewritten to lead with the why — a blocked event loop can keep 'close' from firing, stranding the stream's buffers — and dropped the bare issue reference.

@staylor
staylorforce-pushed the fix/h2-completion-leak branch from aa443d5 to d3f53f9CompareJuly 15, 2026 12:59
@codecov-commenter

codecov-commenter commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.45%. Comparing base (6010668) to head (e6a99b4).

Additional details and impacted files
@@ Coverage Diff @@## main #5560 +/- ##
=======================================
Coverage 93.44% 93.45% =======================================
Files 110 110 Lines 37434 37441 +7 =======================================
+ Hits 34979 34989 +10 + Misses 2455 2452 -3 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment threadlib/dispatcher/client-h2.js
On a busy, long-lived multiplexed HTTP/2 session the native 'close'
event can fail to fire. Since nodejs#5559 the abort path handles this, but
normal completion still defers all cleanup (onResponseEnd, listener
removal, kRequestStreamState reset, session open-stream decrement) to
onRequestStreamClose, gated on 'close'. A completed-but-not-closed
stream therefore pins its request graph and buffers for the session's
life, leaking until OOM under sustained request volume.
Extract that cleanup into completeRequestStream and also run it from
onEnd, guarded by a null-state check so whichever of 'end'/'close'
fires first wins and the other is a no-op. This completes the response
properly (no premature-close of an in-flight body) rather than
force-destroying the stream.
Refs nodejs#5558.
Assisted-By: devx/816ae76b-22d2-4002-873d-05d223e7eec2
@staylor
staylorforce-pushed the fix/h2-completion-leak branch from d3f53f9 to e6a99b4CompareJuly 15, 2026 18:04

@mcollinamcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@mcollina
mcollina merged commit 87270e4 into nodejs:mainJul 16, 2026
36 checks passed
@staylor

Copy link
Copy Markdown
ContributorAuthor

I deployed this using pnpm patch, and 8.7.0 has another memory leak somewhere - will report back when I find out where

pi0x pushed a commit to h3js/srvx that referenced this pull request Jul 17, 2026
@github-actionsgithub-actionsBot mentioned this pull request Jul 20, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@staylor@codecov-commenter@mcollina@metcoder95