Skip to content

Commit 683eddc

Browse files
committed
stream: resume flow when an errored pipe destination is removed
When a source is piped to multiple destinations and one destination errors synchronously in `_write()`, `write()` returns false without setting `needDrain` (since the "avoid unnecessary drain for sync stream" change). The pipe cleanup only re-invoked the source's drain handler when the destination still had `needDrain` set, so the errored destination was never removed from the source's `awaitDrainWriters` set. The source stayed paused forever and any remaining healthy destination stopped receiving data. Always invoke the drain handler during cleanup. `pipeOnDrain` only removes this destination from the awaiting-drain set and resumes the source once nothing else is awaiting a drain, so it is safe to call unconditionally and no-ops when this destination was not awaiting a drain. Fixes: #53185 Signed-off-by: Mahin Anowar <86069420+MahinAnowar@users.noreply.github.com>
1 parent 4d27d80 commit 683eddc

2 files changed

Lines changed: 55 additions & 3 deletions

File tree

lib/internal/streams/readable.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -985,9 +985,13 @@ Readable.prototype.pipe = function(dest, pipeOpts) {
985985
// specific writer, then it would cause it to never start
986986
// flowing again.
987987
// So, if this is awaiting a drain, then we just call it now.
988-
// If we don't know, then assume that we are waiting for one.
989-
if (ondrain && state.awaitDrainWriters &&
990-
(!dest._writableState || dest._writableState.needDrain))
988+
// `pipeOnDrain` only removes this destination from the awaiting-drain set
989+
// and resumes the source once nothing else is awaiting a drain, so it is
990+
// safe to call unconditionally and no-ops when this writer wasn't awaiting
991+
// a drain. Guarding on `needDrain` used to skip this for a destination that
992+
// errored synchronously, leaving it stuck in the set and starving other
993+
// destinations piped from the same source (see #53185).
994+
if (ondrain && state.awaitDrainWriters)
991995
ondrain();
992996
}
993997

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
'use strict';
2+
const common = require('../common');
3+
4+
// Regression test for https://github.com/nodejs/node/issues/53185
5+
// When a source is piped to multiple destinations and one destination errors
6+
// synchronously in `_write`, the source must keep flowing to the remaining
7+
// healthy destination(s) instead of stalling forever.
8+
9+
const assert = require('assert');
10+
const { PassThrough, Writable, finished } = require('stream');
11+
12+
const source = new PassThrough({ highWaterMark: 16 * 1024 });
13+
14+
// A destination that errors synchronously on its first write.
15+
const failing = new Writable({
16+
highWaterMark: 16 * 1024,
17+
write(chunk, encoding, callback) {
18+
callback(new Error('boom'));
19+
},
20+
});
21+
failing.on('error', common.mustCall());
22+
23+
// A healthy destination that counts everything it receives.
24+
let received = 0;
25+
const healthy = new Writable({
26+
highWaterMark: 16 * 1024,
27+
write(chunk, encoding, callback) {
28+
received += chunk.length;
29+
callback();
30+
},
31+
});
32+
33+
source.pipe(failing);
34+
source.pipe(healthy);
35+
36+
// Two chunks each larger than the highWaterMark so that `write()` returns
37+
// false and the source is paused awaiting a drain.
38+
const chunk = Buffer.alloc(20000, 'a');
39+
source.write(chunk);
40+
source.write(chunk);
41+
source.end();
42+
43+
finished(healthy, common.mustSucceed(() => {
44+
// Without the fix, the errored destination stays in the source's
45+
// awaitDrainWriters set, the source never resumes, and `healthy` only
46+
// receives the first chunk.
47+
assert.strictEqual(received, chunk.length * 2);
48+
}));

0 commit comments

Comments
 (0)