Pass { signal } to stream operator callbacks and Duplex.from bodies

This commit is contained in:
Chris Tate
2026-10-22 17:50:29 -05:00
parent 5c29845909
commit 5372c69459
43 changed files with 1321 additions and 301 deletions
@@ -10,7 +10,7 @@
The `objectMode` option, and the `readableObjectMode` and `writableObjectMode` options of a `Duplex`, `Transform`, or `PassThrough`, take `true` or `false` literals in an options object literal; options passed as a value are read when the stream is built.
In typed code, a `write` or `transform` callback, or a `_write` or `_transform` method, may declare its chunk with any type that converts to a checked-dynamic value: the chunk converts when the callback runs, and a value of another type throws a TypeError there.
An object-mode write passes its encoding argument to `write` and `transform` as Node does (`undefined` when absent, `null` when a callback took its place); a parameter declared `string` receives `""` for `undefined` and `null`, and one declared `string | null | undefined` receives Node's value.
`readable.read()` on an object-mode stream answers its Buffers; another value throws a not-supported error (read such values with `'data'` listeners, `for await`, or `pipe`).
`readable.read()` on an object-mode stream answers any value it holds.
`node:stream/consumers` and readline interfaces read object-mode streams of strings and Buffers; other values throw a not-supported error.
`pipeline` accepts function stages anywhere among its streams: async generator functions (`async function* (source)`, iterating `source` with `for await`), an async generator source function (`async function* ({ signal })`), an async generator function as the destination, and an async function destination whose value resolves the promise form and reaches the callback's second argument.
A stage is written inline, or named by a function declaration or a `const` bound to a function expression that nothing reassigns.
@@ -20,10 +20,11 @@
`@types/node` types that result as `NodeJS.WritableStream`, so `for await` over it needs the `PassThrough` type (`pipeline(...) as PassThrough`, or a JSDoc `@type`).
In typed code, the callback of a pipeline with an async function destination receives `null` instead of `undefined` when there is no error.
`Readable.from` accepts `objectMode` and `highWaterMark` options.
The `Readable` operators (`map`, `filter`, `forEach`, `toArray`, `some`, `find`, `every`, `flatMap`, `drop`, `take`, and `reduce`) accept `concurrency`, `highWaterMark`, and `signal` options; their callbacks take only the chunk, `map` returns strings or Buffers, and `flatMap` returns strings or arrays of them.
The `Readable` operators (`map`, `filter`, `forEach`, `toArray`, `some`, `find`, `every`, `flatMap`, `drop`, `take`, and `reduce`) accept `concurrency`, `highWaterMark`, and `signal` options.
Their callbacks may take the `{ signal }` argument, `map` may return any value, and `flatMap` may return any iterable, async iterable, or stream.
`Duplex.from` accepts an async generator function, which reads what is written to the Duplex, a `Duplex`, `Transform` or `PassThrough`, which it returns, and the values `Readable.from` accepts, which give a Duplex that is not writable.
It also accepts a lone `Readable` or `Writable` and a `{ readable, writable }` pair, which the Duplex reads and writes through, a promise, whose value it reads, and an async function, which reads what is written and must resolve to `null` or `undefined`.
A web stream and a body function that declares the options argument are unsupported in `Duplex.from`.
A `Duplex.from` body function may take the `{ signal }` argument, which is aborted with the error the Duplex is destroyed with.
`stream.compose` accepts streams and async generator functions in any position, an async function as the last stage, and an array, a string, or a generator as the first stage; one stage is `Duplex.from` of it.
A web `ReadableStream` is read as an async iterable by `Readable.from` and as the first stage of `compose` or `pipeline`, and through its reader by `Readable.fromWeb` (with `objectMode`, `highWaterMark`, `encoding`, and `signal` in an options object literal) and `Duplex.from`.
A web stream in a later compose stage, `Readable.toWeb`, and web `WritableStream` and `TransformStream` interop are unsupported.
+2 -2
View File
@@ -72,7 +72,7 @@ describe.runIf(supported)("macOS arm64 native link info", () => {
minimum_os: "14.0",
},
program: { object, entry_symbol: "main" },
runtime_abi: { version: 31, marker: "scr_runtime_abi_v31" },
runtime_abi: { version: 32, marker: "scr_runtime_abi_v32" },
runtime_pack: {
kind: "precompiled",
package: "@scriptc/runtime-darwin-arm64",
@@ -165,6 +165,6 @@ describe.runIf(supported)("macOS arm64 native link info", () => {
() => null,
(failure: { stderr?: string }) => failure,
);
expect(error?.stderr).toContain("scr_runtime_abi_v31");
expect(error?.stderr).toContain("scr_runtime_abi_v32");
});
});
+3 -1
View File
@@ -7272,7 +7272,9 @@ declare module "stream" {
push(chunk: any, encoding?: string): boolean;
unshift(chunk: Buffer | string, encoding?: string): void;
unshift(chunk: any, encoding?: string): void;
read(size?: number): Buffer | null;
/* A Buffer, a string once encoded, an object-mode value, or null (as
* @types/node types it). */
read(size?: number): any;
pause(): this;
resume(): this;
isPaused(): boolean;
@@ -1134,6 +1134,8 @@ export const LIB_FN_SYMS: Record<string, string> = {
"stream.duplexifyPair": "scr_stream_duplexify_pair",
"stream.duplexFromPromise": "scr_stream_duplex_from_promise",
"stream.duplexFromAsyncFn": "scr_stream_duplex_from_async_fn",
"stream.duplexFromGenSignal": "scr_stream_duplex_from_gen_signal",
"stream.duplexFromAsyncFnSignal": "scr_stream_duplex_from_async_fn_signal",
"fetch.readableFromWeb": "scr_stream_readable_from_web",
"writable.writeEncoding": "scr_stream_write_encoding",
"writable.setDefaultEncoding": "scr_stream_set_default_encoding",
@@ -1200,6 +1202,8 @@ export const LIB_FN_SYMS: Record<string, string> = {
"readable.opMap": "scr_stream_op_map",
"readable.opFilter": "scr_stream_op_filter",
"readable.opFlatMap": "scr_stream_op_flat_map",
"readable.opFlatMapAsyncGen": "scr_stream_op_flat_map_async_gen",
"readable.opFlatMapSyncGen": "scr_stream_op_flat_map_sync_gen",
"readable.opDrop": "scr_stream_op_drop",
"readable.opTake": "scr_stream_op_take",
"readable.opToArray": "scr_stream_op_to_array",
@@ -1208,6 +1212,11 @@ export const LIB_FN_SYMS: Record<string, string> = {
"readable.opEvery": "scr_stream_op_every",
"readable.opFind": "scr_stream_op_find",
"readable.opReduce": "scr_stream_op_reduce",
"readable.opSignalList": "scr_stream_op_signal_list",
"readable.opReduceSignal": "scr_stream_op_reduce_signal",
"readable.opSignalOf": "scr_stream_op_signal_of",
"readable.opOnceOptions": "scr_stream_op_once_options",
"readable.opSignalOpts": "scr_stream_op_signal_opts",
"readable.pipe": "scr_stream_pipe",
"readable.pipeGeneral": "scr_stream_pipe_general",
"writable.cork": "scr_stream_cork",
@@ -1711,6 +1720,8 @@ export const USES_TIMERS_LIB_FNS = new Set<string>([
"readable.opMap",
"readable.opFilter",
"readable.opFlatMap",
"readable.opFlatMapAsyncGen",
"readable.opFlatMapSyncGen",
"readable.opDrop",
"readable.opTake",
"readable.opToArray",
@@ -1719,6 +1730,7 @@ export const USES_TIMERS_LIB_FNS = new Set<string>([
"readable.opEvery",
"readable.opFind",
"readable.opReduce",
"readable.opReduceSignal",
"readable.resume",
"readable.pipe",
"readable.pipeGeneral",
@@ -1748,6 +1760,8 @@ export const USES_TIMERS_LIB_FNS = new Set<string>([
"stream.duplexifyPair",
"stream.duplexFromPromise",
"stream.duplexFromAsyncFn",
"stream.duplexFromGenSignal",
"stream.duplexFromAsyncFnSignal",
"fetch.readableFromWeb",
"stream.setWritev",
"stream.setWritevDefault",
+2 -2
View File
@@ -2,8 +2,8 @@
* of the link contract: a program object keeps this symbol undefined and the
* matching runtime defines it, so incompatible manual links fail before the
* program can start. */
export const RUNTIME_ABI_VERSION = 31 as const;
export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v31" as const;
export const RUNTIME_ABI_VERSION = 32 as const;
export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v32" as const;
/** Object consumption is useful today, but the complete scr_* surface may
* still change before 1.0. The versioned marker prevents accidental mixing;
@@ -52,7 +52,7 @@ async function fixture(target: NativeTargetSpec, workers = false) {
object_format: target.objectFormat,
minimum_os: target.minimumOs,
},
runtime_abi: { version: 31, marker: "scr_runtime_abi_v31" },
runtime_abi: { version: 32, marker: "scr_runtime_abi_v32" },
compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple },
macros: { executable: [], excluded: ["SCR_LIB"], sanitizer: "external-toolchain-required" },
flavors: {
@@ -127,7 +127,7 @@ async function fixture(target: NativeTargetSpec = MACOS_ARM64_TARGET) {
object_format: target.objectFormat,
minimum_os: target.minimumOs,
},
runtime_abi: { version: 31, marker: "scr_runtime_abi_v31" },
runtime_abi: { version: 32, marker: "scr_runtime_abi_v32" },
compiler: {
command: "clang",
identity: "fixture clang",
@@ -85,7 +85,7 @@ import {
lowerDynamicEmitterOn,
} from "./lower-event-emitter.js";
import { protocolArm, protocolFieldArm } from "./class-protocol-dispatch.js";
import { STREAM_OPERATORS } from "./lower-stream.js";
import { STREAM_OPERATORS, operatorSignalOptions, reduceSignalSetup } from "./lower-stream.js";
import { deletedPropertyNames, fixedFieldFamily } from "./js-class-fields.js";
const STREAM_METHODS = new Set([
@@ -5640,13 +5640,16 @@ export class ClassDynamicDispatch {
const readableT: IrType = { kind: "object", className: "%Readable" };
const op = (fn: string, args: IrExpr[], type: IrType): IrStmt[] =>
ret({ kind: "libCall", fn: fn as IrLibFn, args: [receiver, ...args], type, loc });
// Node passes every callback its { signal }; the callback's
// declared parameters are not known here.
const sig = (): IrExpr => operatorSignalOptions(lowerer, arg(1), loc);
switch (method) {
case "map":
return op("readable.opMap", [arg(0), arg(1)], readableT);
return op("readable.opMap", [arg(0), arg(1), sig()], readableT);
case "filter":
return op("readable.opFilter", [arg(0), arg(1)], readableT);
return op("readable.opFilter", [arg(0), arg(1), sig()], readableT);
case "flatMap":
return op("readable.opFlatMap", [arg(0), arg(1)], readableT);
return op("readable.opFlatMap", [arg(0), arg(1), sig()], readableT);
case "drop":
return op("readable.opDrop", [arg(0), arg(1)], readableT);
case "take":
@@ -5654,25 +5657,49 @@ export class ClassDynamicDispatch {
case "toArray":
return op("readable.opToArray", [arg(0)], { kind: "promise", inner: DYN });
case "forEach":
return op("readable.opForEach", [arg(0), arg(1)], { kind: "promise", inner: VOID });
return op("readable.opForEach", [arg(0), arg(1), sig()], {
kind: "promise",
inner: VOID,
});
case "some":
return op("readable.opSome", [arg(0), arg(1)], { kind: "promise", inner: BOOL });
return op("readable.opSome", [arg(0), arg(1), sig()], { kind: "promise", inner: BOOL });
case "every":
return op("readable.opEvery", [arg(0), arg(1)], { kind: "promise", inner: BOOL });
return op("readable.opEvery", [arg(0), arg(1), sig()], {
kind: "promise",
inner: BOOL,
});
case "find":
return op("readable.opFind", [arg(0), arg(1)], { kind: "promise", inner: DYN });
case "reduce":
return op(
"readable.opReduce",
[
arg(0),
arg(1),
{ kind: "boolLit", value: dispatch.source.args.length >= 2, type: BOOL, loc },
arg(2),
{ kind: "numLit", value: 0, type: F64, loc },
],
{ kind: "promise", inner: DYN },
);
return op("readable.opFind", [arg(0), arg(1), sig()], { kind: "promise", inner: DYN });
case "reduce": {
// the dispatch function's locals: there is no function
// context while dispatch bodies are built
const setup = reduceSignalSetup(lowerer, arg(2), loc, (name, type) => {
const local = {
id: `${name}.${dispatch.fn.locals.length}`,
name,
type,
mutable: false,
};
dispatch.fn.locals.push(local);
return local;
});
return [
...setup.stmts,
...op(
"readable.opReduceSignal",
[
arg(0),
arg(1),
{ kind: "boolLit", value: dispatch.source.args.length >= 2, type: BOOL, loc },
arg(2),
{ kind: "numLit", value: 0, type: F64, loc },
setup.abort,
setup.cbOpts,
],
{ kind: "promise", inner: DYN },
),
];
}
}
}
if (method === "destroy" && (!incoming.length || incoming[0]?.kind === "unitLit"))
@@ -3923,21 +3923,27 @@ function pipelineBoxHelper(
/** `() => ac.abort()` over the pipeline's AbortController: an interned
* lifted function takes the controller and answers the closure. */
function pipelineAbortHelper(lowerer: Lowerer, ac: IrExpr, loc: SrcLoc): IrExpr {
const key = "pipelineAbort";
function pipelineAbortHelper(
lowerer: Lowerer,
ac: IrExpr,
loc: SrcLoc,
withReason = false,
): IrExpr {
const key = withReason ? "pipelineAbortReason" : "pipelineAbort";
let name = lowerer.valueHelpers.get(key);
const abortT = funcOf([], VOID);
const abortT = funcOf(withReason ? [DYN] : [], VOID);
if (!name) {
name = keyedHelperName(lowerer, "%stream.pipelineAbort", key);
lowerer.valueHelpers.set(key, name);
const acLocal: IrLocal = { id: "ac.0", name: "ac", type: DYN, mutable: false, boxed: true };
const acRef = varRef(acLocal.id, DYN, loc);
const reasonLocal: IrLocal = { id: "reason.0", name: "reason", type: DYN, mutable: false };
lowerer.liftedFns.push({
name: `${name}.abort`,
params: [],
params: withReason ? [{ localId: reasonLocal.id, name: reasonLocal.name, type: DYN }] : [],
returnType: VOID,
captures: [{ localId: acLocal.id, name: acLocal.name, type: DYN }],
locals: [acLocal],
locals: withReason ? [acLocal, reasonLocal] : [acLocal],
body: [
{
kind: "exprStmt",
@@ -3946,7 +3952,7 @@ function pipelineAbortHelper(lowerer: Lowerer, ac: IrExpr, loc: SrcLoc): IrExpr
recv: acRef,
method: "abort",
calleeName: "ac.abort",
args: [],
args: withReason ? [varRef(reasonLocal.id, DYN, loc)] : [],
type: DYN,
loc,
},
@@ -4165,11 +4171,11 @@ function duplexFromBody(lowerer: Lowerer, node: ts.Expression, loc: SrcLoc): IrE
"write the body as async function* (source) { ... } or async function (source) { ... }",
);
}
if (fn.parameters.nodes.length > 1) {
if (fn.parameters.nodes.length > 2) {
lowerer.noLowering(
"a Duplex.from body that declares the options argument",
fn.parameters.nodes[1]!,
"the body takes only its source",
"a Duplex.from body with parameters after the options argument",
fn.parameters.nodes[2]!,
"the body takes the source and { signal }",
);
}
const sourceT: SyncGenType = {
@@ -4180,6 +4186,46 @@ function duplexFromBody(lowerer: Lowerer, node: ts.Expression, loc: SrcLoc): IrE
nextT: UNDEFINED_T,
};
const lowered = lowerStageFunction(lowerer, fn, sourceT);
const params = lowered.closure.type.kind === "func" ? lowered.closure.type.params : [];
// fromAsyncGen calls the body with { signal } of an AbortController
// that destroy aborts with its error.
let signalArgs: IrExpr[] = [];
let signalDecls: IrStmt[] = [];
if (params.length > 1) {
if (params[1]!.kind !== "dyn") {
lowerer.noLowering(
"a Duplex.from body's options argument with this type",
fn.parameters.nodes[1]!,
"type it as { signal: AbortSignal }",
);
}
const acLocal = lowerer.declareHiddenLocal("%duplexAbort", DYN);
const ac = varRef(acLocal.id, DYN, loc);
signalDecls = [
{
kind: "varDecl",
localId: acLocal.id,
init: { kind: "libCall", fn: "fetch.abortControllerNew", args: [], type: DYN, loc },
loc,
},
];
signalArgs = [
pipelineAbortHelper(lowerer, ac, loc, true),
{ kind: "dynKeyGet", value: ac, key: strLit("signal", loc), type: DYN, loc },
];
}
const withSignal = (fnName: IrLibFn, args: IrExpr[]): IrExpr => {
const call: IrExpr = {
kind: "libCall",
fn: signalArgs.length > 0 ? (`${fnName}Signal` as IrLibFn) : fnName,
args: [...args, ...signalArgs],
type: duplex,
loc,
};
return signalDecls.length > 0
? { kind: "seqExpr", stmts: signalDecls, result: call, type: duplex, loc }
: call;
};
if (!isGen) {
// an async function: a writable Duplexify whose final waits for
// the function's promise
@@ -4200,26 +4246,18 @@ function duplexFromBody(lowerer: Lowerer, node: ts.Expression, loc: SrcLoc): IrE
node,
);
}
return {
kind: "libCall",
fn: "stream.duplexFromAsyncFn",
args: [lowered.closure, asyncSource, pipelineBoxHelper(lowerer, out, loc)],
type: duplex,
loc,
};
return withSignal("stream.duplexFromAsyncFn", [
lowered.closure,
asyncSource,
pipelineBoxHelper(lowerer, out, loc),
]);
}
const helpers =
lowered.out.kind === "generator" ? fromGenHelpers(lowerer, lowered.out, loc) : null;
const source = duplexSourceHelper(lowerer, sourceT, loc);
if (!helpers || !source)
lowerer.noLowering("Duplex.from over a function with this generator type", node);
return {
kind: "libCall",
fn: "stream.duplexFromGen",
args: [lowered.closure, source!, ...helpers!],
type: duplex,
loc,
};
return withSignal("stream.duplexFromGen", [lowered.closure, source!, ...helpers!]);
}
// duplexify.js: a stream answers itself (a Duplex) or is wrapped; any
// other body reads as from(Duplexify, body, { objectMode: true,
@@ -4510,39 +4548,38 @@ export const STREAM_OPERATORS: ReadonlySet<string> = new Set([
"reduce",
]);
/** An operator callback as a checked-dynamic function value. Callbacks
* that declare the options argument ({ signal }) are refused: the
* AbortSignal Node passes there has no representation. map and flatMap
* callbacks must produce strings or Buffers (flatMap: arrays of them, or
* strings) — or checked-dynamic values, which the runtime checks. */
/** An operator callback as a checked-dynamic function value, and whether
* it declares the options argument ({ signal }), which the runtime then
* passes. Its results convert to checked-dynamic values (flatMap's are
* iterated with yield*). */
function lowerOperatorCallback(
lowerer: Lowerer,
node: ts.Expression,
member: string,
maxParams: number,
): IrExpr {
): { fn: IrExpr; takesOptions: boolean; gen?: IrType & { kind: "generator" } } {
const loc = locOf(node);
const optionsParam = (at: ts.Node): never =>
lowerer.noLowering(
`${member} callbacks that declare the options argument`,
`${member} callbacks with parameters after the options argument`,
at,
"the { signal } argument has no lowering — take only the chunk",
"the callback takes the chunk and { signal }",
);
if (
(ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
node.parameters.nodes.length > maxParams
) {
optionsParam(node.parameters.nodes[maxParams]!);
let takesOptions = false;
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
if (node.parameters.nodes.length > maxParams + 1)
optionsParam(node.parameters.nodes[maxParams + 1]!);
takesOptions = node.parameters.nodes.length > maxParams;
}
const v = lowerer.lowerExpr(node);
if (v.type.kind === "dyn") return v;
if (v.type.kind === "dyn") return { fn: v, takesOptions };
if (
v.type.kind !== "func" &&
isJsSourceFile(ts.getSourceFileOfNode(node)) &&
lowerer.dynConvertible(v.type)
) {
// JavaScript passes any value; Node's validateFunction throws.
return { kind: "dynFrom", value: v, type: DYN, loc };
return { fn: { kind: "dynFrom", value: v, type: DYN, loc }, takesOptions: false };
}
if (v.type.kind !== "func") {
lowerer.noLowering(
@@ -4551,25 +4588,8 @@ function lowerOperatorCallback(
"pass a function",
);
}
if (v.type.params.length > maxParams) optionsParam(node);
if (member === "map" || member === "flatMap") {
const ret = v.type.ret.kind === "promise" ? v.type.ret.inner : v.type.ret;
const chunk = (t: IrType): boolean =>
t.kind === "string" || t.kind === "dyn" || (t.kind === "bytes" && t.elem === "u8");
const ok =
member === "map"
? chunk(ret)
: ret.kind === "dyn" || ret.kind === "string" || (ret.kind === "array" && chunk(ret.elem));
if (!ok) {
lowerer.noLowering(
`${member} callbacks that return '${lowerer.fmt(ret)}'`,
node,
member === "map"
? "map results are stream chunks: strings or Buffers (object-mode streams of other values are not lowered)"
: "flatMap results are arrays of strings or Buffers, or strings",
);
}
}
if (v.type.params.length > maxParams + 1) optionsParam(node);
if (v.type.params.length > maxParams) takesOptions = true;
const getRecord = (id: string) => lowerer.shapes.get(id);
const getUnion = (id: string) => lowerer.unions.get(id);
if (!canBoxFuncIntoDyn(v.type, getRecord, getUnion)) {
@@ -4579,7 +4599,95 @@ function lowerOperatorCallback(
"the callback's parameter and result types must cross the checked-dynamic boundary",
);
}
return { kind: "dynFrom", value: v, type: DYN, loc };
// flatMap over a compiled generator: the runtime steps it through the
// helpers over its type
const gen = member === "flatMap" && v.type.ret.kind === "generator" ? v.type.ret : undefined;
return { fn: { kind: "dynFrom", value: v, type: DYN, loc }, takesOptions, ...(gen && { gen }) };
}
/** The operator callbacks' `{ signal }` (operators.js): map and the
* operators over it pass `{ signal: AbortSignal.any([options?.signal]
* .filter(Boolean)) }`. options is the operator's options value. */
export function operatorSignalOptions(lowerer: Lowerer, options: IrExpr, loc: SrcLoc): IrExpr {
void lowerer;
const list: IrExpr = {
kind: "libCall",
fn: "readable.opSignalList",
args: [options],
type: DYN,
loc,
};
const signal: IrExpr = { kind: "libCall", fn: "fetch.abortAny", args: [list], type: DYN, loc };
return { kind: "libCall", fn: "readable.opSignalOpts", args: [signal], type: DYN, loc };
}
/** reduce's signal for a reducer that reads it (operators.js): `const ac
* = new AbortController()`, `options.signal.addEventListener('abort', ()
* => ac.abort(), { once: true })`, then reduceSignal's ac.abort() closure
* and `{ signal: ac.signal }`. options is the bound options value. */
export function reduceSignalSetup(
lowerer: Lowerer,
options: IrExpr,
loc: SrcLoc,
declare: (name: string, type: IrType) => IrLocal = (name, type) =>
lowerer.declareHiddenLocal(name, type),
): { stmts: IrStmt[]; abort: IrExpr; cbOpts: IrExpr } {
const acLocal = declare("%opAbort", DYN);
const ac = varRef(acLocal.id, DYN, loc);
const sigLocal = declare("%opUserSignal", DYN);
const sig = varRef(sigLocal.id, DYN, loc);
const abort = pipelineAbortHelper(lowerer, ac, loc);
const stmts: IrStmt[] = [
{
kind: "varDecl",
localId: acLocal.id,
init: { kind: "libCall", fn: "fetch.abortControllerNew", args: [], type: DYN, loc },
loc,
},
{
kind: "varDecl",
localId: sigLocal.id,
init: { kind: "libCall", fn: "readable.opSignalOf", args: [options], type: DYN, loc },
loc,
},
{
kind: "if",
cond: { kind: "dynTest", test: "truthy", value: sig, type: BOOL, loc },
then: [
{
kind: "exprStmt",
expr: {
kind: "dynInvoke",
recv: sig,
method: "addEventListener",
calleeName: "options.signal.addEventListener",
args: [
{ kind: "dynFrom", value: strLit("abort", loc), type: DYN, loc },
{ kind: "dynFrom", value: pipelineAbortHelper(lowerer, ac, loc), type: DYN, loc },
{ kind: "libCall", fn: "readable.opOnceOptions", args: [], type: DYN, loc },
],
type: DYN,
loc,
},
loc,
},
],
else_: null,
loc,
},
];
const signal: IrExpr = {
kind: "dynKeyGet",
value: ac,
key: strLit("signal", loc),
type: DYN,
loc,
};
return {
stmts,
abort,
cbOpts: { kind: "libCall", fn: "readable.opSignalOpts", args: [signal], type: DYN, loc },
};
}
/** An operator's options (or reduce's initial value): an object literal
@@ -4618,21 +4726,37 @@ function lowerStreamOperator(
): IrExpr {
const loc = locOf(call);
const args = call.arguments.nodes;
const recv = streamReceiverExpr(lowerer, access.expression);
// JS order: the receiver, the callback, then the options; the
// callback's { signal } is built over the options value afterwards.
const decls: IrStmt[] = [];
const bind = (value: IrExpr, name: string): IrExpr => {
const local = lowerer.declareHiddenLocal(name, value.type);
decls.push({ kind: "varDecl", localId: local.id, init: value, loc });
return varRef(local.id, value.type, loc);
};
const recv = bind(streamReceiverExpr(lowerer, access.expression), "%opRecv");
const readable: IrType = { kind: "object", className: "%Readable" };
const opts = (i: number): IrExpr =>
lowerOperatorValue(lowerer, args[i], `${member} options`, loc, true);
let takesOptions = false;
let callbackGen: (IrType & { kind: "generator" }) | undefined;
const fnArg = (maxParams: number): IrExpr => {
if (args[0] === undefined) return dynUndefinedExpr(loc);
return lowerOperatorCallback(lowerer, args[0], member, maxParams);
const lowered = lowerOperatorCallback(lowerer, args[0], member, maxParams);
takesOptions = lowered.takesOptions;
callbackGen = lowered.gen;
return bind(lowered.fn, "%opFn");
};
const lib = (fn: string, a: IrExpr[], type: IrType): IrExpr => {
const call: IrExpr = { kind: "libCall", fn: fn as IrLibFn, args: a, type, loc };
return { kind: "seqExpr", stmts: decls, result: call, type, loc };
};
// the options argument (bound), and the callback's { signal } when it
// declares one
const withSignal = (optsValue: IrExpr): IrExpr[] => {
const o = bind(optsValue, "%opOptions");
return [o, takesOptions ? operatorSignalOptions(lowerer, o, loc) : dynUndefinedExpr(loc)];
};
const lib = (fn: string, a: IrExpr[], type: IrType): IrExpr => ({
kind: "libCall",
fn: fn as IrLibFn,
args: a,
type,
loc,
});
switch (member) {
case "map":
case "filter":
@@ -4644,7 +4768,16 @@ function lowerStreamOperator(
: member === "filter"
? "readable.opFilter"
: "readable.opFlatMap";
return lib(fnName, [recv, fn, opts(1)], readable);
const rest = withSignal(opts(1));
const genHelpers = callbackGen ? fromGenHelpers(lowerer, callbackGen, loc) : null;
if (callbackGen && genHelpers) {
return lib(
callbackGen.async ? "readable.opFlatMapAsyncGen" : "readable.opFlatMapSyncGen",
[recv, fn, ...rest, ...genHelpers],
readable,
);
}
return lib(fnName, [recv, fn, ...rest], readable);
}
case "drop":
case "take": {
@@ -4657,15 +4790,24 @@ function lowerStreamOperator(
}
case "toArray":
return lib("readable.opToArray", [recv, opts(0)], { kind: "promise", inner: DYN });
case "forEach":
return lib("readable.opForEach", [recv, fnArg(1), opts(1)], { kind: "promise", inner: VOID });
case "forEach": {
const fn = fnArg(1);
return lib("readable.opForEach", [recv, fn, ...withSignal(opts(1))], {
kind: "promise",
inner: VOID,
});
}
case "some":
case "every": {
const fn = fnArg(1);
return lib(member === "some" ? "readable.opSome" : "readable.opEvery", [recv, fn, opts(1)], {
kind: "promise",
inner: BOOL,
});
return lib(
member === "some" ? "readable.opSome" : "readable.opEvery",
[recv, fn, ...withSignal(opts(1))],
{
kind: "promise",
inner: BOOL,
},
);
}
case "find": {
const t = lowerer.typeOf(call);
@@ -4677,7 +4819,11 @@ function lowerStreamOperator(
"the result is a checked-dynamic value — use a boolean predicate and narrow the result",
);
}
return lib("readable.opFind", [recv, fnArg(1), opts(1)], { kind: "promise", inner: DYN });
const fn = fnArg(1);
return lib("readable.opFind", [recv, fn, ...withSignal(opts(1))], {
kind: "promise",
inner: DYN,
});
}
case "reduce": {
const t = lowerer.typeOf(call);
@@ -4704,12 +4850,19 @@ function lowerStreamOperator(
}
}
const fn = fnArg(2);
const init = lowerOperatorValue(lowerer, args[1], "reduce's initial value", loc, false);
return lib(
"readable.opReduce",
[recv, fn, init, boolLit(args.length >= 2, loc), opts(2), numLit(kind, loc)],
{ kind: "promise", inner },
const init = bind(
lowerOperatorValue(lowerer, args[1], "reduce's initial value", loc, false),
"%opInit",
);
const o = bind(opts(2), "%opOptions");
const head = [recv, fn, init, boolLit(args.length >= 2, loc), o, numLit(kind, loc)];
if (!takesOptions) return lib("readable.opReduce", head, { kind: "promise", inner });
const setup = reduceSignalSetup(lowerer, o, loc);
decls.push(...setup.stmts);
return lib("readable.opReduceSignal", [...head, setup.abort, setup.cbOpts], {
kind: "promise",
inner,
});
}
}
throw new InternalCompilerError(`unknown stream operator ${member}`);
@@ -5253,6 +5406,22 @@ export function lowerStreamMethodCall(
requireSide(readableSide, "read");
if (args.length > 1) lowerer.noLowering(`read with ${args.length} arguments`, call);
const receiver = streamReceiverExpr(lowerer, access.expression);
// JavaScript (and a result the declarations type as any) reads any
// chunk: a Buffer, a string once encoded, or an object-mode value.
const resultTs = lowerer.typeOf(call);
if (
isJsSourceFile(ts.getSourceFileOfNode(call)) ||
(resultTs.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0
) {
const sizeDyn = args[0] ? lowerer.lowerExprExpecting(args[0], DYN) : dynUndefinedExpr(loc);
return {
kind: "libCall",
fn: "readable.readDyn",
args: [receiver, sizeDyn],
type: DYN,
loc,
};
}
const size = args[0] ? lowerer.lowerExprExpecting(args[0], F64) : numLit(-1, loc);
const type: IrType = unionOf(lowerer, [BYTES, { kind: "nullT" }]);
const read: IrExpr = {
@@ -386,6 +386,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"stream.duplexFromDyn",
"stream.duplexFromPromise",
"stream.duplexFromAsyncFn",
"stream.duplexFromGenSignal",
"stream.duplexFromAsyncFnSignal",
"fetch.readableFromWeb",
"stream.destroy",
"stream.destroyErr",
@@ -521,6 +523,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"readable.opMap",
"readable.opFilter",
"readable.opFlatMap",
"readable.opFlatMapAsyncGen",
"readable.opFlatMapSyncGen",
"readable.opDrop",
"readable.opTake",
"rl.createSignal",
+36 -7
View File
@@ -4082,6 +4082,13 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
/** Duplex.from(asyncFunction): the stage, its source helper and the
* promise's box helper. */
"stream.duplexFromAsyncFn": { argTypes: [null, null, null], result: VOID },
/** The same for a body that declares the options argument: then the
* AbortController's abort() and its signal. */
"stream.duplexFromGenSignal": {
argTypes: [null, null, null, null, null, null, null, DYN],
result: VOID,
},
"stream.duplexFromAsyncFnSignal": { argTypes: [null, null, null, null, DYN], result: VOID },
"writable.writeEncoding": { argTypes: [null, STRING], result: VOID },
"writable.setDefaultEncoding": { argTypes: [null, DYN], result: VOID },
/** Whether a readable.nextChunkDyn step answered the end (the done
@@ -4119,17 +4126,39 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
* forEach nothing; reduce: [stream, fn, init, hasInit, options, kind]
* with the payload kind (0 dyn, 1 number, 2 boolean, 3 string). The
* promise-returning ones reject instead of throwing. */
"readable.opMap": { argTypes: [null, DYN, DYN], result: VOID },
"readable.opFilter": { argTypes: [null, DYN, DYN], result: VOID },
"readable.opFlatMap": { argTypes: [null, DYN, DYN], result: VOID },
"readable.opMap": { argTypes: [null, DYN, DYN, DYN], result: VOID },
"readable.opFilter": { argTypes: [null, DYN, DYN, DYN], result: VOID },
"readable.opFlatMap": { argTypes: [null, DYN, DYN, DYN], result: VOID },
/** flatMap whose callback returns a compiled generator: the helpers
* over its type follow (async: next, read, return, throw; sync: next,
* return, throw). */
"readable.opFlatMapAsyncGen": {
argTypes: [null, DYN, DYN, DYN, null, null, null, null],
result: VOID,
},
"readable.opFlatMapSyncGen": { argTypes: [null, DYN, DYN, DYN, null, null, null], result: VOID },
"readable.opDrop": { argTypes: [null, DYN, DYN], result: VOID },
"readable.opTake": { argTypes: [null, DYN, DYN], result: VOID },
"readable.opToArray": { argTypes: [null, DYN], result: { kind: "promise", inner: DYN } },
"readable.opForEach": { argTypes: [null, DYN, DYN], result: { kind: "promise", inner: VOID } },
"readable.opSome": { argTypes: [null, DYN, DYN], result: { kind: "promise", inner: BOOL } },
"readable.opEvery": { argTypes: [null, DYN, DYN], result: { kind: "promise", inner: BOOL } },
"readable.opFind": { argTypes: [null, DYN, DYN], result: { kind: "promise", inner: DYN } },
"readable.opForEach": {
argTypes: [null, DYN, DYN, DYN],
result: { kind: "promise", inner: VOID },
},
"readable.opSome": { argTypes: [null, DYN, DYN, DYN], result: { kind: "promise", inner: BOOL } },
"readable.opEvery": { argTypes: [null, DYN, DYN, DYN], result: { kind: "promise", inner: BOOL } },
"readable.opFind": { argTypes: [null, DYN, DYN, DYN], result: { kind: "promise", inner: DYN } },
/** The map family's last argument is the callback's { signal } (or
* undefined). reduceSignal adds ac.abort() (a closure) and
* { signal: ac.signal } for a reducer that reads them. */
"readable.opReduce": { argTypes: [null, DYN, DYN, BOOL, DYN, F64], result: VOID },
"readable.opReduceSignal": {
argTypes: [null, DYN, DYN, BOOL, DYN, F64, null, DYN],
result: VOID,
},
"readable.opSignalList": { argTypes: [DYN], result: DYN },
"readable.opSignalOpts": { argTypes: [DYN], result: DYN },
"readable.opSignalOf": { argTypes: [DYN], result: DYN },
"readable.opOnceOptions": { argTypes: [], result: DYN },
"readable.resume": { argTypes: [null], result: VOID },
"readable.isPaused": { argTypes: [null], result: BOOL },
"readable.pipe": { argTypes: [null, null, BOOL], result: VOID },
@@ -223,6 +223,8 @@ const STREAM_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"stream.duplexFromDyn",
"stream.duplexFromPromise",
"stream.duplexFromAsyncFn",
"stream.duplexFromGenSignal",
"stream.duplexFromAsyncFnSignal",
"fetch.readableFromWeb",
"stream.duplexifyReadable",
"stream.duplexifyWritable",
@@ -285,6 +287,8 @@ const READABLE_DYN_ASYNC_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"stream.duplexFromDyn",
"stream.duplexFromPromise",
"stream.duplexFromAsyncFn",
"stream.duplexFromGenSignal",
"stream.duplexFromAsyncFnSignal",
]);
/** libCall name prefixes of the Readable entry points that await through the dyn-async unit. */
+25 -2
View File
@@ -5520,6 +5520,8 @@ function validateFunction(
e.fn === "stream.duplexifyPair" ||
e.fn === "stream.duplexFromPromise" ||
e.fn === "stream.duplexFromAsyncFn" ||
e.fn === "stream.duplexFromGenSignal" ||
e.fn === "stream.duplexFromAsyncFnSignal" ||
e.fn === "stream.setConstruct" ||
e.fn === "stream.setWritev" ||
e.fn === "stream.setWritevDefault" ||
@@ -5597,7 +5599,9 @@ function validateFunction(
if (
e.fn === "stream.duplexFromGen" ||
e.fn === "stream.duplexFromDyn" ||
e.fn === "stream.duplexFromAsyncFn"
e.fn === "stream.duplexFromAsyncFn" ||
e.fn === "stream.duplexFromGenSignal" ||
e.fn === "stream.duplexFromAsyncFnSignal"
) {
// Duplex.from: (stage, source, helpers...) or (body) → a Duplex.
if (!isStreamObject(e.type)) {
@@ -5786,6 +5790,8 @@ function validateFunction(
e.fn === "readable.opMap" ||
e.fn === "readable.opFilter" ||
e.fn === "readable.opFlatMap" ||
e.fn === "readable.opFlatMapAsyncGen" ||
e.fn === "readable.opFlatMapSyncGen" ||
e.fn === "readable.opDrop" ||
e.fn === "readable.opTake"
) {
@@ -5794,13 +5800,30 @@ function validateFunction(
}
return true;
}
if (e.fn === "readable.opReduce") {
if (e.fn === "readable.opReduce" || e.fn === "readable.opReduceSignal") {
const inner = e.type.kind === "promise" ? e.type.inner : null;
const kind = e.args[5]?.kind === "numLit" ? e.args[5].value : -1;
const want = [DYN, F64, BOOL, STRING][kind];
if (!isStreamObject(e.args[0]?.type) || !inner || !want || !typeEquals(inner, want)) {
err(`libCall readable.opReduce must return the promise its kind names`, e.loc);
}
if (e.fn === "readable.opReduceSignal" && e.args[6]?.type.kind !== "func")
err(`libCall ${e.fn} takes ac.abort() as a closure`, e.loc);
return true;
}
if (e.fn === "readable.opOnceOptions") {
if (e.args.length !== 0 || e.type.kind !== "dyn")
err(`libCall ${e.fn} takes no arguments`, e.loc);
return true;
}
if (
e.fn === "readable.opSignalList" ||
e.fn === "readable.opSignalOpts" ||
e.fn === "readable.opSignalOf"
) {
// The operator callbacks' { signal }: (dyn) → dyn.
if (e.args.length !== 1 || e.args[0]?.type.kind !== "dyn" || e.type.kind !== "dyn")
err(`libCall ${e.fn} takes and returns a checked-dynamic value`, e.loc);
return true;
}
if (e.fn === "readable.fromDone") {
@@ -378,7 +378,7 @@ done:
expect(await symbols(helperObject, ["-u"])).toEqual(await symbols(clangObject, ["-u"]));
expect(await symbols(helperObject, ["-gU"])).toEqual(await symbols(clangObject, ["-gU"]));
expect(await symbols(helperObject, ["-gU"])).toEqual(["0000000000000000 T _main"]);
expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v31");
expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v32");
const clangExe = join(dir, "clang-program");
const linkDriver = join(dir, "clang-driver.c");
@@ -712,7 +712,7 @@ done:
.split("\n");
expect(undefinedSymbols).toContain("_sf_scale");
expect(undefinedSymbols).toContain("_sf_callback_mix");
expect(undefinedSymbols).toContain("_scr_runtime_abi_v31");
expect(undefinedSymbols).toContain("_scr_runtime_abi_v32");
});
test("helper and clang object paths retain the same outbound FFI C ABI", async () => {
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7433-readable-operator-signal.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7434-readable-operator-values.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7435-readable-flatmap-iterables.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7436-readable-read-any-value.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7437-duplex-from-signal.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7438-duplex-from-signal.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7439-readable-read-string-chunks.ts"
],
"diags": []
}
@@ -340,7 +340,7 @@ async function build() {
package: packageManifest.name,
version: packageManifest.version,
target: RUNTIME_PACK_MATRIX.target,
runtime_abi: { version: 31, marker: "scr_runtime_abi_v31" },
runtime_abi: { version: 32, marker: "scr_runtime_abi_v32" },
compiler: {
command: compiler,
identity: compilerVersion,
@@ -437,7 +437,7 @@ async function build() {
package: packageManifest.name,
version: packageManifest.version,
target: matrix.target,
runtime_abi: { version: 31, marker: "scr_runtime_abi_v31" },
runtime_abi: { version: 32, marker: "scr_runtime_abi_v32" },
compiler: {
command: compiler,
identity: compilerVersion,
+1 -1
View File
@@ -148,7 +148,7 @@ static void scr_enable_utf8_console_output(void) {
}
#endif
void scr_runtime_abi_v31(void) {}
void scr_runtime_abi_v32(void) {}
void scr_init(void) {
#ifdef SCR_WORKERS
+1 -1
View File
@@ -81,7 +81,7 @@ void scr_init(void);
/* Program objects emitted by the bundled LLVM helper reference this symbol.
* Its versioned spelling makes a mismatched manual runtime link fail before
* the program can start. */
void scr_runtime_abi_v31(void);
void scr_runtime_abi_v32(void);
void scr_runtime_workers_v8(void);
/* ── the trap funnel (scr_console.c; scr_library.c under -DSCR_LIB) ──────
+40 -7
View File
@@ -431,18 +431,41 @@ int scr_stream_push_object(ScrStream *s, const ScrDyn *v);
* dyn array, find and reduce a dyn value — reduce's `kind` picks the
* payload instead: 0 dyn, 1 number, 2 boolean, 3 string — some and every
* a boolean, forEach nothing. */
ScrStream *scr_stream_op_map(ScrStream *s, ScrDyn *fn, ScrDyn *options);
ScrStream *scr_stream_op_filter(ScrStream *s, ScrDyn *fn, ScrDyn *options);
ScrStream *scr_stream_op_flat_map(ScrStream *s, ScrDyn *fn, ScrDyn *options);
ScrStream *scr_stream_op_map(ScrStream *s, ScrDyn *fn, ScrDyn *options, ScrDyn *cb_opts);
ScrStream *scr_stream_op_filter(ScrStream *s, ScrDyn *fn, ScrDyn *options, ScrDyn *cb_opts);
ScrStream *scr_stream_op_flat_map(ScrStream *s, ScrDyn *fn, ScrDyn *options, ScrDyn *cb_opts);
/* flatMap whose callback returns a compiled generator: the helpers over its
* type (async: next, read, return, throw; sync: next, return, throw;
* borrowed). */
ScrStream *scr_stream_op_flat_map_async_gen(ScrStream *s, ScrDyn *fn, ScrDyn *options,
ScrDyn *cb_opts, ScrClosure *next, ScrClosure *read,
ScrClosure *ret, ScrClosure *thr);
ScrStream *scr_stream_op_flat_map_sync_gen(ScrStream *s, ScrDyn *fn, ScrDyn *options,
ScrDyn *cb_opts, ScrClosure *next, ScrClosure *ret,
ScrClosure *thr);
ScrStream *scr_stream_op_drop(ScrStream *s, ScrDyn *number, ScrDyn *options);
ScrStream *scr_stream_op_take(ScrStream *s, ScrDyn *number, ScrDyn *options);
ScrPromise *scr_stream_op_to_array(ScrStream *s, ScrDyn *options);
ScrPromise *scr_stream_op_for_each(ScrStream *s, ScrDyn *fn, ScrDyn *options);
ScrPromise *scr_stream_op_some(ScrStream *s, ScrDyn *fn, ScrDyn *options);
ScrPromise *scr_stream_op_every(ScrStream *s, ScrDyn *fn, ScrDyn *options);
ScrPromise *scr_stream_op_find(ScrStream *s, ScrDyn *fn, ScrDyn *options);
ScrPromise *scr_stream_op_for_each(ScrStream *s, ScrDyn *fn, ScrDyn *options, ScrDyn *cb_opts);
ScrPromise *scr_stream_op_some(ScrStream *s, ScrDyn *fn, ScrDyn *options, ScrDyn *cb_opts);
ScrPromise *scr_stream_op_every(ScrStream *s, ScrDyn *fn, ScrDyn *options, ScrDyn *cb_opts);
ScrPromise *scr_stream_op_find(ScrStream *s, ScrDyn *fn, ScrDyn *options, ScrDyn *cb_opts);
ScrPromise *scr_stream_op_reduce(ScrStream *s, ScrDyn *fn, ScrDyn *init, bool has_init,
ScrDyn *options, double kind);
/* reduce whose reducer reads its { signal } (cb_opts): abort is ac.abort()
* (borrowed), run when the reduction ends. */
ScrPromise *scr_stream_op_reduce_signal(ScrStream *s, ScrDyn *fn, ScrDyn *init, bool has_init,
ScrDyn *options, double kind, ScrClosure *abort,
ScrDyn *cb_opts);
/* `options?.signal` (+1, undefined when absent) and `{ once: true }` (+1),
* for the listener that makes options.signal abort reduce's controller. */
ScrDyn *scr_stream_op_signal_of(ScrDyn *options);
ScrDyn *scr_stream_op_once_options(void);
/* The operator callbacks' { signal } (the options argument): the signals
* AbortSignal.any combines (`[options?.signal].filter(Boolean)`) and the
* object around the combined signal. +1 each. */
ScrDyn *scr_stream_op_signal_list(ScrDyn *options);
ScrDyn *scr_stream_op_signal_opts(ScrDyn *signal);
/* finished(s, options[, cb]) — Node's eos with its listeners and state
* predicates: readable/writable are -1 (the default), 0 or 1; on_error is
* options.error !== false; signal is NULL or borrowed (ERR_INVALID_ARG_TYPE
@@ -556,6 +579,16 @@ ScrStream *scr_stream_readable_from_web(struct ScrDyn *web, bool object_mode, do
* generator and answers its promise; source and the promise's box helper
* borrowed): a writable Duplexify whose final waits for the promise. +1. */
ScrStream *scr_stream_duplex_from_async_fn(ScrClosure *stage, ScrClosure *source, ScrClosure *box);
/* The same for a body that declares the options argument: it takes
* { signal } (signal borrowed), and destroy calls abort(err) (borrowed),
* the AbortController's abort, before it settles (fromAsyncGen's
* ac.abort(err)). */
ScrStream *scr_stream_duplex_from_gen_signal(ScrClosure *stage, ScrClosure *source, ScrClosure *next,
ScrClosure *read, ScrClosure *ret, ScrClosure *thr,
ScrClosure *abort, struct ScrDyn *signal);
ScrStream *scr_stream_duplex_from_async_fn_signal(ScrClosure *stage, ScrClosure *source,
ScrClosure *box, ScrClosure *abort,
struct ScrDyn *signal);
/* Duplex.from over a Readable, a Writable, or a { readable, writable }
* pair (duplexify.js's _duplexify): a Duplexify that reads r and writes w
* (either NULL in scr_stream_duplexify; streams borrowed). +1. */
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
// The Readable operators pass their callbacks { signal } as the second
// argument; map and filter's signal aborts when the options' signal
// does, reduce's signal is aborted once reduce settles, and map may
// return any value.
import { Readable } from "node:stream";
const log = [];
log.push(JSON.stringify(await Readable.from([1, 2, 3]).map((n) => ({ n, sq: n * n })).toArray()));
log.push(JSON.stringify(await Readable.from(["a", "bb"]).map((s) => s.length).toArray()));
log.push(JSON.stringify(await Readable.from([1, 2]).map((n, { signal }) => `${n}:${signal.aborted}`).toArray()));
log.push(JSON.stringify(await Readable.from([1, 2, 3, 4]).filter(async (n, opts) => n % 2 === 0 && !opts.signal.aborted).toArray()));
log.push(String(await Readable.from([1, 2, 3]).reduce((acc, n, { signal }) => acc + n + (signal.aborted ? 100 : 0), 0)));
let kept;
await Readable.from([1]).reduce((acc, n, { signal }) => { kept = signal; return acc + n; }, 0);
log.push(`reduce signal after ${kept.aborted}`);
let mapKept;
await Readable.from([1]).map((n, { signal }) => { mapKept = signal; return n; }).toArray();
log.push(`map signal after ${mapKept.aborted}`);
const ac = new AbortController();
const events = [];
const p = Readable.from([1, 2, 3]).map(async (n, { signal }) => {
signal.addEventListener("abort", () => events.push(`abort ${n}`));
await new Promise((r) => setTimeout(r, 10));
return n;
}, { signal: ac.signal }).toArray();
setTimeout(() => ac.abort(), 5);
try { await p; } catch (e) { log.push(`map aborted ${e.name}`); }
log.push(events.join(","));
const dyn = [Readable.from([4, 5])][0];
log.push(JSON.stringify(await dyn.map((n, { signal }) => n + Number(signal.aborted)).toArray()));
let seen = 0;
await Readable.from([5, 6]).forEach((n, { signal }) => { if (signal) seen++; });
log.push(`forEach ${seen}`);
log.push(String(await Readable.from([1, 2, 3]).some((n, { signal }) => n > 2 && !signal.aborted)));
log.push(String(await Readable.from([1, 2, 3]).find((n, { signal }) => n > 1 && !signal.aborted)));
log.push(String(await Readable.from([1, 2, 3]).every((n, { signal }) => n > 0 && !signal.aborted)));
console.log(log.join("\n"));
@@ -0,0 +1,32 @@
// Typed Readable operators: map returns numbers and records, flatMap
// takes generator functions and sets, the callbacks may declare the
// { signal } argument, and read() on an object-mode stream answers the
// values pushed.
import { Readable } from "node:stream";
async function* pair(n: number): AsyncGenerator<number> {
yield n;
yield n * 100;
}
function* letters(s: string): Generator<string> {
for (const c of s) yield c;
}
async function main(): Promise<void> {
const squares = await Readable.from([1, 2, 3]).map((n: number) => n * n).toArray();
const records = await Readable.from(["a", "bb"]).map((s: string) => ({ s, len: s.length })).toArray();
const pairs = await Readable.from([1, 2]).flatMap(pair).toArray();
const chars = await Readable.from(["hi"]).flatMap(letters).toArray();
const sets = await Readable.from([7]).flatMap((n: number) => new Set([n, n + 1])).toArray();
const signals = await Readable.from([1]).map((n: number, opts?: { signal?: AbortSignal }) => `${n}:${opts?.signal?.aborted}`).toArray();
const total = await Readable.from([1, 2]).reduce((acc: number, n: number, opts?: { signal?: AbortSignal }) => acc + n + (opts?.signal?.aborted ? 1 : 0), 0);
console.log(JSON.stringify({ squares, records, pairs, chars, sets, signals, total }));
const r = new Readable({ objectMode: true, read(): void {} });
r.push({ a: 1 });
r.push(5);
r.push(null);
r.once("readable", () => {
const first: unknown = r.read();
const second: unknown = r.read();
console.log(JSON.stringify(first), second, r.read());
});
}
void main();
@@ -0,0 +1,29 @@
// flatMap over what the callback returns: async and sync generators, a
// Set, a Map, a Readable, and a value that is not iterable (V8's
// TypeError); the generator's steps interleave with process.nextTick as
// in Node, breaking out of the loop runs the inner generator's finally,
// and an inner throw rejects toArray().
import { Readable } from "node:stream";
const log = [];
log.push(JSON.stringify(await Readable.from(["x"]).flatMap(async function* (s) { yield s; yield s + s; }).toArray()));
log.push(JSON.stringify(await Readable.from([2]).flatMap(function* (n) { yield n; yield -n; }).toArray()));
log.push(JSON.stringify(await Readable.from([1, 2]).flatMap((n) => new Set([n, n])).toArray()));
log.push(JSON.stringify(await Readable.from(["p"]).flatMap((s) => Readable.from([s, s.toUpperCase()])).toArray()));
log.push(JSON.stringify(await Readable.from([3]).flatMap((n) => new Map([[n, "v"]])).toArray()));
log.push(JSON.stringify(await Readable.from([{ k: 1 }]).flatMap((o) => [o, { k: o.k + 1 }]).toArray()));
try { await Readable.from([1]).flatMap(() => 5).toArray(); } catch (e) { log.push(`${e.name} ${e.message}`); }
try { await Readable.from([1]).flatMap(() => null).toArray(); } catch (e) { log.push(`${e.name} ${e.message}`); }
const order = [];
let n = 0;
const tick = () => { order.push(`t${n}`); if (++n < 5) process.nextTick(tick); };
process.nextTick(tick);
for await (const v of Readable.from([1]).flatMap(async function* (k) { order.push("gen start"); yield k; order.push("gen mid"); yield k + 1; })) order.push(`v${v}`);
log.push(order.join(" "));
const closed = [];
const early = Readable.from([1, 2]).flatMap(async function* (k) { try { yield k; yield k * 10; } finally { closed.push(`finally ${k}`); } });
for await (const v of early) { closed.push(`got ${v}`); break; }
log.push(closed.join(" "));
const thrown = [];
try { await Readable.from([1]).flatMap(async function* () { yield 1; throw new Error("inner boom"); }).toArray(); } catch (e) { thrown.push(e.message); }
log.push(thrown.join(" "));
console.log(log.join("\n"));
@@ -0,0 +1,17 @@
// readable.read() answers any value on an object-mode stream, and a
// Buffer or a string (with an encoding) in byte mode.
import { Readable, PassThrough } from "node:stream";
const r = new Readable({ objectMode: true, read() {} });
r.push({ a: 1 }); r.push(2); r.push("three"); r.push([4]); r.push(null);
r.once("readable", () => { let v; while ((v = r.read()) !== null) console.log("read", JSON.stringify(v)); });
const p = new PassThrough({ objectMode: true });
p.write([1, 2]);
p.end();
p.once("readable", () => console.log("pt", JSON.stringify(p.read()), p.read()));
const b = new PassThrough();
b.end("bytes!");
b.once("readable", () => { const c = b.read(3); console.log("bytes", Buffer.isBuffer(c), String(c), String(b.read())); });
const e = new PassThrough();
e.setEncoding("utf8");
e.end("text");
e.once("readable", () => { const t = e.read(); console.log("encoded", typeof t, t); });
+54
View File
@@ -0,0 +1,54 @@
// A Duplex.from body may take { signal } (duplexify.js's fromAsyncGen):
// destroy aborts it with the destroy error, or with an AbortError when
// there is none, and compose passes it to its function stages.
import { Duplex, Readable, compose } from "node:stream";
import { once } from "node:events";
const d = Duplex.from(async function* (source, { signal }) {
console.log("body", typeof signal, signal.aborted);
signal.addEventListener("abort", () => console.log("abort event", signal.aborted, signal.reason));
for await (const chunk of source) yield String(chunk).toUpperCase();
console.log("source ended");
});
d.on("data", (c) => console.log("data", c));
d.write("a");
d.write("b");
await new Promise((r) => setTimeout(r, 10));
d.destroy();
await once(d, "close");
console.log("closed d");
const seen = [];
const fin = Duplex.from(async function (source, { signal }) {
signal.addEventListener("abort", () => console.log("abort", seen.join(",")));
for await (const chunk of source) seen.push(String(chunk));
console.log("done", signal.aborted);
});
fin.on("finish", () => console.log("finish"));
fin.write("1");
fin.write("2");
fin.end();
await once(fin, "close");
console.log("closed fin");
const boom = new Error("boom");
const e = Duplex.from(async function (source, { signal }) {
signal.addEventListener("abort", () => console.log("e abort", String(signal.reason), signal.reason === boom));
await new Promise((r) => setTimeout(r, 50));
});
e.on("error", (err) => console.log("e error", err === boom));
setTimeout(() => e.destroy(boom), 5);
await new Promise((r) => e.on("close", r));
console.log("closed e");
const c = compose(
async function* (source, { signal }) {
for await (const chunk of source) yield `${chunk}!${signal.aborted}`;
},
async function* (source, { signal }) {
for await (const chunk of source) yield chunk.toUpperCase();
},
);
Readable.from(["a", "b"]).pipe(c);
c.on("data", (x) => console.log("compose", String(x)));
c.on("end", () => console.log("compose end"));
+26
View File
@@ -0,0 +1,26 @@
// Typed Duplex.from bodies that declare the options argument: an async
// generator function and an async function.
import { Duplex } from "node:stream";
const d = Duplex.from(async function* (source: AsyncIterable<string>, opts: { signal: AbortSignal }) {
opts.signal.addEventListener("abort", () => console.log("aborted", opts.signal.aborted));
for await (const chunk of source) {
yield `<${chunk}>`;
}
});
d.on("data", (c: string) => console.log("data", c));
d.on("end", () => console.log("end"));
d.on("close", () => {
console.log("close");
const w = Duplex.from(async function (source: AsyncIterable<string>, { signal }: { signal: AbortSignal }): Promise<void> {
let n = 0;
for await (const chunk of source) n += chunk.length;
console.log("total", n, signal.aborted);
});
w.on("finish", () => console.log("finish"));
w.on("close", () => console.log("w close"));
w.write("abc");
w.end("de");
});
d.write("x");
d.end("y");
@@ -0,0 +1,17 @@
// A typed read() answers the strings a stream holds: the first value a
// lazy Readable.from pull over a parsed array produces, and the decoded
// text once _read calls setEncoding().
import { Readable } from "node:stream";
const r = Readable.from(JSON.parse('["j1", "j2"]'));
const chunk = r.read();
console.log("first", typeof chunk, chunk === null ? "null" : chunk.toString("hex"));
r.once("readable", () => console.log("later", JSON.stringify(r.read())));
const e = new Readable({
read() {
this.setEncoding("utf8");
this.push(Buffer.from("ab"));
this.push(null);
},
});
const text = e.read();
console.log("encoded", typeof text, text === null ? "null" : text.toString("hex"));
@@ -0,0 +1 @@
{ "plain": 3885, "san": 5128 }
@@ -0,0 +1 @@
{ "plain": 2465, "san": 2933 }
@@ -0,0 +1 @@
{ "plain": 2265, "san": 2990 }
@@ -0,0 +1 @@
{ "plain": 1675, "san": 2211 }
@@ -0,0 +1 @@
{ "plain": 2383, "san": 3146 }
@@ -0,0 +1 @@
{ "plain": 1777, "san": 2722 }
@@ -0,0 +1 @@
{ "plain": 2578, "san": 3403 }
@@ -108,7 +108,7 @@ for (const backend of ["llvm"] as const) {
object_format: target.objectFormat,
minimum_os: target.minimumOs,
},
runtime_abi: { version: 31, marker: "scr_runtime_abi_v31" },
runtime_abi: { version: 32, marker: "scr_runtime_abi_v32" },
compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple },
macros: { executable: [], excluded: [], sanitizer: "external-toolchain-required" },
flavors: {
+4 -73
View File
@@ -1,14 +1,9 @@
/* Stream chunk kinds Node cannot oracle — scriptc-only.
*
* A typed read() answers `Buffer | null`. When the read itself makes the
* stream hold strings (the first string a lazy Readable.from pull over a
* dynamic array produces, or setEncoding() inside _read), Node returns
* the string, which the typed result cannot hold, so the read throws the
* encoding fence instead (caught here, so the program exits normally).
* These pins keep that refusal exact and, under
* SCRIPTC_SAN=1 (ASan and the RC audit, like every harness lane), prove
* no string reaches Buffer code. The Node-observable cases are corpus
* programs (stream-dynamic-entry-kinds.*).
* Under SCRIPTC_SAN=1 (ASan and the RC audit, like every harness lane)
* these pins prove no string reaches Buffer code. The Node-observable
* cases are corpus programs (stream-dynamic-entry-kinds.*, and
* 7439-readable-read-string-chunks for read() of strings).
*/
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
@@ -73,71 +68,7 @@ function stripAsanFiberWarning(s: string): string {
);
}
const READ_FENCE_MESSAGE =
"read() on a stream with an encoding set is not supported yet (consume 'data' events, which deliver strings)";
describe(`typed stream reads of string chunks (scriptc-only${sanitize ? ", sanitized" : ""})`, () => {
test("a typed read() whose lazy pull yields the first string throws the encoding fence", async () => {
const r = await compileAndRun(
"typed-read-json-string",
`import { Readable } from "node:stream";
const r = Readable.from(JSON.parse('["j1", "j2"]'));
try {
const chunk = r.read();
console.log("hex", chunk === null ? "null" : chunk.toString("hex"));
} catch (err) {
console.log("caught", (err as Error).message);
}
`,
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe(`caught ${READ_FENCE_MESSAGE}\n`);
});
test("a typed read() whose _read calls setEncoding() throws the encoding fence", async () => {
const r = await compileAndRun(
"typed-read-encoding-in-read",
`import { Readable } from "node:stream";
const r = new Readable({
read() {
this.setEncoding("utf8");
this.push(Buffer.from("ab"));
this.push(null);
},
});
try {
const chunk = r.read();
console.log("hex", chunk === null ? "null" : chunk.toString("hex"));
} catch (err) {
console.log("caught", (err as Error).message);
}
`,
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe(`caught ${READ_FENCE_MESSAGE}\n`);
});
test("the JavaScript lane's read() on a JSON source throws the same fence", async () => {
const r = await compileAndRun(
"js-read-json-string",
`'use strict';
const { Readable } = require('node:stream');
const r = Readable.from(JSON.parse('["j1"]'));
r.once('readable', () => {
try {
const chunk = r.read();
console.log('got', chunk === null ? 'null' : chunk.length);
} catch (err) {
console.log('caught', err.message);
}
});
`,
"cjs",
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe(`caught ${READ_FENCE_MESSAGE}\n`);
});
test("a dynamic source that mixes Buffers and strings yields each as it is, as Node does", async () => {
const r = await compileAndRun(
"mixed-dynamic-source",