-
-
Couldn't load subscription status.
- Fork 33.6k
Description
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 8080const { 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)