mirror of
https://github.com/nodejs/node.git
synced 2026-10-28 18:25:13 +08:00
If a shareSync() source read from a consumer of the same share while producing a value, the nested read re-entered the source iterator. For generators this threw "Generator is already running" from inside the nested read, which recorded that error as the share's source error while the outer read was still in progress, leaving every consumer in an error state. Fail the nested read with ERR_INVALID_STATE before touching the share's state. The source sees the error and may handle it; if it lets it escape, it becomes the source error as with any other source failure. Assisted-by: OpenCode Signed-off-by: James M Snell <jasnell@gmail.com> PR-URL: https://github.com/nodejs/node/pull/66483 Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
335 lines
10 KiB
JavaScript
335 lines
10 KiB
JavaScript
// Flags: --experimental-stream-iter
|
|
'use strict';
|
|
|
|
const common = require('../common');
|
|
const assert = require('assert');
|
|
const {
|
|
shareSync,
|
|
fromSync,
|
|
textSync,
|
|
|
|
} = require('stream/iter');
|
|
|
|
// =============================================================================
|
|
// Sync share
|
|
// =============================================================================
|
|
|
|
async function testShareSyncBasic() {
|
|
const shared = shareSync(fromSync('sync shared'));
|
|
|
|
const consumer = shared.pull();
|
|
const data = textSync(consumer);
|
|
assert.strictEqual(data, 'sync shared');
|
|
}
|
|
|
|
async function testShareSyncMultipleConsumers() {
|
|
const enc = new TextEncoder();
|
|
function* gen() {
|
|
yield [enc.encode('a')];
|
|
yield [enc.encode('b')];
|
|
yield [enc.encode('c')];
|
|
}
|
|
|
|
const shared = shareSync(gen(), { budget: 16384 });
|
|
|
|
const c1 = shared.pull();
|
|
const c2 = shared.pull();
|
|
|
|
const data1 = textSync(c1);
|
|
const data2 = textSync(c2);
|
|
|
|
assert.strictEqual(data1, 'abc');
|
|
assert.strictEqual(data2, 'abc');
|
|
}
|
|
|
|
function testShareSyncCancel() {
|
|
// Verify that cancel() on a pre-iteration share yields nothing
|
|
const shared = shareSync(fromSync('data'));
|
|
const consumer = shared.pull();
|
|
|
|
shared.cancel();
|
|
assert.strictEqual(shared.consumerCount, 0);
|
|
|
|
const batches = [];
|
|
for (const batch of consumer) {
|
|
batches.push(batch);
|
|
}
|
|
assert.strictEqual(batches.length, 0);
|
|
}
|
|
|
|
function testShareSyncCancelMidIteration() {
|
|
// Verify cancel during iteration stops data flow and cleans up
|
|
const enc = new TextEncoder();
|
|
let sourceReturnCalled = false;
|
|
function* gen() {
|
|
try {
|
|
yield [enc.encode('a')];
|
|
yield [enc.encode('b')];
|
|
yield [enc.encode('c')];
|
|
} finally {
|
|
sourceReturnCalled = true;
|
|
}
|
|
}
|
|
const shared = shareSync(gen(), { budget: 16384 });
|
|
const consumer = shared.pull();
|
|
|
|
const items = [];
|
|
for (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');
|
|
assert.strictEqual(sourceReturnCalled, true);
|
|
}
|
|
|
|
function testShareSyncCancelWithReason() {
|
|
const enc = new TextEncoder();
|
|
function* gen() {
|
|
yield [enc.encode('a')];
|
|
yield [enc.encode('b')];
|
|
}
|
|
const shared = shareSync(gen(), { budget: 16384 });
|
|
const iterator1 = shared.pull()[Symbol.iterator]();
|
|
const iterator2 = shared.pull()[Symbol.iterator]();
|
|
const reason = new Error('sync cancel reason');
|
|
|
|
iterator1.next();
|
|
shared.cancel(reason);
|
|
|
|
assert.throws(() => iterator1.next(), (error) => error === reason);
|
|
assert.throws(() => iterator2.next(), (error) => error === reason);
|
|
}
|
|
|
|
function testShareSyncCancelWithFalsyReason() {
|
|
for (const reason of [0, '', false, null]) {
|
|
const shared = shareSync(fromSync('data'));
|
|
const iterator = shared.pull()[Symbol.iterator]();
|
|
|
|
shared.cancel(reason);
|
|
|
|
assert.throws(() => iterator.next(), (error) => error === reason);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Source error propagation
|
|
// =============================================================================
|
|
|
|
function testShareSyncSourceError() {
|
|
function* failingSource() {
|
|
yield [new TextEncoder().encode('ok')];
|
|
throw new Error('sync share boom');
|
|
}
|
|
const shared = shareSync(failingSource());
|
|
const c1 = shared.pull();
|
|
const c2 = shared.pull();
|
|
|
|
// Both consumers should see the error
|
|
assert.throws(() => {
|
|
// eslint-disable-next-line no-unused-vars
|
|
for (const _ of c1) { /* consume */ }
|
|
}, { message: 'sync share boom' });
|
|
assert.throws(() => {
|
|
// eslint-disable-next-line no-unused-vars
|
|
for (const _ of c2) { /* consume */ }
|
|
}, { message: 'sync share boom' });
|
|
}
|
|
|
|
function testShareSyncRejectsUnbounded() {
|
|
assert.throws(
|
|
() => shareSync(fromSync('data'), { backpressure: 'unbounded' }),
|
|
{ code: 'ERR_INVALID_ARG_VALUE' },
|
|
);
|
|
}
|
|
|
|
function testShareSyncRejectsDropNewest() {
|
|
// A synchronous consumer can neither wait for the slowest consumer nor keep
|
|
// discarding until it advances, so 'drop-newest' is rejected like
|
|
// 'unbounded'.
|
|
assert.throws(
|
|
() => shareSync(fromSync('data'), { backpressure: 'drop-newest' }),
|
|
{ code: 'ERR_INVALID_ARG_VALUE' },
|
|
);
|
|
}
|
|
|
|
// shareSync() accepts string source directly (normalized via fromSync())
|
|
function testShareSyncRetainsBufferWhenAllConsumersDetach() {
|
|
// Data that a consumer had not read yet must stay available to consumers
|
|
// that attach after every previous consumer has detached.
|
|
const enc = new TextEncoder();
|
|
function* gen() {
|
|
yield [enc.encode('a')];
|
|
yield [enc.encode('b')];
|
|
yield [enc.encode('c')];
|
|
}
|
|
const shared = shareSync(gen(), { budget: 16384 });
|
|
const c1 = shared.pull()[Symbol.iterator]();
|
|
const c2 = shared.pull()[Symbol.iterator]();
|
|
assert.deepStrictEqual(c1.next().value, [enc.encode('a')]);
|
|
c1.return();
|
|
c2.return();
|
|
assert.strictEqual(shared.consumerCount, 0);
|
|
|
|
assert.strictEqual(textSync(shared.pull()), 'abc');
|
|
}
|
|
|
|
function testShareSyncStrictBackpressureDetaches() {
|
|
for (const transformed of [false, true]) {
|
|
function* source() {
|
|
for (let i = 0; i < 10; i++) {
|
|
yield [new Uint8Array(16384)];
|
|
}
|
|
}
|
|
const shared = shareSync(source(), {
|
|
budget: 32768,
|
|
backpressure: 'strict',
|
|
});
|
|
const consumer = transformed ?
|
|
shared.pull((chunks) => chunks) : shared.pull();
|
|
const fast = consumer[Symbol.iterator]();
|
|
// This consumer prevents the buffer from being trimmed.
|
|
const slow = shared.pull()[Symbol.iterator]();
|
|
|
|
fast.next();
|
|
fast.next();
|
|
assert.throws(() => fast.next(), { code: 'ERR_OUT_OF_RANGE' });
|
|
// The rejected consumer is detached, as with the async share.
|
|
assert.strictEqual(shared.consumerCount, 1);
|
|
assert.strictEqual(fast.next().done, true);
|
|
|
|
// The detached consumer no longer pins the buffer, so the remaining
|
|
// consumer can read the whole source.
|
|
let count = 0;
|
|
while (!slow.next().done) count++;
|
|
assert.strictEqual(count, 10);
|
|
}
|
|
}
|
|
|
|
function testShareSyncStrictForOfDoesNotWedgeOthers() {
|
|
// for...of does not call return() when next() throws. The consumer that
|
|
// hit the budget must still not keep the other consumers from reading.
|
|
function* source() {
|
|
for (let i = 0; i < 20; i++) yield [new Uint8Array(8192)];
|
|
}
|
|
const shared = shareSync(source(), { budget: 16384, backpressure: 'strict' });
|
|
const slow = shared.pull()[Symbol.iterator]();
|
|
assert.throws(() => {
|
|
// eslint-disable-next-line no-unused-vars
|
|
for (const _ of shared.pull()) { /* consume */ }
|
|
}, { code: 'ERR_OUT_OF_RANGE' });
|
|
assert.strictEqual(shared.consumerCount, 1);
|
|
let count = 0;
|
|
while (!slow.next().done) count++;
|
|
assert.strictEqual(count, 20);
|
|
}
|
|
|
|
function testShareSyncDropOldestSplitsOversizedBatches() {
|
|
// fromSync() combines the values of this generator into a single batch
|
|
// that is much larger than the budget. Evicting that batch as a whole would
|
|
// leave the slower consumer with nothing at all.
|
|
function* source() {
|
|
for (let i = 0; i < 50; i++) {
|
|
const chunk = new Uint8Array(4096);
|
|
chunk[0] = i;
|
|
yield chunk;
|
|
}
|
|
}
|
|
const shared = shareSync(source(), {
|
|
budget: 65536,
|
|
backpressure: 'drop-oldest',
|
|
});
|
|
const fast = shared.pull()[Symbol.iterator]();
|
|
const slow = shared.pull()[Symbol.iterator]();
|
|
|
|
const fastSeen = [];
|
|
for (let r = fast.next(); !r.done; r = fast.next()) {
|
|
for (const chunk of r.value) fastSeen.push(chunk[0]);
|
|
}
|
|
assert.deepStrictEqual(fastSeen, Array.from({ length: 50 }, (_, i) => i));
|
|
|
|
const slowSeen = [];
|
|
for (let r = slow.next(); !r.done; r = slow.next()) {
|
|
for (const chunk of r.value) slowSeen.push(chunk[0]);
|
|
}
|
|
// The slow consumer lost the oldest chunks but keeps an in-order suffix
|
|
// that fits the budget.
|
|
assert.ok(slowSeen.length > 0);
|
|
assert.ok(slowSeen.length * 4096 < 65536);
|
|
assert.deepStrictEqual(
|
|
slowSeen,
|
|
Array.from({ length: slowSeen.length }, (_, i) => 50 - slowSeen.length + i));
|
|
}
|
|
|
|
function testShareSyncReentrantSourceRead() {
|
|
const enc = new TextEncoder();
|
|
let sibling;
|
|
let reentrantError;
|
|
function* source() {
|
|
yield [enc.encode('a')];
|
|
try {
|
|
sibling.next();
|
|
} catch (err) {
|
|
reentrantError = err;
|
|
}
|
|
yield [enc.encode('b')];
|
|
}
|
|
const shared = shareSync(source());
|
|
const c1 = shared.pull()[Symbol.iterator]();
|
|
sibling = shared.pull()[Symbol.iterator]();
|
|
|
|
assert.deepStrictEqual(c1.next().value, [enc.encode('a')]);
|
|
assert.deepStrictEqual(sibling.next().value, [enc.encode('a')]);
|
|
// Pulling 'b' runs the source, which tries to read its own share.
|
|
assert.deepStrictEqual(c1.next().value, [enc.encode('b')]);
|
|
assert.strictEqual(reentrantError?.code, 'ERR_INVALID_STATE');
|
|
// The failed re-entrant read left the share intact.
|
|
assert.deepStrictEqual(sibling.next().value, [enc.encode('b')]);
|
|
assert.strictEqual(c1.next().done, true);
|
|
assert.strictEqual(sibling.next().done, true);
|
|
}
|
|
|
|
function testShareSyncReentrantSourceReadUncaught() {
|
|
let sibling;
|
|
function* source() {
|
|
yield [new Uint8Array(1)];
|
|
sibling.next();
|
|
}
|
|
const shared = shareSync(source());
|
|
const c1 = shared.pull()[Symbol.iterator]();
|
|
sibling = shared.pull()[Symbol.iterator]();
|
|
c1.next();
|
|
sibling.next();
|
|
// The error escapes the source, so it becomes the share's source error.
|
|
assert.throws(() => c1.next(), { code: 'ERR_INVALID_STATE' });
|
|
assert.throws(() => sibling.next(), { code: 'ERR_INVALID_STATE' });
|
|
}
|
|
|
|
function testShareSyncStringSource() {
|
|
const shared = shareSync('hello-sync-share');
|
|
const result = textSync(shared.pull());
|
|
assert.strictEqual(result, 'hello-sync-share');
|
|
}
|
|
|
|
Promise.all([
|
|
testShareSyncBasic(),
|
|
testShareSyncMultipleConsumers(),
|
|
testShareSyncCancel(),
|
|
testShareSyncCancelMidIteration(),
|
|
testShareSyncCancelWithReason(),
|
|
testShareSyncCancelWithFalsyReason(),
|
|
testShareSyncSourceError(),
|
|
testShareSyncRejectsUnbounded(),
|
|
testShareSyncRejectsDropNewest(),
|
|
testShareSyncDropOldestSplitsOversizedBatches(),
|
|
testShareSyncReentrantSourceRead(),
|
|
testShareSyncReentrantSourceReadUncaught(),
|
|
testShareSyncStringSource(),
|
|
testShareSyncRetainsBufferWhenAllConsumersDetach(),
|
|
testShareSyncStrictBackpressureDetaches(),
|
|
testShareSyncStrictForOfDoesNotWedgeOthers(),
|
|
]).then(common.mustCall());
|