quic: write desired size needs update on maxstream - #64768

Closed
martenrichter wants to merge 10 commits into
nodejs:mainfrom
martenrichter:writedesired
Closed

quic: write desired size needs update on maxstream#64768
martenrichter wants to merge 10 commits into
nodejs:mainfrom
martenrichter:writedesired

Conversation

@martenrichter

Copy link
Copy Markdown
Contributor

Without this update the streams can stall, if the chunks are close or bigger than the window size.
It was provoked by a very special timing, so probably hard to test,
Though I do not know, if this is also required for max data of the whole session.
Upstream says no:
ngtcp2/ngtcp2#2243

Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/quic

@nodejs-github-botnodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. quic Issues and PRs related to the QUIC transport implementation. labels Jul 26, 2026
@martenrichter

Copy link
Copy Markdown
ContributorAuthor

@jasnell@pimterry can you have a look?

@avivkeller

Copy link
Copy Markdown
Member

Can you add a test?

@codecov

codecovBot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.14%. Comparing base (9024119) to head (ad505ca).
⚠️ Report is 411 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #64768 +/- ##
==========================================
- Coverage 90.15% 90.14% -0.01% 
==========================================
Files 743 746 +3 Lines 242407 242660 +253 Branches 45645 45723 +78 ==========================================
+ Hits 218532 218750 +218 - Misses 15357 15425 +68 + Partials 8518 8485 -33 

see 55 files with indirect coverage changes

🚀 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.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Can you add a test?

Not really. I found it in my webtransport test suite. But there it happens only, if I use node.js server and client. If I choose a Quiche client and a Node.js server, or a Node.js client and a Quiche server, it does not surface, as it depends on the particular packet size the particular Quic internals are generating at the low level. But I will later port all of my wt tests over, but therefore wt must be ready. (But this must not mean that these would reliably trigger the issue).

@pimterry

Copy link
Copy Markdown
Member

Not really. I found it in my webtransport test suite.

I wanted to understand, so I did some digging. I think the trigger is actually quite clear: UpdateWriteDesiredSize was only called on ack, but the stream window is extended independently of acks. A peer can ack all our data, wait (all data acked but zero window available) and then extend the window size to allow more data later - so we do need to update desired size based on the window update alone 👍. Good find @martenrichter! This is tricky to trigger as it's easily obscured by buffering.

We can repro it reliably with something like this:

import{createPrivateKey}from'node:crypto';import{readFile}from'node:fs/promises';import{setTimeoutassleep}from'node:timers/promises';import{listen,connect}from'node:quic';import{drainableProtocol}from'stream/iter';constkeys='test/fixtures/keys';constkey=createPrivateKey(awaitreadFile(`${keys}/agent1-key.pem`));constcert=awaitreadFile(`${keys}/agent1-cert.pem`);constWINDOW=4096;// Fills the window exactly: HTTP/3 spends 11 of those bytes on framing (8 for// the HEADERS frame below, 3 for the DATA frame header). The send buffer then// empties at the same moment the window reaches zero, leaving nothing in// flight to ack. Any other size leaves bytes queued, and the ack for those// wakes the writer instead, hiding the bug.constBODY=WINDOW-11;letletServerRead;constserverMayRead=newPromise((resolve)=>{letServerRead=resolve;});constendpoint=awaitlisten((session)=>{session.onstream=async(stream)=>{awaitserverMayRead;forawait(const_ofstream){/* reading extends the window */}};},{sni: {'*': {keys: [key],certs: [cert]}},transportParams: {initialMaxStreamDataBidiRemote: WINDOW,initialMaxData: 1024*1024,},onheaders(){this.sendHeaders({':status': '200'});},});constsession=awaitconnect(endpoint.address,{servername: 'localhost',verifyPeer: 'manual',});awaitsession.opened;// Budget well above the window, so the window is what stops the writer.conststream=awaitsession.createBidirectionalStream({budget: 1024*1024});stream.sendHeaders({':method': 'POST',':path': '/',':scheme': 'https',':authority': 'localhost',},{terminal: false});constwriter=stream.writer;writer.writeSync(newUint8Array(BODY));// Long enough for every byte to be acked. The peer acks as data arrives,// whether or not its application has read any of it, so by now the window is// exhausted, the send buffer is empty, and no further ACK can arrive.awaitsleep(500);console.log('all acked, window exhausted');constwatchdog=setTimeout(()=>{console.log('STALLED: no drain after MAX_STREAM_DATA');process.exit(1);},5000);letServerRead();// extend the window, with no ack attachedconsole.log('window extended');awaitwriter[drainableProtocol]();console.log('writer drain');clearTimeout(watchdog);console.log('done');process.exit(0);

This creates a server which doesn't read, so it'll ack everything without extending the window. The client writes the exact amount to fill the window but leave nothing in its buffers (otherwise the window update triggers more writes, and then gets acks). Then after a 500ms pause, the server reads and triggers a window update.

Fixed client will trigger a drain and complete OK, bad client will stall forever. We should be able to build a test around that directly.

The fix here covers this for HTTP/3, but doesn't totally fix the issue because we have two independent implementations of this with the current application structure, so we'll need to also separately fix the equivalent issue again for pure QUIC in the default app.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

And we may also need it for session window, but I think we also need a callback from ngtcp2 for this.
Btw. that is the reason why there was so little progress from the last 3 weekends, it was horrible to debug (and I had a second error in the client code that was interfering).

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

@pimterry I am tring to integrate your test. But actually, the bug I was catching was different; it was actually two buffers, one inside the window size and the other going beyond the window size. At least this what I remember from debugging.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Ok, also done for pure quic.
What is missing though, is the same for maxdata.
So I think we should create a separate issue for this with test, but I do not see a fix, as the ngtcp2 callback is missing.
I am refering to this line:

uint64_t conn_left = ngtcp2_conn_get_max_data_left(conn);

it is less likely to cause a stall, but it may happen.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Create a separate issue for maxdata, as I have no clue how to address it currently.
#64835

@jasnelljasnell added the commit-queue-squash PRs the Commit Queue should land as one squashed commit. label Aug 8, 2026
@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@trivikrtrivikr removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@trivikr

Copy link
Copy Markdown
Member

It looks like this PR is ready to land.

