mirror of
https://github.com/nodejs/node.git
synced 2026-11-09 15:11:11 -05:00
Just say no to `new Promise(() => timer(...))` use promisified timers Signed-off-by: James M Snell <jasnell@gmail.com> PR-URL: https://github.com/nodejs/node/pull/66079 Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
561 lines
17 KiB
JavaScript
561 lines
17 KiB
JavaScript
// Flags: --experimental-stream-iter
|
|
'use strict';
|
|
|
|
const common = require('../common');
|
|
const assert = require('assert');
|
|
const {
|
|
broadcast,
|
|
from,
|
|
pull,
|
|
push,
|
|
share,
|
|
tap,
|
|
text,
|
|
toAsyncStreamable,
|
|
} = require('stream/iter');
|
|
|
|
const { setImmediate } = require('timers/promises');
|
|
|
|
async function testPullIdentity() {
|
|
const data = await text(pull(from('hello-async')));
|
|
assert.strictEqual(data, 'hello-async');
|
|
}
|
|
|
|
async function testPullStatelessTransform() {
|
|
const upper = (chunks) => {
|
|
if (chunks === null) return null;
|
|
return chunks.map((c) => {
|
|
const str = new TextDecoder().decode(c);
|
|
return new TextEncoder().encode(str.toUpperCase());
|
|
});
|
|
};
|
|
const data = await text(pull(from('abc'), upper));
|
|
assert.strictEqual(data, 'ABC');
|
|
}
|
|
|
|
async function testPullStatefulTransform() {
|
|
const stateful = {
|
|
transform: async function*(source) {
|
|
for await (const chunks of source) {
|
|
if (chunks === null) {
|
|
yield new TextEncoder().encode('-ASYNC-END');
|
|
continue;
|
|
}
|
|
for (const chunk of chunks) {
|
|
yield chunk;
|
|
}
|
|
}
|
|
},
|
|
};
|
|
const data = await text(pull(from('data'), stateful));
|
|
assert.strictEqual(data, 'data-ASYNC-END');
|
|
}
|
|
|
|
async function testPullStatefulTransformReceiver() {
|
|
const descriptor = {};
|
|
descriptor.transform = common.mustCall(
|
|
async function*(source) {
|
|
assert.strictEqual(this, descriptor);
|
|
for await (const chunks of source) {
|
|
yield chunks;
|
|
}
|
|
});
|
|
|
|
assert.strictEqual(await text(pull(from('receiver'), descriptor)), 'receiver');
|
|
}
|
|
|
|
async function testPullWithAbortSignal() {
|
|
async function* gen() {
|
|
yield [new Uint8Array([1])];
|
|
}
|
|
|
|
assert.throws(
|
|
() => pull(gen(), { signal: AbortSignal.abort() }),
|
|
{ name: 'AbortError' },
|
|
);
|
|
}
|
|
|
|
async function testPullNormalizesSourceAtCallTime() {
|
|
let protocolCalls = 0;
|
|
let iteratorCalls = 0;
|
|
const source = {
|
|
[toAsyncStreamable]() {
|
|
protocolCalls++;
|
|
return {
|
|
async *[Symbol.asyncIterator]() {
|
|
iteratorCalls++;
|
|
yield 'data';
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
const result = pull(source);
|
|
assert.strictEqual(protocolCalls, 1);
|
|
assert.strictEqual(iteratorCalls, 0);
|
|
assert.strictEqual(await text(result), 'data');
|
|
assert.strictEqual(protocolCalls, 1);
|
|
assert.strictEqual(iteratorCalls, 1);
|
|
}
|
|
|
|
function testPullPreAbortOrdering() {
|
|
const reason = new Error('already aborted');
|
|
let protocolCalls = 0;
|
|
const source = {
|
|
[toAsyncStreamable]() {
|
|
protocolCalls++;
|
|
return from('data');
|
|
},
|
|
};
|
|
const signal = AbortSignal.abort(reason);
|
|
|
|
assert.throws(() => pull(source, { signal }), (error) => error === reason);
|
|
assert.strictEqual(protocolCalls, 1);
|
|
assert.throws(
|
|
() => pull(null, { signal }),
|
|
{ code: 'ERR_INVALID_ARG_TYPE' },
|
|
);
|
|
}
|
|
|
|
async function testPullChainedTransforms() {
|
|
const enc = new TextEncoder();
|
|
const transforms = [
|
|
(chunks) => {
|
|
if (chunks === null) return null;
|
|
return [...chunks, enc.encode('!')];
|
|
},
|
|
(chunks) => {
|
|
if (chunks === null) return null;
|
|
return [...chunks, enc.encode('?')];
|
|
},
|
|
];
|
|
const data = await text(pull(from('hello'), ...transforms));
|
|
assert.strictEqual(data, 'hello!?');
|
|
}
|
|
|
|
// Source error → controller.abort() → transform listener throws →
|
|
// source error propagates to consumer; listener error becomes uncaught
|
|
// exception (per EventTarget spec behavior).
|
|
async function testTransformSignalListenerErrorOnSourceError() {
|
|
// Listener errors from dispatchEvent are rethrown via process.nextTick,
|
|
// so we must catch them as uncaught exceptions.
|
|
const uncaughtErrors = [];
|
|
const handler = (err) => uncaughtErrors.push(err);
|
|
process.on('uncaughtException', handler);
|
|
|
|
const throwingTransform = {
|
|
transform(source, options) {
|
|
options.signal.addEventListener('abort', () => {
|
|
throw new Error('listener boom');
|
|
});
|
|
return source;
|
|
},
|
|
};
|
|
|
|
async function* failingSource() {
|
|
yield [new TextEncoder().encode('a')];
|
|
throw new Error('source error');
|
|
}
|
|
|
|
await assert.rejects(
|
|
async () => {
|
|
// eslint-disable-next-line no-unused-vars
|
|
for await (const _ of pull(failingSource(), throwingTransform)) {
|
|
// Consume
|
|
}
|
|
},
|
|
{ message: 'source error' },
|
|
);
|
|
|
|
// Give the nextTick rethrow a chance to fire
|
|
await setImmediate();
|
|
process.removeListener('uncaughtException', handler);
|
|
|
|
assert.strictEqual(uncaughtErrors.length, 1);
|
|
assert.strictEqual(uncaughtErrors[0].message, 'listener boom');
|
|
}
|
|
|
|
// Pull source error propagates to consumer
|
|
async function testPullSourceError() {
|
|
async function* failingSource() {
|
|
yield [new TextEncoder().encode('a')];
|
|
throw new Error('source boom');
|
|
}
|
|
await assert.rejects(async () => {
|
|
// eslint-disable-next-line no-unused-vars
|
|
for await (const _ of pull(failingSource())) { /* consume */ }
|
|
}, { message: 'source boom' });
|
|
}
|
|
|
|
// Tap callback error propagates through pipeline
|
|
async function testTapCallbackError() {
|
|
const badTap = tap(() => { throw new Error('tap boom'); });
|
|
await assert.rejects(async () => {
|
|
// eslint-disable-next-line no-unused-vars
|
|
for await (const _ of pull(from('hello'), badTap)) { /* consume */ }
|
|
}, { message: 'tap boom' });
|
|
}
|
|
|
|
// Pull signal aborted mid-iteration (not pre-aborted)
|
|
async function testPullSignalAbortMidIteration() {
|
|
const ac = new AbortController();
|
|
const enc = new TextEncoder();
|
|
async function* slowSource() {
|
|
yield [enc.encode('a')];
|
|
yield [enc.encode('b')];
|
|
yield [enc.encode('c')];
|
|
}
|
|
const result = pull(slowSource(), { signal: ac.signal });
|
|
const iter = result[Symbol.asyncIterator]();
|
|
const first = await iter.next(); // Read first batch
|
|
assert.strictEqual(first.done, false);
|
|
ac.abort();
|
|
await assert.rejects(() => iter.next(), { name: 'AbortError' });
|
|
}
|
|
|
|
async function testPullSignalAbortWhileSourceNextPending() {
|
|
const source = {
|
|
[Symbol.asyncIterator]() {
|
|
return {
|
|
async next() {
|
|
await new Promise(() => {});
|
|
},
|
|
};
|
|
},
|
|
};
|
|
const ac = new AbortController();
|
|
const iter = pull(source, { signal: ac.signal })[Symbol.asyncIterator]();
|
|
const next = iter.next();
|
|
ac.abort();
|
|
await assert.rejects(next, { name: 'AbortError' });
|
|
}
|
|
|
|
async function testPullReturnWhileSourceNextPending() {
|
|
let startNext;
|
|
const nextStarted = new Promise((resolve) => { startNext = resolve; });
|
|
const source = {
|
|
[Symbol.asyncIterator]() {
|
|
return {
|
|
next() {
|
|
startNext();
|
|
return new Promise(() => {});
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
const iter = pull(source)[Symbol.asyncIterator]();
|
|
const next = assert.rejects(iter.next(), { name: 'AbortError' });
|
|
await nextStarted;
|
|
|
|
const timeout = {};
|
|
const result = await Promise.race([
|
|
iter.return(),
|
|
setImmediate(timeout),
|
|
]);
|
|
|
|
assert.notStrictEqual(result, timeout);
|
|
assert.deepStrictEqual(result, { value: undefined, done: true });
|
|
await next;
|
|
}
|
|
|
|
async function testTransformedConsumerReturnBeforeNext() {
|
|
const identity = (chunks) => chunks;
|
|
const pushed = push(identity);
|
|
const { broadcast: bc } = broadcast();
|
|
const broadcastConsumer = bc.push(identity);
|
|
const shared = share(from('shared'));
|
|
const sharedConsumer = shared.pull(identity);
|
|
|
|
assert.strictEqual(bc.consumerCount, 1);
|
|
assert.strictEqual(shared.consumerCount, 1);
|
|
|
|
const cases = [
|
|
[pushed.readable, common.mustCall(
|
|
() => assert.strictEqual(pushed.writer.canWrite, null))],
|
|
[broadcastConsumer, common.mustCall(
|
|
() => assert.strictEqual(bc.consumerCount, 0))],
|
|
[sharedConsumer, common.mustCall(
|
|
() => assert.strictEqual(shared.consumerCount, 0))],
|
|
];
|
|
|
|
for (const [readable, verify] of cases) {
|
|
await readable[Symbol.asyncIterator]().return();
|
|
verify();
|
|
}
|
|
}
|
|
|
|
async function testPullSignalAbortWithTransformWhileSourceNextPending() {
|
|
const source = {
|
|
[Symbol.asyncIterator]() {
|
|
return {
|
|
async next() {
|
|
await new Promise(() => {});
|
|
},
|
|
};
|
|
},
|
|
};
|
|
const ac = new AbortController();
|
|
const iter = pull(
|
|
source,
|
|
(chunks) => chunks,
|
|
{ signal: ac.signal },
|
|
)[Symbol.asyncIterator]();
|
|
const next = iter.next();
|
|
ac.abort();
|
|
await assert.rejects(next, { name: 'AbortError' });
|
|
}
|
|
|
|
// Pull consumer break (return()) cleans up transform signal
|
|
async function testPullConsumerBreakCleanup() {
|
|
let signalAborted = false;
|
|
const trackingTransform = {
|
|
transform(source, options) {
|
|
options.signal.addEventListener('abort', () => {
|
|
signalAborted = true;
|
|
});
|
|
return source;
|
|
},
|
|
};
|
|
async function* infiniteSource() {
|
|
let i = 0;
|
|
while (true) {
|
|
yield [new TextEncoder().encode(`chunk${i++}`)];
|
|
}
|
|
}
|
|
// Consumer breaks after first chunk
|
|
// eslint-disable-next-line no-unused-vars
|
|
for await (const _ of pull(infiniteSource(), trackingTransform)) {
|
|
break;
|
|
}
|
|
// Give the abort handler a tick to fire
|
|
await setImmediate();
|
|
assert.strictEqual(signalAborted, true);
|
|
}
|
|
|
|
// Pull transform returning a Promise
|
|
async function testPullTransformReturnsPromise() {
|
|
const asyncTransform = async (chunks) => {
|
|
if (chunks === null) return null;
|
|
return chunks;
|
|
};
|
|
const result = await text(pull(from('hello'), asyncTransform));
|
|
assert.strictEqual(result, 'hello');
|
|
}
|
|
|
|
// Stateless transform error propagates
|
|
async function testPullStatelessTransformError() {
|
|
const badTransform = (chunks) => {
|
|
if (chunks === null) return null;
|
|
throw new Error('async stateless boom');
|
|
};
|
|
await assert.rejects(async () => {
|
|
// eslint-disable-next-line no-unused-vars
|
|
for await (const _ of pull(from('hello'), badTransform)) { /* consume */ }
|
|
}, { message: 'async stateless boom' });
|
|
}
|
|
|
|
// Stateful transform error propagates
|
|
async function testPullStatefulTransformError() {
|
|
const badStateful = {
|
|
transform: async function*(source) { // eslint-disable-line require-yield
|
|
for await (const chunks of source) {
|
|
if (chunks === null) continue;
|
|
throw new Error('async stateful boom');
|
|
}
|
|
},
|
|
};
|
|
await assert.rejects(async () => {
|
|
// eslint-disable-next-line no-unused-vars
|
|
for await (const _ of pull(from('hello'), badStateful)) { /* consume */ }
|
|
}, { message: 'async stateful boom' });
|
|
}
|
|
|
|
// Stateless transform flush emitting data
|
|
async function testPullStatelessTransformFlush() {
|
|
const withTrailer = (chunks) => {
|
|
if (chunks === null) {
|
|
return [new TextEncoder().encode('-TRAILER')];
|
|
}
|
|
return chunks;
|
|
};
|
|
const data = await text(pull(from('data'), withTrailer));
|
|
assert.strictEqual(data, 'data-TRAILER');
|
|
}
|
|
|
|
// Consecutive stateless transforms each receive a final flush signal after
|
|
// upstream flush output has been processed.
|
|
async function testPullConsecutiveStatelessTransformFlush() {
|
|
const enc = new TextEncoder();
|
|
const addAOnFlush = (chunks) => (chunks === null ?
|
|
[enc.encode('-A')] : chunks);
|
|
const addBOnFlush = (chunks) => (chunks === null ?
|
|
[enc.encode('-B')] : chunks);
|
|
|
|
const data = await text(pull(from('x'), addAOnFlush, addBOnFlush));
|
|
assert.strictEqual(data, 'x-A-B');
|
|
}
|
|
|
|
// Stateless transform flush error propagates
|
|
async function testPullStatelessTransformFlushError() {
|
|
const badFlush = (chunks) => {
|
|
if (chunks === null) {
|
|
throw new Error('async flush boom');
|
|
}
|
|
return chunks;
|
|
};
|
|
await assert.rejects(async () => {
|
|
// eslint-disable-next-line no-unused-vars
|
|
for await (const _ of pull(from('hello'), badFlush)) { /* consume */ }
|
|
}, { message: 'async flush boom' });
|
|
}
|
|
|
|
// An abort during an async flush must not be swallowed when the flush resolves
|
|
// to null and therefore produces no final batch.
|
|
async function testPullSignalAbortDuringAsyncFlush() {
|
|
const ac = new AbortController();
|
|
const reason = new Error('aborted during flush');
|
|
const transform = async (chunks) => {
|
|
if (chunks !== null) return chunks;
|
|
ac.abort(reason);
|
|
return null;
|
|
};
|
|
|
|
await assert.rejects(
|
|
() => text(pull(from('x'), transform, { signal: ac.signal })),
|
|
(error) => error === reason,
|
|
);
|
|
}
|
|
|
|
// Pull with a sync iterable source (not async)
|
|
async function testPullWithSyncSource() {
|
|
function* gen() {
|
|
yield new TextEncoder().encode('sync-source');
|
|
}
|
|
const data = await text(pull(gen()));
|
|
assert.strictEqual(data, 'sync-source');
|
|
}
|
|
|
|
// Pull transform yielding strings
|
|
async function testPullTransformYieldsStrings() {
|
|
const stringTransform = (chunks) => {
|
|
if (chunks === null) return null;
|
|
return chunks.map((c) => new TextDecoder().decode(c));
|
|
};
|
|
const result = await text(pull(from('hello'), stringTransform));
|
|
assert.strictEqual(result, 'hello');
|
|
}
|
|
|
|
// pull() accepts a string source directly (normalized via from())
|
|
async function testPullStringSource() {
|
|
const data = await text(pull('hello-direct'));
|
|
assert.strictEqual(data, 'hello-direct');
|
|
}
|
|
|
|
// Transform returning a single Uint8Array should be wrapped as a batch,
|
|
// not iterated byte-by-byte
|
|
async function testTransformReturnsSingleUint8Array() {
|
|
const transform = (chunks) => {
|
|
if (chunks === null) return null;
|
|
// Return a single Uint8Array, not an array
|
|
const enc = new TextEncoder();
|
|
return enc.encode('transformed');
|
|
};
|
|
const data = await text(pull(from('input'), transform));
|
|
assert.strictEqual(data, 'transformed');
|
|
}
|
|
|
|
// Transform returning a single string should be UTF-8 encoded,
|
|
// not iterated character-by-character
|
|
async function testTransformReturnsSingleString() {
|
|
const transform = (chunks) => {
|
|
if (chunks === null) return null;
|
|
return 'hello-string';
|
|
};
|
|
const data = await text(pull(from('input'), transform));
|
|
assert.strictEqual(data, 'hello-string');
|
|
}
|
|
|
|
// Transform returning an ArrayBuffer should be converted to Uint8Array
|
|
async function testTransformReturnsArrayBuffer() {
|
|
const transform = (chunks) => {
|
|
if (chunks === null) return null;
|
|
const enc = new TextEncoder();
|
|
return enc.encode('arraybuf').buffer;
|
|
};
|
|
const data = await text(pull(from('input'), transform));
|
|
assert.strictEqual(data, 'arraybuf');
|
|
}
|
|
|
|
// pipeTo() accepts a string source directly (normalized via from())
|
|
async function testPipeToStringSource() {
|
|
const { pipeTo, push: pushFn, text: textFn } = require('stream/iter');
|
|
const { writer, readable } = pushFn({ budget: 16384 });
|
|
const consume = (async () => textFn(readable))();
|
|
await pipeTo('hello-pipe', writer);
|
|
const data = await consume;
|
|
assert.strictEqual(data, 'hello-pipe');
|
|
}
|
|
|
|
// INVARIANT: Each transform invocation receives its own options object.
|
|
// A transform that mutates options must not affect subsequent transforms.
|
|
async function testTransformOptionsNotShared() {
|
|
const seen = [];
|
|
const transform1 = (chunks, options) => {
|
|
// Mutate the options object
|
|
options.mutated = true;
|
|
seen.push({ id: 1, mutated: options.mutated });
|
|
return chunks;
|
|
};
|
|
const transform2 = (chunks, options) => {
|
|
// Should NOT see mutation from transform1
|
|
seen.push({ id: 2, mutated: options.mutated });
|
|
return chunks;
|
|
};
|
|
await text(pull(from('test'), transform1, transform2));
|
|
// transform1 sees its own mutation
|
|
assert.strictEqual(seen[0].mutated, true);
|
|
// transform2 gets a fresh options object - no mutation visible
|
|
assert.strictEqual(seen[1].mutated, undefined);
|
|
}
|
|
|
|
// Run the uncaughtException test sequentially (it installs a global handler
|
|
// that would interfere with concurrent tests).
|
|
(async () => {
|
|
await Promise.all([
|
|
testPullIdentity(),
|
|
testPullStatelessTransform(),
|
|
testPullStatefulTransform(),
|
|
testPullStatefulTransformReceiver(),
|
|
testPullWithAbortSignal(),
|
|
testPullNormalizesSourceAtCallTime(),
|
|
testPullPreAbortOrdering(),
|
|
testPullChainedTransforms(),
|
|
testPullSourceError(),
|
|
testTapCallbackError(),
|
|
testPullSignalAbortMidIteration(),
|
|
testPullSignalAbortWhileSourceNextPending(),
|
|
testPullReturnWhileSourceNextPending(),
|
|
testTransformedConsumerReturnBeforeNext(),
|
|
testPullSignalAbortWithTransformWhileSourceNextPending(),
|
|
testPullConsumerBreakCleanup(),
|
|
testPullTransformReturnsPromise(),
|
|
testPullTransformYieldsStrings(),
|
|
testPullStatelessTransformError(),
|
|
testPullStatefulTransformError(),
|
|
testPullStatelessTransformFlush(),
|
|
testPullConsecutiveStatelessTransformFlush(),
|
|
testPullStatelessTransformFlushError(),
|
|
testPullSignalAbortDuringAsyncFlush(),
|
|
testPullWithSyncSource(),
|
|
testPullStringSource(),
|
|
testTransformReturnsSingleUint8Array(),
|
|
testTransformReturnsSingleString(),
|
|
testTransformReturnsArrayBuffer(),
|
|
testPipeToStringSource(),
|
|
testTransformOptionsNotShared(),
|
|
]);
|
|
// Run after all concurrent tests complete to avoid global handler races
|
|
await testTransformSignalListenerErrorOnSourceError();
|
|
})().then(common.mustCall());
|