mirror of
https://github.com/nodejs/node.git
synced 2026-11-09 15:11:11 -05:00
Updates the implementation to use the updated backpressure model that landed here: https://github.com/WinterTC55/iter-streams/pull/23 Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode/Opus PR-URL: https://github.com/nodejs/node/pull/64464 Reviewed-By: Tim Perry <pimterry@gmail.com> Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com> Reviewed-By: Ethan Arrowood <ethan@arrowood.dev>
371 lines
9.9 KiB
JavaScript
371 lines
9.9 KiB
JavaScript
// Flags: --experimental-stream-iter
|
|
'use strict';
|
|
|
|
const common = require('../common');
|
|
const assert = require('assert');
|
|
const {
|
|
from,
|
|
share,
|
|
text,
|
|
} = require('stream/iter');
|
|
|
|
const { setTimeout } = require('timers/promises');
|
|
|
|
// =============================================================================
|
|
// Async share()
|
|
// =============================================================================
|
|
|
|
async function testBasicShare() {
|
|
const shared = share(from('hello shared'));
|
|
|
|
const consumer = shared.pull();
|
|
const data = await text(consumer);
|
|
assert.strictEqual(data, 'hello shared');
|
|
}
|
|
|
|
async function testShareMultipleConsumers() {
|
|
async function* gen() {
|
|
yield [new TextEncoder().encode('chunk1')];
|
|
yield [new TextEncoder().encode('chunk2')];
|
|
yield [new TextEncoder().encode('chunk3')];
|
|
}
|
|
|
|
const shared = share(gen(), { budget: 16384 });
|
|
|
|
const c1 = shared.pull();
|
|
const c2 = shared.pull();
|
|
|
|
assert.strictEqual(shared.consumerCount, 2);
|
|
|
|
const [data1, data2] = await Promise.all([
|
|
text(c1),
|
|
text(c2),
|
|
]);
|
|
|
|
assert.strictEqual(data1, 'chunk1chunk2chunk3');
|
|
assert.strictEqual(data2, 'chunk1chunk2chunk3');
|
|
}
|
|
|
|
async function testShareConsumerCount() {
|
|
const shared = share(from('data'));
|
|
|
|
assert.strictEqual(shared.consumerCount, 0);
|
|
|
|
const c1 = shared.pull();
|
|
assert.strictEqual(shared.consumerCount, 1);
|
|
|
|
const c2 = shared.pull();
|
|
assert.strictEqual(shared.consumerCount, 2);
|
|
|
|
// Cancel detaches all consumers
|
|
shared.cancel();
|
|
assert.strictEqual(shared.consumerCount, 0);
|
|
|
|
// Both should complete immediately
|
|
const [data1, data2] = await Promise.all([
|
|
text(c1),
|
|
text(c2),
|
|
]);
|
|
assert.strictEqual(data1, '');
|
|
assert.strictEqual(data2, '');
|
|
}
|
|
|
|
async function testShareCancel() {
|
|
const shared = share(from('data'));
|
|
const consumer = shared.pull();
|
|
|
|
shared.cancel();
|
|
assert.strictEqual(shared.consumerCount, 0);
|
|
|
|
const batches = [];
|
|
for await (const batch of consumer) {
|
|
batches.push(batch);
|
|
}
|
|
assert.strictEqual(batches.length, 0);
|
|
}
|
|
|
|
async function testShareCancelMidIteration() {
|
|
// Verify that cancel during iteration stops data flow
|
|
let sourceReturnCalled = false;
|
|
const enc = new TextEncoder();
|
|
async function* gen() {
|
|
try {
|
|
yield [enc.encode('a')];
|
|
yield [enc.encode('b')];
|
|
yield [enc.encode('c')];
|
|
} finally {
|
|
sourceReturnCalled = true;
|
|
}
|
|
}
|
|
const shared = share(gen(), { budget: 16384 });
|
|
const consumer = shared.pull();
|
|
|
|
const items = [];
|
|
for await (const batch of consumer) {
|
|
for (const chunk of batch) {
|
|
items.push(new TextDecoder().decode(chunk));
|
|
}
|
|
// Cancel after first batch
|
|
shared.cancel();
|
|
}
|
|
assert.strictEqual(items.length, 1);
|
|
assert.strictEqual(items[0], 'a');
|
|
|
|
await new Promise(setImmediate);
|
|
assert.strictEqual(sourceReturnCalled, true);
|
|
}
|
|
|
|
async function testShareCancelWithReason() {
|
|
const shared = share(from('data'));
|
|
const consumer = shared.pull();
|
|
|
|
shared.cancel(new Error('share cancelled'));
|
|
|
|
await assert.rejects(
|
|
async () => {
|
|
// eslint-disable-next-line no-unused-vars
|
|
for await (const _ of consumer) {
|
|
assert.fail('Should not reach here');
|
|
}
|
|
},
|
|
{ message: 'share cancelled' },
|
|
);
|
|
}
|
|
|
|
async function testShareAbortSignal() {
|
|
const ac = new AbortController();
|
|
const reason = new Error('share aborted');
|
|
const enc = new TextEncoder();
|
|
async function* source() {
|
|
yield [enc.encode('a')];
|
|
yield [enc.encode('b')];
|
|
}
|
|
const shared = share(source(), {
|
|
budget: 16384,
|
|
backpressure: 'unbounded',
|
|
signal: ac.signal,
|
|
});
|
|
const fast = shared.pull()[Symbol.asyncIterator]();
|
|
shared.pull();
|
|
|
|
await fast.next();
|
|
const read = fast.next();
|
|
const rejected = assert.rejects(read, (error) => error === reason);
|
|
ac.abort(reason);
|
|
|
|
await rejected;
|
|
}
|
|
|
|
async function testShareAbortSignalWhileSourcePullPending() {
|
|
const ac = new AbortController();
|
|
const {
|
|
promise: resumePromise,
|
|
resolve: resume,
|
|
} = Promise.withResolvers();
|
|
const {
|
|
promise: sourceStartedPromise,
|
|
resolve: sourceStarted,
|
|
} = Promise.withResolvers();
|
|
|
|
const source = {
|
|
__proto__: null,
|
|
[Symbol.asyncIterator]() {
|
|
return {
|
|
__proto__: null,
|
|
async next() {
|
|
sourceStarted();
|
|
await resumePromise;
|
|
return { __proto__: null, done: true, value: undefined };
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
const shared = share(source, { signal: ac.signal });
|
|
const iter1 = shared.pull()[Symbol.asyncIterator]();
|
|
const iter2 = shared.pull()[Symbol.asyncIterator]();
|
|
const read1 = iter1.next();
|
|
const read2 = iter2.next();
|
|
const rejected1 = assert.rejects(read1, { name: 'AbortError' });
|
|
const rejected2 = assert.rejects(read2, { name: 'AbortError' });
|
|
|
|
await sourceStartedPromise;
|
|
ac.abort();
|
|
resume();
|
|
|
|
await Promise.all([rejected1, rejected2]);
|
|
}
|
|
|
|
async function testSharePullAbortSignalRejectsPendingNext() {
|
|
const ac = new AbortController();
|
|
const reason = new Error('pull aborted');
|
|
const shared = share(
|
|
// eslint-disable-next-line require-yield
|
|
(async function* never() {
|
|
await new Promise(() => {});
|
|
})(),
|
|
);
|
|
const iter = shared.pull({ signal: ac.signal })[Symbol.asyncIterator]();
|
|
|
|
const pendingNext = iter.next();
|
|
const rejected = assert.rejects(pendingNext, (error) => error === reason);
|
|
ac.abort(reason);
|
|
|
|
await rejected;
|
|
shared.cancel();
|
|
}
|
|
|
|
async function testShareAlreadyAborted() {
|
|
const shared = share(from('data'), { signal: AbortSignal.abort() });
|
|
const consumer = shared.pull();
|
|
|
|
await assert.rejects(async () => {
|
|
// eslint-disable-next-line no-unused-vars
|
|
for await (const _ of consumer) {
|
|
assert.fail('Should not reach here');
|
|
}
|
|
}, { name: 'AbortError' });
|
|
}
|
|
|
|
// =============================================================================
|
|
// Source error propagation
|
|
// =============================================================================
|
|
|
|
async function testShareSourceError() {
|
|
async function* failingSource() {
|
|
yield [new TextEncoder().encode('a')];
|
|
throw new Error('share source boom');
|
|
}
|
|
const shared = share(failingSource());
|
|
const c1 = shared.pull();
|
|
const c2 = shared.pull();
|
|
|
|
await assert.rejects(async () => {
|
|
// eslint-disable-next-line no-unused-vars
|
|
for await (const _ of c1) { /* consume */ }
|
|
}, { message: 'share source boom' });
|
|
await assert.rejects(async () => {
|
|
// eslint-disable-next-line no-unused-vars
|
|
for await (const _ of c2) { /* consume */ }
|
|
}, { message: 'share source boom' });
|
|
}
|
|
|
|
async function testShareLateJoiningConsumer() {
|
|
// A consumer that joins after some data has been consumed should only
|
|
// see data remaining in the buffer (not items already trimmed).
|
|
const enc = new TextEncoder();
|
|
async function* gen() {
|
|
yield [enc.encode('a')];
|
|
yield [enc.encode('b')];
|
|
yield [enc.encode('c')];
|
|
}
|
|
const shared = share(gen(), { budget: 16384 });
|
|
|
|
// First consumer reads all data
|
|
const c1 = shared.pull();
|
|
const data1 = await text(c1);
|
|
assert.strictEqual(data1, 'abc');
|
|
|
|
// Late-joining consumer: source is exhausted, buffer has been trimmed
|
|
// past all data by c1's reads, so c2 gets nothing.
|
|
const c2 = shared.pull();
|
|
const data2 = await text(c2);
|
|
assert.strictEqual(data2, '');
|
|
}
|
|
|
|
async function testShareConsumerBreak() {
|
|
// Verify that a consumer breaking mid-iteration detaches properly
|
|
const enc = new TextEncoder();
|
|
async function* gen() {
|
|
yield [enc.encode('a')];
|
|
yield [enc.encode('b')];
|
|
yield [enc.encode('c')];
|
|
}
|
|
const shared = share(gen(), { budget: 16384 });
|
|
const c1 = shared.pull();
|
|
const c2 = shared.pull();
|
|
|
|
assert.strictEqual(shared.consumerCount, 2);
|
|
|
|
// c1 breaks after first batch
|
|
// eslint-disable-next-line no-unused-vars
|
|
for await (const _ of c1) {
|
|
break;
|
|
}
|
|
// c1 should be detached
|
|
assert.strictEqual(shared.consumerCount, 1);
|
|
|
|
// c2 should still get all data
|
|
const data2 = await text(c2);
|
|
assert.strictEqual(data2, 'abc');
|
|
}
|
|
|
|
async function testShareMultipleConsumersConcurrentPull() {
|
|
// Multiple consumers pulling concurrently should each receive all items
|
|
// even when only one item is pulled from source at a time.
|
|
async function* slowSource() {
|
|
const enc = new TextEncoder();
|
|
for (let i = 0; i < 5; i++) {
|
|
await setTimeout(1);
|
|
yield [enc.encode(`item-${i}`)];
|
|
}
|
|
}
|
|
const shared = share(slowSource());
|
|
const c1 = shared.pull();
|
|
const c2 = shared.pull();
|
|
const c3 = shared.pull();
|
|
|
|
const [t1, t2, t3] = await Promise.all([
|
|
text(c1), text(c2), text(c3),
|
|
]);
|
|
|
|
const expected = 'item-0item-1item-2item-3item-4';
|
|
assert.strictEqual(t1, expected);
|
|
assert.strictEqual(t2, expected);
|
|
assert.strictEqual(t3, expected);
|
|
}
|
|
|
|
async function testShareConsumerConcurrentNextCalls() {
|
|
async function* source() {
|
|
const enc = new TextEncoder();
|
|
yield [enc.encode('first')];
|
|
yield [enc.encode('second')];
|
|
}
|
|
|
|
const shared = share(source());
|
|
const it = shared.pull()[Symbol.asyncIterator]();
|
|
const first = it.next();
|
|
const second = it.next();
|
|
|
|
const [r1, r2] = await Promise.all([first, second]);
|
|
const dec = new TextDecoder();
|
|
assert.strictEqual(dec.decode(r1.value[0]), 'first');
|
|
assert.strictEqual(dec.decode(r2.value[0]), 'second');
|
|
}
|
|
|
|
// share() accepts string source directly (normalized via from())
|
|
async function testShareStringSource() {
|
|
const shared = share('hello-share');
|
|
const result = await text(shared.pull());
|
|
assert.strictEqual(result, 'hello-share');
|
|
}
|
|
|
|
Promise.all([
|
|
testBasicShare(),
|
|
testShareMultipleConsumers(),
|
|
testShareConsumerCount(),
|
|
testShareCancel(),
|
|
testShareCancelMidIteration(),
|
|
testShareCancelWithReason(),
|
|
testShareAbortSignal(),
|
|
testShareAbortSignalWhileSourcePullPending(),
|
|
testSharePullAbortSignalRejectsPendingNext(),
|
|
testShareAlreadyAborted(),
|
|
testShareSourceError(),
|
|
testShareLateJoiningConsumer(),
|
|
testShareConsumerBreak(),
|
|
testShareMultipleConsumersConcurrentPull(),
|
|
testShareConsumerConcurrentNextCalls(),
|
|
testShareStringSource(),
|
|
]).then(common.mustCall());
|