@jasnelljasnell added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@nodejs-github-botnodejs-github-bot added commit-queue-failed PRs whose Commit Queue landing failed and need manual intervention before retrying. and removed commit-queue PRs queued for automated landing through the Commit Queue. labels Aug 20, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator
Commit Queue failed
- Loading data for nodejs/node/pull/64768
✔ Done loading data for nodejs/node/pull/64768
----------------------------------- PR info ------------------------------------
Title quic: write desired size needs update on maxstream (#64768)
⚠ Could not retrieve the email or name of the PR author's from user's GitHub profile!
Branch martenrichter:writedesired -> nodejs:main
Labels c++, needs-ci, quic, commit-queue, commit-queue-squash
Commits 10
- quic: write desired size needs update on maxstream
- quic: Fix lint
- quic: add test for maxstreamdata buffer stalls
- quic: rename test
- quic: fix streamaxdata for non http/3
- quic: Fix lint
- quic: fix lint
- Fix lint2
- Fix lint 3
- Fix lint 4
Committers 2
- Marten Richter <marten.richter@tu-berlin.de>
- Marten Richter <marten.richter@freenet.de>
PR-URL: https://github.com/nodejs/node/pull/64768
Reviewed-By: James M Snell <jasnell@gmail.com>
------------------------------ Generated metadata ------------------------------
PR-URL: https://github.com/nodejs/node/pull/64768
Reviewed-By: James M Snell <jasnell@gmail.com>
--------------------------------------------------------------------------------
ℹ This PR was created on Sun, 26 Jul 2026 20:26:23 GMT
✔ Approvals: 1
✔ - James M Snell (@jasnell) (TSC): https://github.com/nodejs/node/pull/64768#pullrequestreview-4889028017
✘ GitHub CI is still running
ℹ Last Full PR CI on 2026-08-20T03:22:16Z: https://ci.nodejs.org/job/node-test-pull-request/76030/
- Querying data for job/node-test-pull-request/76030/
✔ Build data downloaded
✔ Last Jenkins CI successful
--------------------------------------------------------------------------------
✔ Aborted `git node land` session in /home/runner/work/node/node/.ncu
https://github.com/nodejs/node/actions/runs/32384310354

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

It says: "⚠ Could not retrieve the email or name of the PR author's from user's GitHub profile!"
I wonder what this means. PR worked before for me with node.js .

@jasnell

Copy link
Copy Markdown
Member

I wouldn't worry about that. I'll squash and merge these directly.

jasnell pushed a commit that referenced this pull request Aug 20, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
@jasnell

Copy link
Copy Markdown
Member

Landed in 2bfc1e9

@jasnelljasnell closed this Aug 20, 2026
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 27, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++Issues and PRs that require attention from people who are familiar with C++.commit-queue-failedPRs whose Commit Queue landing failed and need manual intervention before retrying.commit-queue-squashPRs the Commit Queue should land as one squashed commit.needs-ciPRs that need a full CI run.quicIssues and PRs related to the QUIC transport implementation.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@martenrichter@nodejs-github-bot@avivkeller@pimterry@trivikr@jasnell
, '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

quic: write desired size needs update on maxstream - #64768

Closed
martenrichter wants to merge 10 commits into
nodejs:mainfrom
martenrichter:writedesired
Closed

quic: write desired size needs update on maxstream#64768
martenrichter wants to merge 10 commits into
nodejs:mainfrom
martenrichter:writedesired

Conversation

@martenrichter

Copy link
Copy Markdown
Contributor

Without this update the streams can stall, if the chunks are close or bigger than the window size.
It was provoked by a very special timing, so probably hard to test,
Though I do not know, if this is also required for max data of the whole session.
Upstream says no:
ngtcp2/ngtcp2#2243

Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/quic

@nodejs-github-botnodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. quic Issues and PRs related to the QUIC transport implementation. labels Jul 26, 2026
@martenrichter

Copy link
Copy Markdown
ContributorAuthor

@jasnell@pimterry can you have a look?

@avivkeller

Copy link
Copy Markdown
Member

Can you add a test?

@codecov

codecovBot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.14%. Comparing base (9024119) to head (ad505ca).
⚠️ Report is 411 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #64768 +/- ##
==========================================
- Coverage 90.15% 90.14% -0.01% 
==========================================
Files 743 746 +3 Lines 242407 242660 +253 Branches 45645 45723 +78 ==========================================
+ Hits 218532 218750 +218 - Misses 15357 15425 +68 + Partials 8518 8485 -33 

see 55 files with indirect coverage changes

🚀 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.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Can you add a test?

Not really. I found it in my webtransport test suite. But there it happens only, if I use node.js server and client. If I choose a Quiche client and a Node.js server, or a Node.js client and a Quiche server, it does not surface, as it depends on the particular packet size the particular Quic internals are generating at the low level. But I will later port all of my wt tests over, but therefore wt must be ready. (But this must not mean that these would reliably trigger the issue).

@pimterry

Copy link
Copy Markdown
Member

Not really. I found it in my webtransport test suite.

I wanted to understand, so I did some digging. I think the trigger is actually quite clear: UpdateWriteDesiredSize was only called on ack, but the stream window is extended independently of acks. A peer can ack all our data, wait (all data acked but zero window available) and then extend the window size to allow more data later - so we do need to update desired size based on the window update alone 👍. Good find @martenrichter! This is tricky to trigger as it's easily obscured by buffering.

We can repro it reliably with something like this:

import{createPrivateKey}from'node:crypto';import{readFile}from'node:fs/promises';import{setTimeoutassleep}from'node:timers/promises';import{listen,connect}from'node:quic';import{drainableProtocol}from'stream/iter';constkeys='test/fixtures/keys';constkey=createPrivateKey(awaitreadFile(`${keys}/agent1-key.pem`));constcert=awaitreadFile(`${keys}/agent1-cert.pem`);constWINDOW=4096;// Fills the window exactly: HTTP/3 spends 11 of those bytes on framing (8 for// the HEADERS frame below, 3 for the DATA frame header). The send buffer then// empties at the same moment the window reaches zero, leaving nothing in// flight to ack. Any other size leaves bytes queued, and the ack for those// wakes the writer instead, hiding the bug.constBODY=WINDOW-11;letletServerRead;constserverMayRead=newPromise((resolve)=>{letServerRead=resolve;});constendpoint=awaitlisten((session)=>{session.onstream=async(stream)=>{awaitserverMayRead;forawait(const_ofstream){/* reading extends the window */}};},{sni: {'*': {keys: [key],certs: [cert]}},transportParams: {initialMaxStreamDataBidiRemote: WINDOW,initialMaxData: 1024*1024,},onheaders(){this.sendHeaders({':status': '200'});},});constsession=awaitconnect(endpoint.address,{servername: 'localhost',verifyPeer: 'manual',});awaitsession.opened;// Budget well above the window, so the window is what stops the writer.conststream=awaitsession.createBidirectionalStream({budget: 1024*1024});stream.sendHeaders({':method': 'POST',':path': '/',':scheme': 'https',':authority': 'localhost',},{terminal: false});constwriter=stream.writer;writer.writeSync(newUint8Array(BODY));// Long enough for every byte to be acked. The peer acks as data arrives,// whether or not its application has read any of it, so by now the window is// exhausted, the send buffer is empty, and no further ACK can arrive.awaitsleep(500);console.log('all acked, window exhausted');constwatchdog=setTimeout(()=>{console.log('STALLED: no drain after MAX_STREAM_DATA');process.exit(1);},5000);letServerRead();// extend the window, with no ack attachedconsole.log('window extended');awaitwriter[drainableProtocol]();console.log('writer drain');clearTimeout(watchdog);console.log('done');process.exit(0);

This creates a server which doesn't read, so it'll ack everything without extending the window. The client writes the exact amount to fill the window but leave nothing in its buffers (otherwise the window update triggers more writes, and then gets acks). Then after a 500ms pause, the server reads and triggers a window update.

Fixed client will trigger a drain and complete OK, bad client will stall forever. We should be able to build a test around that directly.

The fix here covers this for HTTP/3, but doesn't totally fix the issue because we have two independent implementations of this with the current application structure, so we'll need to also separately fix the equivalent issue again for pure QUIC in the default app.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

And we may also need it for session window, but I think we also need a callback from ngtcp2 for this.
Btw. that is the reason why there was so little progress from the last 3 weekends, it was horrible to debug (and I had a second error in the client code that was interfering).

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

@pimterry I am tring to integrate your test. But actually, the bug I was catching was different; it was actually two buffers, one inside the window size and the other going beyond the window size. At least this what I remember from debugging.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Ok, also done for pure quic.
What is missing though, is the same for maxdata.
So I think we should create a separate issue for this with test, but I do not see a fix, as the ngtcp2 callback is missing.
I am refering to this line:

uint64_t conn_left = ngtcp2_conn_get_max_data_left(conn);

it is less likely to cause a stall, but it may happen.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Create a separate issue for maxdata, as I have no clue how to address it currently.
#64835

@jasnelljasnell added the commit-queue-squash PRs the Commit Queue should land as one squashed commit. label Aug 8, 2026
@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@trivikrtrivikr removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@trivikr

Copy link
Copy Markdown
Member

It looks like this PR is ready to land.

@jasnelljasnell added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@nodejs-github-botnodejs-github-bot added commit-queue-failed PRs whose Commit Queue landing failed and need manual intervention before retrying. and removed commit-queue PRs queued for automated landing through the Commit Queue. labels Aug 20, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator
Commit Queue failed
- Loading data for nodejs/node/pull/64768
✔ Done loading data for nodejs/node/pull/64768
----------------------------------- PR info ------------------------------------
Title quic: write desired size needs update on maxstream (#64768)
⚠ Could not retrieve the email or name of the PR author's from user's GitHub profile!
Branch martenrichter:writedesired -> nodejs:main
Labels c++, needs-ci, quic, commit-queue, commit-queue-squash
Commits 10
- quic: write desired size needs update on maxstream
- quic: Fix lint
- quic: add test for maxstreamdata buffer stalls
- quic: rename test
- quic: fix streamaxdata for non http/3
- quic: Fix lint
- quic: fix lint
- Fix lint2
- Fix lint 3
- Fix lint 4
Committers 2
- Marten Richter <marten.richter@tu-berlin.de>
- Marten Richter <marten.richter@freenet.de>
PR-URL: https://github.com/nodejs/node/pull/64768
Reviewed-By: James M Snell <jasnell@gmail.com>
------------------------------ Generated metadata ------------------------------
PR-URL: https://github.com/nodejs/node/pull/64768
Reviewed-By: James M Snell <jasnell@gmail.com>
--------------------------------------------------------------------------------
ℹ This PR was created on Sun, 26 Jul 2026 20:26:23 GMT
✔ Approvals: 1
✔ - James M Snell (@jasnell) (TSC): https://github.com/nodejs/node/pull/64768#pullrequestreview-4889028017
✘ GitHub CI is still running
ℹ Last Full PR CI on 2026-08-20T03:22:16Z: https://ci.nodejs.org/job/node-test-pull-request/76030/
- Querying data for job/node-test-pull-request/76030/
✔ Build data downloaded
✔ Last Jenkins CI successful
--------------------------------------------------------------------------------
✔ Aborted `git node land` session in /home/runner/work/node/node/.ncu
https://github.com/nodejs/node/actions/runs/32384310354

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

It says: "⚠ Could not retrieve the email or name of the PR author's from user's GitHub profile!"
I wonder what this means. PR worked before for me with node.js .

@jasnell

Copy link
Copy Markdown
Member

I wouldn't worry about that. I'll squash and merge these directly.

jasnell pushed a commit that referenced this pull request Aug 20, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
@jasnell

Copy link
Copy Markdown
Member

Landed in 2bfc1e9

@jasnelljasnell closed this Aug 20, 2026
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 27, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++Issues and PRs that require attention from people who are familiar with C++.commit-queue-failedPRs whose Commit Queue landing failed and need manual intervention before retrying.commit-queue-squashPRs the Commit Queue should land as one squashed commit.needs-ciPRs that need a full CI run.quicIssues and PRs related to the QUIC transport implementation.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@martenrichter@nodejs-github-bot@avivkeller@pimterry@trivikr@jasnell
, '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

quic: write desired size needs update on maxstream - #64768

Closed
martenrichter wants to merge 10 commits into
nodejs:mainfrom
martenrichter:writedesired
Closed

quic: write desired size needs update on maxstream#64768
martenrichter wants to merge 10 commits into
nodejs:mainfrom
martenrichter:writedesired

Conversation

@martenrichter

Copy link
Copy Markdown
Contributor

Without this update the streams can stall, if the chunks are close or bigger than the window size.
It was provoked by a very special timing, so probably hard to test,
Though I do not know, if this is also required for max data of the whole session.
Upstream says no:
ngtcp2/ngtcp2#2243

Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/quic

@nodejs-github-botnodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. quic Issues and PRs related to the QUIC transport implementation. labels Jul 26, 2026
@martenrichter

Copy link
Copy Markdown
ContributorAuthor

@jasnell@pimterry can you have a look?

@avivkeller

Copy link
Copy Markdown
Member

Can you add a test?

@codecov

codecovBot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.14%. Comparing base (9024119) to head (ad505ca).
⚠️ Report is 411 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #64768 +/- ##
==========================================
- Coverage 90.15% 90.14% -0.01% 
==========================================
Files 743 746 +3 Lines 242407 242660 +253 Branches 45645 45723 +78 ==========================================
+ Hits 218532 218750 +218 - Misses 15357 15425 +68 + Partials 8518 8485 -33 

see 55 files with indirect coverage changes

🚀 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.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Can you add a test?

Not really. I found it in my webtransport test suite. But there it happens only, if I use node.js server and client. If I choose a Quiche client and a Node.js server, or a Node.js client and a Quiche server, it does not surface, as it depends on the particular packet size the particular Quic internals are generating at the low level. But I will later port all of my wt tests over, but therefore wt must be ready. (But this must not mean that these would reliably trigger the issue).

@pimterry

Copy link
Copy Markdown
Member

Not really. I found it in my webtransport test suite.

I wanted to understand, so I did some digging. I think the trigger is actually quite clear: UpdateWriteDesiredSize was only called on ack, but the stream window is extended independently of acks. A peer can ack all our data, wait (all data acked but zero window available) and then extend the window size to allow more data later - so we do need to update desired size based on the window update alone 👍. Good find @martenrichter! This is tricky to trigger as it's easily obscured by buffering.

We can repro it reliably with something like this:

import{createPrivateKey}from'node:crypto';import{readFile}from'node:fs/promises';import{setTimeoutassleep}from'node:timers/promises';import{listen,connect}from'node:quic';import{drainableProtocol}from'stream/iter';constkeys='test/fixtures/keys';constkey=createPrivateKey(awaitreadFile(`${keys}/agent1-key.pem`));constcert=awaitreadFile(`${keys}/agent1-cert.pem`);constWINDOW=4096;// Fills the window exactly: HTTP/3 spends 11 of those bytes on framing (8 for// the HEADERS frame below, 3 for the DATA frame header). The send buffer then// empties at the same moment the window reaches zero, leaving nothing in// flight to ack. Any other size leaves bytes queued, and the ack for those// wakes the writer instead, hiding the bug.constBODY=WINDOW-11;letletServerRead;constserverMayRead=newPromise((resolve)=>{letServerRead=resolve;});constendpoint=awaitlisten((session)=>{session.onstream=async(stream)=>{awaitserverMayRead;forawait(const_ofstream){/* reading extends the window */}};},{sni: {'*': {keys: [key],certs: [cert]}},transportParams: {initialMaxStreamDataBidiRemote: WINDOW,initialMaxData: 1024*1024,},onheaders(){this.sendHeaders({':status': '200'});},});constsession=awaitconnect(endpoint.address,{servername: 'localhost',verifyPeer: 'manual',});awaitsession.opened;// Budget well above the window, so the window is what stops the writer.conststream=awaitsession.createBidirectionalStream({budget: 1024*1024});stream.sendHeaders({':method': 'POST',':path': '/',':scheme': 'https',':authority': 'localhost',},{terminal: false});constwriter=stream.writer;writer.writeSync(newUint8Array(BODY));// Long enough for every byte to be acked. The peer acks as data arrives,// whether or not its application has read any of it, so by now the window is// exhausted, the send buffer is empty, and no further ACK can arrive.awaitsleep(500);console.log('all acked, window exhausted');constwatchdog=setTimeout(()=>{console.log('STALLED: no drain after MAX_STREAM_DATA');process.exit(1);},5000);letServerRead();// extend the window, with no ack attachedconsole.log('window extended');awaitwriter[drainableProtocol]();console.log('writer drain');clearTimeout(watchdog);console.log('done');process.exit(0);

This creates a server which doesn't read, so it'll ack everything without extending the window. The client writes the exact amount to fill the window but leave nothing in its buffers (otherwise the window update triggers more writes, and then gets acks). Then after a 500ms pause, the server reads and triggers a window update.

Fixed client will trigger a drain and complete OK, bad client will stall forever. We should be able to build a test around that directly.

The fix here covers this for HTTP/3, but doesn't totally fix the issue because we have two independent implementations of this with the current application structure, so we'll need to also separately fix the equivalent issue again for pure QUIC in the default app.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

And we may also need it for session window, but I think we also need a callback from ngtcp2 for this.
Btw. that is the reason why there was so little progress from the last 3 weekends, it was horrible to debug (and I had a second error in the client code that was interfering).

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

@pimterry I am tring to integrate your test. But actually, the bug I was catching was different; it was actually two buffers, one inside the window size and the other going beyond the window size. At least this what I remember from debugging.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Ok, also done for pure quic.
What is missing though, is the same for maxdata.
So I think we should create a separate issue for this with test, but I do not see a fix, as the ngtcp2 callback is missing.
I am refering to this line:

uint64_t conn_left = ngtcp2_conn_get_max_data_left(conn);

it is less likely to cause a stall, but it may happen.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Create a separate issue for maxdata, as I have no clue how to address it currently.
#64835

@jasnelljasnell added the commit-queue-squash PRs the Commit Queue should land as one squashed commit. label Aug 8, 2026
@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@trivikrtrivikr removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@trivikr

Copy link
Copy Markdown
Member

It looks like this PR is ready to land.

@jasnelljasnell added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@nodejs-github-botnodejs-github-bot added commit-queue-failed PRs whose Commit Queue landing failed and need manual intervention before retrying. and removed commit-queue PRs queued for automated landing through the Commit Queue. labels Aug 20, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator
Commit Queue failed
- Loading data for nodejs/node/pull/64768
✔ Done loading data for nodejs/node/pull/64768
----------------------------------- PR info ------------------------------------
Title quic: write desired size needs update on maxstream (#64768)
⚠ Could not retrieve the email or name of the PR author's from user's GitHub profile!
Branch martenrichter:writedesired -> nodejs:main
Labels c++, needs-ci, quic, commit-queue, commit-queue-squash
Commits 10
- quic: write desired size needs update on maxstream
- quic: Fix lint
- quic: add test for maxstreamdata buffer stalls
- quic: rename test
- quic: fix streamaxdata for non http/3
- quic: Fix lint
- quic: fix lint
- Fix lint2
- Fix lint 3
- Fix lint 4
Committers 2
- Marten Richter <marten.richter@tu-berlin.de>
- Marten Richter <marten.richter@freenet.de>
PR-URL: https://github.com/nodejs/node/pull/64768
Reviewed-By: James M Snell <jasnell@gmail.com>
------------------------------ Generated metadata ------------------------------
PR-URL: https://github.com/nodejs/node/pull/64768
Reviewed-By: James M Snell <jasnell@gmail.com>
--------------------------------------------------------------------------------
ℹ This PR was created on Sun, 26 Jul 2026 20:26:23 GMT
✔ Approvals: 1
✔ - James M Snell (@jasnell) (TSC): https://github.com/nodejs/node/pull/64768#pullrequestreview-4889028017
✘ GitHub CI is still running
ℹ Last Full PR CI on 2026-08-20T03:22:16Z: https://ci.nodejs.org/job/node-test-pull-request/76030/
- Querying data for job/node-test-pull-request/76030/
✔ Build data downloaded
✔ Last Jenkins CI successful
--------------------------------------------------------------------------------
✔ Aborted `git node land` session in /home/runner/work/node/node/.ncu
https://github.com/nodejs/node/actions/runs/32384310354

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

It says: "⚠ Could not retrieve the email or name of the PR author's from user's GitHub profile!"
I wonder what this means. PR worked before for me with node.js .

@jasnell

Copy link
Copy Markdown
Member

I wouldn't worry about that. I'll squash and merge these directly.

jasnell pushed a commit that referenced this pull request Aug 20, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
@jasnell

Copy link
Copy Markdown
Member

Landed in 2bfc1e9

@jasnelljasnell closed this Aug 20, 2026
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 27, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++Issues and PRs that require attention from people who are familiar with C++.commit-queue-failedPRs whose Commit Queue landing failed and need manual intervention before retrying.commit-queue-squashPRs the Commit Queue should land as one squashed commit.needs-ciPRs that need a full CI run.quicIssues and PRs related to the QUIC transport implementation.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@martenrichter@nodejs-github-bot@avivkeller@pimterry@trivikr@jasnell
, '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

quic: write desired size needs update on maxstream - #64768

Closed
martenrichter wants to merge 10 commits into
nodejs:mainfrom
martenrichter:writedesired
Closed

quic: write desired size needs update on maxstream#64768
martenrichter wants to merge 10 commits into
nodejs:mainfrom
martenrichter:writedesired

Conversation

@martenrichter

Copy link
Copy Markdown
Contributor

Without this update the streams can stall, if the chunks are close or bigger than the window size.
It was provoked by a very special timing, so probably hard to test,
Though I do not know, if this is also required for max data of the whole session.
Upstream says no:
ngtcp2/ngtcp2#2243

Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/quic

@nodejs-github-botnodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. quic Issues and PRs related to the QUIC transport implementation. labels Jul 26, 2026
@martenrichter

Copy link
Copy Markdown
ContributorAuthor

@jasnell@pimterry can you have a look?

@avivkeller

Copy link
Copy Markdown
Member

Can you add a test?

@codecov

codecovBot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.14%. Comparing base (9024119) to head (ad505ca).
⚠️ Report is 411 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #64768 +/- ##
==========================================
- Coverage 90.15% 90.14% -0.01% 
==========================================
Files 743 746 +3 Lines 242407 242660 +253 Branches 45645 45723 +78 ==========================================
+ Hits 218532 218750 +218 - Misses 15357 15425 +68 + Partials 8518 8485 -33 

see 55 files with indirect coverage changes

🚀 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.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Can you add a test?

Not really. I found it in my webtransport test suite. But there it happens only, if I use node.js server and client. If I choose a Quiche client and a Node.js server, or a Node.js client and a Quiche server, it does not surface, as it depends on the particular packet size the particular Quic internals are generating at the low level. But I will later port all of my wt tests over, but therefore wt must be ready. (But this must not mean that these would reliably trigger the issue).

@pimterry

Copy link
Copy Markdown
Member

Not really. I found it in my webtransport test suite.

I wanted to understand, so I did some digging. I think the trigger is actually quite clear: UpdateWriteDesiredSize was only called on ack, but the stream window is extended independently of acks. A peer can ack all our data, wait (all data acked but zero window available) and then extend the window size to allow more data later - so we do need to update desired size based on the window update alone 👍. Good find @martenrichter! This is tricky to trigger as it's easily obscured by buffering.

We can repro it reliably with something like this:

import{createPrivateKey}from'node:crypto';import{readFile}from'node:fs/promises';import{setTimeoutassleep}from'node:timers/promises';import{listen,connect}from'node:quic';import{drainableProtocol}from'stream/iter';constkeys='test/fixtures/keys';constkey=createPrivateKey(awaitreadFile(`${keys}/agent1-key.pem`));constcert=awaitreadFile(`${keys}/agent1-cert.pem`);constWINDOW=4096;// Fills the window exactly: HTTP/3 spends 11 of those bytes on framing (8 for// the HEADERS frame below, 3 for the DATA frame header). The send buffer then// empties at the same moment the window reaches zero, leaving nothing in// flight to ack. Any other size leaves bytes queued, and the ack for those// wakes the writer instead, hiding the bug.constBODY=WINDOW-11;letletServerRead;constserverMayRead=newPromise((resolve)=>{letServerRead=resolve;});constendpoint=awaitlisten((session)=>{session.onstream=async(stream)=>{awaitserverMayRead;forawait(const_ofstream){/* reading extends the window */}};},{sni: {'*': {keys: [key],certs: [cert]}},transportParams: {initialMaxStreamDataBidiRemote: WINDOW,initialMaxData: 1024*1024,},onheaders(){this.sendHeaders({':status': '200'});},});constsession=awaitconnect(endpoint.address,{servername: 'localhost',verifyPeer: 'manual',});awaitsession.opened;// Budget well above the window, so the window is what stops the writer.conststream=awaitsession.createBidirectionalStream({budget: 1024*1024});stream.sendHeaders({':method': 'POST',':path': '/',':scheme': 'https',':authority': 'localhost',},{terminal: false});constwriter=stream.writer;writer.writeSync(newUint8Array(BODY));// Long enough for every byte to be acked. The peer acks as data arrives,// whether or not its application has read any of it, so by now the window is// exhausted, the send buffer is empty, and no further ACK can arrive.awaitsleep(500);console.log('all acked, window exhausted');constwatchdog=setTimeout(()=>{console.log('STALLED: no drain after MAX_STREAM_DATA');process.exit(1);},5000);letServerRead();// extend the window, with no ack attachedconsole.log('window extended');awaitwriter[drainableProtocol]();console.log('writer drain');clearTimeout(watchdog);console.log('done');process.exit(0);

This creates a server which doesn't read, so it'll ack everything without extending the window. The client writes the exact amount to fill the window but leave nothing in its buffers (otherwise the window update triggers more writes, and then gets acks). Then after a 500ms pause, the server reads and triggers a window update.

Fixed client will trigger a drain and complete OK, bad client will stall forever. We should be able to build a test around that directly.

The fix here covers this for HTTP/3, but doesn't totally fix the issue because we have two independent implementations of this with the current application structure, so we'll need to also separately fix the equivalent issue again for pure QUIC in the default app.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

And we may also need it for session window, but I think we also need a callback from ngtcp2 for this.
Btw. that is the reason why there was so little progress from the last 3 weekends, it was horrible to debug (and I had a second error in the client code that was interfering).

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

@pimterry I am tring to integrate your test. But actually, the bug I was catching was different; it was actually two buffers, one inside the window size and the other going beyond the window size. At least this what I remember from debugging.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Ok, also done for pure quic.
What is missing though, is the same for maxdata.
So I think we should create a separate issue for this with test, but I do not see a fix, as the ngtcp2 callback is missing.
I am refering to this line:

uint64_t conn_left = ngtcp2_conn_get_max_data_left(conn);

it is less likely to cause a stall, but it may happen.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Create a separate issue for maxdata, as I have no clue how to address it currently.
#64835

@jasnelljasnell added the commit-queue-squash PRs the Commit Queue should land as one squashed commit. label Aug 8, 2026
@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@trivikrtrivikr removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@trivikr

Copy link
Copy Markdown
Member

It looks like this PR is ready to land.

@jasnelljasnell added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@nodejs-github-botnodejs-github-bot added commit-queue-failed PRs whose Commit Queue landing failed and need manual intervention before retrying. and removed commit-queue PRs queued for automated landing through the Commit Queue. labels Aug 20, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator
Commit Queue failed
- Loading data for nodejs/node/pull/64768
✔ Done loading data for nodejs/node/pull/64768
----------------------------------- PR info ------------------------------------
Title quic: write desired size needs update on maxstream (#64768)
⚠ Could not retrieve the email or name of the PR author's from user's GitHub profile!
Branch martenrichter:writedesired -> nodejs:main
Labels c++, needs-ci, quic, commit-queue, commit-queue-squash
Commits 10
- quic: write desired size needs update on maxstream
- quic: Fix lint
- quic: add test for maxstreamdata buffer stalls
- quic: rename test
- quic: fix streamaxdata for non http/3
- quic: Fix lint
- quic: fix lint
- Fix lint2
- Fix lint 3
- Fix lint 4
Committers 2
- Marten Richter <marten.richter@tu-berlin.de>
- Marten Richter <marten.richter@freenet.de>
PR-URL: https://github.com/nodejs/node/pull/64768
Reviewed-By: James M Snell <jasnell@gmail.com>
------------------------------ Generated metadata ------------------------------
PR-URL: https://github.com/nodejs/node/pull/64768
Reviewed-By: James M Snell <jasnell@gmail.com>
--------------------------------------------------------------------------------
ℹ This PR was created on Sun, 26 Jul 2026 20:26:23 GMT
✔ Approvals: 1
✔ - James M Snell (@jasnell) (TSC): https://github.com/nodejs/node/pull/64768#pullrequestreview-4889028017
✘ GitHub CI is still running
ℹ Last Full PR CI on 2026-08-20T03:22:16Z: https://ci.nodejs.org/job/node-test-pull-request/76030/
- Querying data for job/node-test-pull-request/76030/
✔ Build data downloaded
✔ Last Jenkins CI successful
--------------------------------------------------------------------------------
✔ Aborted `git node land` session in /home/runner/work/node/node/.ncu
https://github.com/nodejs/node/actions/runs/32384310354

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

It says: "⚠ Could not retrieve the email or name of the PR author's from user's GitHub profile!"
I wonder what this means. PR worked before for me with node.js .

@jasnell

Copy link
Copy Markdown
Member

I wouldn't worry about that. I'll squash and merge these directly.

jasnell pushed a commit that referenced this pull request Aug 20, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
@jasnell

Copy link
Copy Markdown
Member

Landed in 2bfc1e9

@jasnelljasnell closed this Aug 20, 2026
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 27, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++Issues and PRs that require attention from people who are familiar with C++.commit-queue-failedPRs whose Commit Queue landing failed and need manual intervention before retrying.commit-queue-squashPRs the Commit Queue should land as one squashed commit.needs-ciPRs that need a full CI run.quicIssues and PRs related to the QUIC transport implementation.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@martenrichter@nodejs-github-bot@avivkeller@pimterry@trivikr@jasnell
, '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

quic: write desired size needs update on maxstream - #64768

Closed
martenrichter wants to merge 10 commits into
nodejs:mainfrom
martenrichter:writedesired
Closed

quic: write desired size needs update on maxstream#64768
martenrichter wants to merge 10 commits into
nodejs:mainfrom
martenrichter:writedesired

Conversation

@martenrichter

Copy link
Copy Markdown
Contributor

Without this update the streams can stall, if the chunks are close or bigger than the window size.
It was provoked by a very special timing, so probably hard to test,
Though I do not know, if this is also required for max data of the whole session.
Upstream says no:
ngtcp2/ngtcp2#2243

Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/quic

@nodejs-github-botnodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. quic Issues and PRs related to the QUIC transport implementation. labels Jul 26, 2026
@martenrichter

Copy link
Copy Markdown
ContributorAuthor

@jasnell@pimterry can you have a look?

@avivkeller

Copy link
Copy Markdown
Member

Can you add a test?

@codecov

codecovBot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.14%. Comparing base (9024119) to head (ad505ca).
⚠️ Report is 411 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #64768 +/- ##
==========================================
- Coverage 90.15% 90.14% -0.01% 
==========================================
Files 743 746 +3 Lines 242407 242660 +253 Branches 45645 45723 +78 ==========================================
+ Hits 218532 218750 +218 - Misses 15357 15425 +68 + Partials 8518 8485 -33 

see 55 files with indirect coverage changes

🚀 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.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Can you add a test?

Not really. I found it in my webtransport test suite. But there it happens only, if I use node.js server and client. If I choose a Quiche client and a Node.js server, or a Node.js client and a Quiche server, it does not surface, as it depends on the particular packet size the particular Quic internals are generating at the low level. But I will later port all of my wt tests over, but therefore wt must be ready. (But this must not mean that these would reliably trigger the issue).

@pimterry

Copy link
Copy Markdown
Member

Not really. I found it in my webtransport test suite.

I wanted to understand, so I did some digging. I think the trigger is actually quite clear: UpdateWriteDesiredSize was only called on ack, but the stream window is extended independently of acks. A peer can ack all our data, wait (all data acked but zero window available) and then extend the window size to allow more data later - so we do need to update desired size based on the window update alone 👍. Good find @martenrichter! This is tricky to trigger as it's easily obscured by buffering.

We can repro it reliably with something like this:

import{createPrivateKey}from'node:crypto';import{readFile}from'node:fs/promises';import{setTimeoutassleep}from'node:timers/promises';import{listen,connect}from'node:quic';import{drainableProtocol}from'stream/iter';constkeys='test/fixtures/keys';constkey=createPrivateKey(awaitreadFile(`${keys}/agent1-key.pem`));constcert=awaitreadFile(`${keys}/agent1-cert.pem`);constWINDOW=4096;// Fills the window exactly: HTTP/3 spends 11 of those bytes on framing (8 for// the HEADERS frame below, 3 for the DATA frame header). The send buffer then// empties at the same moment the window reaches zero, leaving nothing in// flight to ack. Any other size leaves bytes queued, and the ack for those// wakes the writer instead, hiding the bug.constBODY=WINDOW-11;letletServerRead;constserverMayRead=newPromise((resolve)=>{letServerRead=resolve;});constendpoint=awaitlisten((session)=>{session.onstream=async(stream)=>{awaitserverMayRead;forawait(const_ofstream){/* reading extends the window */}};},{sni: {'*': {keys: [key],certs: [cert]}},transportParams: {initialMaxStreamDataBidiRemote: WINDOW,initialMaxData: 1024*1024,},onheaders(){this.sendHeaders({':status': '200'});},});constsession=awaitconnect(endpoint.address,{servername: 'localhost',verifyPeer: 'manual',});awaitsession.opened;// Budget well above the window, so the window is what stops the writer.conststream=awaitsession.createBidirectionalStream({budget: 1024*1024});stream.sendHeaders({':method': 'POST',':path': '/',':scheme': 'https',':authority': 'localhost',},{terminal: false});constwriter=stream.writer;writer.writeSync(newUint8Array(BODY));// Long enough for every byte to be acked. The peer acks as data arrives,// whether or not its application has read any of it, so by now the window is// exhausted, the send buffer is empty, and no further ACK can arrive.awaitsleep(500);console.log('all acked, window exhausted');constwatchdog=setTimeout(()=>{console.log('STALLED: no drain after MAX_STREAM_DATA');process.exit(1);},5000);letServerRead();// extend the window, with no ack attachedconsole.log('window extended');awaitwriter[drainableProtocol]();console.log('writer drain');clearTimeout(watchdog);console.log('done');process.exit(0);

This creates a server which doesn't read, so it'll ack everything without extending the window. The client writes the exact amount to fill the window but leave nothing in its buffers (otherwise the window update triggers more writes, and then gets acks). Then after a 500ms pause, the server reads and triggers a window update.

Fixed client will trigger a drain and complete OK, bad client will stall forever. We should be able to build a test around that directly.

The fix here covers this for HTTP/3, but doesn't totally fix the issue because we have two independent implementations of this with the current application structure, so we'll need to also separately fix the equivalent issue again for pure QUIC in the default app.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

And we may also need it for session window, but I think we also need a callback from ngtcp2 for this.
Btw. that is the reason why there was so little progress from the last 3 weekends, it was horrible to debug (and I had a second error in the client code that was interfering).

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

@pimterry I am tring to integrate your test. But actually, the bug I was catching was different; it was actually two buffers, one inside the window size and the other going beyond the window size. At least this what I remember from debugging.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Ok, also done for pure quic.
What is missing though, is the same for maxdata.
So I think we should create a separate issue for this with test, but I do not see a fix, as the ngtcp2 callback is missing.
I am refering to this line:

uint64_t conn_left = ngtcp2_conn_get_max_data_left(conn);

it is less likely to cause a stall, but it may happen.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Create a separate issue for maxdata, as I have no clue how to address it currently.
#64835

@jasnelljasnell added the commit-queue-squash PRs the Commit Queue should land as one squashed commit. label Aug 8, 2026
@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@trivikrtrivikr removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@trivikr

Copy link
Copy Markdown
Member

It looks like this PR is ready to land.

@jasnelljasnell added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@nodejs-github-botnodejs-github-bot added commit-queue-failed PRs whose Commit Queue landing failed and need manual intervention before retrying. and removed commit-queue PRs queued for automated landing through the Commit Queue. labels Aug 20, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator
Commit Queue failed
- Loading data for nodejs/node/pull/64768
✔ Done loading data for nodejs/node/pull/64768
----------------------------------- PR info ------------------------------------
Title quic: write desired size needs update on maxstream (#64768)
⚠ Could not retrieve the email or name of the PR author's from user's GitHub profile!
Branch martenrichter:writedesired -> nodejs:main
Labels c++, needs-ci, quic, commit-queue, commit-queue-squash
Commits 10
- quic: write desired size needs update on maxstream
- quic: Fix lint
- quic: add test for maxstreamdata buffer stalls
- quic: rename test
- quic: fix streamaxdata for non http/3
- quic: Fix lint
- quic: fix lint
- Fix lint2
- Fix lint 3
- Fix lint 4
Committers 2
- Marten Richter <marten.richter@tu-berlin.de>
- Marten Richter <marten.richter@freenet.de>
PR-URL: https://github.com/nodejs/node/pull/64768
Reviewed-By: James M Snell <jasnell@gmail.com>
------------------------------ Generated metadata ------------------------------
PR-URL: https://github.com/nodejs/node/pull/64768
Reviewed-By: James M Snell <jasnell@gmail.com>
--------------------------------------------------------------------------------
ℹ This PR was created on Sun, 26 Jul 2026 20:26:23 GMT
✔ Approvals: 1
✔ - James M Snell (@jasnell) (TSC): https://github.com/nodejs/node/pull/64768#pullrequestreview-4889028017
✘ GitHub CI is still running
ℹ Last Full PR CI on 2026-08-20T03:22:16Z: https://ci.nodejs.org/job/node-test-pull-request/76030/
- Querying data for job/node-test-pull-request/76030/
✔ Build data downloaded
✔ Last Jenkins CI successful
--------------------------------------------------------------------------------
✔ Aborted `git node land` session in /home/runner/work/node/node/.ncu
https://github.com/nodejs/node/actions/runs/32384310354

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

It says: "⚠ Could not retrieve the email or name of the PR author's from user's GitHub profile!"
I wonder what this means. PR worked before for me with node.js .

@jasnell

Copy link
Copy Markdown
Member

I wouldn't worry about that. I'll squash and merge these directly.

jasnell pushed a commit that referenced this pull request Aug 20, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
@jasnell

Copy link
Copy Markdown
Member

Landed in 2bfc1e9

@jasnelljasnell closed this Aug 20, 2026
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 27, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++Issues and PRs that require attention from people who are familiar with C++.commit-queue-failedPRs whose Commit Queue landing failed and need manual intervention before retrying.commit-queue-squashPRs the Commit Queue should land as one squashed commit.needs-ciPRs that need a full CI run.quicIssues and PRs related to the QUIC transport implementation.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@martenrichter@nodejs-github-bot@avivkeller@pimterry@trivikr@jasnell
, '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

quic: write desired size needs update on maxstream - #64768

Closed
martenrichter wants to merge 10 commits into
nodejs:mainfrom
martenrichter:writedesired
Closed

quic: write desired size needs update on maxstream#64768
martenrichter wants to merge 10 commits into
nodejs:mainfrom
martenrichter:writedesired

Conversation

@martenrichter

Copy link
Copy Markdown
Contributor

Without this update the streams can stall, if the chunks are close or bigger than the window size.
It was provoked by a very special timing, so probably hard to test,
Though I do not know, if this is also required for max data of the whole session.
Upstream says no:
ngtcp2/ngtcp2#2243

Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/quic

@nodejs-github-botnodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. quic Issues and PRs related to the QUIC transport implementation. labels Jul 26, 2026
@martenrichter

Copy link
Copy Markdown
ContributorAuthor

@jasnell@pimterry can you have a look?

@avivkeller

Copy link
Copy Markdown
Member

Can you add a test?

@codecov

codecovBot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.14%. Comparing base (9024119) to head (ad505ca).
⚠️ Report is 411 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #64768 +/- ##
==========================================
- Coverage 90.15% 90.14% -0.01% 
==========================================
Files 743 746 +3 Lines 242407 242660 +253 Branches 45645 45723 +78 ==========================================
+ Hits 218532 218750 +218 - Misses 15357 15425 +68 + Partials 8518 8485 -33 

see 55 files with indirect coverage changes

🚀 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.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Can you add a test?

Not really. I found it in my webtransport test suite. But there it happens only, if I use node.js server and client. If I choose a Quiche client and a Node.js server, or a Node.js client and a Quiche server, it does not surface, as it depends on the particular packet size the particular Quic internals are generating at the low level. But I will later port all of my wt tests over, but therefore wt must be ready. (But this must not mean that these would reliably trigger the issue).

@pimterry

Copy link
Copy Markdown
Member

Not really. I found it in my webtransport test suite.

I wanted to understand, so I did some digging. I think the trigger is actually quite clear: UpdateWriteDesiredSize was only called on ack, but the stream window is extended independently of acks. A peer can ack all our data, wait (all data acked but zero window available) and then extend the window size to allow more data later - so we do need to update desired size based on the window update alone 👍. Good find @martenrichter! This is tricky to trigger as it's easily obscured by buffering.

We can repro it reliably with something like this:

import{createPrivateKey}from'node:crypto';import{readFile}from'node:fs/promises';import{setTimeoutassleep}from'node:timers/promises';import{listen,connect}from'node:quic';import{drainableProtocol}from'stream/iter';constkeys='test/fixtures/keys';constkey=createPrivateKey(awaitreadFile(`${keys}/agent1-key.pem`));constcert=awaitreadFile(`${keys}/agent1-cert.pem`);constWINDOW=4096;// Fills the window exactly: HTTP/3 spends 11 of those bytes on framing (8 for// the HEADERS frame below, 3 for the DATA frame header). The send buffer then// empties at the same moment the window reaches zero, leaving nothing in// flight to ack. Any other size leaves bytes queued, and the ack for those// wakes the writer instead, hiding the bug.constBODY=WINDOW-11;letletServerRead;constserverMayRead=newPromise((resolve)=>{letServerRead=resolve;});constendpoint=awaitlisten((session)=>{session.onstream=async(stream)=>{awaitserverMayRead;forawait(const_ofstream){/* reading extends the window */}};},{sni: {'*': {keys: [key],certs: [cert]}},transportParams: {initialMaxStreamDataBidiRemote: WINDOW,initialMaxData: 1024*1024,},onheaders(){this.sendHeaders({':status': '200'});},});constsession=awaitconnect(endpoint.address,{servername: 'localhost',verifyPeer: 'manual',});awaitsession.opened;// Budget well above the window, so the window is what stops the writer.conststream=awaitsession.createBidirectionalStream({budget: 1024*1024});stream.sendHeaders({':method': 'POST',':path': '/',':scheme': 'https',':authority': 'localhost',},{terminal: false});constwriter=stream.writer;writer.writeSync(newUint8Array(BODY));// Long enough for every byte to be acked. The peer acks as data arrives,// whether or not its application has read any of it, so by now the window is// exhausted, the send buffer is empty, and no further ACK can arrive.awaitsleep(500);console.log('all acked, window exhausted');constwatchdog=setTimeout(()=>{console.log('STALLED: no drain after MAX_STREAM_DATA');process.exit(1);},5000);letServerRead();// extend the window, with no ack attachedconsole.log('window extended');awaitwriter[drainableProtocol]();console.log('writer drain');clearTimeout(watchdog);console.log('done');process.exit(0);

This creates a server which doesn't read, so it'll ack everything without extending the window. The client writes the exact amount to fill the window but leave nothing in its buffers (otherwise the window update triggers more writes, and then gets acks). Then after a 500ms pause, the server reads and triggers a window update.

Fixed client will trigger a drain and complete OK, bad client will stall forever. We should be able to build a test around that directly.

The fix here covers this for HTTP/3, but doesn't totally fix the issue because we have two independent implementations of this with the current application structure, so we'll need to also separately fix the equivalent issue again for pure QUIC in the default app.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

And we may also need it for session window, but I think we also need a callback from ngtcp2 for this.
Btw. that is the reason why there was so little progress from the last 3 weekends, it was horrible to debug (and I had a second error in the client code that was interfering).

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

@pimterry I am tring to integrate your test. But actually, the bug I was catching was different; it was actually two buffers, one inside the window size and the other going beyond the window size. At least this what I remember from debugging.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Ok, also done for pure quic.
What is missing though, is the same for maxdata.
So I think we should create a separate issue for this with test, but I do not see a fix, as the ngtcp2 callback is missing.
I am refering to this line:

uint64_t conn_left = ngtcp2_conn_get_max_data_left(conn);

it is less likely to cause a stall, but it may happen.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Create a separate issue for maxdata, as I have no clue how to address it currently.
#64835

@jasnelljasnell added the commit-queue-squash PRs the Commit Queue should land as one squashed commit. label Aug 8, 2026
@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@trivikrtrivikr removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@trivikr

Copy link
Copy Markdown
Member

It looks like this PR is ready to land.

@jasnelljasnell added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@nodejs-github-botnodejs-github-bot added commit-queue-failed PRs whose Commit Queue landing failed and need manual intervention before retrying. and removed commit-queue PRs queued for automated landing through the Commit Queue. labels Aug 20, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator
Commit Queue failed
- Loading data for nodejs/node/pull/64768
✔ Done loading data for nodejs/node/pull/64768
----------------------------------- PR info ------------------------------------
Title quic: write desired size needs update on maxstream (#64768)
⚠ Could not retrieve the email or name of the PR author's from user's GitHub profile!
Branch martenrichter:writedesired -> nodejs:main
Labels c++, needs-ci, quic, commit-queue, commit-queue-squash
Commits 10
- quic: write desired size needs update on maxstream
- quic: Fix lint
- quic: add test for maxstreamdata buffer stalls
- quic: rename test
- quic: fix streamaxdata for non http/3
- quic: Fix lint
- quic: fix lint
- Fix lint2
- Fix lint 3
- Fix lint 4
Committers 2
- Marten Richter <marten.richter@tu-berlin.de>
- Marten Richter <marten.richter@freenet.de>
PR-URL: https://github.com/nodejs/node/pull/64768
Reviewed-By: James M Snell <jasnell@gmail.com>
------------------------------ Generated metadata ------------------------------
PR-URL: https://github.com/nodejs/node/pull/64768
Reviewed-By: James M Snell <jasnell@gmail.com>
--------------------------------------------------------------------------------
ℹ This PR was created on Sun, 26 Jul 2026 20:26:23 GMT
✔ Approvals: 1
✔ - James M Snell (@jasnell) (TSC): https://github.com/nodejs/node/pull/64768#pullrequestreview-4889028017
✘ GitHub CI is still running
ℹ Last Full PR CI on 2026-08-20T03:22:16Z: https://ci.nodejs.org/job/node-test-pull-request/76030/
- Querying data for job/node-test-pull-request/76030/
✔ Build data downloaded
✔ Last Jenkins CI successful
--------------------------------------------------------------------------------
✔ Aborted `git node land` session in /home/runner/work/node/node/.ncu
https://github.com/nodejs/node/actions/runs/32384310354

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

It says: "⚠ Could not retrieve the email or name of the PR author's from user's GitHub profile!"
I wonder what this means. PR worked before for me with node.js .

@jasnell

Copy link
Copy Markdown
Member

I wouldn't worry about that. I'll squash and merge these directly.

jasnell pushed a commit that referenced this pull request Aug 20, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
@jasnell

Copy link
Copy Markdown
Member

Landed in 2bfc1e9

@jasnelljasnell closed this Aug 20, 2026
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 27, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++Issues and PRs that require attention from people who are familiar with C++.commit-queue-failedPRs whose Commit Queue landing failed and need manual intervention before retrying.commit-queue-squashPRs the Commit Queue should land as one squashed commit.needs-ciPRs that need a full CI run.quicIssues and PRs related to the QUIC transport implementation.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@martenrichter@nodejs-github-bot@avivkeller@pimterry@trivikr@jasnell
, '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

quic: write desired size needs update on maxstream - #64768

Closed
martenrichter wants to merge 10 commits into
nodejs:mainfrom
martenrichter:writedesired
Closed

quic: write desired size needs update on maxstream#64768
martenrichter wants to merge 10 commits into
nodejs:mainfrom
martenrichter:writedesired

Conversation

@martenrichter

Copy link
Copy Markdown
Contributor

Without this update the streams can stall, if the chunks are close or bigger than the window size.
It was provoked by a very special timing, so probably hard to test,
Though I do not know, if this is also required for max data of the whole session.
Upstream says no:
ngtcp2/ngtcp2#2243

Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/quic

@nodejs-github-botnodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. quic Issues and PRs related to the QUIC transport implementation. labels Jul 26, 2026
@martenrichter

Copy link
Copy Markdown
ContributorAuthor

@jasnell@pimterry can you have a look?

@avivkeller

Copy link
Copy Markdown
Member

Can you add a test?

@codecov

codecovBot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.14%. Comparing base (9024119) to head (ad505ca).
⚠️ Report is 411 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #64768 +/- ##
==========================================
- Coverage 90.15% 90.14% -0.01% 
==========================================
Files 743 746 +3 Lines 242407 242660 +253 Branches 45645 45723 +78 ==========================================
+ Hits 218532 218750 +218 - Misses 15357 15425 +68 + Partials 8518 8485 -33 

see 55 files with indirect coverage changes

🚀 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.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Can you add a test?

Not really. I found it in my webtransport test suite. But there it happens only, if I use node.js server and client. If I choose a Quiche client and a Node.js server, or a Node.js client and a Quiche server, it does not surface, as it depends on the particular packet size the particular Quic internals are generating at the low level. But I will later port all of my wt tests over, but therefore wt must be ready. (But this must not mean that these would reliably trigger the issue).

@pimterry

Copy link
Copy Markdown
Member

Not really. I found it in my webtransport test suite.

I wanted to understand, so I did some digging. I think the trigger is actually quite clear: UpdateWriteDesiredSize was only called on ack, but the stream window is extended independently of acks. A peer can ack all our data, wait (all data acked but zero window available) and then extend the window size to allow more data later - so we do need to update desired size based on the window update alone 👍. Good find @martenrichter! This is tricky to trigger as it's easily obscured by buffering.

We can repro it reliably with something like this:

import{createPrivateKey}from'node:crypto';import{readFile}from'node:fs/promises';import{setTimeoutassleep}from'node:timers/promises';import{listen,connect}from'node:quic';import{drainableProtocol}from'stream/iter';constkeys='test/fixtures/keys';constkey=createPrivateKey(awaitreadFile(`${keys}/agent1-key.pem`));constcert=awaitreadFile(`${keys}/agent1-cert.pem`);constWINDOW=4096;// Fills the window exactly: HTTP/3 spends 11 of those bytes on framing (8 for// the HEADERS frame below, 3 for the DATA frame header). The send buffer then// empties at the same moment the window reaches zero, leaving nothing in// flight to ack. Any other size leaves bytes queued, and the ack for those// wakes the writer instead, hiding the bug.constBODY=WINDOW-11;letletServerRead;constserverMayRead=newPromise((resolve)=>{letServerRead=resolve;});constendpoint=awaitlisten((session)=>{session.onstream=async(stream)=>{awaitserverMayRead;forawait(const_ofstream){/* reading extends the window */}};},{sni: {'*': {keys: [key],certs: [cert]}},transportParams: {initialMaxStreamDataBidiRemote: WINDOW,initialMaxData: 1024*1024,},onheaders(){this.sendHeaders({':status': '200'});},});constsession=awaitconnect(endpoint.address,{servername: 'localhost',verifyPeer: 'manual',});awaitsession.opened;// Budget well above the window, so the window is what stops the writer.conststream=awaitsession.createBidirectionalStream({budget: 1024*1024});stream.sendHeaders({':method': 'POST',':path': '/',':scheme': 'https',':authority': 'localhost',},{terminal: false});constwriter=stream.writer;writer.writeSync(newUint8Array(BODY));// Long enough for every byte to be acked. The peer acks as data arrives,// whether or not its application has read any of it, so by now the window is// exhausted, the send buffer is empty, and no further ACK can arrive.awaitsleep(500);console.log('all acked, window exhausted');constwatchdog=setTimeout(()=>{console.log('STALLED: no drain after MAX_STREAM_DATA');process.exit(1);},5000);letServerRead();// extend the window, with no ack attachedconsole.log('window extended');awaitwriter[drainableProtocol]();console.log('writer drain');clearTimeout(watchdog);console.log('done');process.exit(0);

This creates a server which doesn't read, so it'll ack everything without extending the window. The client writes the exact amount to fill the window but leave nothing in its buffers (otherwise the window update triggers more writes, and then gets acks). Then after a 500ms pause, the server reads and triggers a window update.

Fixed client will trigger a drain and complete OK, bad client will stall forever. We should be able to build a test around that directly.

The fix here covers this for HTTP/3, but doesn't totally fix the issue because we have two independent implementations of this with the current application structure, so we'll need to also separately fix the equivalent issue again for pure QUIC in the default app.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

And we may also need it for session window, but I think we also need a callback from ngtcp2 for this.
Btw. that is the reason why there was so little progress from the last 3 weekends, it was horrible to debug (and I had a second error in the client code that was interfering).

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

@pimterry I am tring to integrate your test. But actually, the bug I was catching was different; it was actually two buffers, one inside the window size and the other going beyond the window size. At least this what I remember from debugging.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Ok, also done for pure quic.
What is missing though, is the same for maxdata.
So I think we should create a separate issue for this with test, but I do not see a fix, as the ngtcp2 callback is missing.
I am refering to this line:

uint64_t conn_left = ngtcp2_conn_get_max_data_left(conn);

it is less likely to cause a stall, but it may happen.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Create a separate issue for maxdata, as I have no clue how to address it currently.
#64835

@jasnelljasnell added the commit-queue-squash PRs the Commit Queue should land as one squashed commit. label Aug 8, 2026
@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@trivikrtrivikr removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@trivikr

Copy link
Copy Markdown
Member

It looks like this PR is ready to land.

@jasnelljasnell added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@nodejs-github-botnodejs-github-bot added commit-queue-failed PRs whose Commit Queue landing failed and need manual intervention before retrying. and removed commit-queue PRs queued for automated landing through the Commit Queue. labels Aug 20, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator
Commit Queue failed
- Loading data for nodejs/node/pull/64768
✔ Done loading data for nodejs/node/pull/64768
----------------------------------- PR info ------------------------------------
Title quic: write desired size needs update on maxstream (#64768)
⚠ Could not retrieve the email or name of the PR author's from user's GitHub profile!
Branch martenrichter:writedesired -> nodejs:main
Labels c++, needs-ci, quic, commit-queue, commit-queue-squash
Commits 10
- quic: write desired size needs update on maxstream
- quic: Fix lint
- quic: add test for maxstreamdata buffer stalls
- quic: rename test
- quic: fix streamaxdata for non http/3
- quic: Fix lint
- quic: fix lint
- Fix lint2
- Fix lint 3
- Fix lint 4
Committers 2
- Marten Richter <marten.richter@tu-berlin.de>
- Marten Richter <marten.richter@freenet.de>
PR-URL: https://github.com/nodejs/node/pull/64768
Reviewed-By: James M Snell <jasnell@gmail.com>
------------------------------ Generated metadata ------------------------------
PR-URL: https://github.com/nodejs/node/pull/64768
Reviewed-By: James M Snell <jasnell@gmail.com>
--------------------------------------------------------------------------------
ℹ This PR was created on Sun, 26 Jul 2026 20:26:23 GMT
✔ Approvals: 1
✔ - James M Snell (@jasnell) (TSC): https://github.com/nodejs/node/pull/64768#pullrequestreview-4889028017
✘ GitHub CI is still running
ℹ Last Full PR CI on 2026-08-20T03:22:16Z: https://ci.nodejs.org/job/node-test-pull-request/76030/
- Querying data for job/node-test-pull-request/76030/
✔ Build data downloaded
✔ Last Jenkins CI successful
--------------------------------------------------------------------------------
✔ Aborted `git node land` session in /home/runner/work/node/node/.ncu
https://github.com/nodejs/node/actions/runs/32384310354

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

It says: "⚠ Could not retrieve the email or name of the PR author's from user's GitHub profile!"
I wonder what this means. PR worked before for me with node.js .

@jasnell

Copy link
Copy Markdown
Member

I wouldn't worry about that. I'll squash and merge these directly.

jasnell pushed a commit that referenced this pull request Aug 20, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
@jasnell

Copy link
Copy Markdown
Member

Landed in 2bfc1e9

@jasnelljasnell closed this Aug 20, 2026
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 27, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++Issues and PRs that require attention from people who are familiar with C++.commit-queue-failedPRs whose Commit Queue landing failed and need manual intervention before retrying.commit-queue-squashPRs the Commit Queue should land as one squashed commit.needs-ciPRs that need a full CI run.quicIssues and PRs related to the QUIC transport implementation.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@martenrichter@nodejs-github-bot@avivkeller@pimterry@trivikr@jasnell
, '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

quic: write desired size needs update on maxstream - #64768

Closed
martenrichter wants to merge 10 commits into
nodejs:mainfrom
martenrichter:writedesired
Closed

quic: write desired size needs update on maxstream#64768
martenrichter wants to merge 10 commits into
nodejs:mainfrom
martenrichter:writedesired

Conversation

@martenrichter

Copy link
Copy Markdown
Contributor

Without this update the streams can stall, if the chunks are close or bigger than the window size.
It was provoked by a very special timing, so probably hard to test,
Though I do not know, if this is also required for max data of the whole session.
Upstream says no:
ngtcp2/ngtcp2#2243

Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/quic

@nodejs-github-botnodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. quic Issues and PRs related to the QUIC transport implementation. labels Jul 26, 2026
@martenrichter

Copy link
Copy Markdown
ContributorAuthor

@jasnell@pimterry can you have a look?

@avivkeller

Copy link
Copy Markdown
Member

Can you add a test?

@codecov

codecovBot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.14%. Comparing base (9024119) to head (ad505ca).
⚠️ Report is 411 commits behind head on main.

Additional details and impacted files
@@ Coverage Diff @@## main #64768 +/- ##
==========================================
- Coverage 90.15% 90.14% -0.01% 
==========================================
Files 743 746 +3 Lines 242407 242660 +253 Branches 45645 45723 +78 ==========================================
+ Hits 218532 218750 +218 - Misses 15357 15425 +68 + Partials 8518 8485 -33 

see 55 files with indirect coverage changes

🚀 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.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Can you add a test?

Not really. I found it in my webtransport test suite. But there it happens only, if I use node.js server and client. If I choose a Quiche client and a Node.js server, or a Node.js client and a Quiche server, it does not surface, as it depends on the particular packet size the particular Quic internals are generating at the low level. But I will later port all of my wt tests over, but therefore wt must be ready. (But this must not mean that these would reliably trigger the issue).

@pimterry

Copy link
Copy Markdown
Member

Not really. I found it in my webtransport test suite.

I wanted to understand, so I did some digging. I think the trigger is actually quite clear: UpdateWriteDesiredSize was only called on ack, but the stream window is extended independently of acks. A peer can ack all our data, wait (all data acked but zero window available) and then extend the window size to allow more data later - so we do need to update desired size based on the window update alone 👍. Good find @martenrichter! This is tricky to trigger as it's easily obscured by buffering.

We can repro it reliably with something like this:

import{createPrivateKey}from'node:crypto';import{readFile}from'node:fs/promises';import{setTimeoutassleep}from'node:timers/promises';import{listen,connect}from'node:quic';import{drainableProtocol}from'stream/iter';constkeys='test/fixtures/keys';constkey=createPrivateKey(awaitreadFile(`${keys}/agent1-key.pem`));constcert=awaitreadFile(`${keys}/agent1-cert.pem`);constWINDOW=4096;// Fills the window exactly: HTTP/3 spends 11 of those bytes on framing (8 for// the HEADERS frame below, 3 for the DATA frame header). The send buffer then// empties at the same moment the window reaches zero, leaving nothing in// flight to ack. Any other size leaves bytes queued, and the ack for those// wakes the writer instead, hiding the bug.constBODY=WINDOW-11;letletServerRead;constserverMayRead=newPromise((resolve)=>{letServerRead=resolve;});constendpoint=awaitlisten((session)=>{session.onstream=async(stream)=>{awaitserverMayRead;forawait(const_ofstream){/* reading extends the window */}};},{sni: {'*': {keys: [key],certs: [cert]}},transportParams: {initialMaxStreamDataBidiRemote: WINDOW,initialMaxData: 1024*1024,},onheaders(){this.sendHeaders({':status': '200'});},});constsession=awaitconnect(endpoint.address,{servername: 'localhost',verifyPeer: 'manual',});awaitsession.opened;// Budget well above the window, so the window is what stops the writer.conststream=awaitsession.createBidirectionalStream({budget: 1024*1024});stream.sendHeaders({':method': 'POST',':path': '/',':scheme': 'https',':authority': 'localhost',},{terminal: false});constwriter=stream.writer;writer.writeSync(newUint8Array(BODY));// Long enough for every byte to be acked. The peer acks as data arrives,// whether or not its application has read any of it, so by now the window is// exhausted, the send buffer is empty, and no further ACK can arrive.awaitsleep(500);console.log('all acked, window exhausted');constwatchdog=setTimeout(()=>{console.log('STALLED: no drain after MAX_STREAM_DATA');process.exit(1);},5000);letServerRead();// extend the window, with no ack attachedconsole.log('window extended');awaitwriter[drainableProtocol]();console.log('writer drain');clearTimeout(watchdog);console.log('done');process.exit(0);

This creates a server which doesn't read, so it'll ack everything without extending the window. The client writes the exact amount to fill the window but leave nothing in its buffers (otherwise the window update triggers more writes, and then gets acks). Then after a 500ms pause, the server reads and triggers a window update.

Fixed client will trigger a drain and complete OK, bad client will stall forever. We should be able to build a test around that directly.

The fix here covers this for HTTP/3, but doesn't totally fix the issue because we have two independent implementations of this with the current application structure, so we'll need to also separately fix the equivalent issue again for pure QUIC in the default app.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

And we may also need it for session window, but I think we also need a callback from ngtcp2 for this.
Btw. that is the reason why there was so little progress from the last 3 weekends, it was horrible to debug (and I had a second error in the client code that was interfering).

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

@pimterry I am tring to integrate your test. But actually, the bug I was catching was different; it was actually two buffers, one inside the window size and the other going beyond the window size. At least this what I remember from debugging.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Ok, also done for pure quic.
What is missing though, is the same for maxdata.
So I think we should create a separate issue for this with test, but I do not see a fix, as the ngtcp2 callback is missing.
I am refering to this line:

uint64_t conn_left = ngtcp2_conn_get_max_data_left(conn);

it is less likely to cause a stall, but it may happen.

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

Create a separate issue for maxdata, as I have no clue how to address it currently.
#64835

@jasnelljasnell added the commit-queue-squash PRs the Commit Queue should land as one squashed commit. label Aug 8, 2026
@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@trivikrtrivikr added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@trivikrtrivikr removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@trivikr

Copy link
Copy Markdown
Member

It looks like this PR is ready to land.

@jasnelljasnell added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 20, 2026
@nodejs-github-botnodejs-github-bot added commit-queue-failed PRs whose Commit Queue landing failed and need manual intervention before retrying. and removed commit-queue PRs queued for automated landing through the Commit Queue. labels Aug 20, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator
Commit Queue failed
- Loading data for nodejs/node/pull/64768
✔ Done loading data for nodejs/node/pull/64768
----------------------------------- PR info ------------------------------------
Title quic: write desired size needs update on maxstream (#64768)
⚠ Could not retrieve the email or name of the PR author's from user's GitHub profile!
Branch martenrichter:writedesired -> nodejs:main
Labels c++, needs-ci, quic, commit-queue, commit-queue-squash
Commits 10
- quic: write desired size needs update on maxstream
- quic: Fix lint
- quic: add test for maxstreamdata buffer stalls
- quic: rename test
- quic: fix streamaxdata for non http/3
- quic: Fix lint
- quic: fix lint
- Fix lint2
- Fix lint 3
- Fix lint 4
Committers 2
- Marten Richter <marten.richter@tu-berlin.de>
- Marten Richter <marten.richter@freenet.de>
PR-URL: https://github.com/nodejs/node/pull/64768
Reviewed-By: James M Snell <jasnell@gmail.com>
------------------------------ Generated metadata ------------------------------
PR-URL: https://github.com/nodejs/node/pull/64768
Reviewed-By: James M Snell <jasnell@gmail.com>
--------------------------------------------------------------------------------
ℹ This PR was created on Sun, 26 Jul 2026 20:26:23 GMT
✔ Approvals: 1
✔ - James M Snell (@jasnell) (TSC): https://github.com/nodejs/node/pull/64768#pullrequestreview-4889028017
✘ GitHub CI is still running
ℹ Last Full PR CI on 2026-08-20T03:22:16Z: https://ci.nodejs.org/job/node-test-pull-request/76030/
- Querying data for job/node-test-pull-request/76030/
✔ Build data downloaded
✔ Last Jenkins CI successful
--------------------------------------------------------------------------------
✔ Aborted `git node land` session in /home/runner/work/node/node/.ncu
https://github.com/nodejs/node/actions/runs/32384310354

@martenrichter

Copy link
Copy Markdown
ContributorAuthor

It says: "⚠ Could not retrieve the email or name of the PR author's from user's GitHub profile!"
I wonder what this means. PR worked before for me with node.js .

@jasnell

Copy link
Copy Markdown
Member

I wouldn't worry about that. I'll squash and merge these directly.

jasnell pushed a commit that referenced this pull request Aug 20, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
@jasnell

Copy link
Copy Markdown
Member

Landed in 2bfc1e9

@jasnelljasnell closed this Aug 20, 2026
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
aduh95 pushed a commit that referenced this pull request Aug 27, 2026
Without this update the streams can stall, if the
chunks are close or bigger than the window size.
Signed-off-by: Marten Richter <marten.richter@freenet.de>
PR-URL: #64768
Reviewed-By: James M Snell <jasnell@gmail.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++Issues and PRs that require attention from people who are familiar with C++.commit-queue-failedPRs whose Commit Queue landing failed and need manual intervention before retrying.commit-queue-squashPRs the Commit Queue should land as one squashed commit.needs-ciPRs that need a full CI run.quicIssues and PRs related to the QUIC transport implementation.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@martenrichter@nodejs-github-bot@avivkeller@pimterry@trivikr@jasnell