Skip to content

Commit 71c2c02

Browse files
committed
stream: fill out stream/iter test coverage
Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode
1 parent 0b2fffe commit 71c2c02

6 files changed

Lines changed: 556 additions & 13 deletions

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

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,7 @@ function fromWritable(writable, options = kNullPrototype) {
631631
entry.onAbort = undefined;
632632
}
633633

634+
// Without preserveReason (a clean finish), reason is unused.
634635
function cleanup(
635636
reason,
636637
preserveReason = false,
@@ -639,13 +640,10 @@ function fromWritable(writable, options = kNullPrototype) {
639640
const pending = drainWaiters;
640641
drainWaiters = [];
641642
for (let i = 0; i < pending.length; i++) {
642-
if (!preserveReason &&
643-
(reason === undefined || reason === null) &&
644-
pending[i].close !== undefined) {
645-
pending[i].close();
643+
if (preserveReason) {
644+
pending[i].reject(reason);
646645
} else {
647-
pending[i].reject(
648-
preserveReason ? reason : reason ?? new AbortError());
646+
pending[i].close();
649647
}
650648
}
651649

@@ -654,8 +652,7 @@ function fromWritable(writable, options = kNullPrototype) {
654652
while (writes.length !== 0) {
655653
const entry = writes.shift();
656654
cleanupPendingSignal(entry);
657-
entry.reject(
658-
preserveReason ? reason : reason ?? new AbortError());
655+
entry.reject(preserveReason ? reason : new AbortError());
659656
}
660657

661658
if (drainListenerInstalled) {
@@ -773,6 +770,7 @@ function fromWritable(writable, options = kNullPrototype) {
773770
entry.signal = signal;
774771
entry.onAbort = () => {
775772
const index = pendingWrites.indexOf(entry);
773+
// Defensive: every dequeue path removes this listener first.
776774
if (index === -1) return;
777775
pendingWrites.removeAt(index);
778776
cleanupPendingSignal(entry);
@@ -792,6 +790,7 @@ function fromWritable(writable, options = kNullPrototype) {
792790
}
793791

794792
function finishWithError(reason, keepTerminalListeners = false) {
793+
// Defensive: callers already check errored.
795794
if (errored) return;
796795
errored = true;
797796
error = reason;
@@ -834,7 +833,8 @@ function fromWritable(writable, options = kNullPrototype) {
834833
removeTerminalListeners();
835834
return;
836835
}
837-
if (finished || (writable.writableFinished ?? false)) {
836+
// Duck-typed Writables may finish without emitting 'finish'.
837+
if (writable.writableFinished ?? false) {
838838
onFinish();
839839
removeTerminalListeners();
840840
return;
@@ -850,6 +850,7 @@ function fromWritable(writable, options = kNullPrototype) {
850850
}
851851

852852
function startEnd() {
853+
// Defensive: nothing flushes again once the end has started.
853854
if (endStarted || pendingEnd === undefined) return;
854855
endStarted = true;
855856
const end = pendingEnd;
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
// Flags: --experimental-stream-iter
2+
'use strict';
3+
4+
// Tests for fromWritable() error, close, and end edge cases.
5+
6+
const common = require('../common');
7+
const assert = require('assert');
8+
const { EventEmitter, once } = require('events');
9+
const { Writable } = require('stream');
10+
const { setImmediate } = require('timers/promises');
11+
const { fromWritable, ondrain } = require('stream/iter');
12+
13+
function createDuckWritable(methods) {
14+
const writable = new EventEmitter();
15+
writable.write = () => true;
16+
writable.end = () => {};
17+
writable.destroy = () => {};
18+
return Object.assign(writable, methods);
19+
}
20+
21+
function assertNoTerminalListeners(writable) {
22+
assert.strictEqual(writable.listenerCount('error'), 0);
23+
assert.strictEqual(writable.listenerCount('finish'), 0);
24+
assert.strictEqual(writable.listenerCount('close'), 0);
25+
}
26+
27+
// A queued write that throws while being flushed on drain rejects on its own;
28+
// later queued writes are still committed.
29+
async function testQueuedWriteThrowsDuringFlush() {
30+
const reason = new Error('queued write failed');
31+
const written = [];
32+
let calls = 0;
33+
const writable = createDuckWritable({
34+
write(chunk) {
35+
calls++;
36+
if (calls === 1) return false;
37+
if (calls === 2) throw reason;
38+
written.push(Buffer.from(chunk).toString());
39+
return true;
40+
},
41+
});
42+
const writer = fromWritable(writable, { backpressure: 'unbounded' });
43+
44+
await writer.write('a');
45+
const second = writer.write('b');
46+
const third = writer.write('c');
47+
writable.emit('drain');
48+
49+
await assert.rejects(second, (error) => error === reason);
50+
await third;
51+
assert.deepStrictEqual(written, ['c']);
52+
}
53+
54+
// A queued write that errors the Writable while being flushed rejects with
55+
// that error, as does every write still queued behind it.
56+
async function testQueuedWriteErrorsDuringFlush() {
57+
const reason = new Error('queued write errored');
58+
let calls = 0;
59+
const writable = createDuckWritable({
60+
write() {
61+
calls++;
62+
if (calls === 1) return false;
63+
writable.emit('error', reason);
64+
return true;
65+
},
66+
});
67+
const writer = fromWritable(writable, { backpressure: 'unbounded' });
68+
69+
await writer.write('a');
70+
const second = writer.write('b');
71+
const third = writer.write('c');
72+
writable.emit('drain');
73+
74+
await assert.rejects(second, (error) => error === reason);
75+
await assert.rejects(third, (error) => error === reason);
76+
await assert.rejects(writer.end(), (error) => error === reason);
77+
assert.strictEqual(calls, 2);
78+
assertNoTerminalListeners(writable);
79+
}
80+
81+
// A Writable that errors synchronously inside write() rejects the write that
82+
// triggered it.
83+
async function testWriteErrorsSynchronously() {
84+
for (const method of ['write', 'writev']) {
85+
const reason = new Error(`sync ${method} error`);
86+
let emitted = false;
87+
const writable = createDuckWritable({
88+
write() {
89+
if (!emitted) {
90+
emitted = true;
91+
writable.emit('error', reason);
92+
}
93+
return true;
94+
},
95+
});
96+
const writer = fromWritable(writable);
97+
const chunk = method === 'write' ? 'a' : ['a', 'b'];
98+
await assert.rejects(writer[method](chunk),
99+
(error) => error === reason);
100+
assertNoTerminalListeners(writable);
101+
}
102+
}
103+
104+
async function testWritevThrowRejects() {
105+
const reason = new Error('writev failed');
106+
const writable = createDuckWritable({
107+
write() { throw reason; },
108+
});
109+
const writer = fromWritable(writable);
110+
111+
await assert.rejects(writer.writev(['a', 'b']), (error) => error === reason);
112+
writer.fail();
113+
}
114+
115+
async function testEmptyWritev() {
116+
const writable = new Writable({ write: common.mustNotCall() });
117+
const writer = fromWritable(writable);
118+
119+
await writer.writev([]);
120+
assert.strictEqual(await writer.end(), 0);
121+
}
122+
123+
// Ending the Writable directly while writes are queued in the adapter rejects
124+
// them, since they can no longer be committed, and settles drain waiters.
125+
async function testExternalEndRejectsQueuedWrites() {
126+
const callbacks = [];
127+
const writable = new Writable({
128+
highWaterMark: 1,
129+
write(chunk, encoding, cb) { callbacks.push(cb); },
130+
});
131+
const writer = fromWritable(writable, { backpressure: 'unbounded' });
132+
133+
await writer.write('a');
134+
const queued = writer.write('b');
135+
const drained = ondrain(writer);
136+
writable.end();
137+
callbacks.shift()();
138+
139+
await assert.rejects(queued, { name: 'AbortError' });
140+
assert.strictEqual(await drained, false);
141+
assert.strictEqual(callbacks.length, 0);
142+
}
143+
144+
// A Writable that errored before the adapter was created is detected
145+
// synchronously; the adapter keeps its listeners until 'close'.
146+
async function testErroredBeforeAdapterReleasesListenersOnClose() {
147+
const reason = new Error('write failed');
148+
const writable = new Writable({
149+
autoDestroy: false,
150+
write(chunk, encoding, cb) { cb(reason); },
151+
});
152+
writable.on('error', common.mustCall());
153+
writable.write('a');
154+
await setImmediate();
155+
156+
const writer = fromWritable(writable);
157+
await assert.rejects(writer.write('b'), (error) => error === reason);
158+
assert.strictEqual(writable.listenerCount('close'), 1);
159+
160+
writable.destroy();
161+
await setImmediate();
162+
assert.strictEqual(writable.listenerCount('close'), 0);
163+
assert.strictEqual(writable.listenerCount('finish'), 0);
164+
}
165+
166+
// Duck-typed Writables may report completion only through writableFinished
167+
// and 'close', without emitting 'finish'.
168+
async function testCloseAfterFinishWithoutFinishEvent() {
169+
const writable = createDuckWritable({
170+
end() {
171+
process.nextTick(() => {
172+
writable.writableFinished = true;
173+
writable.emit('close');
174+
});
175+
},
176+
});
177+
const writer = fromWritable(writable);
178+
179+
await writer.write('ab');
180+
assert.strictEqual(await writer.end(), 2);
181+
assertNoTerminalListeners(writable);
182+
}
183+
184+
// A 'close' without any indication of finishing is a premature close.
185+
async function testDuckPrematureClose() {
186+
const writable = createDuckWritable({
187+
end() {
188+
process.nextTick(() => writable.emit('close'));
189+
},
190+
});
191+
const writer = fromWritable(writable);
192+
193+
await assert.rejects(writer.end(), { name: 'AbortError' });
194+
assertNoTerminalListeners(writable);
195+
}
196+
197+
async function testAlreadyFinished() {
198+
const writable = new Writable({ write(chunk, encoding, cb) { cb(); } });
199+
writable.end();
200+
await once(writable, 'finish');
201+
202+
const writer = fromWritable(writable);
203+
assert.strictEqual(writer.canWrite, null);
204+
await assert.rejects(writer.write('a'),
205+
{ code: 'ERR_STREAM_WRITE_AFTER_END' });
206+
assert.strictEqual(await writer.end(), 0);
207+
assertNoTerminalListeners(writable);
208+
}
209+
210+
async function testAlreadyDestroyed() {
211+
const writable = new Writable({ write: common.mustNotCall() });
212+
writable.destroy();
213+
await once(writable, 'close');
214+
215+
const writer = fromWritable(writable);
216+
assert.strictEqual(writer.canWrite, null);
217+
await assert.rejects(writer.write('a'),
218+
{ code: 'ERR_STREAM_WRITE_AFTER_END' });
219+
assertNoTerminalListeners(writable);
220+
}
221+
222+
// When end() and the destroy() that follows both throw, the end() failure
223+
// is reported and the adapter's listeners are released.
224+
async function testEndAndDestroyThrow() {
225+
const endError = new Error('end failed');
226+
const writable = createDuckWritable({
227+
end() { throw endError; },
228+
destroy() { throw new Error('destroy failed'); },
229+
});
230+
const writer = fromWritable(writable);
231+
232+
await assert.rejects(writer.end(), (error) => error === endError);
233+
assertNoTerminalListeners(writable);
234+
}
235+
236+
async function testFailDestroyThrows() {
237+
const reason = new Error('fail reason');
238+
const destroyError = new Error('destroy failed');
239+
const writable = createDuckWritable({
240+
destroy() { throw destroyError; },
241+
});
242+
const writer = fromWritable(writable);
243+
244+
assert.throws(() => writer.fail(reason), (error) => error === destroyError);
245+
assertNoTerminalListeners(writable);
246+
await assert.rejects(writer.write('a'), (error) => error === reason);
247+
}
248+
249+
// An end() with a signal rejects with the Writable's error if ending fails.
250+
async function testFinalErrorRejectsSignaledEnd() {
251+
const reason = new Error('final failed');
252+
const writable = new Writable({
253+
write(chunk, encoding, cb) { cb(); },
254+
final(cb) { cb(reason); },
255+
});
256+
writable.on('error', common.mustCall());
257+
const writer = fromWritable(writable);
258+
const { signal } = new AbortController();
259+
260+
await writer.write('a');
261+
await assert.rejects(writer.end({ signal }), (error) => error === reason);
262+
}
263+
264+
// The signal can be aborted synchronously by the Writable's own end(), after
265+
// end() has checked it but before it waits on it.
266+
async function testSignalAbortedByUnderlyingEnd() {
267+
const controller = new AbortController();
268+
const writable = createDuckWritable({
269+
end: common.mustCall(() => controller.abort('stop')),
270+
});
271+
const writer = fromWritable(writable);
272+
273+
await assert.rejects(writer.end({ signal: controller.signal }),
274+
(reason) => reason === 'stop');
275+
const ending = writer.end();
276+
writable.emit('finish');
277+
assert.strictEqual(await ending, 0);
278+
}
279+
280+
Promise.all([
281+
testQueuedWriteThrowsDuringFlush(),
282+
testQueuedWriteErrorsDuringFlush(),
283+
testWriteErrorsSynchronously(),
284+
testWritevThrowRejects(),
285+
testEmptyWritev(),
286+
testExternalEndRejectsQueuedWrites(),
287+
testErroredBeforeAdapterReleasesListenersOnClose(),
288+
testCloseAfterFinishWithoutFinishEvent(),
289+
testDuckPrematureClose(),
290+
testAlreadyFinished(),
291+
testAlreadyDestroyed(),
292+
testEndAndDestroyThrow(),
293+
testFailDestroyThrows(),
294+
testFinalErrorRejectsSignaledEnd(),
295+
testSignalAbortedByUnderlyingEnd(),
296+
]).then(common.mustCall());

0 commit comments

Comments
 (0)