mirror of
https://github.com/nodejs/node.git
synced 2026-11-09 15:11:11 -05:00
Validate Broadcast.push() and Share.pull() signals before registering raw consumers. Return a rejecting iterable for pre-aborted signals without adding a cursor. This prevents failed subscriptions from leaving unreachable cursors that inflate consumerCount and can permanently impose backpressure. Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> Assisted-by: codex:gpt-5.6-sol PR-URL: https://github.com/nodejs/node/pull/65299 Fixes: https://github.com/nodejs/node/issues/65298 Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Jason Zhang <xzha4350@gmail.com>
430 lines
17 KiB
JavaScript
430 lines
17 KiB
JavaScript
// Flags: --experimental-stream-iter
|
|
'use strict';
|
|
|
|
const common = require('../common');
|
|
const assert = require('assert');
|
|
const { Writable } = require('stream');
|
|
const {
|
|
from, fromSync, pull, pullSync, pipeTo, fromWritable,
|
|
push, duplex, broadcast, Broadcast, share, shareSync,
|
|
Share, SyncShare,
|
|
bytes, bytesSync, text, textSync,
|
|
arrayBuffer, arrayBufferSync, array, arraySync,
|
|
tap, tapSync,
|
|
} = require('stream/iter');
|
|
const {
|
|
compressGzip, compressBrotli, compressZstd,
|
|
decompressGzip, decompressBrotli, decompressZstd,
|
|
} = require('zlib/iter');
|
|
|
|
// =============================================================================
|
|
// push() validation
|
|
// =============================================================================
|
|
|
|
// Budget must be integer >= 16384
|
|
assert.throws(() => push({ budget: 'bad' }), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => push({ budget: 1.5 }), { code: 'ERR_OUT_OF_RANGE' });
|
|
// Values < 16384 are rejected
|
|
assert.throws(() => push({ budget: 0 }), { code: 'ERR_OUT_OF_RANGE' });
|
|
assert.throws(() => push({ budget: -1 }), { code: 'ERR_OUT_OF_RANGE' });
|
|
assert.throws(() => push({ budget: -100 }), { code: 'ERR_OUT_OF_RANGE' });
|
|
assert.throws(() => push({ budget: 16383 }), { code: 'ERR_OUT_OF_RANGE' });
|
|
// 16384 is the minimum accepted value
|
|
assert.strictEqual(push({ budget: 16384 }).writer.canWrite, true);
|
|
// MAX_SAFE_INTEGER is accepted
|
|
assert.strictEqual(push({ budget: Number.MAX_SAFE_INTEGER }).writer.canWrite,
|
|
true);
|
|
// Values above MAX_SAFE_INTEGER are rejected by validateInteger
|
|
assert.throws(() => push({ budget: Number.MAX_SAFE_INTEGER + 1 }),
|
|
{ code: 'ERR_OUT_OF_RANGE' });
|
|
|
|
// Signal must be AbortSignal
|
|
assert.throws(() => push({ signal: 'bad' }), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => push({ signal: {} }), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
|
|
// Transforms must be functions or transform objects
|
|
assert.throws(() => push(42, {}), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => push('bad', {}), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
|
|
// Writer options.signal must be AbortSignal
|
|
{
|
|
const { writer } = push();
|
|
const badOptions = { signal: 'bad' };
|
|
assert.throws(() => writer.write('a', badOptions),
|
|
{ code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => writer.writev(['b'], badOptions),
|
|
{ code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => writer.end(badOptions),
|
|
{ code: 'ERR_INVALID_ARG_TYPE' });
|
|
writer.endSync();
|
|
}
|
|
|
|
// Writer.writev requires array
|
|
{
|
|
const { writer } = push();
|
|
assert.throws(() => writer.writev('bad'), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => writer.writev(42), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => writer.writevSync('bad'), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
writer.endSync();
|
|
}
|
|
|
|
// Writer.write rejects non-string/non-Uint8Array
|
|
{
|
|
const { writer } = push();
|
|
assert.throws(() => writer.writeSync(42), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => writer.writeSync({}), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => writer.writeSync(true), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
writer.endSync();
|
|
}
|
|
|
|
// =============================================================================
|
|
// duplex() validation
|
|
// =============================================================================
|
|
|
|
assert.throws(() => duplex(42), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => duplex('bad'), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => duplex({ a: 42 }), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => duplex({ b: 'bad' }), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
|
|
// Budget validation (cascades through to push())
|
|
assert.throws(() => duplex({ budget: 'bad' }), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => duplex({ budget: 1.5 }), { code: 'ERR_OUT_OF_RANGE' });
|
|
assert.throws(() => duplex({ budget: Number.MAX_SAFE_INTEGER + 1 }),
|
|
{ code: 'ERR_OUT_OF_RANGE' });
|
|
|
|
// Values < 16384 are rejected (both directions)
|
|
assert.throws(() => duplex({ budget: 0 }), { code: 'ERR_OUT_OF_RANGE' });
|
|
// MAX_SAFE_INTEGER is accepted
|
|
{
|
|
const [a, b] = duplex({ budget: Number.MAX_SAFE_INTEGER });
|
|
assert.strictEqual(a.writer.canWrite, true);
|
|
assert.strictEqual(b.writer.canWrite, true);
|
|
a.close();
|
|
b.close();
|
|
}
|
|
// Per-direction overrides
|
|
{
|
|
const [a, b] = duplex({ a: { budget: 16384 }, b: { budget: 32768 } });
|
|
assert.strictEqual(a.writer.canWrite, true);
|
|
assert.strictEqual(b.writer.canWrite, true);
|
|
a.close();
|
|
b.close();
|
|
}
|
|
|
|
assert.throws(() => duplex({ signal: {} }), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
|
|
// =============================================================================
|
|
// pull() / pullSync() validation
|
|
// =============================================================================
|
|
|
|
// Signal must be AbortSignal
|
|
assert.throws(() => pull(from('a'), { signal: 'bad' }), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
|
|
// Transforms must be functions or transform objects
|
|
assert.throws(() => pull(from('a'), 42), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => pull(from('a'), 'bad'), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => pullSync(fromSync('a'), 42), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
|
|
// =============================================================================
|
|
// broadcast() validation
|
|
// =============================================================================
|
|
|
|
assert.throws(() => broadcast({ budget: 'bad' }), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => broadcast({ budget: 1.5 }), { code: 'ERR_OUT_OF_RANGE' });
|
|
assert.throws(() => broadcast({ budget: Number.MAX_SAFE_INTEGER + 1 }),
|
|
{ code: 'ERR_OUT_OF_RANGE' });
|
|
|
|
// Values < 16384 are rejected
|
|
assert.throws(() => broadcast({ budget: 0 }), { code: 'ERR_OUT_OF_RANGE' });
|
|
assert.throws(() => broadcast({ budget: -1 }), { code: 'ERR_OUT_OF_RANGE' });
|
|
assert.throws(() => broadcast({ budget: 16383 }), { code: 'ERR_OUT_OF_RANGE' });
|
|
// 16384 is the minimum accepted value
|
|
{
|
|
const bc = broadcast({ budget: 16384 });
|
|
bc.broadcast.push();
|
|
assert.strictEqual(bc.writer.canWrite, true);
|
|
bc.writer.endSync();
|
|
}
|
|
// MAX_SAFE_INTEGER is accepted
|
|
{
|
|
const bc = broadcast({ budget: Number.MAX_SAFE_INTEGER });
|
|
bc.broadcast.push();
|
|
assert.strictEqual(bc.writer.canWrite, true);
|
|
bc.writer.endSync();
|
|
}
|
|
|
|
assert.throws(() => broadcast({ signal: {} }), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => broadcast({ backpressure: 'bad' }), { code: 'ERR_INVALID_ARG_VALUE' });
|
|
|
|
// Broadcast consumer options.signal must be AbortSignal and validation must
|
|
// not leave a consumer registered.
|
|
{
|
|
const { broadcast: bc } = broadcast();
|
|
assert.throws(() => bc.push({ signal: {} }),
|
|
{ code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.strictEqual(bc.consumerCount, 0);
|
|
}
|
|
|
|
// BroadcastWriter options.signal must be AbortSignal
|
|
{
|
|
const { writer } = broadcast();
|
|
const badOptions = { signal: 'bad' };
|
|
assert.throws(() => writer.write('a', badOptions),
|
|
{ code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => writer.writev(['b'], badOptions),
|
|
{ code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => writer.end(badOptions),
|
|
{ code: 'ERR_INVALID_ARG_TYPE' });
|
|
writer.endSync();
|
|
}
|
|
|
|
// BroadcastWriter.writev requires array
|
|
{
|
|
const { writer } = broadcast();
|
|
assert.throws(() => writer.writev('bad'), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => writer.writev(42), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => writer.writevSync('bad'), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => writer.writevSync(42), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
writer.endSync();
|
|
}
|
|
|
|
// Broadcast.from rejects non-streamable input
|
|
assert.throws(() => Broadcast.from(42), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
|
|
// fromWritable Writer options.signal must be AbortSignal
|
|
{
|
|
const writable = new Writable({
|
|
write(chunk, encoding, callback) {
|
|
callback();
|
|
},
|
|
});
|
|
const writer = fromWritable(writable);
|
|
const badOptions = { signal: 'bad' };
|
|
assert.throws(() => writer.write('a', badOptions),
|
|
{ code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => writer.writev(['b'], badOptions),
|
|
{ code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => writer.end(badOptions),
|
|
{ code: 'ERR_INVALID_ARG_TYPE' });
|
|
writable.destroy();
|
|
}
|
|
|
|
// =============================================================================
|
|
// share() / shareSync() validation
|
|
// =============================================================================
|
|
|
|
assert.throws(() => share(42), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => share(from('a'), { budget: 'bad' }), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => share(from('a'), { budget: 1.5 }), { code: 'ERR_OUT_OF_RANGE' });
|
|
assert.throws(() => share(from('a'), { budget: Number.MAX_SAFE_INTEGER + 1 }),
|
|
{ code: 'ERR_OUT_OF_RANGE' });
|
|
assert.throws(() => share(from('a'), { signal: {} }), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => share(from('a'), { backpressure: 'bad' }), { code: 'ERR_INVALID_ARG_VALUE' });
|
|
|
|
// Share consumer options.signal must be AbortSignal and validation must not
|
|
// leave a consumer registered.
|
|
{
|
|
const shared = share(from('a'));
|
|
assert.throws(() => shared.pull({ signal: {} }),
|
|
{ code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.strictEqual(shared.consumerCount, 0);
|
|
}
|
|
|
|
// share() values < 16384 are rejected
|
|
assert.throws(() => share(from('a'), { budget: 0 }), { code: 'ERR_OUT_OF_RANGE' });
|
|
assert.throws(() => share(from('a'), { budget: -1 }), { code: 'ERR_OUT_OF_RANGE' });
|
|
// 16384 is the minimum, MAX_SAFE_INTEGER is accepted
|
|
share(from('a'), { budget: 16384 }).cancel();
|
|
share(from('a'), { budget: Number.MAX_SAFE_INTEGER }).cancel();
|
|
|
|
assert.throws(() => shareSync(42), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => shareSync(fromSync('a'), { budget: 'bad' }),
|
|
{ code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => shareSync(fromSync('a'), { budget: 1.5 }),
|
|
{ code: 'ERR_OUT_OF_RANGE' });
|
|
assert.throws(() => shareSync(fromSync('a'), { budget: Number.MAX_SAFE_INTEGER + 1 }),
|
|
{ code: 'ERR_OUT_OF_RANGE' });
|
|
|
|
// shareSync() values < 16384 are rejected
|
|
assert.throws(() => shareSync(fromSync('a'), { budget: 0 }),
|
|
{ code: 'ERR_OUT_OF_RANGE' });
|
|
assert.throws(() => shareSync(fromSync('a'), { budget: -1 }),
|
|
{ code: 'ERR_OUT_OF_RANGE' });
|
|
// 16384 is the minimum, MAX_SAFE_INTEGER is accepted
|
|
shareSync(fromSync('a'), { budget: 16384 }).cancel();
|
|
shareSync(fromSync('a'), { budget: Number.MAX_SAFE_INTEGER }).cancel();
|
|
|
|
// Share.from / SyncShare.fromSync reject non-iterable
|
|
assert.throws(() => Share.from(42), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => SyncShare.fromSync(42), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
|
|
// =============================================================================
|
|
// Consumer validation (synchronous)
|
|
// =============================================================================
|
|
|
|
// tap / tapSync require function
|
|
assert.throws(() => tap(42), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => tap('bad'), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => tapSync(42), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => tapSync(null), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
|
|
// Sync consumer options
|
|
assert.throws(() => bytesSync(fromSync('a'), { limit: 'bad' }),
|
|
{ code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => bytesSync(fromSync('a'), { limit: -1 }),
|
|
{ code: 'ERR_OUT_OF_RANGE' });
|
|
assert.throws(() => textSync(fromSync('a'), { encoding: 42 }),
|
|
{ code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => textSync(fromSync('a'), { encoding: 'bogus' }),
|
|
{ code: 'ERR_INVALID_ARG_VALUE' });
|
|
assert.throws(() => arrayBufferSync(fromSync('a'), { limit: 'bad' }),
|
|
{ code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => arraySync(fromSync('a'), { limit: -1 }),
|
|
{ code: 'ERR_OUT_OF_RANGE' });
|
|
|
|
// Options must be object if provided
|
|
assert.throws(() => bytesSync(fromSync('a'), 42), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => textSync(fromSync('a'), 'bad'), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
|
|
// Compression options must be object
|
|
assert.throws(() => compressGzip(42), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => decompressGzip('bad'), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => compressBrotli(42), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => decompressBrotli('bad'), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => compressZstd(42), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
assert.throws(() => decompressZstd('bad'), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
|
|
// =============================================================================
|
|
// Async consumer and compression validation
|
|
// =============================================================================
|
|
|
|
// Helper: consume a transform through a pipeline to trigger lazy validation.
|
|
const consume = (transform) => bytes(pull(from('test'), transform));
|
|
|
|
async function testAsyncValidation() {
|
|
// pipeTo signal
|
|
await assert.rejects(
|
|
() => pipeTo(from('a'), { write() {} }, { signal: 'bad' }),
|
|
{ code: 'ERR_INVALID_ARG_TYPE' },
|
|
);
|
|
|
|
// Async consumer options
|
|
await assert.rejects(
|
|
() => bytes(from('a'), 42), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
await assert.rejects(
|
|
() => bytes(from('a'), { signal: 'bad' }), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
await assert.rejects(
|
|
() => bytes(from('a'), { limit: 'bad' }), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
await assert.rejects(
|
|
() => bytes(from('a'), { limit: -1 }), { code: 'ERR_OUT_OF_RANGE' });
|
|
await assert.rejects(
|
|
() => text(from('a'), { encoding: 42 }), { code: 'ERR_INVALID_ARG_TYPE' });
|
|
await assert.rejects(
|
|
() => text(from('a'), { encoding: 'not-a-real-encoding' }),
|
|
{ code: 'ERR_INVALID_ARG_VALUE' });
|
|
await assert.rejects(
|
|
() => arrayBuffer(from('a'), { limit: 'bad' }),
|
|
{ code: 'ERR_INVALID_ARG_TYPE' });
|
|
await assert.rejects(
|
|
() => array(from('a'), { limit: -1 }), { code: 'ERR_OUT_OF_RANGE' });
|
|
|
|
const TYPE = { code: 'ERR_INVALID_ARG_TYPE' };
|
|
const RANGE = { code: 'ERR_OUT_OF_RANGE' };
|
|
const BROTLI = { code: 'ERR_BROTLI_INVALID_PARAM' };
|
|
const ZSTD = { code: 'ERR_ZSTD_INVALID_PARAM' };
|
|
|
|
// ChunkSize
|
|
await assert.rejects(consume(compressGzip({ chunkSize: 'bad' })), TYPE);
|
|
await assert.rejects(consume(compressGzip({ chunkSize: 0 })), RANGE);
|
|
await assert.rejects(consume(compressGzip({ chunkSize: 10 })), RANGE);
|
|
|
|
// WindowBits
|
|
await assert.rejects(consume(compressGzip({ windowBits: 'bad' })), TYPE);
|
|
await assert.rejects(consume(compressGzip({ windowBits: 100 })), RANGE);
|
|
|
|
// Level
|
|
await assert.rejects(consume(compressGzip({ level: 'bad' })), TYPE);
|
|
await assert.rejects(consume(compressGzip({ level: 100 })), RANGE);
|
|
|
|
// MemLevel
|
|
await assert.rejects(consume(compressGzip({ memLevel: 'bad' })), TYPE);
|
|
await assert.rejects(consume(compressGzip({ memLevel: 100 })), RANGE);
|
|
|
|
// Strategy
|
|
await assert.rejects(consume(compressGzip({ strategy: 'bad' })), TYPE);
|
|
await assert.rejects(consume(compressGzip({ strategy: 100 })), RANGE);
|
|
|
|
// Dictionary
|
|
await assert.rejects(consume(compressGzip({ dictionary: 42 })), TYPE);
|
|
await assert.rejects(consume(compressGzip({ dictionary: 'bad' })), TYPE);
|
|
|
|
// Brotli params
|
|
await assert.rejects(consume(compressBrotli({ params: 42 })), TYPE);
|
|
await assert.rejects(consume(compressBrotli({ params: { bad: 1 } })), BROTLI);
|
|
await assert.rejects(consume(compressBrotli({ params: { [-1]: 1 } })), BROTLI);
|
|
await assert.rejects(consume(compressBrotli({ params: { 0: 'bad' } })), TYPE);
|
|
|
|
// Zstd params
|
|
await assert.rejects(consume(compressZstd({ params: 42 })), TYPE);
|
|
await assert.rejects(consume(compressZstd({ params: { bad: 1 } })), ZSTD);
|
|
await assert.rejects(consume(compressZstd({ params: { 0: 'bad' } })), TYPE);
|
|
|
|
// Zstd pledgedSrcSize
|
|
await assert.rejects(consume(compressZstd({ pledgedSrcSize: 'bad' })), TYPE);
|
|
for (const pledgedSrcSize of [
|
|
NaN,
|
|
Infinity,
|
|
-Infinity,
|
|
1.9,
|
|
-1,
|
|
Number.MAX_SAFE_INTEGER + 1,
|
|
]) {
|
|
await assert.rejects(consume(compressZstd({ pledgedSrcSize })), RANGE);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Valid calls still work
|
|
// =============================================================================
|
|
|
|
// Push with valid options
|
|
{
|
|
const { writer } = push({ budget: 16384 });
|
|
writer.writeSync('hello');
|
|
writer.endSync();
|
|
}
|
|
|
|
// Duplex with valid options
|
|
{
|
|
const [a, b] = duplex({ budget: 16384 });
|
|
a.close();
|
|
b.close();
|
|
}
|
|
|
|
// Broadcast with valid options
|
|
{
|
|
const { writer } = broadcast({ budget: 16384 });
|
|
writer.endSync();
|
|
}
|
|
|
|
// Share with valid options
|
|
{
|
|
const shared = share(from('hello'), { budget: 16384 });
|
|
shared.cancel();
|
|
}
|
|
|
|
// Compression with valid options
|
|
{
|
|
const transform = compressGzip({ chunkSize: 1024, level: 6 });
|
|
assert.strictEqual(typeof transform.transform, 'function');
|
|
}
|
|
|
|
// Brotli with valid params
|
|
{
|
|
const { constants: { BROTLI_PARAM_QUALITY } } = require('zlib');
|
|
const transform = compressBrotli({ params: { [BROTLI_PARAM_QUALITY]: 5 } });
|
|
assert.strictEqual(typeof transform.transform, 'function');
|
|
}
|
|
|
|
testAsyncValidation().then(common.mustCall());
|