Skip to content

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

@davidje13

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)

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