Skip to content

Commit 7ba60dc

Browse files
jasnellrichardlau
authored andcommitted
stream: keep the original error when writer.fail() throws
When pipeTo() or pipeToSync() failed, they called writer.fail(error) and then rethrew the error. If fail() itself threw, its exception replaced the error that made the pipe fail, which was then lost. Call fail() on a best-effort basis and always surface the original error. Assisted-by: OpenCode Signed-off-by: James M Snell <[email protected]> PR-URL: #66483 Reviewed-By: Trivikram Kamat <[email protected]>
1 parent dcac51a commit 7ba60dc

2 files changed

Lines changed: 51 additions & 3 deletions

File tree

‎lib/internal/streams/iter/pull.js‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,14 +1083,25 @@ function pipeToSync(source, ...args) {
10831083
}
10841084
} catch (error) {
10851085
if (!options.preventFail) {
1086-
writer.fail?.(error);
1086+
failWriterQuietly(writer, error);
10871087
}
10881088
throw error;
10891089
}
10901090

10911091
return totalBytes;
10921092
}
10931093

1094+
// Call writer.fail(error) on a best-effort basis. The error that made the
1095+
// pipe fail is what the caller must see; an exception from fail() itself
1096+
// must not replace it.
1097+
function failWriterQuietly(writer, error) {
1098+
try {
1099+
writer.fail?.(error);
1100+
} catch {
1101+
// Ignored, see above.
1102+
}
1103+
}
1104+
10941105
/**
10951106
* Write an async source through transforms to a writer.
10961107
* @param {AsyncIterable<Uint8Array[]>|Iterable<Uint8Array[]>} source
@@ -1108,7 +1119,7 @@ async function pipeTo(source, ...args) {
11081119

11091120
function failWriter(error) {
11101121
if (!options.preventFail) {
1111-
writer.fail?.(error);
1122+
failWriterQuietly(writer, error);
11121123
}
11131124
}
11141125

‎test/parallel/test-stream-iter-pipeto-edge.js‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
const common = require('../common');
77
const assert = require('assert');
8-
const { pipeToSync, fromSync } = require('stream/iter');
8+
const { pipeTo, pipeToSync, fromSync } = require('stream/iter');
99

1010
// pipeToSync cannot complete when endSync() requires async fallback.
1111
async function testPipeToSyncEndSyncFailure() {
@@ -68,9 +68,46 @@ async function testPipeToSyncPreventClose() {
6868
assert.strictEqual(endCalled, false);
6969
}
7070

71+
// An exception thrown by writer.fail() must not replace the error that made
72+
// the pipe fail.
73+
async function testFailThrowingDoesNotMaskError() {
74+
const cause = new Error('write failed');
75+
const syncWriter = {
76+
writeSync() { throw cause; },
77+
endSync: common.mustNotCall(),
78+
fail: common.mustCall((error) => {
79+
assert.strictEqual(error, cause);
80+
throw new Error('fail() threw');
81+
}),
82+
};
83+
assert.throws(() => pipeToSync(fromSync('data'), syncWriter),
84+
(error) => error === cause);
85+
86+
const asyncWriter = {
87+
async write() { throw cause; },
88+
end: common.mustNotCall(),
89+
fail: common.mustCall((error) => {
90+
assert.strictEqual(error, cause);
91+
throw new Error('fail() threw');
92+
}),
93+
};
94+
await assert.rejects(pipeTo(fromSync('data'), asyncWriter),
95+
(error) => error === cause);
96+
97+
// Same for the already-aborted signal path of pipeTo().
98+
const signal = AbortSignal.abort();
99+
await assert.rejects(
100+
pipeTo(fromSync('data'), {
101+
write: common.mustNotCall(),
102+
fail() { throw new Error('fail() threw'); },
103+
}, { signal }),
104+
(error) => error === signal.reason);
105+
}
106+
71107
Promise.all([
72108
testPipeToSyncEndSyncFailure(),
73109
testPipeToSyncNoEndSync(),
74110
testPipeToSyncPreventFail(),
75111
testPipeToSyncPreventClose(),
112+
testFailThrowingDoesNotMaskError(),
76113
]).then(common.mustCall());

0 commit comments

Comments
 (0)