Skip to content

drain event is unreliable when using cork/uncork with ServerResponse #60432

Description

@davidje13

Version

v24.10.0

Platform

Darwin blah.local 24.6.0 Darwin Kernel Version 24.6.0: Mon Aug 11 21:15:09 PDT 2025; root:xnu-11417.140.69.701.11~1/RELEASE_ARM64_T6041 arm64

Subsystem

No response

What steps will reproduce the bug?

This code snippet will occasionally hang while waiting for the second drain event on MacOS, and will reliably hang while waiting for the first drain event on Linux:

const { createServer } = require('node:http');

function drainUncorked(target) {
	return new Promise((resolve) => {
		console.log('drain required:', target.writableNeedDrain);
		target.once('drain', () => {
			console.log('drain complete');
			target.cork();
			resolve();
		});
		target.uncork();
	});
}

const s = createServer(async (req, res) => {
	console.log('request');
	res.cork();
	console.log('1');
	if (!res.write('1'.repeat(100))) await drainUncorked(res);
	console.log('2');
	if (!res.write('2'.repeat(1000000))) await drainUncorked(res);
	console.log('3');
	if (!res.write('3'.repeat(100))) await drainUncorked(res);
	console.log('4');
	if (!res.write('4'.repeat(1000000))) await drainUncorked(res);
	console.log('5');
	res.uncork();
	res.end();
	console.log('done');
});

s.listen(8080, 'localhost', async () => {
	console.log('listening');

	const res = await fetch('http://localhost:8080');
	await res.text();
	s.close();
});

How often does it reproduce? Is there a required condition?

This reproduces intermittently on v24.10.0 on MacOS (tested: 3 failures in 30 attempts; ~10% failure rate), and reliably on v22.20.0 on Linux (specifically a Raspberry Pi running Linux pi5 6.12.47+rpt-rpi-v8 #1 SMP PREEMPT Debian 1:6.12.47-1+rpt1~bookworm (2025-09-16) aarch64 GNU/Linux)

What is the expected behavior? Why is that the expected behavior?

When writableNeedDrain is true (and equivalently if .write returns false), there should always be a drain event emitted once the stream has been consumed. This should apply regardless of whether the cork feature is being used (as long as the stream is uncorked once it needs to drain), and any listener registered while writableNeedDrain is true should be guaranteed to receive this drain event.

What do you see instead?

on macOS, the code above frequently hangs at this point:

listening
request
1
2
drain required: true
drain complete
3
4
drain required: true

By using curl -vvv localhost:8080 instead of the fetch example code, I can see that the response content is being drained successfully (i.e. the correct number of '4's are downloaded), so the drain event ought to be fired.

Testing by adding additional delays surprisingly makes this more likely to fail. For example, this adapted version of drainUncorked which waits for an event loop between each command fails every time on the second drain on macOS:

function drainUncorked(target) {
	return new Promise((resolve) => {
		console.log('drain required:', target.writableNeedDrain);
		target.once('drain', async () => {
			console.log('drain complete');
			await new Promise((resolve) => setTimeout(resolve, 0));
			target.cork();
			await new Promise((resolve) => setTimeout(resolve, 0));
			resolve();
		});
		target.uncork();
	});
}

Additional information

Without cork/uncork, the code always succeeds. I have also tested this on a raw socket and confirmed the drain event fires correctly there; this code succeeds every time:

# run netcat in the background as a destination for the socket
nc -l 8080
const { Socket } = require('node:net');

function drainUncorked(target) {
	return new Promise((resolve) => {
		console.log('drain required:', target.writableNeedDrain);
		target.once('drain', () => {
			console.log('drain complete');
			target.cork();
			resolve();
		});
		target.uncork();
	});
}

(async () => {
	const s = new Socket();
	s.connect(8080, 'localhost');
	s.cork();
	console.log('1');
	if (!s.write('1'.repeat(100))) await drainUncorked(s);
	console.log('2');
	if (!s.write('2'.repeat(1000000))) await drainUncorked(s);
	console.log('3');
	if (!s.write('3'.repeat(100))) await drainUncorked(s);
	console.log('4');
	if (!s.write('4'.repeat(1000000))) await drainUncorked(s);
	console.log('5');
	s.uncork();
	s.end();
})();

Since this is unique to ServerResponse, I suspect it is related to the chunk merging behaviour from #50167 (and chunk merging is why I want to use cork in the first place)

Activity

  1. davidje13 commented on Oct 27, 2025

    @davidje13
    ContributorAuthor

    From adding the following additional logging, I can confirm that the specific issue is that the drain event is not firing (and writableNeedDrain is not resetting to false) despite the stream draining:

    function drainUncorked(target) {
    	console.log(target.writableLength, target.writableHighWaterMark, target.writableNeedDrain);
    	// existing implementation from above
    }
    
    const s = createServer(async (req, res) => {
    	res.on('drain', () => console.log('got drain event'));
    	const i = setInterval(() => console.log(res.writableLength, res.writableHighWaterMark, res.writableNeedDrain), 10);
    	// existing handling code from above
    	clearInterval(i);
    }
    listening
    request
    1
    2
    1000237 65536 true
    drain required: true
    1000002 65536 true
    1000002 65536 true
    1000002 65536 true
    got drain event
    drain complete
    3
    4
    1000100 65536 true
    drain required: true
    0 65536 true           <-- note: writableLength has returned to 0 but writableNeedDrain is still true and no drain event has fired
    0 65536 true
    [repeats infinitely]
    
  2. davidje13 commented on Oct 27, 2025

    @davidje13
    ContributorAuthor

    A workaround for this issue is to use the callback of write instead of listening for drain:

    - target.once('drain', () => { ... });
    + target.write('', () => { ... });
  3. added 2 commits that reference this issue on Oct 27, 2025
    63b516b
    972f733
  4. github-actions commented on May 26, 2026

    @github-actions
    Contributor

    This issue has been marked as stale due to 210 days of inactivity.
    It will be automatically closed in 30 days if no further activity occurs. If this is still relevant, please leave a comment or update it to keep it open.

  5. added
    staleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.
    on May 26, 2026
  6. davidje13 commented on May 26, 2026

    @davidje13
    ContributorAuthor

    Looks like #60437 would have fixed this but tripped up on some test formatting and was closed / abandoned. @ThierryMT is it OK for me to pick up your changes and make the final tweaks requested by the maintainers? I see that your changes will still cleanly apply to the latest main.

  7. removed
    staleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.
    on May 27, 2026
  8. added a commit that references this issue on Jul 8, 2026
  9. added a commit that references this issue on Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions