From cfb50d16e288a4b6b7fb9581d0e3f5d7a306f315 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 14:03:44 -0700 Subject: [PATCH 01/31] Specialize variadic generic wrappers to the wrapped function's signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Higher-order wrappers over `(...args: any) => any` that forward `arguments` with `fn.apply(this, arguments)` and type their options with `Parameters` / `ReturnType`, and their rest-tuple rewrite, did not compile. Generic calls already monomorphize per call site; these pieces now resolve to that site's concrete signature: - Rest parameters typed as fixed tuples (`...args: [A, B]`, `...args: A` bound to a tuple, `...args: Parameters`) flatten to positional parameters, so `(...args: [A, B]) => R` and `(a: A, b: B) => R` share one function type. Trailing optional positions become `T | undefined` slots. - `Parameters`, `ReturnType`, `Awaited>` and the `ReturnType extends PromiseLike ? U : …` spelling resolve through F's binding. - A parameterless function expression reading `arguments` lowers against the fixed signature its value is asserted to (or contextually typed as); `arguments` is that signature's tuple. - `f.apply(thisArg, args)` and `f(...args)` over a forwarded tuple call directly, passing `thisArg` as the receiver. A tuple that is only forwarded is never allocated. - `fn.name` on a function parameter folds to the call site's static name; the instantiation key carries it. Also: optional callback fields narrowed at the call (`o.cb ? o.cb(x) : …`) compile, effect-free surplus arguments to fixed-arity function values are dropped, and `arguments.length` no longer counts a `this` parameter. Co-Authored-By: Claude Opus 5.5 --- .../language-features/generics.mdx | 1 + .../src/frontend/lowering/call-arguments.ts | 64 ++- .../src/frontend/lowering/call-signatures.ts | 94 +++- .../lowering/expressions/optional-chains.ts | 17 + .../frontend/lowering/generic-functions.ts | 31 +- .../src/frontend/lowering/lower-calls.ts | 20 + .../src/frontend/lowering/lower-exprs.ts | 34 +- .../src/frontend/lowering/lower-functions.ts | 3 + .../src/frontend/lowering/lower-stmts.ts | 14 + .../compiler/src/frontend/lowering/lowerer.ts | 96 ++++ .../lowering/static-function-names.ts | 221 ++++++++ .../src/frontend/lowering/typed-arguments.ts | 511 ++++++++++++++++++ packages/compiler/src/frontend/type-mapper.ts | 283 +++++++++- ...0-variadic-wrapper-arguments-apply.ts.json | 6 + .../6381-variadic-wrapper-async.ts.json | 6 + ...6382-variadic-wrapper-methods-this.ts.json | 6 + .../6383-variadic-wrapper-rest-tuple.ts.json | 6 + .../6384-variadic-wrapper-nested.ts.json | 6 + .../variadic-wrapper-fences.ts.json | 6 + .../6380-variadic-wrapper-arguments-apply.ts | 77 +++ tests/corpus/6381-variadic-wrapper-async.ts | 81 +++ .../6382-variadic-wrapper-methods-this.ts | 46 ++ .../6383-variadic-wrapper-rest-tuple.ts | 54 ++ tests/corpus/6384-variadic-wrapper-nested.ts | 37 ++ tests/diagnostics/arrow-bind.ts | 3 +- tests/diagnostics/variadic-wrapper-fences.ts | 28 + tests/harness/__snapshots__/arrow-bind.ts.txt | 17 +- .../variadic-wrapper-fences.ts.txt | 29 + 28 files changed, 1763 insertions(+), 34 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/static-function-names.ts create mode 100644 packages/compiler/src/frontend/lowering/typed-arguments.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6380-variadic-wrapper-arguments-apply.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6381-variadic-wrapper-async.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6382-variadic-wrapper-methods-this.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6383-variadic-wrapper-rest-tuple.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6384-variadic-wrapper-nested.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/variadic-wrapper-fences.ts.json create mode 100644 tests/corpus/6380-variadic-wrapper-arguments-apply.ts create mode 100644 tests/corpus/6381-variadic-wrapper-async.ts create mode 100644 tests/corpus/6382-variadic-wrapper-methods-this.ts create mode 100644 tests/corpus/6383-variadic-wrapper-rest-tuple.ts create mode 100644 tests/corpus/6384-variadic-wrapper-nested.ts create mode 100644 tests/diagnostics/variadic-wrapper-fences.ts create mode 100644 tests/harness/__snapshots__/variadic-wrapper-fences.ts.txt diff --git a/docs/content/limitations/language-features/generics.mdx b/docs/content/limitations/language-features/generics.mdx index 7668e0d47..5b252ebf0 100644 --- a/docs/content/limitations/language-features/generics.mdx +++ b/docs/content/limitations/language-features/generics.mdx @@ -1 +1,2 @@ - **Generics:** Generic functions declared inside other functions, generic class expressions, and generic methods requiring dynamic dispatch are unsupported. Generic classes can extend generic bases when each specialization has a concrete layout. Independent concrete uses have no specialization-count limit; recursively expanding demands and type layouts produce diagnostics. Other generic calls compile when the target resolves statically. Generic function values require a concrete signature at use and a binding that is never reassigned. +- **Variadic wrappers:** A generic higher-order wrapper is specialized to each wrapped function's exact signature. `Parameters`, `ReturnType`, and rest parameters typed as fixed tuples become that signature's parameter list. A function expression with no parameters that reads `arguments` compiles only when its value is asserted to, or contextually typed as, one fixed-arity signature; `arguments` is then that signature's tuple. Forwarding it with `fn.apply(this, arguments)` passes `this` through. Reading `fn.name` on a wrapped function requires its name to be known where the wrapper is called, such as a function declaration or a `const`-bound function. `Parameters` over a signature with optional or array-typed rest parameters is unsupported, because that tuple has no fixed length. diff --git a/packages/compiler/src/frontend/lowering/call-arguments.ts b/packages/compiler/src/frontend/lowering/call-arguments.ts index f5600b033..db7691a1c 100644 --- a/packages/compiler/src/frontend/lowering/call-arguments.ts +++ b/packages/compiler/src/frontend/lowering/call-arguments.ts @@ -1,3 +1,4 @@ +import { forwardedTupleType, pureForwardedValues, stripTypeWrappers } from "./typed-arguments.js"; import { dynUndefinedExpr } from "../../ir/build.js"; import { InternalCompilerError } from "../../errors.js"; import * as ts from "../ts7/adapter.js"; @@ -26,13 +27,13 @@ import { checkedIterableSpread } from "./checked-iterable-spread.js"; import { type ParamShape } from "./call-signatures.js"; import { arrayValueRead, arrayValueType } from "./array-values.js"; import { buildArrayConversion } from "./coercions/builders.js"; -import { typeKey } from "../type-mapper.js"; +import { restTupleElementTypes, typeKey } from "../type-mapper.js"; function fixedTupleSpreadInfo( lowerer: Lowerer, node: ts.Expression, ): { type: IrType & { kind: "record" }; fields: { name: string; type: IrType }[] } | null { - const type = lowerer.mapTypeOf(lowerer.typeOf(node)); + const type = forwardedTupleType(lowerer, node) ?? lowerer.mapTypeOf(lowerer.typeOf(node)); if (type?.kind !== "record") return null; const shape = lowerer.shapes.get(type.shapeId); if (!shape?.tuple || shape.fields.length === 0) return null; @@ -42,6 +43,18 @@ function fixedTupleSpreadInfo( }; } +/** `...t` over a provably EMPTY tuple bound to a plain name (`[]`, a rest + * tuple whose type parameter or `Parameters` resolved to no positions): + * it contributes no arguments, and reading the name has no effect. */ +function emptyTupleSpread(lowerer: Lowerer, node: ts.Expression): boolean { + let operand = node; + while (ts.isParenthesizedExpression(operand)) operand = operand.expression; + if (!ts.isIdentifier(operand)) return false; + const forwarded = forwardedTupleType(lowerer, operand); + if (forwarded) return forwarded.kind === "array"; + return restTupleElementTypes(lowerer.typeOf(operand), lowerer.typeCtx)?.length === 0; +} + export function completeArgs( lowerer: Lowerer, argNodes: readonly ts.Expression[], @@ -59,7 +72,9 @@ export function completeArgs( // graph. Ordinary calls and fixed tuples never need that analysis. const runtimeSpreads = argNodes.filter( (arg): arg is ts.SpreadElement => - ts.isSpreadElement(arg) && !fixedTupleSpreadInfo(lowerer, arg.expression), + ts.isSpreadElement(arg) && + !fixedTupleSpreadInfo(lowerer, arg.expression) && + !emptyTupleSpread(lowerer, arg.expression), ); // Spread operands lowered while choosing the completion, each exactly once. const loweredSpreads = new Map(); @@ -190,6 +205,12 @@ export function completeArgs( sources.push(arg); continue; } + if (emptyTupleSpread(lowerer, arg.expression)) continue; + const pure = pureForwardedValues(lowerer, arg.expression); + if (pure) { + for (const value of pure) sources.push({ ir: value }); + continue; + } const tuple = fixedTupleSpreadInfo(lowerer, arg.expression); if (!tuple) { sources.push(arg); @@ -204,7 +225,13 @@ export function completeArgs( // values as separately written arguments while the saved tuple stays // alive through the call. const spreadLoc = locOf(arg); - const value = lowerer.coerceInto(arg.expression, lowerer.lowerExpr(arg.expression), tuple.type); + // A forwarded tuple seen through type assertions (`...(arguments as + // unknown as Parameters)`) spreads the tuple itself. + const operand = + forwardedTupleType(lowerer, arg.expression) !== null + ? stripTypeWrappers(arg.expression) + : arg.expression; + const value = lowerer.coerceInto(arg.expression, lowerer.lowerExpr(operand), tuple.type); const saved = lowerer.declareHiddenLocal("%callSpread", tuple.type); const savedRef = (): IrExpr => ({ kind: "varRef", @@ -506,9 +533,38 @@ export function completeFuncValueArgs( shapes.push({ type: rest, mode: "rest" }); return completeArgs(lowerer, call.arguments.nodes, shapes, loc, call); } + // A fixed-tuple spread (`fn(...args)` over a tuple-typed rest binding) + // has a compile-time arity: completion flattens it into the positional + // slots exactly like a direct call's. + if ( + funcType.rest !== true && + call.arguments.nodes.some(ts.isSpreadElement) && + call.arguments.nodes.every( + (arg) => + !ts.isSpreadElement(arg) || + fixedTupleSpreadInfo(lowerer, arg.expression) !== null || + emptyTupleSpread(lowerer, arg.expression), + ) + ) { + const shapes: ParamShape[] = funcType.params.map((type) => ({ + type, + mode: omittedArgFor(lowerer, type, loc) ? "omittable" : "required", + })); + return completeArgs(lowerer, call.arguments.nodes, shapes, loc, call); + } const args = call.arguments.nodes.map((arg, index) => lowerer.lowerExprExpecting(arg, funcType.params[index]), ); + // Effect-free surplus arguments of a fixed-arity value (`(r: void) => + // void` drops its void slot) evaluate to nothing observable: drop them, + // as the callee ignores them. + while ( + funcType.rest !== true && + args.length > funcType.params.length && + isSafeToDiscard(args[args.length - 1]!) + ) { + args.pop(); + } for (let i = args.length; i < funcType.params.length; i++) { const absent = omittedArgFor(lowerer, funcType.params[i]!, loc); if (!absent) { diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts index 9d9c9668c..ef2900a7c 100644 --- a/packages/compiler/src/frontend/lowering/call-signatures.ts +++ b/packages/compiler/src/frontend/lowering/call-signatures.ts @@ -1,3 +1,8 @@ +import { + typedArgumentsCandidate, + typedArgumentsShapes, + typedArgumentsTarget, +} from "./typed-arguments.js"; import { InternalCompilerError } from "../../errors.js"; import * as ts from "../ts7/adapter.js"; import { bodyReadsArguments } from "../arguments-usage.js"; @@ -15,6 +20,7 @@ import { import { isJsSourceFile, isNodeEsmFile } from "../program.js"; import { genResultRecord, + restTupleParamTypes, isAnyArrayType, isUnitOnlyTsType, jsOpenObjectType, @@ -52,6 +58,10 @@ export interface ParamShape { mode: ParamMode; bodyType?: IrType; callDefault?: IrExpr; + /** One flattened position of a fixed-tuple rest parameter + * (restTupleShapes): the declaration's last parameter owns every shape + * from its index on, and the body binds the tuple (declareParams). */ + restTuple?: true; } export interface FnSig { @@ -369,6 +379,28 @@ export function isThisParameter(param: ts.ParameterDeclaration): boolean { return ts.isIdentifier(param.name) && param.name.text === "this"; } +/** A TypeScript rest parameter bound to a name whose type is a fixed tuple + * (`...args: [A, B]`, `...args: A` with A bound to a tuple, `...args: + * Parameters` with F bound): one required shape per position. Call + * sites and function values see the equivalent fixed signature; the body + * rebuilds the tuple (declareParams) unless it only forwards it. Null for + * every other parameter. */ +export function restTupleShapes( + lowerer: Lowerer, + param: ts.ParameterDeclaration, + tsType?: ts.Type, +): ParamShape[] | null { + if ( + param.dotDotDotToken === undefined || + !ts.isIdentifier(param.name) || + isJsSourceFile(param.getSourceFile()) + ) { + return null; + } + const elems = restTupleParamTypes(tsType ?? lowerer.typeOf(param.name), lowerer.typeCtx); + return elems ? elems.map((type) => ({ type, mode: "required", restTuple: true })) : null; +} + /** ParamShapes for a whole parameter list. */ export function paramShapes( lowerer: Lowerer, @@ -376,16 +408,37 @@ export function paramShapes( signature?: ts.Signature, blameOf?: (param: ts.ParameterDeclaration, index: number) => ts.Node, ): ParamShape[] { - if (!signature) - return params.filter((p) => !isThisParameter(p)).map((param) => lowerer.paramShape(param)); + const out: ParamShape[] = []; + if (!signature) { + for (const param of params) { + if (isThisParameter(param)) continue; + const flattened = restTupleShapes(lowerer, param); + if (flattened) out.push(...flattened); + else out.push(lowerer.paramShape(param)); + } + return out; + } const symbols = signature.getParameters(); - return params.map((declParam, i) => { + const shapeAt = (declParam: ts.ParameterDeclaration, i: number): ParamShape => { const stage = lowerer.stageSourceType(declParam.name); if (stage) return { type: stage, mode: "required" as const }; const symbol = symbols[i]; const tsType = symbol ? lowerer.checker.getTypeOfSymbol(symbol) : lowerer.typeOf(declParam.name); + // A call-site instantiation whose concrete argument type does not map + // is that argument's blocker: name it there, with its concrete type, + // rather than as the declaration's unbound type parameter. + if ( + blameOf !== undefined && + !isJsSourceFile(declParam.getSourceFile()) && + (tsType.flags & ts.TypeFlags.Any) === 0 && + declParam.type !== undefined && + mentionsTypeParameter(lowerer, lowerer.checker.getTypeFromTypeNode(declParam.type)) && + lowerer.mapTypeOf(tsType) === null + ) { + lowerer.badType(blameOf(declParam, i), tsType); + } let mapped = jsOpenObjectType( declParam, lowerer.runtimeOptionalBindingType( @@ -416,7 +469,32 @@ export function paramShapes( ); } return { type: mapped, mode: declParam.questionToken ? "omittable" : "required" }; + }; + params.forEach((declParam, i) => { + const symbol = symbols[i]; + const flattened = restTupleShapes( + lowerer, + declParam, + symbol ? lowerer.checker.getTypeOfSymbol(symbol) : undefined, + ); + if (flattened) out.push(...flattened); + else out.push(shapeAt(declParam, i)); }); + return out; +} + +/** A declared type that is (or is a union over) a bare type parameter + * or an instantiation of a generic alias/interface over type arguments. */ +function mentionsTypeParameter(lowerer: Lowerer, t: ts.Type): boolean { + if (t.flags & ts.TypeFlags.TypeParameter) return true; + const mentions = (part: ts.Type): boolean => mentionsTypeParameter(lowerer, part); + if (t.isUnionType()) return ts.constituentTypes(t).some(mentions); + if (t.getAliasTypeArguments().some(mentions)) return true; + return ( + (t.flags & ts.TypeFlags.Object) !== 0 && + ((t as ts.ObjectType).objectFlags & ts.ObjectFlags.Reference) !== 0 && + lowerer.checker.getTypeArguments(t as ts.TypeReference).some(mentions) + ); } /** A default expression may itself yield undefined. Keep that arm in the @@ -758,6 +836,16 @@ export function lambdaSignature( | ts.SetAccessorDeclaration, ): { shapes: ParamShape[]; funcType: IrType & { kind: "func" } } { if (!node.body) lowerer.unsupported("SC1090", node, "function overload signatures"); + // A parameterless function expression forwarding its `arguments`, + // asserted to a fixed signature: that signature is its ABI. + if (typedArgumentsCandidate(node) && !node.typeParameters?.nodes.length) { + const target = typedArgumentsTarget(lowerer, node); + if (target) { + const shapes = typedArgumentsShapes(target); + lowerer.typedArgumentsLambdas.add(node); + return { shapes, funcType: funcTypeFromParamShapes(shapes, target.ret) }; + } + } // A JS replacement of a native stream's write method receives the // actual call tuple, including overloads and omitted arguments. Node's // contextual overload signature cannot define this closure's ABI. diff --git a/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts b/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts index 930e1a7cb..995514a4d 100644 --- a/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts +++ b/packages/compiler/src/frontend/lowering/expressions/optional-chains.ts @@ -1,3 +1,5 @@ +import { isSafeToDiscard } from "./evaluation-safety.js"; +import { completeFuncValueArgs } from "../call-arguments.js"; import { dynUndefinedExpr } from "../../../ir/build.js"; import * as ts from "../../ts7/adapter.js"; import { DYN, STRING, UNDEFINED_T, VOID, isUnitType, typeEquals } from "../../../ir/ir.js"; @@ -429,7 +431,22 @@ export function lowerOptionalChain( if (dotNode === expr && ts.isCallExpression(expr)) { if (narrowed.kind !== "func") lowerer.badType(recvNode, lowerer.typeOf(recvNode)); const params = narrowed.params; + // Forwarded fixed tuples (`f?.(...args)`) complete into the slots. + if (expr.arguments.nodes.some(ts.isSpreadElement)) { + const completed = completeFuncValueArgs(lowerer, expr, narrowed, loc); + const call: IrExpr = { + kind: "callValue", + callee: recvRef, + args: completed, + type: narrowed.ret, + loc, + }; + return lowerer.finishOptionalChain(expr, id, receiver, call, loc); + } const args = expr.arguments.nodes.map((a, i) => lowerer.lowerExprExpecting(a, params[i])); + // Effect-free surplus arguments (a dropped `void` slot) are ignored. + while (narrowed.rest !== true && args.length > params.length && isSafeToDiscard(args.at(-1)!)) + args.pop(); const body: IrExpr = { kind: "callValue", callee: recvRef, args, type: narrowed.ret, loc }; return lowerer.finishOptionalChain(expr, id, receiver, body, loc); } diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index bf5f2563d..e1fa5994d 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -1,3 +1,4 @@ +import { staticNameBindings } from "./static-function-names.js"; import * as ts from "../ts7/adapter.js"; import { bodyReadsArguments } from "../arguments-usage.js"; import type { Lowerer } from "./lowerer.js"; @@ -100,6 +101,9 @@ export interface GenericFnInfo { * names where the slot binds the call site's object type. Computed on * first use. */ implicitPatterns?: (ImplicitPattern | null)[]; + /** Parameter index → symbol for the function parameters whose `.name` + * the body reads (static-function-names.ts); computed on first use. */ + nameReadParams?: Map; } export interface GenericInstance { @@ -140,6 +144,9 @@ export interface GenericInstance { implicitInferReturn?: true; /** An erased instance (every type parameter bound to `unknown`). */ erased?: true; + /** Name-keyed instances: function parameter → its call site's static + * name (null when only known at run time), folded at `.name` reads. */ + staticFnNames?: Map; } /** Defaults bind during inference; collection only resolves symbols in @@ -317,6 +324,7 @@ export function genericCallInstance( // different fields). Non-literal bindings (a key union, plain string) // share one runtime-keyed instance per IR signature, exactly the // widened discipline. + const named = staticNameBindings(lowerer, expr, info); const keyofTps = keyofConstrainedTypeParams(info); if (keyofTps.size > 0) { const tsBindings = new Map(); @@ -333,12 +341,19 @@ export function genericCallInstance( }) .join(","); return internGenericInstance(lowerer, expr, info, params, returnType, () => bindings, { - extraKey: `@${litKey}`, + extraKey: `@${litKey}${named?.key ?? ""}`, tsBindings, + ...(named ? { staticFnNames: named.names } : {}), }); } - return internGenericInstance(lowerer, expr, info, params, returnType, (tsBindings) => - lowerer.inferTypeParamBindings(expr, info, rsig, tsBindings), + return internGenericInstance( + lowerer, + expr, + info, + params, + returnType, + (tsBindings) => lowerer.inferTypeParamBindings(expr, info, rsig, tsBindings), + named ? { extraKey: named.key, staticFnNames: named.names } : undefined, ); } @@ -498,7 +513,11 @@ function internGenericInstance( params: ParamShape[], returnType: IrType, makeBindings: (tsBindings: Map) => Map, - opts?: { extraKey?: string; tsBindings?: Map }, + opts?: { + extraKey?: string; + tsBindings?: Map; + staticFnNames?: Map; + }, ): GenericInstance { // keyof-constrained instantiations append their literal keys // (extraKey): the IR signature alone under-discriminates there — two @@ -541,6 +560,7 @@ function internGenericInstance( bindings, tsBindings, typeArgsText, + ...(opts?.staticFnNames ? { staticFnNames: opts.staticFnNames } : {}), }; info.instances.set(key, inst); lowerer.instantiationQueue.push({ info, inst }); @@ -824,6 +844,8 @@ export function lowerGenericInstance( const prevSuppress = lowerer.suppressStats; const prevClass = lowerer.currentClass; const prevImplicit = lowerer.implicitParamTypes; + const prevStaticFnNames = lowerer.staticFnNames; + lowerer.staticFnNames = inst.staticFnNames ?? null; // A generic METHOD of a generic-class INSTANTIATION lowers under BOTH // binding sets: the receiver instantiation's class type parameters // underneath, the method instantiation's own on top (disjoint symbol @@ -984,6 +1006,7 @@ export function lowerGenericInstance( lowerer.typeParamBindings = prevBindings; lowerer.typeParamTsBindings = prevTsBindings; lowerer.implicitParamTypes = prevImplicit; + lowerer.staticFnNames = prevStaticFnNames; lowerer.instantiationContext = prevContext; lowerer.localClassInstantiations.pop(); lowerer.suppressStats = prevSuppress; diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 67ca01b3f..3bebfd2af 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -1,3 +1,4 @@ +import { isForwardedTupleSpread, lowerForwardingApply } from "./typed-arguments.js"; import { guardNullProto, nullProtoRecordShape } from "./null-prototype.js"; import { lowerUnionEquality, tagEqualityMayMissAlias } from "./strict-equality.js"; import { lowerHashStreamCall } from "./builtins/hash-stream.js"; @@ -1364,6 +1365,9 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { if (ts.isPropertyAccessExpression(expr.expression)) { const intrinsic = fenceNodeModuleMutationCall(lowerer, expr, expr.expression) ?? + // `f.apply(thisArg, args)` forwarding a fixed argument tuple (typed + // `arguments`, a tuple rest parameter): a direct call. + lowerForwardingApply(lowerer, expr, expr.expression) ?? // Builtin namespace imports first (`fs.readFileSync(...)` where fs // is `import * as fs from "node:fs"`): the same tables and fences // as named builtin imports — before anything below tries to lower @@ -1854,6 +1858,19 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { // or marshals the callee and applies through a runtime-built argument // list (lowerSpreadArgsCall). Shapes outside its lanes fall through // to the historical fences. + // Forwarded fixed tuples (`fn(...args)` over a tuple rest parameter or a + // typed `arguments`) have a compile-time arity: complete them into the + // callee's positional slots directly, without the runtime-arity lane. + if ( + callee.type.rest !== true && + expr.arguments.nodes.some(ts.isSpreadElement) && + expr.arguments.nodes.every( + (a) => !ts.isSpreadElement(a) || isForwardedTupleSpread(lowerer, a.expression), + ) + ) { + const args = completeFuncValueArgs(lowerer, expr, callee.type, loc); + return { kind: "callValue", callee, args, type: callee.type.ret, loc }; + } if (expr.arguments.nodes.some((a) => ts.isSpreadElement(a))) { const spreadServed = lowerSpreadArgsCall(lowerer, expr, callee, loc); if (spreadServed) return spreadServed; @@ -8504,6 +8521,9 @@ export function lowerRecordFieldCall( }; return init ? { kind: "seqExpr", stmts: [init], result, type: result.type, loc } : result; } + // An optional callback field the checker narrowed at this call + // (`o.cb ? o.cb(x) : …`, `if (o.cb) o.cb(x)`): extract the func arm. + if (callee.type.kind === "union") callee = lowerer.maybeNarrow(callee, access); if (callee.type.kind !== "func") lowerer.badType(access, lowerer.typeOf(access)); // JavaScript passes the record as `this`. A record with no dynamic form // cannot be that receiver; the library's own function fields (the diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 09407f71f..bc970957d 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1,3 +1,5 @@ +import { staticFunctionNameRead } from "./static-function-names.js"; +import { argumentsOwner, typedArgumentsArity, typedArgumentsCandidate } from "./typed-arguments.js"; import { dateJsonType, isDateJsonSource } from "./builtins/dates.js"; import { guardNullProto, nullProtoRecordShape, recordNullProtoTest } from "./null-prototype.js"; import { lowerProtocolPropertyRead } from "./class-protocol-dispatch.js"; @@ -191,7 +193,7 @@ import { } from "./lower-classes.js"; import { hasRuntimeStatics } from "./class-runtime-statics.js"; import { bindingNeverReassigned, nullishGenericBindingUnitOf } from "./binding-analysis.js"; -import { funcTypeFromParamShapes } from "./call-signatures.js"; +import { funcTypeFromParamShapes, isThisParameter } from "./call-signatures.js"; import { implicitMonoFile, objLitGenericFnInfoOf, @@ -906,7 +908,18 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { // type and lowerLambda declared the local. if (expr.text === "arguments") { const binding = captureContextArguments(lowerer.fnStack); - if (binding) return { kind: "varRef", localId: binding.id, type: DYN, loc }; + if (binding && lowerer.pureTuples.has(binding.id)) { + throw new InternalCompilerError("lowerer bug: forwarded-only 'arguments' read as a value"); + } + if (binding) return { kind: "varRef", localId: binding.id, type: binding.type, loc }; + const owner = argumentsOwner(expr); + if (owner && typedArgumentsCandidate(owner)) { + lowerer.unsupported( + "SC1090", + expr, + "'arguments' in a function expression whose value is not asserted to one fixed-arity signature (assert it, as in `function () { … } as F`, and the function lowers against that signature)", + ); + } } // A compiler-projected adapter as a VALUE: the adapter has no // runtime function object (its calls rewrite directly) — call it @@ -1929,6 +1942,18 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { if (ts.isConditionalExpression(expr)) return lowerTernary(lowerer, expr); if (ts.isPropertyAccessExpression(expr)) { + // `fn.name` on a function parameter of a generic instance keyed by + // its argument's static name (call-site specialization). + { + const staticName = staticFunctionNameRead(lowerer, expr); + if (staticName !== null) return { kind: "strLit", value: staticName, type: STRING, loc }; + } + // `.length` of a forwarded fixed tuple (a typed `arguments`, a tuple + // rest parameter, an alias of either): the signature's arity. + if (expr.name.text === "length" && !expr.questionDotToken) { + const arity = typedArgumentsArity(lowerer, expr.expression); + if (arity !== null) return { kind: "numLit", value: arity, type: F64, loc }; + } // `require("./x").member` over a program module's export table: the // module's %init at this position, then the member read exactly like // a `const m = require("./x")` binding's (cjs-inline-require.ts). @@ -2233,9 +2258,12 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { p.dotDotDotToken === undefined, ); if (fixedArity) { + // A `this` parameter is type-only: callers never pass it. return { kind: "numLit", - value: (fn as ts.FunctionLikeDeclaration).parameters.nodes.length, + value: (fn as ts.FunctionLikeDeclaration).parameters.nodes.filter( + (p) => !isThisParameter(p), + ).length, type: F64, loc, }; diff --git a/packages/compiler/src/frontend/lowering/lower-functions.ts b/packages/compiler/src/frontend/lowering/lower-functions.ts index 7cc448994..74dd362e5 100644 --- a/packages/compiler/src/frontend/lowering/lower-functions.ts +++ b/packages/compiler/src/frontend/lowering/lower-functions.ts @@ -1,3 +1,4 @@ +import { declareTypedArguments } from "./typed-arguments.js"; import { varRef } from "../../ir/build.js"; import { InternalCompilerError } from "../../errors.js"; import * as ts from "../ts7/adapter.js"; @@ -309,6 +310,8 @@ export function lowerLambda( : null; const { params, prologue } = lowerer.declareParams(node.parameters.nodes, shapes); if (receiver) prologue.unshift(receiver); + if (ts.isFunctionExpression(node) && lowerer.typedArgumentsLambdas.has(node)) + declareTypedArguments(lowerer, node, shapes, params, prologue); declareFunctionArguments( lowerer, node, diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 36abb9130..01ff5f5c1 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -1,3 +1,4 @@ +import { lowerTupleAliasDecl, stripTypeWrappers } from "./typed-arguments.js"; import { matchAllRowsReadByElements, storedMatchAllRowsReadByElements, @@ -4463,6 +4464,10 @@ export function lowerVarDecl( } } if (!ts.isIdentifier(decl.name)) lowerer.unsupported("SC1031", decl.name); + { + const alias = lowerTupleAliasDecl(lowerer, decl); + if (alias) return alias; + } // A NULLISH generic binding (`const i: I = null as any` — the // declared type has no mapping, the value is provably null/undefined @@ -4876,6 +4881,15 @@ export function lowerVarDecl( init.type.kind === "func" ) type = init.type; + // A typed-arguments function expression (typed-arguments.ts): the + // checker types the binding variadic, but its value has the fixed + // signature every use asserts. + if ( + !decl.type && + init.type.kind === "func" && + lowerer.typedArgumentsLambdas.has(stripTypeWrappers(decl.initializer)) + ) + type = init.type; // A TypeScript lambda whose closure returns the runtime result (a missing // read, a `??` default of another type) instead of the checker's bare // return type: the binding keeps the checker's parameters and takes the diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index d252af250..ccc140e9e 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1,3 +1,4 @@ +import { onlyForwarded } from "./typed-arguments.js"; import { jsDynamicDeclarations } from "./js-dynamic-objects.js"; import { lifetimeRegionType, @@ -1910,6 +1911,19 @@ export class Lowerer { * type parameters are bound to `unknown`, so lambdaSignature admits * them. */ readonly erasingLambdas = new Set(); + /** Function expressions lowered against a fixed target signature with a + * tuple-typed `arguments` (typed-arguments.ts). */ + readonly typedArgumentsLambdas = new Set(); + /** Const bindings holding a forwarded fixed tuple (`const args = + * arguments as any` in a typed-arguments function) → the tuple type. */ + readonly tupleAliasSymbols = new Map(); + /** Tuple bindings that are only forwarded (typed-arguments.ts + * onlyForwarded): binding local id → the native slots forwarding reads. + * Such a binding is never materialized. */ + readonly pureTuples = new Map(); + /** The static names of the current generic instance's name-keyed + * function parameters (static-function-names.ts). */ + staticFnNames: Map | null = null; /** Nesting depth of erased generic bodies being lowered (erased lambdas * and erased instances). */ erasingDepth = 0; @@ -10681,6 +10695,70 @@ export class Lowerer { return declareContextLocal(this.ctx, name, type, false, undefined, undefined); } + /** The body binding of a fixed-tuple rest parameter: one native slot per + * position (the flattened ABI), rebuilt into the tuple the source names. + * The empty tuple rides the empty-array representation. */ + declareRestTupleParam( + decl: ts.ParameterDeclaration, + nameNode: ts.Identifier, + elemShapes: readonly ParamShape[], + params: IrParam[], + prologue: IrStmt[], + ): void { + const loc = locOf(decl); + const name = nameNode.text; + const tupleType = this.irTypeOf(nameNode); + const slots = elemShapes.map((shape, i) => { + const slot = this.declareHiddenLocal(`%${name}.${i}`, shape.type); + params.push({ localId: slot.id, name: `%${name}.${i}`, type: shape.type }); + return slot; + }); + const body = this.declareLocal(nameNode, name, tupleType, true); + const fn = decl.parent; + const symbol = this.checker.getSymbolAtLocation(nameNode); + if ( + fn !== undefined && + symbol !== undefined && + onlyForwarded( + this, + fn, + (id) => id !== nameNode && this.checker.getSymbolAtLocation(id) === symbol, + ) + ) { + this.pureTuples.set(body.id, slots); + return; + } + const init = this.tupleOfSlots(slots, tupleType, nameNode, loc); + prologue.push({ kind: "varDecl", localId: body.id, init, loc }); + } + + /** A fresh tuple holding the given positional slots, typed `tupleType` + * (a tuple record, or the empty-array form of `[]`). */ + tupleOfSlots(slots: readonly IrLocal[], tupleType: IrType, blame: ts.Node, loc: SrcLoc): IrExpr { + if (slots.length === 0) { + if (tupleType.kind !== "array") this.badType(blame, this.typeOf(blame)); + return { kind: "arrayLit", elems: [], type: tupleType, loc }; + } + const shape = tupleType.kind === "record" ? this.shapes.get(tupleType.shapeId) : undefined; + if (!shape?.tuple || shape.fields.length !== slots.length) + this.badType(blame, this.typeOf(blame)); + return { + kind: "recordLit", + fields: slots.map((slot, i) => { + const field = shape.fields.find((f) => f.name === String(i))!; + return { + name: field.name, + value: this.coerceToExpected( + { kind: "varRef", localId: slot.id, type: slot.type, loc }, + field.type, + ), + }; + }), + type: tupleType, + loc, + }; + } + /** Declares a callee's parameter locals from its ParamShapes and builds * the DEFAULT-PARAM PROLOGUE. Required/optional/rest params bind their * symbol directly (one local of the ABI type). A defaulted param `x: T = e` @@ -10717,6 +10795,17 @@ export class Lowerer { return defaultAfterUndefined(undefinedValue, this.coerceInto(node, absent, expected)); }; decls.forEach((decl, i) => { + // A fixed-tuple rest parameter owns every remaining (flattened) + // shape, possibly none for `[]` (restTupleShapes). + if ( + decl.dotDotDotToken !== undefined && + ts.isIdentifier(decl.name) && + i === decls.length - 1 && + (shapes[i] === undefined ? shapes.length === i : shapes[i]!.restTuple === true) + ) { + this.declareRestTupleParam(decl, decl.name, shapes.slice(i), params, prologue); + return; + } const shape = shapes[i]!; if (ts.isArrayBindingPattern(decl.name) || ts.isObjectBindingPattern(decl.name)) { // Pattern parameter: one hidden ABI slot carries the source value; @@ -11033,6 +11122,13 @@ export class Lowerer { } if (!symbol) return null; const direct = this.resolveKey(symbol, ident); + if (direct && this.pureTuples.has(direct.id)) { + // onlyForwarded proved every read forwards; a plain read here means + // the analysis and the lowering disagree. + throw new InternalCompilerError( + `lowerer bug: forwarded-only tuple '${ident.text}' read as a value`, + ); + } if (direct) return direct; // A PARAMETER PROPERTY declares two symbols: references resolve to the // PARAMETER symbol, while the declaration's name binds the PROPERTY diff --git a/packages/compiler/src/frontend/lowering/static-function-names.ts b/packages/compiler/src/frontend/lowering/static-function-names.ts new file mode 100644 index 000000000..1fcab901b --- /dev/null +++ b/packages/compiler/src/frontend/lowering/static-function-names.ts @@ -0,0 +1,221 @@ +import * as ts from "../ts7/adapter.js"; +import { isJsSourceFile } from "../program.js"; +import type { Lowerer } from "./lowerer.js"; +import type { GenericFnInfo } from "./generic-functions.js"; +import { stripTypeWrappers } from "./typed-arguments.js"; + +/** `fn.name` inside generic higher-order functions. + * + * Native closures carry no name, but a generic wrapper is instantiated per + * call site anyway: when its body reads `.name` of a function parameter, + * the instantiation key also carries the static name of that call's + * argument (the function declaration's name, a const binding's inferred + * name, the name of the function a wrapper call returns), and the read + * folds to that string. A call whose argument's name is only known at run + * time keeps a precise refusal at the read. */ + +/** Parameter index → symbol for the parameters whose `.name` the generic + * body reads (and never reassigns). Computed once per generic. */ +export function nameReadParams(lowerer: Lowerer, info: GenericFnInfo): Map { + if (info.nameReadParams) return info.nameReadParams; + const out = new Map(); + // JavaScript generics read function names through the checked-value + // box, which carries the runtime name already. + if (isJsSourceFile(info.decl.getSourceFile())) { + info.nameReadParams = out; + return out; + } + const bySymbol = new Map(); + info.decl.parameters.nodes.forEach((param, i) => { + if (!ts.isIdentifier(param.name) || param.dotDotDotToken) return; + if (!functionTypedParameter(lowerer, param)) return; + const symbol = lowerer.checker.getSymbolAtLocation(param.name); + if (symbol) bySymbol.set(symbol, i); + }); + if (bySymbol.size > 0) { + const written = new Set(); + const read = new Set(); + ts.walkPreorder(info.decl, (node) => { + if (ts.isTypeNode(node)) return "skip"; + if ( + ts.isPropertyAccessExpression(node) && + node.name.text === "name" && + ts.isIdentifier(node.expression) + ) { + const symbol = lowerer.checker.getSymbolAtLocation(node.expression); + if (symbol && bySymbol.has(symbol)) read.add(symbol); + } + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + node.operatorToken.kind <= ts.SyntaxKind.LastAssignment && + ts.isIdentifier(node.left) + ) { + const symbol = lowerer.checker.getSymbolAtLocation(node.left); + if (symbol) written.add(symbol); + } + return undefined; + }); + for (const symbol of read) { + if (!written.has(symbol)) out.set(bySymbol.get(symbol)!, symbol); + } + } + info.nameReadParams = out; + return out; +} + +/** A parameter declared with a function type, or with a type parameter + * constrained to one (`fn: F` where `F extends (...args: any) => any`). */ +function functionTypedParameter(lowerer: Lowerer, param: ts.ParameterDeclaration): boolean { + const t = lowerer.typeOf(param.name); + if (lowerer.checker.getCallSignatures(t).length > 0) return true; + if (!(t.flags & ts.TypeFlags.TypeParameter)) return false; + const symbol = t.getSymbol(); + const decl = symbol + ? lowerer.checker.declarationsOf(symbol).find(ts.isTypeParameterDeclaration) + : undefined; + if (!decl?.constraint) return false; + const constraint = lowerer.checker.getTypeFromTypeNode(decl.constraint); + return lowerer.checker.getCallSignatures(constraint).length > 0; +} + +/** The instantiation key suffix and name bindings of one call of `info`, + * or null when the body reads no parameter's name. */ +export function staticNameBindings( + lowerer: Lowerer, + call: ts.CallExpression, + info: GenericFnInfo, +): { key: string; names: Map } | null { + const params = nameReadParams(lowerer, info); + if (params.size === 0) return null; + const names = new Map(); + const parts: string[] = []; + for (const [index, symbol] of params) { + const arg = call.arguments.nodes[index]; + const name = arg && !ts.isSpreadElement(arg) ? staticFunctionName(lowerer, arg) : null; + names.set(symbol, name); + parts.push(name === null ? "?" : JSON.stringify(name)); + } + return { key: `@name(${parts.join(",")})`, names }; +} + +/** The `name` JavaScript gives the function `expr` evaluates to, when it is + * statically known; null otherwise. */ +export function staticFunctionName(lowerer: Lowerer, expr: ts.Expression): string | null { + return staticNameOf(lowerer, expr, new Set()); +} + +function staticNameOf(lowerer: Lowerer, expr: ts.Expression, seen: Set): string | null { + const e = stripTypeWrappers(expr); + if (seen.has(e)) return null; + seen.add(e); + // An anonymous function as a call argument gets no inferred name. + if (ts.isFunctionExpression(e) || ts.isArrowFunction(e)) { + return !ts.isArrowFunction(e) && e.name ? e.name.text : ""; + } + if (ts.isIdentifier(e)) { + const symbol = lowerer.resolveValueSymbol(e); + if (!symbol) return null; + const bound = lowerer.staticFnNames?.get(symbol); + if (bound !== undefined) return bound; + const decl = lowerer.checker.valueDeclarationOf(symbol); + if (decl === undefined) return null; + if (ts.isFunctionDeclaration(decl)) return decl.name ? decl.name.text : "default"; + if ( + ts.isVariableDeclaration(decl) && + ts.isIdentifier(decl.name) && + decl.initializer !== undefined && + (ts.getCombinedNodeFlags(decl) & ts.NodeFlags.Const) !== 0 + ) { + const init = stripTypeWrappers(decl.initializer); + // `const f = () => …` / `const f = function () {}`: the binding names + // an anonymous function definition. + if (ts.isArrowFunction(init)) return decl.name.text; + if (ts.isFunctionExpression(init)) return init.name ? init.name.text : decl.name.text; + return staticNameOf(lowerer, init, seen); + } + return null; + } + if (ts.isCallExpression(e) && ts.isIdentifier(e.expression)) { + // A call of a generic higher-order function returns the function its + // body returns: every `return` names the same anonymous definition or + // the same local const binding. + const symbol = lowerer.resolveValueSymbol(e.expression); + const info = symbol ? lowerer.genericFnsBySymbol.get(symbol) : undefined; + if (!info) return null; + return returnedFunctionName(lowerer, info.decl, seen); + } + if (ts.isPropertyAccessExpression(e)) { + const symbol = lowerer.checker.getSymbolAtLocation(e.name); + const decl = symbol ? lowerer.checker.valueDeclarationOf(symbol) : undefined; + if (decl && ts.isMethodDeclaration(decl) && ts.isIdentifier(decl.name)) return decl.name.text; + return null; + } + return null; +} + +function returnedFunctionName( + lowerer: Lowerer, + decl: GenericFnInfo["decl"], + seen: Set, +): string | null { + const body = decl.body; + if (body === undefined) return null; + if (!ts.isBlock(body)) return functionValueName(lowerer, body, seen); + let name: string | null | undefined; + let ok = true; + ts.walkPreorder(body, (node) => { + if (!ok) return "stop"; + if (ts.isFunctionLike(node)) return "skip"; + if (!ts.isReturnStatement(node)) return undefined; + const here = node.expression ? functionValueName(lowerer, node.expression, seen) : null; + if (here === null || (name !== undefined && name !== here)) ok = false; + else name = here; + return undefined; + }); + return ok && name !== undefined ? name : null; +} + +/** The name of a returned function value: an anonymous definition returned + * directly is nameless; a local const binding names its definition. */ +function functionValueName( + lowerer: Lowerer, + expr: ts.Expression, + seen: Set, +): string | null { + const e = stripTypeWrappers(expr); + if (ts.isFunctionExpression(e) || ts.isArrowFunction(e)) { + return !ts.isArrowFunction(e) && e.name ? e.name.text : ""; + } + if (ts.isIdentifier(e)) return staticNameOf(lowerer, e, seen); + return null; +} + +/** `fn.name` where `fn` is a name-keyed parameter of the generic instance + * being lowered: the folded name, or a precise refusal when this call + * site's argument has no static name. Null for every other read. */ +export function staticFunctionNameRead( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, +): string | null { + const names = lowerer.staticFnNames; + if ( + names === null || + expr.name.text !== "name" || + expr.questionDotToken !== undefined || + !ts.isIdentifier(expr.expression) + ) { + return null; + } + const symbol = lowerer.checker.getSymbolAtLocation(expr.expression); + if (!symbol || !names.has(symbol)) return null; + const name = names.get(symbol); + if (name === null || name === undefined) { + lowerer.unsupported( + "SC1090", + expr, + `reading '${expr.expression.text}.name' when the call passes a function whose name is only known at run time (generic wrappers fold the name of a function declaration, a const-bound function, or a wrapper call's result)`, + ); + } + return name; +} diff --git a/packages/compiler/src/frontend/lowering/typed-arguments.ts b/packages/compiler/src/frontend/lowering/typed-arguments.ts new file mode 100644 index 000000000..751d0d6be --- /dev/null +++ b/packages/compiler/src/frontend/lowering/typed-arguments.ts @@ -0,0 +1,511 @@ +import * as ts from "../ts7/adapter.js"; +import { bodyReadsArguments } from "../arguments-usage.js"; +import { isJsSourceFile, locOf } from "../program.js"; +import { unitOnlyUnion } from "../type-mapper.js"; +import { + DYN, + type IrExpr, + type IrLocal, + type IrStmt, + type IrType, + arrayOf, + typeEquals, +} from "../../ir/ir.js"; +import type { Lowerer } from "./lowerer.js"; +import { captureContextArguments } from "./function-context.js"; +import { isThisParameter, type ParamShape } from "./call-signatures.js"; +import { tryLowerExpression } from "./expressions/try-lower-expression.js"; +import { omittedArgFor } from "./call-arguments.js"; + +/** Variadic forwarding through a FIXED signature. + * + * A TypeScript function expression that declares no parameters and reads + * `arguments` (`function (this: unknown) { return fn.apply(this, arguments) } + * as F`) is lowered against the fixed function type its value is asserted + * to: one native slot per position of that type, and `arguments` is the + * tuple of those slots. Inside a generic instantiation F is the call + * site's concrete signature, so a higher-order wrapper specializes to the + * wrapped function's exact ABI. Forwarding (`f.apply(thisArg, args)`, + * `f(...args)`) then calls with the tuple's positions directly. */ + +/** `e`, with the type-only wrappers that never change its value removed. */ +export function stripTypeWrappers(e: ts.Expression): ts.Expression { + let cur = e; + for (;;) { + if (ts.isParenthesizedExpression(cur)) cur = cur.expression; + else if (ts.isAsExpression(cur) || ts.isTypeAssertion(cur) || ts.isSatisfiesExpression(cur)) + cur = cur.expression; + else if (ts.isNonNullExpression(cur)) cur = cur.expression; + else return cur; + } +} + +/** The outermost type assertion around `node` (`node as unknown as F` → F), + * looking through parentheses; null when the value is not asserted. */ +function assertedTypeNode(node: ts.Node): ts.TypeNode | null { + let cur: ts.Node = node; + let found: ts.TypeNode | null = null; + for (let parent = cur.parent; parent !== undefined; parent = cur.parent) { + if (ts.isParenthesizedExpression(parent)) { + cur = parent; + continue; + } + if ((ts.isAsExpression(parent) || ts.isTypeAssertion(parent)) && parent.expression === cur) { + found = parent.type; + cur = parent; + continue; + } + break; + } + return found; +} + +/** The fixed function type a value is asserted to, or null. */ +function assertedFuncType(lowerer: Lowerer, node: ts.Node): (IrType & { kind: "func" }) | null { + const typeNode = assertedTypeNode(node); + if (!typeNode) return null; + const mapped = lowerer.mapTypeOf(lowerer.checker.getTypeFromTypeNode(typeNode)); + return mapped?.kind === "func" ? mapped : null; +} + +function sameFuncType(a: IrType & { kind: "func" }, b: IrType & { kind: "func" }): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +/** A function expression whose `arguments` can be typed: TypeScript source, + * no runtime parameters (a `this` parameter is type-only), an ordinary + * (non-generator) function that reads its own `arguments`. */ +export function typedArgumentsCandidate(node: ts.Node): node is ts.FunctionExpression { + return ( + ts.isFunctionExpression(node) && + node.asteriskToken === undefined && + !isJsSourceFile(node.getSourceFile()) && + node.parameters.nodes.every(isThisParameter) && + bodyReadsArguments(node) + ); +} + +/** The fixed signature a typed-arguments function expression is lowered + * against: the type its value is asserted to (`function () {…} as F`), the + * one type every use of its const binding asserts it to (`const w = + * function () {…}; return w as unknown as F`), or its contextual function + * type. Null when none names one fixed-arity signature. */ +export function typedArgumentsTarget( + lowerer: Lowerer, + node: ts.FunctionExpression, +): (IrType & { kind: "func" }) | null { + const fixed = (t: (IrType & { kind: "func" }) | null) => (t && t.rest !== true ? t : null); + const direct = assertedFuncType(lowerer, node); + if (direct) return fixed(direct); + let top: ts.Node = node; + while (top.parent && ts.isParenthesizedExpression(top.parent)) top = top.parent; + const decl = top.parent; + if ( + decl && + ts.isVariableDeclaration(decl) && + decl.initializer === top && + decl.type === undefined && + ts.isIdentifier(decl.name) && + (ts.getCombinedNodeFlags(decl) & ts.NodeFlags.Const) !== 0 + ) { + const symbol = lowerer.checker.getSymbolAtLocation(decl.name); + const scope = enclosingScope(decl); + if (symbol && scope) { + let target: (IrType & { kind: "func" }) | null = null; + let ok = true; + let uses = 0; + ts.walkPreorder(scope, (n) => { + if (!ok) return "stop"; + if (n === decl.name || !ts.isIdentifier(n) || n.text !== decl.name.text) return undefined; + if (lowerer.checker.getSymbolAtLocation(n) !== symbol) return undefined; + uses++; + const asserted = assertedFuncType(lowerer, n); + if (!asserted || (target && !sameFuncType(target, asserted))) ok = false; + else target = asserted; + return undefined; + }); + if (ok && uses > 0) return fixed(target); + } + } + const ctxType = lowerer.checker.getContextualType(node); + if (!ctxType) return null; + const sigs = lowerer.checker.getCallSignatures(lowerer.checker.getNonNullableType(ctxType)); + if (sigs.length !== 1) return null; + const mapped = lowerer.mapTypeOf(lowerer.checker.getNonNullableType(ctxType)); + return mapped?.kind === "func" ? fixed(mapped) : null; +} + +function enclosingScope(node: ts.Node): ts.Node | null { + for (let cur = node.parent; cur !== undefined; cur = cur.parent) { + if (ts.isSourceFile(cur) || (ts.isFunctionLike(cur) && cur.body !== undefined)) return cur; + } + return null; +} + +/** The tuple type of a fixed parameter list, interned exactly as the + * spelled tuple (and `Parameters`) map: positional fields, or the + * empty-array form for no parameters. */ +export function tupleTypeOfParams(lowerer: Lowerer, params: readonly IrType[]): IrType { + if (params.length === 0) return arrayOf(unitOnlyUnion(lowerer.unions)); + const fields = params.map((type, i) => ({ name: String(i), type })); + fields.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + return { kind: "record", shapeId: lowerer.shapes.intern(fields, true) }; +} + +/** The ABI shapes of a typed-arguments function: one required slot per + * position of its target signature. */ +export function typedArgumentsShapes(target: IrType & { kind: "func" }): ParamShape[] { + return target.params.map((type) => ({ type, mode: "required" })); +} + +/** Declares the hidden positional slots of a typed-arguments function and + * binds its `arguments` to their tuple. */ +export function declareTypedArguments( + lowerer: Lowerer, + node: ts.FunctionExpression, + shapes: readonly ParamShape[], + params: { localId: string; name: string; type: IrType }[], + prologue: IrStmt[], +): void { + const loc = locOf(node); + const slots = shapes.map((shape, i) => { + const slot = lowerer.declareHiddenLocal(`%arguments.${i}`, shape.type); + params.push({ localId: slot.id, name: `%arguments.${i}`, type: shape.type }); + return slot; + }); + const tupleType = tupleTypeOfParams( + lowerer, + shapes.map((s) => s.type), + ); + const local = lowerer.declareHiddenLocal("%arguments", tupleType); + lowerer.ctx.argumentsLocal = local; + // Pure forwarding (every read is forwarded, counted, or aliased by a + // binding that is itself only forwarded): no tuple exists at all. + if ( + onlyForwarded(lowerer, node, (id) => id.text === "arguments" && argumentsOwner(id) === node) + ) { + lowerer.pureTuples.set(local.id, slots); + return; + } + prologue.push({ + kind: "varDecl", + localId: local.id, + init: lowerer.tupleOfSlots(slots, tupleType, node, loc), + loc, + }); +} + +/** Whether every read of a tuple binding in `fn` (identified by `isRoot`) + * only forwards it: `f.apply(t, x)`, `g(...x)` into a program function, + * `x.length`, or a const alias whose own reads only forward. Reads inside + * nested functions (arrows included) capture the binding and need the + * tuple. Type assertions around a read never matter. */ +export function onlyForwarded( + lowerer: Lowerer, + fn: ts.Node, + isRoot: (id: ts.Identifier) => boolean, +): boolean { + const body = (fn as ts.Node & { body?: ts.Node }).body; + if (body === undefined) return false; + const roots: ((id: ts.Identifier) => boolean)[] = [isRoot]; + let ok = true; + const visit = (scope: ts.Node, test: (id: ts.Identifier) => boolean): void => { + ts.walkPreorder(scope, (n) => { + if (!ok) return "stop"; + if (ts.isTypeNode(n)) return "skip"; + if (!ts.isIdentifier(n) || !test(n)) return undefined; + for (let cur = n.parent; cur !== undefined && cur !== fn; cur = cur.parent) { + if (ts.isFunctionLike(cur)) { + ok = false; + return "stop"; + } + } + let top: ts.Node = n; + while ( + top.parent && + (ts.isParenthesizedExpression(top.parent) || + ts.isAsExpression(top.parent) || + ts.isTypeAssertion(top.parent) || + ts.isSatisfiesExpression(top.parent) || + ts.isNonNullExpression(top.parent)) + ) + top = top.parent; + const p = top.parent; + if (!p) { + ok = false; + return "stop"; + } + if (ts.isPropertyAccessExpression(p) && p.expression === top && p.name.text === "length") + return undefined; + if ( + ts.isCallExpression(p) && + p.arguments.nodes.length === 2 && + p.arguments.nodes[1] === top && + ts.isPropertyAccessExpression(p.expression) && + p.expression.name.text === "apply" && + p.expression.questionDotToken === undefined + ) + return undefined; + if (ts.isSpreadElement(p) && p.parent && ts.isCallExpression(p.parent)) { + const sigs = lowerer.checker.getCallSignatures( + lowerer.checker.getNonNullableType(lowerer.typeOf(p.parent.expression)), + ); + const decl = sigs.length === 1 ? lowerer.checker.signatureDeclaration(sigs[0]!) : undefined; + if (decl && !decl.getSourceFile().isDeclarationFile) return undefined; + } + if ( + ts.isVariableDeclaration(p) && + p.initializer === top && + ts.isIdentifier(p.name) && + (ts.getCombinedNodeFlags(p) & ts.NodeFlags.Const) !== 0 + ) { + const alias = lowerer.checker.getSymbolAtLocation(p.name); + const aliasName = p.name; + if (alias) { + roots.push((id) => id !== aliasName && lowerer.checker.getSymbolAtLocation(id) === alias); + return undefined; + } + } + ok = false; + return "stop"; + }); + }; + for (let i = 0; i < roots.length && ok; i++) visit(body, roots[i]!); + return ok; +} + +/** The native slots of a pure-forwarded tuple binding read at `node` in + * the current function, or null when the tuple is materialized. */ +function pureSlotsOf(lowerer: Lowerer, node: ts.Expression): IrLocal[] | null { + const operand = stripTypeWrappers(node); + if (!ts.isIdentifier(operand)) return null; + const local = + operand.text === "arguments" ? lowerer.ctx.argumentsLocal : lowerer.peekLocal(operand); + return local ? (lowerer.pureTuples.get(local.id) ?? null) : null; +} + +/** The positional values `node` forwards when it is a pure-forwarded tuple + * (slot reads, in order), or null. */ +export function pureForwardedValues(lowerer: Lowerer, node: ts.Expression): IrExpr[] | null { + const slots = pureSlotsOf(lowerer, node); + if (!slots) return null; + const loc = locOf(node); + return slots.map((slot) => ({ kind: "varRef", localId: slot.id, type: slot.type, loc })); +} + +/** Whether `node` reads a tuple-typed `arguments` binding: the nearest + * ordinary function (arrows inherit it) is a typed-arguments function. + * Looks the binding up without allocating captures. */ +function isTypedArgumentsRead(lowerer: Lowerer, node: ts.Expression): boolean { + if (!ts.isIdentifier(node) || node.text !== "arguments") return false; + const stack = lowerer.fnStack; + let owner = stack.length - 1; + while (owner > 0 && stack[owner]!.inheritsArguments) owner--; + const own = owner >= 0 ? stack[owner]!.argumentsLocal : undefined; + return own !== undefined && own !== null && own.type.kind !== "dyn"; +} + +/** The fixed tuple `node` forwards, or null: the typed `arguments`, a + * fixed-tuple rest parameter, or a const alias of either — seen through + * type assertions, which never change the value. */ +export function forwardedTupleType(lowerer: Lowerer, node: ts.Expression): IrType | null { + const operand = stripTypeWrappers(node); + if (!ts.isIdentifier(operand)) return null; + if (operand.text === "arguments") { + if (!isTypedArgumentsRead(lowerer, operand)) return null; + return captureContextArguments(lowerer.fnStack)!.type; + } + const symbol = lowerer.resolveValueSymbol(operand); + if (!symbol) return null; + const alias = lowerer.tupleAliasSymbols.get(symbol); + if (alias) return alias; + const decl = lowerer.checker.valueDeclarationOf(symbol); + if ( + decl !== undefined && + ts.isParameter(decl) && + decl.dotDotDotToken !== undefined && + ts.isIdentifier(decl.name) && + !isJsSourceFile(decl.getSourceFile()) + ) { + const t = lowerer.mapTypeOf(lowerer.typeOf(operand)); + if (t && isTupleValueType(lowerer, t)) return t; + } + return null; +} + +function isTupleValueType(lowerer: Lowerer, t: IrType): boolean { + if (t.kind === "record") return lowerer.shapes.get(t.shapeId)?.tuple === true; + // The empty tuple's representation (mapType's `[]` rule). + return typeEquals(t, arrayOf(unitOnlyUnion(lowerer.unions))); +} + +/** The positional reads of a forwarded tuple value (already saved in a + * local), in order. */ +function tupleReads( + lowerer: Lowerer, + ref: () => IrExpr, + tupleType: IrType, + loc: IrExpr["loc"], +): IrExpr[] { + if (tupleType.kind !== "record") return []; + const shape = lowerer.shapes.get(tupleType.shapeId)!; + return [...shape.fields] + .sort((a, b) => Number(a.name) - Number(b.name)) + .map((field) => ({ + kind: "recordGet" as const, + obj: ref(), + shapeId: tupleType.shapeId, + field: field.name, + type: field.type, + loc, + })); +} + +/** `f.apply(thisArg, args)` where `args` forwards a fixed tuple and `f` is a + * fixed-arity function value: a direct call of `f` with the tuple's + * positions and `thisArg` as the call-time receiver. Null for every other + * form (the existing Function.prototype paths keep them). */ +export function lowerForwardingApply( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, +): IrExpr | null { + if (access.name.text !== "apply" || access.questionDotToken !== undefined) return null; + const argNodes = call.arguments.nodes; + if (argNodes.length !== 2 || argNodes.some(ts.isSpreadElement)) return null; + const tupleType = forwardedTupleType(lowerer, argNodes[1]!); + if (!tupleType) return null; + const callee = tryLowerExpression(lowerer, access.expression); + if (callee?.type.kind !== "func") return null; + const fnType = callee.type; + const loc = locOf(call); + if (fnType.rest === true) { + lowerer.unsupported( + "SC1090", + call, + "forwarding a fixed argument tuple through 'apply' to a variadic function value", + ); + } + const savedFn = lowerer.declareHiddenLocal("%applyFn", fnType); + const savedThis = lowerer.declareHiddenLocal("%applyThis", DYN); + const stmts: IrStmt[] = [ + { kind: "varDecl", localId: savedFn.id, init: callee, loc }, + { + kind: "varDecl", + localId: savedThis.id, + init: lowerer.lowerExprExpecting(argNodes[0]!, DYN), + loc, + }, + ]; + let reads = pureForwardedValues(lowerer, argNodes[1]!); + if (!reads) { + const savedArgs = lowerer.declareHiddenLocal("%applyArgs", tupleType); + stmts.push({ + kind: "varDecl", + localId: savedArgs.id, + init: lowerer.lowerExpr(stripTypeWrappers(argNodes[1]!)), + loc, + }); + reads = tupleReads( + lowerer, + () => ({ kind: "varRef", localId: savedArgs.id, type: tupleType, loc }), + tupleType, + loc, + ); + } + // JS drops surplus positions and fills missing ones with undefined. + const args = fnType.params.map((param, i): IrExpr => { + const read = reads[i]; + if (read) return lowerer.coerceInto(call, read, param); + const absent = omittedArgFor(lowerer, param, loc); + if (!absent) { + lowerer.unsupported( + "SC1090", + call, + "forwarding fewer arguments through 'apply' than the callee's required parameters", + ); + } + return absent; + }); + const invoked: IrExpr = { + kind: "callValue", + callee: { kind: "varRef", localId: savedFn.id, type: fnType, loc }, + receiver: { kind: "varRef", localId: savedThis.id, type: DYN, loc }, + args, + type: fnType.ret, + loc, + }; + return { kind: "seqExpr", stmts, result: invoked, type: fnType.ret, loc }; +} + +/** `const args = arguments as …` (or an alias of a rest tuple) inside a + * typed-arguments function: the binding holds the tuple itself — the + * assertion only re-describes the value — and stays a forwarding source. */ +export function lowerTupleAliasDecl(lowerer: Lowerer, decl: ts.VariableDeclaration): IrStmt | null { + if ( + !decl.initializer || + !ts.isIdentifier(decl.name) || + (ts.getCombinedNodeFlags(decl) & ts.NodeFlags.Const) === 0 || + lowerer.fnStack.length === 0 + ) { + return null; + } + const tupleType = forwardedTupleType(lowerer, decl.initializer); + if (!tupleType) return null; + const symbol = lowerer.checker.getSymbolAtLocation(decl.name); + if (!symbol) return null; + const declared = lowerer.mapTypeOf(lowerer.typeOf(decl.name)); + if (declared && declared.kind !== "dyn" && JSON.stringify(declared) !== JSON.stringify(tupleType)) + return null; + lowerer.tupleAliasSymbols.set(symbol, tupleType); + const slots = pureSlotsOf(lowerer, decl.initializer); + if (slots) { + // An alias of a pure-forwarded tuple is itself only forwarded + // (onlyForwarded followed it): it names the same slots. + const local = lowerer.declareLocal(decl.name, decl.name.text, tupleType, false); + lowerer.pureTuples.set(local.id, slots); + return { kind: "block", body: [], loc: locOf(decl) }; + } + const init = lowerer.lowerExpr(stripTypeWrappers(decl.initializer)); + const local = lowerer.declareLocal(decl.name, decl.name.text, tupleType, false); + return { kind: "varDecl", localId: local.id, init, loc: locOf(decl) }; +} + +/** A spread operand forwarding a fixed tuple (forwardedTupleType), + * including one that resolved to no positions at all. */ +export function isForwardedTupleSpread(lowerer: Lowerer, node: ts.Expression): boolean { + return forwardedTupleType(lowerer, node) !== null; +} + +/** `arguments` read inside a typed-arguments function: its fixed arity. */ +export function typedArgumentsArity(lowerer: Lowerer, node: ts.Expression): number | null { + if (!ts.isIdentifier(node)) return null; + const t = forwardedTupleType(lowerer, node); + if (!t) return null; + if (t.kind !== "record") return 0; + const fields = lowerer.shapes.get(t.shapeId)?.fields ?? []; + // A position that may be undefined may also be absent at the call: the + // native signature always passes it, so the count is not static. + if ( + fields.some( + (f) => f.type.kind === "union" && lowerer.armTag(f.type.unionId, { kind: "undefinedT" }) >= 0, + ) + ) { + lowerer.unsupported( + "SC1090", + node.parent ?? node, + "'length' of an argument tuple whose positions may be omitted (the native signature always passes every position, so the count is not static)", + ); + } + return fields.length; +} + +/** The function whose `arguments` an identifier reads: the nearest + * enclosing non-arrow function. */ +export function argumentsOwner(node: ts.Node): ts.Node | null { + for (let cur = node.parent; cur !== undefined; cur = cur.parent) { + if (ts.isFunctionLike(cur) && !ts.isArrowFunction(cur)) return cur; + if (ts.isSourceFile(cur)) return null; + } + return null; +} diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 123a5d1a0..19e7775cc 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1552,6 +1552,15 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { contextResolutions++; return viaIndexed; } + // `Parameters` / `ReturnType` over a bound function-typed + // parameter (variadic higher-order wrappers): the checker keeps both + // conditionals symbolic inside the body, and their `infer` positions + // match a call signature, which matchInferReference cannot decide. + const viaSignature = mapBoundSignatureAlias(type, ctx); + if (viaSignature !== null) { + contextResolutions++; + return viaSignature; + } // A conditional type over a bound parameter (`Infer`, a // mapped type's member): evaluate it against the bindings. if (type.isConditionalType()) { @@ -4324,6 +4333,16 @@ function mapCallSignature(sig: ts.Signature, ctx: TypeMapperCtx): IrType | null // declaration as a typed packed-array closure would bypass its boxed // spread/error semantics and change coverage classification. if (rest && decl !== undefined && isJsSourceFile(decl.getSourceFile())) return null; + // A fixed-tuple rest (`...args: [A, B]`, `...args: Parameters` over + // a bound F) flattens to its positional parameters. Pattern rests keep + // their packed-record ABI and stay unmapped as values. + if (rest && decl !== undefined && ts.isParameter(decl) && ts.isIdentifier(decl.name)) { + const elems = restTupleParamTypes(checker.getTypeOfSymbol(p), ctx); + if (elems) { + params.push(...elems); + continue; + } + } const optional = decl !== undefined && ts.isParameter(decl) && @@ -4814,6 +4833,167 @@ function mapBoundIndexedAccess(type: ts.Type, ctx: TypeMapperCtx): IrType | null return mapType(checker.getTypeOfSymbol(prop), ctx); } +/** The standard library's `Parameters` / `ReturnType` over a type + * parameter the current instantiation binds to a callable checker type: + * that type's one call signature. Concrete arguments never reach here (the + * checker evaluates those conditionals itself); generic bodies keep them + * symbolic because their `infer` sits in a call signature. */ +function boundSignatureAlias( + type: ts.Type, + ctx: TypeMapperCtx, +): { name: "Parameters" | "ReturnType"; sig: ts.Signature } | null { + const { checker, resolveTypeParamTs } = ctx; + if (!resolveTypeParamTs) return null; + const alias = type.getAliasSymbol(); + if (!alias || (alias.name !== "Parameters" && alias.name !== "ReturnType")) return null; + const aliasArgs = type.getAliasTypeArguments(); + const arg = aliasArgs[0]; + if (aliasArgs.length !== 1 || !arg || !(arg.flags & ts.TypeFlags.TypeParameter)) return null; + const isLibAlias = checker + .declarationsOf(alias) + .some((d) => ts.isTypeAliasDeclaration(d) && ctx.isStdlibFile(d.getSourceFile())); + if (!isLibAlias) return null; + const bound = resolveTypeParamTs(arg); + if (!bound || bound.flags & ts.TypeFlags.TypeParameter) return null; + const sigs = checker.getCallSignatures(bound); + const sig = sigs[0]; + if (sigs.length !== 1 || !sig || sig.getTypeParameters().length > 0) return null; + return { name: alias.name, sig }; +} + +/** One position of a fixed-arity parameter tuple. An optional position + * still has a native slot (the function type's `T | undefined` slot). */ +interface TupleElement { + type: ts.Type; + optional: boolean; +} + +/** The positional parameters of a signature as a fixed tuple: a trailing + * rest flattened when its own type is a fixed tuple. Null for a variadic + * position, which has no fixed native arity. */ +function signatureParameters(sig: ts.Signature, ctx: TypeMapperCtx): TupleElement[] | null { + const { checker } = ctx; + const out: TupleElement[] = []; + for (const p of sig.getParameters()) { + const decl = checker.valueDeclarationOf(p); + const type = checker.getTypeOfSymbol(p); + if (decl !== undefined && ts.isParameter(decl)) { + if (decl.dotDotDotToken !== undefined) { + const inner = restTupleElements(type, ctx); + if (!inner) return null; + out.push(...inner); + continue; + } + out.push({ + type, + optional: decl.questionToken !== undefined || decl.initializer !== undefined, + }); + continue; + } + out.push({ type, optional: (p.flags & ts.SymbolFlags.Optional) !== 0 }); + } + return out; +} + +/** The required-only positional parameter types of a signature (the + * value-level `Parameters` tuple), null when a position is optional + * or variadic: such a tuple's length varies per call. */ +function signatureParameterTypes(sig: ts.Signature, ctx: TypeMapperCtx): ts.Type[] | null { + const elems = signatureParameters(sig, ctx); + if (!elems || elems.some((e) => e.optional)) return null; + return elems.map((e) => e.type); +} + +/** The element types of a REST parameter's type when it is a fixed tuple: + * a tuple of required elements (`[number, string]`, the empty `[]`), a type + * parameter bound to one, or `Parameters` over a bound function type. + * Such a rest parameter has a compile-time arity, so its native ABI is the + * flattened positional list (exactly the equivalent fixed signature — + * TypeScript treats `(...a: [A, B]) => R` and `(a: A, b: B) => R` alike). */ +export function restTupleElementTypes(type: ts.Type, ctx: TypeMapperCtx): ts.Type[] | null { + return restTupleElements(type, ctx)?.map((e) => e.type) ?? null; +} + +/** restTupleElementTypes with each position's optionality: a rest tuple + * may end in optional positions (`[A, B?]`, `Parameters<(a: A, b?: B) => + * R>`), which flatten to `T | undefined` slots exactly as the function + * type's optional parameters do. */ +function restTupleElements(type: ts.Type, ctx: TypeMapperCtx): TupleElement[] | null { + const { checker } = ctx; + if (type.flags & ts.TypeFlags.TypeParameter) { + const bound = ctx.resolveTypeParamTs?.(type); + return bound && bound !== type ? restTupleElements(bound, ctx) : null; + } + const viaAlias = boundSignatureAlias(type, ctx); + if (viaAlias) { + return viaAlias.name === "Parameters" ? signatureParameters(viaAlias.sig, ctx) : null; + } + if (!checker.isTupleType(type)) return null; + const ref = type as ts.TupleTypeReference; + const tupleShape = + (ref.elementFlags as ts.ElementFlags[] | undefined) !== undefined + ? ref + : (ref.getTarget() as ts.TupleType | undefined); + const args = checker.getTypeArguments(ref); + if (args.length === 0) return []; + const flags = tupleShape?.elementFlags; + if (flags === undefined) return null; + if (flags.some((f) => !(f & (ts.ElementFlags.Required | ts.ElementFlags.Optional)))) return null; + return args.map((t, i) => ({ + type: t, + optional: (flags[i]! & ts.ElementFlags.Optional) !== 0, + })); +} + +/** One tuple element's IR type under the tuple mapping's rules. */ +function mapTupleElement(t: ts.Type, ctx: TypeMapperCtx): IrType | null { + let et = (t.flags & ts.TypeFlags.Any) !== 0 ? DYN : mapType(t, ctx); + if (et?.kind === "void" && isUnitOnlyTsType(t, ctx.resolveTypeParam)) + et = unitOnlyUnion(ctx.unions); + return !et || et.kind === "void" ? null : et; +} + +/** The mapped element types of a fixed-tuple rest parameter type + * (restTupleElementTypes), null when it is not one or an element does not + * map. */ +export function restTupleParamTypes(type: ts.Type, ctx: TypeMapperCtx): IrType[] | null { + const elems = restTupleElements(type, ctx); + if (!elems) return null; + const out: IrType[] = []; + for (const e of elems) { + let et = mapTupleElement(e.type, ctx); + // An optional position's slot holds undefined when the caller omits + // it, as an optional parameter's does (mapCallSignature). + if (et && e.optional && et.kind !== "dyn") et = withUndefinedArm(et, ctx.unions); + if (!et) return null; + out.push(et); + } + return out; +} + +/** `Parameters` (the fixed tuple of F's parameters, interned exactly as + * the spelled tuple maps) and `ReturnType` (F's mapped result) over a + * bound function-typed type parameter. */ +function mapBoundSignatureAlias(type: ts.Type, ctx: TypeMapperCtx): IrType | null { + const target = boundSignatureAlias(type, ctx); + if (!target) return null; + if (target.name === "ReturnType") { + const ret = ctx.checker.getReturnTypeOfSignature(target.sig); + return ret.flags & ts.TypeFlags.Any ? DYN : mapType(ret, ctx); + } + const elems = signatureParameterTypes(target.sig, ctx); + if (!elems) return null; + if (elems.length === 0) return arrayOf(unitOnlyUnion(ctx.unions)); + const fields: { name: string; type: IrType }[] = []; + for (let i = 0; i < elems.length; i++) { + const et = mapTupleElement(elems[i]!, ctx); + if (!et) return null; + fields.push({ name: String(i), type: et }); + } + fields.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + return { kind: "record", shapeId: ctx.shapes.intern(fields, true) }; +} + /** `Partial` / `Readonly` where T is a STILL-GENERIC type parameter — * the one mapped-type spelling a monomorphized body can contain that the * checker cannot resolve for us (`keyof T` is unknown inside the body, so @@ -4885,13 +5065,17 @@ function mapGenericAwaitedAlias(type: ts.Type, ctx: TypeMapperCtx): IrType | nul const aliasArgs = type.getAliasTypeArguments(); const arg = aliasArgs[0]; if (aliasArgs.length !== 1 || !arg) return null; - if (!(arg.flags & ts.TypeFlags.TypeParameter)) return null; + // `Awaited>` (an async wrapper's settled result) resolves + // through F's bound signature like a bare type parameter. + const viaSignature = + arg.flags & ts.TypeFlags.TypeParameter ? null : boundSignatureAlias(arg, ctx); + if (!(arg.flags & ts.TypeFlags.TypeParameter) && viaSignature?.name !== "ReturnType") return null; // Provenance: the STANDARD LIBRARY's alias, not a user's shadowing one. const isLibAlias = ctx.checker .declarationsOf(alias) .some((d) => ts.isTypeAliasDeclaration(d) && ctx.isStdlibFile(d.getSourceFile())); if (!isLibAlias) return null; - const bound = resolveTypeParam(arg); + const bound = viaSignature ? mapBoundSignatureAlias(arg, ctx) : resolveTypeParam(arg); if (!bound) return null; let r = bound; while (r.kind === "promise") r = r.inner; @@ -5083,10 +5267,13 @@ function matchInferReference( const target = (t: ts.Type): ts.Type | undefined => t.isTypeReference?.() ? (t as ts.TypeReference).getTarget?.() : undefined; const wantTarget = target(extendsType) ?? extendsType.getAliasSymbol(); - if (!wantTarget) return undefined; + if (!wantTarget) return matchInferSignature(checked, extendsType, ctx); const gotTarget = target(checked) ?? checked.getAliasSymbol(); - if (!gotTarget) return checked.flags & ts.TypeFlags.Object ? undefined : false; - if (gotTarget !== wantTarget) return undefined; + if (!gotTarget) + return checked.flags & ts.TypeFlags.Object + ? matchInferThenable(checked, extendsType, ctx) + : false; + if (gotTarget !== wantTarget) return matchInferThenable(checked, extendsType, ctx); const wantArgs = target(extendsType) ? checker.getTypeArguments(extendsType as ts.TypeReference) : extendsType.getAliasTypeArguments(); @@ -5109,6 +5296,78 @@ function matchInferReference( return inferred; } +/** The type parameter an `infer X` declares, when `t` is one. */ +function inferSymbolOf(t: ts.Type, ctx: TypeMapperCtx): ts.Symbol | undefined { + if (!(t.flags & ts.TypeFlags.TypeParameter)) return undefined; + const symbol = t.getSymbol(); + return symbol && + ctx.checker + .declarationsOf(symbol) + .some((d) => d.parent !== undefined && ts.isInferTypeNode(d.parent)) + ? symbol + : undefined; +} + +/** `checked extends (...args: any) => infer R` — the shape of the standard + * library's ReturnType, over a concrete function type: R binds to its + * result. Parameters must be `any` (they constrain nothing); anything else + * stays undecided. A primitive never matches a function type. */ +function matchInferSignature( + checked: ts.Type, + extendsType: ts.Type, + ctx: TypeMapperCtx, +): Map | false | undefined { + const { checker } = ctx; + const wantSigs = checker.getCallSignatures(extendsType); + const want = wantSigs[0]; + if (wantSigs.length !== 1 || !want || want.getTypeParameters().length > 0) return undefined; + const anyParams = want.getParameters().every((p) => { + const t = checker.getTypeOfSymbol(p); + return ( + (t.flags & ts.TypeFlags.Any) !== 0 || + (checker.isArrayType(t) && + (checker.getTypeArguments(t as ts.TypeReference)[0]!.flags & ts.TypeFlags.Any) !== 0) + ); + }); + if (!anyParams) return undefined; + if (!(checked.flags & ts.TypeFlags.Object)) return false; + const gotSigs = checker.getCallSignatures(checked); + const got = gotSigs[0]; + if (gotSigs.length !== 1 || !got || got.getTypeParameters().length > 0) return undefined; + const wantRet = checker.getReturnTypeOfSignature(want); + const infer = inferSymbolOf(wantRet, ctx); + if (infer) return new Map([[infer, checker.getReturnTypeOfSignature(got)]]); + return wantRet.flags & ts.TypeFlags.Any ? new Map() : undefined; +} + +/** `checked extends PromiseLike` (or `Promise`) over the + * standard library's Promise: U binds to the settled type. An object with + * no `then` member is no thenable. Everything else stays undecided. */ +function matchInferThenable( + checked: ts.Type, + extendsType: ts.Type, + ctx: TypeMapperCtx, +): Map | false | undefined { + const { checker } = ctx; + const stdlibNamed = (t: ts.Type, names: readonly string[]): boolean => { + const symbol = t.getSymbol(); + return ( + symbol !== undefined && + names.includes(symbol.name) && + checker.declarationsOf(symbol).some((d) => ctx.isStdlibFile(d.getSourceFile())) + ); + }; + if (!stdlibNamed(extendsType, ["PromiseLike", "Promise"])) return undefined; + const wantArgs = checker.getTypeArguments(extendsType as ts.TypeReference); + const infer = wantArgs.length === 1 ? inferSymbolOf(wantArgs[0]!, ctx) : undefined; + if (!infer) return undefined; + if (stdlibNamed(checked, ["Promise", "PromiseLike"])) { + const gotArgs = checker.getTypeArguments(checked as ts.TypeReference); + return gotArgs.length === 1 ? new Map([[infer, gotArgs[0]!]]) : undefined; + } + return checker.getPropertyOfType(checked, "then") === undefined ? false : undefined; +} + /** The type mentions a mapped key or bound alias parameter (a template * shape evalMappedTemplate does not reduce). */ function mentionsMappedParams( @@ -5259,10 +5518,22 @@ export function isUnitOnlyTsType(t: ts.Type, resolveTypeParam?: TypeParamResolve return parts.every( (p) => (p.flags & UNIT) !== 0 || - ((p.flags & ts.TypeFlags.TypeParameter) !== 0 && resolveTypeParam?.(p)?.kind === "void"), + ((p.flags & ts.TypeFlags.TypeParameter) !== 0 && resolveTypeParam?.(p)?.kind === "void") || + boundVoidReturnType(p, resolveTypeParam), ); } +/** `ReturnType` over a type parameter bound to a void-returning + * function (a variadic wrapper's result slot): a unit-only value. */ +function boundVoidReturnType(p: ts.Type, resolveTypeParam?: TypeParamResolver): boolean { + if (!resolveTypeParam || p.getAliasSymbol()?.name !== "ReturnType") return false; + const args = p.getAliasTypeArguments(); + const arg = args[0]; + if (args.length !== 1 || !arg || !(arg.flags & ts.TypeFlags.TypeParameter)) return false; + const bound = resolveTypeParam(arg); + return bound?.kind === "func" && bound.ret.kind === "void"; +} + /** IR-level `t | undefined`, canonicalized and fenced exactly like the * ts-union branch of mapType (typeKey-sorted arms, deduplicated; * Date/dyn/void arms unrepresentable; a func arm IS representable — the diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6380-variadic-wrapper-arguments-apply.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6380-variadic-wrapper-arguments-apply.ts.json new file mode 100644 index 000000000..df0124219 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6380-variadic-wrapper-arguments-apply.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6380-variadic-wrapper-arguments-apply.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6381-variadic-wrapper-async.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6381-variadic-wrapper-async.ts.json new file mode 100644 index 000000000..2138df43d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6381-variadic-wrapper-async.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6381-variadic-wrapper-async.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6382-variadic-wrapper-methods-this.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6382-variadic-wrapper-methods-this.ts.json new file mode 100644 index 000000000..2135f8b6f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6382-variadic-wrapper-methods-this.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6382-variadic-wrapper-methods-this.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6383-variadic-wrapper-rest-tuple.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6383-variadic-wrapper-rest-tuple.ts.json new file mode 100644 index 000000000..a2f7f32c4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6383-variadic-wrapper-rest-tuple.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6383-variadic-wrapper-rest-tuple.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6384-variadic-wrapper-nested.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6384-variadic-wrapper-nested.ts.json new file mode 100644 index 000000000..d69618e3a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6384-variadic-wrapper-nested.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6384-variadic-wrapper-nested.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/variadic-wrapper-fences.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/variadic-wrapper-fences.ts.json new file mode 100644 index 000000000..c3e44b2e5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/variadic-wrapper-fences.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/variadic-wrapper-fences.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6380-variadic-wrapper-arguments-apply.ts b/tests/corpus/6380-variadic-wrapper-arguments-apply.ts new file mode 100644 index 000000000..1a14b6e2f --- /dev/null +++ b/tests/corpus/6380-variadic-wrapper-arguments-apply.ts @@ -0,0 +1,77 @@ +// A generic higher-order wrapper over `(...args: any) => any` that forwards +// `arguments` with `fn.apply(this, arguments)`, and types its options with +// Parameters / ReturnType. Each call site specializes the wrapper to +// the wrapped function's exact signature. +type AnyFn = (...args: any) => any; + +interface TraceOptions { + name?: string; + tags?: (...args: Parameters) => Record; + onResult?: (result: ReturnType) => void; +} + +function trace(fn: F, opts: TraceOptions = {}): F { + return function (this: unknown) { + const args = arguments as unknown as Parameters; + const tags = opts.tags ? opts.tags(...args) : {}; + console.log("enter", opts.name ?? fn.name, JSON.stringify(tags)); + const result: ReturnType = fn.apply(this, arguments as any); + opts.onResult?.(result); + console.log("exit", opts.name ?? fn.name); + return result; + } as F; +} + +function add(a: number, b: number): number { + return a + b; +} +const tracedAdd = trace(add, { + tags: (a, b) => ({ a: String(a), b: String(b) }), + onResult: (r) => console.log("result", r), +}); +console.log(tracedAdd(2, 3)); +console.log(tracedAdd(10, -4)); + +// A wrapped function with an optional parameter: the omitted position +// arrives as undefined, in the wrapper and its tags callback alike. +function titled(name: string, title?: string): string { + return title ? `${title} ${name}` : name; +} +const tracedTitled = trace(titled, { tags: (name, title) => ({ name, title: title ?? "-" }) }); +console.log(tracedTitled("ada")); +console.log(tracedTitled("ada", "dr")); + +const greet = (who: string, punct: string): string => `hi ${who}${punct}`; +const tracedGreet = trace(greet); +console.log(tracedGreet("ada", "!")); + +const tick = trace(function tick(): number { + return 42; +}); +console.log(tick()); + +interface Point { + x: number; + y: number; +} +const norm = trace((p: Point, scale: number): Point => ({ x: p.x * scale, y: p.y * scale }), { + name: "norm", + tags: (p) => ({ x: String(p.x) }), +}); +console.log(JSON.stringify(norm({ x: 1, y: 2 }, 3))); + +// The arguments tuple spread straight into an optional callback. +function audit(fn: F, check?: (...args: Parameters) => boolean): F { + return function (this: unknown) { + console.log("audit", check?.(...(arguments as unknown as Parameters))); + return fn.apply(this, arguments as any); + } as F; +} +const audited = audit(add, (a, b) => a < b); +console.log(audited(1, 2), audited(5, 3)); +console.log(audit(add)(7, 8)); + +const logOnly = trace((msg: string): void => { + console.log("log:", msg); +}); +logOnly("done"); diff --git a/tests/corpus/6381-variadic-wrapper-async.ts b/tests/corpus/6381-variadic-wrapper-async.ts new file mode 100644 index 000000000..200454a5e --- /dev/null +++ b/tests/corpus/6381-variadic-wrapper-async.ts @@ -0,0 +1,81 @@ +// Variadic generic wrappers around async functions: the wrapper forwards +// `arguments`, awaits the wrapped promise and reports its settled value +// through an Awaited> callback; rejections propagate. +type AnyFn = (...args: any) => any; + +interface Opts { + name?: string; + tags?: (...args: Parameters) => Record; + onSettled?: (value: Awaited>) => void; +} + +function traceAsync Promise>(fn: F, opts: Opts = {}): F { + return async function (this: unknown) { + const args = arguments as unknown as Parameters; + const tags = opts.tags ? opts.tags(...args) : {}; + console.log("start", opts.name ?? fn.name, JSON.stringify(tags)); + try { + const value: Awaited> = await fn.apply(this, arguments as any); + opts.onSettled?.(value); + console.log("ok", opts.name ?? fn.name); + return value; + } catch (e) { + console.log("failed", opts.name ?? fn.name, (e as Error).message); + throw e; + } + } as F; +} + +// The settled result spelled as a conditional over ReturnType. +type Settled = ReturnType extends PromiseLike ? U : ReturnType; + +function summarized Promise>( + fn: F, + summarize: (value: Settled) => string, +): F { + return async function (this: unknown) { + const value: Settled = await fn.apply(this, arguments as any); + console.log("summary", summarize(value)); + return value; + } as F; +} + +async function fetchUser(id: number, prefix: string): Promise { + await Promise.resolve(); + if (id < 0) throw new Error(`bad id ${id}`); + return `${prefix}${id}`; +} + +const getUser = traceAsync(fetchUser, { + tags: (id) => ({ id: String(id) }), + onSettled: (v) => console.log("settled", v.toUpperCase()), +}); + +const sleepless = traceAsync(async (): Promise => 7, { name: "seven" }); + +interface Report { + ok: boolean; + items: string[]; +} +const report = summarized( + async (count: number): Promise => ({ ok: count > 0, items: ["a", "b"].slice(0, count) }), + (r) => `${r.ok}:${r.items.join("+")}`, +); +const counted = summarized(async (n: number): Promise => n * 2, (n) => `n=${n + 1}`); + +async function main(): Promise { + console.log(await getUser(1, "u-")); + console.log(await sleepless()); + console.log(JSON.stringify(await report(1))); + console.log(await counted(4)); + try { + await getUser(-2, "u-"); + } catch (e) { + console.log("caught", (e as Error).message); + } + const pending = getUser(3, "a-"); + const second = await getUser(4, "b-"); + console.log(second, await pending); +} + +main().then(() => console.log("main done")); diff --git a/tests/corpus/6382-variadic-wrapper-methods-this.ts b/tests/corpus/6382-variadic-wrapper-methods-this.ts new file mode 100644 index 000000000..54e043207 --- /dev/null +++ b/tests/corpus/6382-variadic-wrapper-methods-this.ts @@ -0,0 +1,46 @@ +// Variadic generic wrappers on methods: the wrapper passes its own `this` +// through `fn.apply(this, arguments)`, so a wrapped `function (this: C)` +// stored as a class property sees the receiver it was called on. +type AnyFn = (...args: any) => any; + +function logged(fn: F, label: string): F { + return function (this: unknown) { + console.log(`[${label}] call with ${arguments.length} args`); + return fn.apply(this, arguments as any); + } as F; +} + +class Counter { + count = 0; + step: number; + constructor(step: number) { + this.step = step; + } + bump = logged(function (this: Counter, times: number): number { + this.count += this.step * times; + return this.count; + }, "bump"); + reset = logged((): void => { + this.count = 0; + }, "reset"); +} + +const c = new Counter(5); +console.log(c.bump(1)); +console.log(c.bump(2)); +c.reset(); +console.log(c.count); + +class Repo { + base: string; + constructor(base: string) { + this.base = base; + } + load = logged(async function (this: Repo, id: string): Promise { + await Promise.resolve(); + return `${this.base}/${id}`; + }, "load"); +} + +const repo = new Repo("https://x.test"); +repo.load("7").then((url) => console.log("loaded", url)); diff --git a/tests/corpus/6383-variadic-wrapper-rest-tuple.ts b/tests/corpus/6383-variadic-wrapper-rest-tuple.ts new file mode 100644 index 000000000..64f1cbacc --- /dev/null +++ b/tests/corpus/6383-variadic-wrapper-rest-tuple.ts @@ -0,0 +1,54 @@ +// The type-safe variadic wrapper: rest tuples instead of `arguments`. +// `...args: A` with A bound to the wrapped function's parameter tuple +// flattens to that fixed signature; `fn.apply(this, args)`, `fn(...args)` +// and `opts.tags(...args)` forward the tuple's positions. +interface Opts { + name?: string; + tags?: (...args: A) => Record; + onResult?: (r: R) => void; +} + +function wrap(fn: (...args: A) => R, opts: Opts = {}): (...args: A) => R { + return function (this: unknown, ...args: A): R { + const tags = opts.tags ? opts.tags(...args) : {}; + console.log("enter", opts.name ?? fn.name, JSON.stringify(tags)); + const r = fn.apply(this, args); + opts.onResult?.(r); + return r; + }; +} + +function viaSpread(fn: (...args: A) => R): (...args: A) => R { + return (...args: A): R => { + console.log("spread", args.length); + return fn(...args); + }; +} + +function mul(a: number, b: number): number { + return a * b; +} +const m = wrap(mul, { tags: (a, b) => ({ a: String(a), b: String(b) }), onResult: (r) => console.log("r", r) }); +console.log(m(6, 7)); + +const concat = wrap((s: string, n: number, flag: boolean) => `${s}:${n}:${flag}`, { name: "concat" }); +console.log(concat("x", 1, true)); + +const opt = wrap((s: string, n?: number) => `${s}:${n ?? "none"}`, { tags: (s, n) => ({ s, n: String(n) }) }); +console.log(opt("a", undefined), opt("b", 2)); + +const zero = wrap(() => "nothing"); +console.log(zero()); + +const spreadMul = viaSpread(mul); +console.log(spreadMul(3, 3)); + +// A concrete tuple rest parameter has the same fixed signature. +function pair(...args: [string, number]): string { + return `${args[0]}=${args[1]}`; +} +console.log(pair("k", 9)); +const asValue: (k: string, v: number) => string = pair; +console.log(asValue("v", 1)); +const t: [string, number] = ["t", 2]; +console.log(pair(...t)); diff --git a/tests/corpus/6384-variadic-wrapper-nested.ts b/tests/corpus/6384-variadic-wrapper-nested.ts new file mode 100644 index 000000000..76f714402 --- /dev/null +++ b/tests/corpus/6384-variadic-wrapper-nested.ts @@ -0,0 +1,37 @@ +// Nested variadic wrappers: wrapping an already wrapped function, mixing +// the `arguments` form and the rest-tuple form. The inner wrapper's +// function binding gives the outer wrapper its `fn.name`. +type AnyFn = (...args: any) => any; + +function trace(fn: F, label?: string): F { + const wrapped = function (this: unknown) { + console.log("trace>", label ?? fn.name); + const out = fn.apply(this, arguments as any); + console.log("trace<", label ?? fn.name); + return out; + }; + return wrapped as unknown as F; +} + +function timed(fn: (...args: A) => R): (...args: A) => R { + return function (this: unknown, ...args: A): R { + console.log("timed>", fn.name, args.length); + return fn.apply(this, args); + }; +} + +function area(w: number, h: number): number { + return w * h; +} + +const twice = trace(trace(area)); +console.log(twice(2, 3)); + +const labelled = trace(trace(area, "inner"), "outer"); +console.log(labelled(4, 5)); + +const mixed = timed(trace(area)); +console.log(mixed(6, 7)); + +const mixed2 = trace(timed(area)); +console.log(mixed2(1, 9)); diff --git a/tests/diagnostics/arrow-bind.ts b/tests/diagnostics/arrow-bind.ts index c78db514b..0b677c5ef 100644 --- a/tests/diagnostics/arrow-bind.ts +++ b/tests/diagnostics/arrow-bind.ts @@ -1,4 +1,5 @@ -// Typed preset and rest signatures still exceed the callable adapter. +// Typed rest signatures still exceed the callable adapter. (A typed preset +// binds to a fixed signature and compiles.) const preset = ((value: number) => value).bind(null, 1); const rest = ((...values: number[]) => values.length).bind(null); export {}; diff --git a/tests/diagnostics/variadic-wrapper-fences.ts b/tests/diagnostics/variadic-wrapper-fences.ts new file mode 100644 index 000000000..d2ad65646 --- /dev/null +++ b/tests/diagnostics/variadic-wrapper-fences.ts @@ -0,0 +1,28 @@ +// Variadic generic wrappers specialize per call site; the forms that have +// no fixed signature or no static name keep precise refusals. +type AnyFn = (...args: any) => any; + +// `arguments` with no fixed signature to lower against. +const loose = function () { + return arguments[0]; +}; + +// `fn.name` where the argument's name is only known at run time. +function named(fn: F): string { + return fn.name; +} +function one(): number { + return 1; +} +function two(): number { + return 2; +} +const pick = (flag: boolean) => (flag ? one : two); +console.log(named(pick(true))); + +// Forwarding a fixed tuple to a variadic function value. +function forward(sink: (...xs: number[]) => number, ...args: A): number { + return sink.apply(null, args); +} +console.log(forward((...xs: number[]) => xs.length, 1, 2)); +export { loose }; diff --git a/tests/harness/__snapshots__/arrow-bind.ts.txt b/tests/harness/__snapshots__/arrow-bind.ts.txt index 4618bdfe2..d63201d59 100644 --- a/tests/harness/__snapshots__/arrow-bind.ts.txt +++ b/tests/harness/__snapshots__/arrow-bind.ts.txt @@ -1,17 +1,8 @@ -arrow-bind.ts:2:16 - error SC1100: passing 'unknown' values where '((null | undefined)[]) => number' is expected is not supported yet +arrow-bind.ts:4:14 - error SC1100: passing 'unknown' values where '(number[]) => number' is expected is not supported yet - 1 | // Typed preset and rest signatures still exceed the callable adapter. - 2 | const preset = ((value: number) => value).bind(null, 1); - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 3 | const rest = ((...values: number[]) => values.length).bind(null); - - hint: validate with 'as ' first — the cast checks the dynamic value at runtime and throws on mismatch - -arrow-bind.ts:3:14 - error SC1100: passing 'unknown' values where '(number[]) => number' is expected is not supported yet - - 2 | const preset = ((value: number) => value).bind(null, 1); - 3 | const rest = ((...values: number[]) => values.length).bind(null); + 3 | const preset = ((value: number) => value).bind(null, 1); + 4 | const rest = ((...values: number[]) => values.length).bind(null); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 4 | export {}; + 5 | export {}; hint: validate with 'as ' first — the cast checks the dynamic value at runtime and throws on mismatch \ No newline at end of file diff --git a/tests/harness/__snapshots__/variadic-wrapper-fences.ts.txt b/tests/harness/__snapshots__/variadic-wrapper-fences.ts.txt new file mode 100644 index 000000000..0cb2774d2 --- /dev/null +++ b/tests/harness/__snapshots__/variadic-wrapper-fences.ts.txt @@ -0,0 +1,29 @@ +variadic-wrapper-fences.ts:6:7 - error SC2009: values of type '() => any' cannot be compiled: the function shape is supported, but its signature is variadic ('arguments'-reading), and a compiled signature is fixed-arity + + 5 | // `arguments` with no fixed signature to lower against. + 6 | const loose = function () { + | ^~~~~ + 7 | return arguments[0]; + + hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') + +variadic-wrapper-fences.ts:7:10 - error SC1090: 'arguments' in a function expression whose value is not asserted to one fixed-arity signature (assert it, as in `function () { … } as F`, and the function lowers against that signature) is not supported yet + + 6 | const loose = function () { + 7 | return arguments[0]; + | ^~~~~~~~~ + 8 | }; + +variadic-wrapper-fences.ts:12:10 - error SC1090: reading 'fn.name' when the call passes a function whose name is only known at run time (generic wrappers fold the name of a function declaration, a const-bound function, or a wrapper call's result) is not supported yet (instantiating 'named' with <() => number>) + + 11 | function named(fn: F): string { + 12 | return fn.name; + | ^~~~~~~ + 13 | } + +variadic-wrapper-fences.ts:25:10 - error SC1090: forwarding a fixed argument tuple through 'apply' to a variadic function value is not supported yet (instantiating 'forward' with <[number, number]>) + + 24 | function forward(sink: (...xs: number[]) => number, ...args: A): number { + 25 | return sink.apply(null, args); + | ^~~~~~~~~~~~~~~~~~~~~~ + 26 | } \ No newline at end of file From 1711c0110e1727323d17241eccf3275ef88c3e6e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 17:05:27 -0500 Subject: [PATCH 02/31] Inspect WebAssembly objects as Node does and declare the wasm entry points in scr_runtime.h util.inspect prints a handle whose Symbol.toStringTag ends with "." and its class name in place of the name, as Node does: WebAssembly.Module {} rather than Module [WebAssembly.Module] {}. The same holds for Instance, Memory, Table and Global. The WebAssembly API entry points move to scr_wasm.h, which scr_runtime.h includes with scr_wasm_abi.h, so every scr_* symbol the backend can emit has a header declaration. The emitted registry array is renamed sc_wasm_registry_modules, following the compiler-emitted symbol prefix. --- .../compiler/src/backend/llvm/wasm-modules.ts | 4 ++-- packages/runtime/src/scr_dyn.h | 3 ++- packages/runtime/src/scr_inspect.c | 22 ++++++++++++++---- packages/runtime/src/scr_runtime.h | 2 ++ packages/runtime/src/scr_wasm.c | 11 --------- packages/runtime/src/scr_wasm.h | 23 +++++++++++++++++++ 6 files changed, 47 insertions(+), 18 deletions(-) create mode 100644 packages/runtime/src/scr_wasm.h diff --git a/packages/compiler/src/backend/llvm/wasm-modules.ts b/packages/compiler/src/backend/llvm/wasm-modules.ts index c37dc36c4..6a79ff5fe 100644 --- a/packages/compiler/src/backend/llvm/wasm-modules.ts +++ b/packages/compiler/src/backend/llvm/wasm-modules.ts @@ -65,8 +65,8 @@ export function emitWasmModules( out.push(`@scr_wasm_registry = constant { i64, ptr } { i64 0, ptr null }`); } else { out.push( - `@scr_wasm_registry_modules = private constant [${descriptors.length} x ptr] [${descriptors.map((d) => `ptr ${d}`).join(", ")}]`, - `@scr_wasm_registry = constant { i64, ptr } { i64 ${descriptors.length}, ptr @scr_wasm_registry_modules }`, + `@sc_wasm_registry_modules = private constant [${descriptors.length} x ptr] [${descriptors.map((d) => `ptr ${d}`).join(", ")}]`, + `@scr_wasm_registry = constant { i64, ptr } { i64 ${descriptors.length}, ptr @sc_wasm_registry_modules }`, ); } out.push(""); diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index b7960ba12..e46fb28d4 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1171,7 +1171,8 @@ typedef struct ScrDynHandleOps { * inspector returns `this`) instead of the bare class name. */ bool inspect_bracketed_past_depth; /* The @@toStringTag when it differs from cls: String() answers - * "[object ]" and util.inspect prints ` [] `. */ + * "[object ]" and util.inspect prints ` [] `, or + * ` ` when the tag ends with "." and cls, as Node does. */ const char *to_string_tag; /* The view has no properties, so util.inspect prints it in full at any * depth, as Node prints `{}`. */ diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index 34438353f..eccd404fc 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -975,6 +975,16 @@ static ScrStr *scr_insp_class_tag(const char *cls, const char *tail) { return ib_take(&out); } +/* Node prints a @@toStringTag that ends with "." and the constructor name + * (WebAssembly.Module for Module) in place of the name, without the + * bracketed tag. */ +static bool insp_tag_names_class(const char *tag, const char *cls) { + if (!tag) return false; + size_t tag_len = strlen(tag), cls_len = strlen(cls); + return tag_len > cls_len + 1 && tag[tag_len - cls_len - 1] == '.' && + memcmp(tag + tag_len - cls_len, cls, cls_len) == 0; +} + /* A native Set or Map held as a checked-dynamic value: `Set(n) { a, b }`, * `Map(n) { k => v }`, at most 100 entries like Node's maxArrayLength. */ /* Node's formatWeakCollection: the entries of a WeakMap or WeakSet are not @@ -1261,11 +1271,15 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { ScrStr *body = scr_insp_dyn(view, recurse, depth); scr_dyn_release(view); InspBuf out = {0}; - ib_cstr(&out, ops->cls); - if (ops->to_string_tag) { - ib_cstr(&out, " ["); + if (insp_tag_names_class(ops->to_string_tag, ops->cls)) { ib_cstr(&out, ops->to_string_tag); - ib_char(&out, ']'); + } else { + ib_cstr(&out, ops->cls); + if (ops->to_string_tag) { + ib_cstr(&out, " ["); + ib_cstr(&out, ops->to_string_tag); + ib_char(&out, ']'); + } } ib_char(&out, ' '); ib_bytes(&out, body->data, body->len); diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index a66d1c554..f52e63088 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -74,5 +74,7 @@ #include "scr_assert.h" #include "scr_console.h" #include "scr_fs_cp.h" +#include "scr_wasm_abi.h" +#include "scr_wasm.h" #endif /* SCR_RUNTIME_H */ diff --git a/packages/runtime/src/scr_wasm.c b/packages/runtime/src/scr_wasm.c index 03d59604f..f9e96ab76 100644 --- a/packages/runtime/src/scr_wasm.c +++ b/packages/runtime/src/scr_wasm.c @@ -76,17 +76,6 @@ #define SCR_WASM_ASAN 1 #endif -ScrPromise *scr_wasm_compile(ScrDyn *bytes); -bool scr_wasm_validate(ScrDyn *bytes); -ScrPromise *scr_wasm_instantiate(ScrDyn *source, ScrDyn *imports); -ScrDyn *scr_wasm_module_new(ScrDyn *bytes); -ScrDyn *scr_wasm_instance_new(ScrDyn *module, ScrDyn *imports); -bool scr_wasm_is(const ScrDyn *value, double kind); -ScrDyn *scr_wasm_error_new(double kind, ScrDyn *message, ScrDyn *options); -ScrDyn *scr_wasm_function(double kind); -ScrDyn *scr_wasm_namespace(void); -ScrDyn *scr_wasm_global_view(void); - /* Entry points of translated code. */ /* Sanitized builds do not declare it noreturn: AddressSanitizer would * call __asan_handle_no_return before every call, which warns on the diff --git a/packages/runtime/src/scr_wasm.h b/packages/runtime/src/scr_wasm.h new file mode 100644 index 000000000..7b0a29689 --- /dev/null +++ b/packages/runtime/src/scr_wasm.h @@ -0,0 +1,23 @@ +/* scriptc runtime — the WebAssembly JavaScript API (scr_wasm.c): the entry + * points the LLVM backend calls for the wasm.* lib functions. Included by + * scr_runtime.h, which says where new declarations go. The contract with + * translated modules, including scr_wasm_registry, is in scr_wasm_abi.h. */ +#ifndef SCR_WASM_H +#define SCR_WASM_H + +#include "scr_async.h" +#include "scr_dyn.h" +#include "scr_wasm_abi.h" + +ScrPromise *scr_wasm_compile(ScrDyn *bytes); +bool scr_wasm_validate(ScrDyn *bytes); +ScrPromise *scr_wasm_instantiate(ScrDyn *source, ScrDyn *imports); +ScrDyn *scr_wasm_module_new(ScrDyn *bytes); +ScrDyn *scr_wasm_instance_new(ScrDyn *module, ScrDyn *imports); +bool scr_wasm_is(const ScrDyn *value, double kind); +ScrDyn *scr_wasm_error_new(double kind, ScrDyn *message, ScrDyn *options); +ScrDyn *scr_wasm_function(double kind); +ScrDyn *scr_wasm_namespace(void); +ScrDyn *scr_wasm_global_view(void); + +#endif /* SCR_WASM_H */ From 46d12eb1e1a1981a9bfcf9faf45a262fcc9a1bc9 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 15:15:02 -0700 Subject: [PATCH 03/31] Probe forwarded tuples without interning types Recognizing the empty-tuple representation interned the unit-only union, and a rest parameter's IR type was mapped before checking that it is a tuple at all. Both shifted union ids in programs with ordinary array rest parameters that read `.length`. Probe through the checker and registry lookups only. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/typed-arguments.ts | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/typed-arguments.ts b/packages/compiler/src/frontend/lowering/typed-arguments.ts index 751d0d6be..90fef0e86 100644 --- a/packages/compiler/src/frontend/lowering/typed-arguments.ts +++ b/packages/compiler/src/frontend/lowering/typed-arguments.ts @@ -1,16 +1,8 @@ import * as ts from "../ts7/adapter.js"; import { bodyReadsArguments } from "../arguments-usage.js"; import { isJsSourceFile, locOf } from "../program.js"; -import { unitOnlyUnion } from "../type-mapper.js"; -import { - DYN, - type IrExpr, - type IrLocal, - type IrStmt, - type IrType, - arrayOf, - typeEquals, -} from "../../ir/ir.js"; +import { restTupleElementTypes, unitOnlyUnion } from "../type-mapper.js"; +import { DYN, type IrExpr, type IrLocal, type IrStmt, type IrType, arrayOf } from "../../ir/ir.js"; import type { Lowerer } from "./lowerer.js"; import { captureContextArguments } from "./function-context.js"; import { isThisParameter, type ParamShape } from "./call-signatures.js"; @@ -325,7 +317,9 @@ export function forwardedTupleType(lowerer: Lowerer, node: ts.Expression): IrTyp ts.isParameter(decl) && decl.dotDotDotToken !== undefined && ts.isIdentifier(decl.name) && - !isJsSourceFile(decl.getSourceFile()) + !isJsSourceFile(decl.getSourceFile()) && + // Checker-only probe first: an ordinary array rest never maps here. + restTupleElementTypes(lowerer.typeOf(operand), lowerer.typeCtx) !== null ) { const t = lowerer.mapTypeOf(lowerer.typeOf(operand)); if (t && isTupleValueType(lowerer, t)) return t; @@ -335,8 +329,17 @@ export function forwardedTupleType(lowerer: Lowerer, node: ts.Expression): IrTyp function isTupleValueType(lowerer: Lowerer, t: IrType): boolean { if (t.kind === "record") return lowerer.shapes.get(t.shapeId)?.tuple === true; - // The empty tuple's representation (mapType's `[]` rule). - return typeEquals(t, arrayOf(unitOnlyUnion(lowerer.unions))); + // The empty tuple's representation (mapType's `[]` rule): an array over + // the unit-only union. Looked up, never interned, so probing a value + // leaves the registries untouched. + if (t.kind !== "array" || t.elem.kind !== "union") return false; + const arms = lowerer.unions.get(t.elem.unionId)?.arms; + return ( + arms !== undefined && + arms.length === 2 && + arms.some((a) => a.kind === "nullT") && + arms.some((a) => a.kind === "undefinedT") + ); } /** The positional reads of a forwarded tuple value (already saved in a From 44bd0a7b1427616f705425e423e529256bebc1db Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 19:24:17 -0500 Subject: [PATCH 04/31] Keep forwarded-only tuple bindings per function The lowerer kept the tuple bindings that are only forwarded in one map for the whole program, keyed by local id. Local ids are unique within one function only: two wrappers that both bind `arguments` (or a `const args = arguments as any` alias) declare the same id, so a wrapper whose tuple exists found the other wrapper's entry, and reading an element of its `arguments` failed with "forwarded-only 'arguments' read as a value". Each function's context now keeps its own map. The corpus program wraps functions with a forwarding-only wrapper and with one that also reads an element of `arguments`, directly and through an alias, in both orders. --- .../src/frontend/lowering/function-context.ts | 7 +++ .../src/frontend/lowering/lower-exprs.ts | 2 +- .../compiler/src/frontend/lowering/lowerer.ts | 8 +-- .../src/frontend/lowering/typed-arguments.ts | 6 +- ...ariadic-wrapper-shared-local-names.ts.json | 6 ++ ...385-variadic-wrapper-shared-local-names.ts | 56 +++++++++++++++++++ ...ariadic-wrapper-shared-local-names.ts.json | 1 + 7 files changed, 76 insertions(+), 10 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6385-variadic-wrapper-shared-local-names.ts.json create mode 100644 tests/corpus/6385-variadic-wrapper-shared-local-names.ts create mode 100644 tests/harness/corpus-costs/6385-variadic-wrapper-shared-local-names.ts.json diff --git a/packages/compiler/src/frontend/lowering/function-context.ts b/packages/compiler/src/frontend/lowering/function-context.ts index e31e194c5..55d8ed0b0 100644 --- a/packages/compiler/src/frontend/lowering/function-context.ts +++ b/packages/compiler/src/frontend/lowering/function-context.ts @@ -48,6 +48,12 @@ export interface FnCtx { argumentsLocal?: IrLocal | null; /** Arrows inherit the nearest ordinary function's arguments binding. */ inheritsArguments?: boolean; + /** Tuple bindings of this function that are only forwarded + * (typed-arguments.ts onlyForwarded): binding local id → the native + * slots forwarding reads. Such a binding is never materialized. Local ids + * are unique within one function only, and nested functions never read + * these bindings (a read there needs the tuple). */ + pureTuples: Map; /** Declared return type — lets `return` detect record-shape mismatches * (SC2002) before the validator would ICE on them. */ returnType: IrType; @@ -81,6 +87,7 @@ export function newFnCtx( captures: lifted ? [] : null, captureSources: [], captureBySymbol: new Map(), + pureTuples: new Map(), selfSymbol, selfType, returnType, diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index bc970957d..80b5bb333 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -908,7 +908,7 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { // type and lowerLambda declared the local. if (expr.text === "arguments") { const binding = captureContextArguments(lowerer.fnStack); - if (binding && lowerer.pureTuples.has(binding.id)) { + if (binding && lowerer.ctx.pureTuples.has(binding.id)) { throw new InternalCompilerError("lowerer bug: forwarded-only 'arguments' read as a value"); } if (binding) return { kind: "varRef", localId: binding.id, type: binding.type, loc }; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index ccc140e9e..26405865c 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1917,10 +1917,6 @@ export class Lowerer { /** Const bindings holding a forwarded fixed tuple (`const args = * arguments as any` in a typed-arguments function) → the tuple type. */ readonly tupleAliasSymbols = new Map(); - /** Tuple bindings that are only forwarded (typed-arguments.ts - * onlyForwarded): binding local id → the native slots forwarding reads. - * Such a binding is never materialized. */ - readonly pureTuples = new Map(); /** The static names of the current generic instance's name-keyed * function parameters (static-function-names.ts). */ staticFnNames: Map | null = null; @@ -10725,7 +10721,7 @@ export class Lowerer { (id) => id !== nameNode && this.checker.getSymbolAtLocation(id) === symbol, ) ) { - this.pureTuples.set(body.id, slots); + this.ctx.pureTuples.set(body.id, slots); return; } const init = this.tupleOfSlots(slots, tupleType, nameNode, loc); @@ -11122,7 +11118,7 @@ export class Lowerer { } if (!symbol) return null; const direct = this.resolveKey(symbol, ident); - if (direct && this.pureTuples.has(direct.id)) { + if (direct && this.ctx.pureTuples.has(direct.id)) { // onlyForwarded proved every read forwards; a plain read here means // the analysis and the lowering disagree. throw new InternalCompilerError( diff --git a/packages/compiler/src/frontend/lowering/typed-arguments.ts b/packages/compiler/src/frontend/lowering/typed-arguments.ts index 90fef0e86..82b7f23c3 100644 --- a/packages/compiler/src/frontend/lowering/typed-arguments.ts +++ b/packages/compiler/src/frontend/lowering/typed-arguments.ts @@ -176,7 +176,7 @@ export function declareTypedArguments( if ( onlyForwarded(lowerer, node, (id) => id.text === "arguments" && argumentsOwner(id) === node) ) { - lowerer.pureTuples.set(local.id, slots); + lowerer.ctx.pureTuples.set(local.id, slots); return; } prologue.push({ @@ -273,7 +273,7 @@ function pureSlotsOf(lowerer: Lowerer, node: ts.Expression): IrLocal[] | null { if (!ts.isIdentifier(operand)) return null; const local = operand.text === "arguments" ? lowerer.ctx.argumentsLocal : lowerer.peekLocal(operand); - return local ? (lowerer.pureTuples.get(local.id) ?? null) : null; + return local ? (lowerer.ctx.pureTuples.get(local.id) ?? null) : null; } /** The positional values `node` forwards when it is a pure-forwarded tuple @@ -466,7 +466,7 @@ export function lowerTupleAliasDecl(lowerer: Lowerer, decl: ts.VariableDeclarati // An alias of a pure-forwarded tuple is itself only forwarded // (onlyForwarded followed it): it names the same slots. const local = lowerer.declareLocal(decl.name, decl.name.text, tupleType, false); - lowerer.pureTuples.set(local.id, slots); + lowerer.ctx.pureTuples.set(local.id, slots); return { kind: "block", body: [], loc: locOf(decl) }; } const init = lowerer.lowerExpr(stripTypeWrappers(decl.initializer)); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6385-variadic-wrapper-shared-local-names.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6385-variadic-wrapper-shared-local-names.ts.json new file mode 100644 index 000000000..5791565f2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6385-variadic-wrapper-shared-local-names.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6385-variadic-wrapper-shared-local-names.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6385-variadic-wrapper-shared-local-names.ts b/tests/corpus/6385-variadic-wrapper-shared-local-names.ts new file mode 100644 index 000000000..2d4272b55 --- /dev/null +++ b/tests/corpus/6385-variadic-wrapper-shared-local-names.ts @@ -0,0 +1,56 @@ +// Variadic wrappers whose bodies declare the same local names: one only +// forwards its `arguments` (or its alias of them), the other also reads an +// element, so its tuple exists. Each wrapper forwards its own arguments. +type AnyFn = (...args: any) => any; + +function forward(fn: F): F { + return function (this: unknown) { + return fn.apply(this, arguments as any); + } as F; +} + +function logFirst(fn: F): F { + return function (this: unknown) { + console.log("first:", arguments[0], "of", arguments.length); + return fn.apply(this, arguments as any); + } as F; +} + +function forwardAlias(fn: F): F { + return function (this: unknown) { + const args = arguments as any; + return fn.apply(this, args); + } as F; +} + +function logFirstAlias(fn: F): F { + return function (this: unknown) { + const args = arguments as any; + console.log("alias first:", args[0]); + return fn.apply(this, args); + } as F; +} + +function add3(a: number, b: number, c: number): number { + return a + b + c; +} + +function greet(name: string): string { + return "hi " + name; +} + +const sum = forward(add3); +console.log(sum(1, 2, 3)); +const hello = logFirst(greet); +console.log(hello("ada")); + +const sumAlias = forwardAlias(add3); +console.log(sumAlias(4, 5, 6)); +const helloAlias = logFirstAlias(greet); +console.log(helloAlias("grace")); + +// The other order: the wrapper that reads an element first. +const hello2 = logFirst(greet); +console.log(hello2("linus")); +const sum2 = forward(add3); +console.log(sum2(7, 8, 9)); diff --git a/tests/harness/corpus-costs/6385-variadic-wrapper-shared-local-names.ts.json b/tests/harness/corpus-costs/6385-variadic-wrapper-shared-local-names.ts.json new file mode 100644 index 000000000..187f1764f --- /dev/null +++ b/tests/harness/corpus-costs/6385-variadic-wrapper-shared-local-names.ts.json @@ -0,0 +1 @@ +{ "plain": 2200, "san": 3300 } From c17eec0261ae43502a29e76ba57a3cde02299d86 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 19:03:28 -0700 Subject: [PATCH 05/31] Compile event-loop metrics, predicate-narrowed catch bindings and other wrapper-reachable gaps Once generic wrappers compile (#1413), the code behind them is reachable. This closes the general gaps found there: - performance.eventLoopUtilization() and monitorEventLoopDelay()'s IntervalHistogram (enable, disable, reset, percentile and the min, max, mean, stddev, count and exceeds readings), under the fallback declarations and @types/node. A new runtime unit, scr_perf_loop.c, links only for programs that read them (the perfLoop feature): an unref'd sampling interval records into HdrHistogram-shaped buckets, and utilization measures active time as the main thread's CPU time since the loop started. - Catch bindings narrowed by a user type predicate or an assertion read their members through the checked-dynamic value. - `??` with a function default of a different signature takes the optional callback's own function type and adapts the default. - Optional calls on interface-typed receivers dispatch like the plain protocol call. - `any` parameters of function declarations and spelled function types are checked-dynamic slots, and a generic instantiated at `any` takes `unknown` there. - `'key' in err` on Error receivers answers from the declaring subclasses. - Object.entries over a generic record resolves `keyof T extends never ? unknown : T[keyof T]` through the binding. Co-Authored-By: Claude Opus 5.5 --- .../builtin-apis/performance-entries.mdx | 1 + .../src/generated/node-v24-compatibility.json | 40 +-- .../generated/node-v24-backlog.json | 94 +++--- .../generated/node-v24-internal.json | 56 +++- internal/compatibility/static-support.json | 4 + .../ambient/scriptc-node-fallback.d.ts | 29 ++ .../src/backend/executable-features.ts | 1 + packages/compiler/src/backend/library-pack.ts | 1 + packages/compiler/src/backend/llvm/emitter.ts | 17 + .../compiler/src/backend/llvm/lib-shared.ts | 8 + .../src/backend/native-link-info-core.ts | 2 + .../compiler/src/backend/native/contracts.ts | 2 + .../compiler/src/backend/native/executable.ts | 2 + .../src/backend/runtime-pack-targets.test.ts | 1 + .../compiler/src/backend/runtime-pack.test.ts | 1 + packages/compiler/src/backend/warm-cache.ts | 1 + .../src/executable/executable-cache.test.ts | 1 + .../src/executable/executable-cache.ts | 2 + .../frontend/lowering/builtins/performance.ts | 313 +++++++++++++++++- .../src/frontend/lowering/call-signatures.ts | 7 +- .../lowering/class-protocol-dispatch.ts | 8 +- .../frontend/lowering/generic-functions.ts | 14 +- .../src/frontend/lowering/lower-calls.ts | 20 +- .../src/frontend/lowering/lower-exprs.ts | 66 ++++ .../src/frontend/lowering/lower-stmts.ts | 3 +- .../compiler/src/frontend/lowering/lowerer.ts | 3 + .../src/frontend/lowering/surfaces.ts | 21 ++ packages/compiler/src/frontend/type-mapper.ts | 86 ++++- packages/compiler/src/index.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 14 + packages/compiler/src/ir/runtime-features.ts | 6 + packages/compiler/src/native/cli.ts | 1 + packages/compiler/src/native/prepare.ts | 1 + .../compiler/src/native/sanitizer.test.ts | 1 + packages/compiler/surface-manifest.json | 14 + .../6390-catch-binding-predicate.ts.json | 6 + .../6391-nullish-function-default.ts.json | 6 + ...6392-optional-chain-protocol-calls.ts.json | 6 + .../6393-function-types-any-params.ts.json | 6 + .../6394-in-key-error-subclasses.ts.json | 6 + .../6395-generic-object-entries.ts.json | 6 + .../6396-generic-any-instantiation.ts.json | 6 + .../corpus/6397-event-loop-metrics.ts.json | 6 + .../node-types/event-loop-metrics.ts.json | 6 + .../fixtures/node-types/timeout-type.ts.json | 6 + .../runtime-pack-matrix.mjs | 2 + packages/runtime/src/scr_lib.h | 15 + packages/runtime/src/scr_perf_loop.c | 305 +++++++++++++++++ tests/corpus/6390-catch-binding-predicate.ts | 42 +++ tests/corpus/6391-nullish-function-default.ts | 13 + .../6392-optional-chain-protocol-calls.ts | 47 +++ .../corpus/6393-function-types-any-params.ts | 8 + tests/corpus/6394-in-key-error-subclasses.ts | 25 ++ tests/corpus/6395-generic-object-entries.ts | 7 + .../corpus/6396-generic-any-instantiation.ts | 18 + tests/corpus/6397-event-loop-metrics.ts | 49 +++ tests/fixtures/node-types/README.md | 1 + .../fixtures/node-types/event-loop-metrics.ts | 49 +++ tests/fixtures/node-types/timeout-type.ts | 30 ++ tests/harness/project-config.test.ts | 26 ++ .../self-hosting-native-toolchain.test.ts | 1 + .../harness/self-hosting-runtime-pack.test.ts | 1 + 62 files changed, 1457 insertions(+), 84 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6390-catch-binding-predicate.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6391-nullish-function-default.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6392-optional-chain-protocol-calls.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6393-function-types-any-params.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6394-in-key-error-subclasses.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6395-generic-object-entries.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6396-generic-any-instantiation.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6397-event-loop-metrics.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/event-loop-metrics.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/timeout-type.ts.json create mode 100644 packages/runtime/src/scr_perf_loop.c create mode 100644 tests/corpus/6390-catch-binding-predicate.ts create mode 100644 tests/corpus/6391-nullish-function-default.ts create mode 100644 tests/corpus/6392-optional-chain-protocol-calls.ts create mode 100644 tests/corpus/6393-function-types-any-params.ts create mode 100644 tests/corpus/6394-in-key-error-subclasses.ts create mode 100644 tests/corpus/6395-generic-object-entries.ts create mode 100644 tests/corpus/6396-generic-any-instantiation.ts create mode 100644 tests/corpus/6397-event-loop-metrics.ts create mode 100644 tests/fixtures/node-types/event-loop-metrics.ts create mode 100644 tests/fixtures/node-types/timeout-type.ts diff --git a/docs/content/limitations/builtin-apis/performance-entries.mdx b/docs/content/limitations/builtin-apis/performance-entries.mdx index 957781b06..f634bb342 100644 --- a/docs/content/limitations/builtin-apis/performance-entries.mdx +++ b/docs/content/limitations/builtin-apis/performance-entries.mdx @@ -1 +1,2 @@ - **Performance entries:** `performance.mark()`, `measure()`, and the `getEntries` methods return entries with Node's fields, `toJSON()`, inspection, and identity. `Object.keys`, `for...in`, object spread, and `hasOwnProperty` on an entry are unsupported, and an entry stored in an `unknown` slot prints as a plain object. +- **Event-loop metrics:** `performance.eventLoopUtilization()` counts the main thread's CPU time since the event loop started as active time, so blocking system calls count as idle where Node counts them as active. `monitorEventLoopDelay()` supports `enable()`, `disable()`, `reset()`, `percentile()`, and the `min`, `max`, `mean`, `stddev`, `count`, and `exceeds` readings; the BigInt readings, `percentiles`, and non-literal option objects are unsupported. diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index e2e4cdd19..b0f0f2450 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -539,9 +539,9 @@ "apiEntries": 131, "static": { "supported": 0, - "partial": 19, + "partial": 25, "refused": 0, - "not-implemented": 112, + "not-implemented": 106, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -54688,9 +54688,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -54803,9 +54803,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -55286,9 +55286,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -55309,9 +55309,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { @@ -55677,9 +55677,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { @@ -55815,9 +55815,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" } }, { diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 0ad30b783..26a0302c0 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -7,8 +7,8 @@ "tierItemCount": 3456, "tiers": { "static": { - "audit-partial": 1163, - "verify-gap": 2165, + "audit-partial": 1169, + "verify-gap": 2159, "replace-refusal": 41, "classify": 2, "implement": 85 @@ -54259,12 +54259,15 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:perf_hooks", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:perf_hooks.histogram.percentile", + "tests": [ + "tests/corpus/6397-event-loop-metrics.ts", + "tests/fixtures/node-types/event-loop-metrics.ts" + ] } } }, @@ -54384,12 +54387,15 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:perf_hooks", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:perf_hooks.histogram.reset", + "tests": [ + "tests/corpus/6397-event-loop-metrics.ts", + "tests/fixtures/node-types/event-loop-metrics.ts" + ] } } }, @@ -54909,12 +54915,15 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:perf_hooks", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:perf_hooks.histogram.disable", + "tests": [ + "tests/corpus/6397-event-loop-metrics.ts", + "tests/fixtures/node-types/event-loop-metrics.ts" + ] } } }, @@ -54934,12 +54943,15 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:perf_hooks", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:perf_hooks.histogram.enable", + "tests": [ + "tests/corpus/6397-event-loop-metrics.ts", + "tests/fixtures/node-types/event-loop-metrics.ts" + ] } } }, @@ -55334,12 +55346,15 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:perf_hooks", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "surface-manifest:node-builtin.perf_hooks.monitorEventLoopDelay", + "tests": [ + "tests/corpus/6397-event-loop-metrics.ts", + "tests/fixtures/node-types/event-loop-metrics.ts" + ] } } }, @@ -55494,12 +55509,15 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:perf_hooks", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:perf_hooks.performance.eventLoopUtilization", + "tests": [ + "tests/corpus/6397-event-loop-metrics.ts", + "tests/fixtures/node-types/event-loop-metrics.ts" + ] } } }, diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 2f0565f84..9870f7c48 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -566,9 +566,9 @@ "apiEntries": 131, "static": { "supported": 0, - "partial": 19, + "partial": 25, "refused": 0, - "not-implemented": 112, + "not-implemented": 106, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -75524,7 +75524,9 @@ "tests/corpus/5212-perf-marks-measures.ts", "tests/corpus/5213-perf-timing-errors.ts", "tests/corpus/5214-perf-entry-inspect.ts", - "tests/corpus/5215-perf-timing-validation.js" + "tests/corpus/5215-perf-timing-validation.js", + "tests/corpus/6397-event-loop-metrics.ts", + "tests/fixtures/node-types/event-loop-metrics.ts" ] }, "anchorSource": "chapter" @@ -77565,8 +77567,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:perf_hooks" + "status": "partial", + "evidence": "compiler-feature:perf_hooks.histogram.percentile", + "tests": [ + "tests/corpus/6397-event-loop-metrics.ts", + "tests/fixtures/node-types/event-loop-metrics.ts" + ] }, "anchorSource": "exact" }, @@ -77680,8 +77686,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:perf_hooks" + "status": "partial", + "evidence": "compiler-feature:perf_hooks.histogram.reset", + "tests": [ + "tests/corpus/6397-event-loop-metrics.ts", + "tests/fixtures/node-types/event-loop-metrics.ts" + ] }, "anchorSource": "exact" }, @@ -78163,8 +78173,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:perf_hooks" + "status": "partial", + "evidence": "compiler-feature:perf_hooks.histogram.disable", + "tests": [ + "tests/corpus/6397-event-loop-metrics.ts", + "tests/fixtures/node-types/event-loop-metrics.ts" + ] }, "anchorSource": "exact" }, @@ -78186,8 +78200,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:perf_hooks" + "status": "partial", + "evidence": "compiler-feature:perf_hooks.histogram.enable", + "tests": [ + "tests/corpus/6397-event-loop-metrics.ts", + "tests/fixtures/node-types/event-loop-metrics.ts" + ] }, "anchorSource": "exact" }, @@ -78554,8 +78572,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:perf_hooks" + "status": "partial", + "evidence": "surface-manifest:node-builtin.perf_hooks.monitorEventLoopDelay", + "tests": [ + "tests/corpus/6397-event-loop-metrics.ts", + "tests/fixtures/node-types/event-loop-metrics.ts" + ] }, "anchorSource": "exact" }, @@ -78704,8 +78726,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:perf_hooks" + "status": "partial", + "evidence": "compiler-feature:perf_hooks.performance.eventLoopUtilization", + "tests": [ + "tests/corpus/6397-event-loop-metrics.ts", + "tests/fixtures/node-types/event-loop-metrics.ts" + ] }, "anchorSource": "exact" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 5807c1ae9..f1130f9b1 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -132,6 +132,8 @@ "node-builtin.perf_hooks.performance.getEntries": ["tests/corpus/5212-perf-marks-measures.ts", "tests/corpus/5214-perf-entry-inspect.ts", "tests/corpus/5215-perf-timing-validation.js"], "node-builtin.perf_hooks.performance.clearMarks": ["tests/corpus/5212-perf-marks-measures.ts", "tests/corpus/5213-perf-timing-errors.ts", "tests/corpus/5215-perf-timing-validation.js"], "node-builtin.perf_hooks.performance.clearMeasures": ["tests/corpus/5212-perf-marks-measures.ts"], + "node-builtin.perf_hooks.performance.eventLoopUtilization": ["tests/corpus/6397-event-loop-metrics.ts", "tests/fixtures/node-types/event-loop-metrics.ts"], + "node-builtin.perf_hooks.monitorEventLoopDelay": ["tests/corpus/6397-event-loop-metrics.ts", "tests/fixtures/node-types/event-loop-metrics.ts"], "node-builtin.process": ["tests/corpus/990-process-basics.ts", "tests/corpus/2314-process-introspection.ts"], "node-builtin.process.hrtime": ["tests/corpus/process-hrtime.js"], "node-builtin.process.memoryUsage": ["tests/corpus/5210-process-memory-usage.ts"], @@ -232,6 +234,8 @@ { "chapter": "util", "symbols": ["util.parseEnv"], "status": "partial", "evidence": ["tests/corpus/node24-env-parse.ts", "tests/corpus/node24-env-parse-errors.ts", "tests/corpus/node24-env-imports.cjs", "tests/fixtures/node-types/env-files.ts", "tests/harness/project-config.test.ts"] }, { "chapter": "process", "symbols": ["process.memoryUsage.rss"], "status": "partial", "evidence": ["tests/corpus/5210-process-memory-usage.ts"] }, { "chapter": "perf_hooks", "symbols": ["performance.now"], "status": "partial", "evidence": ["tests/corpus/2427-perf-hooks-now.ts", "tests/corpus/2446-performance-global.ts"] }, + { "chapter": "perf_hooks", "symbols": ["performance.eventLoopUtilization"], "status": "partial", "evidence": ["tests/corpus/6397-event-loop-metrics.ts", "tests/fixtures/node-types/event-loop-metrics.ts"] }, + { "chapter": "perf_hooks", "symbols": ["histogram.enable", "histogram.disable", "histogram.percentile", "histogram.reset"], "status": "partial", "evidence": ["tests/corpus/6397-event-loop-metrics.ts", "tests/fixtures/node-types/event-loop-metrics.ts"] }, { "chapter": "perf_hooks", "symbols": ["performance.mark", "performance.measure", "performance.getEntries", "performance.getEntriesByName", "performance.getEntriesByType", "performance.clearMarks", "performance.clearMeasures"], "status": "partial", "evidence": ["tests/corpus/5212-perf-marks-measures.ts", "tests/corpus/5213-perf-timing-errors.ts", "tests/corpus/5214-perf-entry-inspect.ts", "tests/corpus/5215-perf-timing-validation.js"] }, { "chapter": "perf_hooks", "symbols": ["PerformanceEntry", "PerformanceMark", "PerformanceMeasure"], "status": "partial", "evidence": ["tests/corpus/5212-perf-marks-measures.ts", "tests/corpus/5214-perf-entry-inspect.ts"] }, { "chapter": "perf_hooks", "parent": "Class: `PerformanceEntry`", "symbols": ["duration", "entryType", "name", "startTime"], "status": "partial", "evidence": ["tests/corpus/5212-perf-marks-measures.ts", "tests/corpus/5214-perf-entry-inspect.ts"] }, diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 0a0837389..dc9f2be2a 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1166,8 +1166,37 @@ declare module "node:perf_hooks" { getEntriesByType(type: EntryType): PerformanceEntry[]; clearMarks(name?: string): void; clearMeasures(name?: string): void; + eventLoopUtilization( + utilization1?: EventLoopUtilization, + utilization2?: EventLoopUtilization, + ): EventLoopUtilization; } export const performance: Performance; + /* Event-loop metrics: utilization (idle/active ms since the loop started) + * and monitorEventLoopDelay's sampling histogram (nanoseconds). */ + export interface EventLoopUtilization { + idle: number; + active: number; + utilization: number; + } + export interface EventLoopMonitorOptions { + resolution?: number | undefined; + } + export interface Histogram { + readonly count: number; + readonly exceeds: number; + readonly max: number; + readonly mean: number; + readonly min: number; + readonly stddev: number; + percentile(percentile: number): number; + reset(): void; + } + export interface IntervalHistogram extends Histogram { + enable(): boolean; + disable(): boolean; + } + export function monitorEventLoopDelay(options?: EventLoopMonitorOptions): IntervalHistogram; } declare module "perf_hooks" { export * from "node:perf_hooks"; diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index a565965f1..141ee4962 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -40,5 +40,6 @@ export function executableLinkFeatures(mod: IrModule): NativeLinkFeatures { tls: features.tls, tlsCa: features.tlsCa, cipher: features.cipher, + perfLoop: features.perfLoop, }; } diff --git a/packages/compiler/src/backend/library-pack.ts b/packages/compiler/src/backend/library-pack.ts index 833ef93e7..e260430e3 100644 --- a/packages/compiler/src/backend/library-pack.ts +++ b/packages/compiler/src/backend/library-pack.ts @@ -52,6 +52,7 @@ export async function compilePackedLibrary( nodeTest: false, tls: false, cipher: false, + perfLoop: false, tlsCa: false, }; const selection = await loadRuntimePack({ diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 9e2d3524e..3981c5da0 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -2285,6 +2285,7 @@ export class LlEmitter { if (runsLoop) { this.declare(`declare zeroext i1 @scr_loop_run(ptr)`); if (this.mod.commonJsEntry === true) this.declare(`declare void @scr_loop_commonjs_entry()`); + if (this.readsLoopMetrics()) this.declare(`declare void @scr_perf_loop_mark()`); this.declare(`declare zeroext i1 @scr_report_unhandled_rejections()`); this.declare(`declare void @scr_discard_unhandled_rejections()`); } @@ -2843,6 +2844,8 @@ export class LlEmitter { ...(runsLoop ? [ ...(this.mod.commonJsEntry === true ? [` call void @scr_loop_commonjs_entry()`] : []), + // performance.eventLoopUtilization measures from here on. + ...(this.readsLoopMetrics() ? [` call void @scr_perf_loop_mark()`] : []), ` %loop_rejection = call zeroext i1 @scr_loop_run(ptr ${asyncEntry ? "%top" : "null"})`, ` %lexc = call zeroext i1 @scr_exc_pending()`, ` br i1 %lexc, label %luncaught, label %lok`, @@ -7858,6 +7861,20 @@ export class LlEmitter { /** Moves need plain slots that nothing else reads behind the binding's * back: no debugger view, no coroutine frame, no borrowed (+0) return. */ + /** Whether the program reads event-loop utilization (perf.loopStart / + * perf.idleTime): main then marks the loop's start before running it. */ + private loopMetricsRead: boolean | undefined; + private readsLoopMetrics(): boolean { + if (this.loopMetricsRead !== undefined) return this.loopMetricsRead; + const visitor = { + expr: (e: IrExpr): boolean => + !(e.kind === "libCall" && (e.fn === "perf.loopStart" || e.fn === "perf.idleTime")), + stmt: (): boolean => true, + }; + this.loopMetricsRead = !this.mod.functions.every((fn) => everyStmtList(fn.body, visitor)); + return this.loopMetricsRead; + } + private lastUseMovesAllowed(): boolean { return ( this.debug === null && diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index be67e1b67..8ab89bd22 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -273,6 +273,14 @@ export const LIB_FN_SYMS: Record = { "perf.entryDetail": "scr_perf_entry_detail", "perf.entryKeep": "scr_perf_entry_keep", "perf.entryRef": "scr_perf_entry_ref", + "perf.loopStart": "scr_perf_loop_start", + "perf.idleTime": "scr_perf_idle_time", + "perf.eldNew": "scr_eld_new", + "perf.eldEnable": "scr_eld_enable", + "perf.eldDisable": "scr_eld_disable", + "perf.eldReset": "scr_eld_reset", + "perf.eldPercentile": "scr_eld_percentile", + "perf.eldStat": "scr_eld_stat", "process.memoryUsageTake": "scr_process_memory_usage_take", "process.memoryUsageField": "scr_process_memory_usage_field", "process.rss": "scr_process_rss", diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 3beb84a20..048deecb1 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -50,6 +50,8 @@ export interface NativeLinkFeatures { /** crypto.createCipheriv/createDecipheriv: scr_cipher.c plus the mbedTLS * archive. */ cipher: boolean; + /** perf_hooks event-loop metrics: scr_perf_loop.c. */ + perfLoop: boolean; tlsCa: boolean; } diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index 875a723bc..bc9df5618 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -199,6 +199,8 @@ export interface CcOptions { /** crypto.createCipheriv/createDecipheriv: compiles scr_cipher.c and links * the mbedTLS archive (shared with tls). */ cipher?: boolean; + /** perf_hooks event-loop metrics: compiles scr_perf_loop.c. */ + perfLoop?: boolean; /** Internal compiler hook: called only after a strict native artifact hit * or a successful stable build has installed `outPath`. The executable * frontend cache uses it to publish its stamp after native dependencies diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 6c8673fe4..0b6ab945c 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -510,6 +510,8 @@ export async function compileCInternal( // The performance timeline (every perf_hooks user-timing call selects // dynAsync, whose unit carries the timeline's buffer warning). ...(opts.dynAsync ? [rt(join(rtDir, "scr_perf.c"))] : []), + // perf_hooks event-loop metrics, only for programs that read them. + ...(opts.perfLoop ? [rt(join(rtDir, "scr_perf_loop.c"))] : []), // Blob and File (every blob.* call selects dynAsync, whose unit boxes // their reads as dyn promises). ...(opts.dynAsync ? [rt(join(rtDir, "scr_blob.c"))] : []), diff --git a/packages/compiler/src/backend/runtime-pack-targets.test.ts b/packages/compiler/src/backend/runtime-pack-targets.test.ts index faa335e33..1d500957a 100644 --- a/packages/compiler/src/backend/runtime-pack-targets.test.ts +++ b/packages/compiler/src/backend/runtime-pack-targets.test.ts @@ -119,6 +119,7 @@ const features = { tls: false, tlsCa: false, cipher: false, + perfLoop: false, }; test.each([ diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts index ffe581366..3521b47bd 100644 --- a/packages/compiler/src/backend/runtime-pack.test.ts +++ b/packages/compiler/src/backend/runtime-pack.test.ts @@ -56,6 +56,7 @@ const BASE: NativeLinkFeatures = { tls: false, tlsCa: false, cipher: false, + perfLoop: false, }; async function fixture(target: NativeTargetSpec = MACOS_ARM64_TARGET) { diff --git a/packages/compiler/src/backend/warm-cache.ts b/packages/compiler/src/backend/warm-cache.ts index 67e627c91..232701e6b 100644 --- a/packages/compiler/src/backend/warm-cache.ts +++ b/packages/compiler/src/backend/warm-cache.ts @@ -61,6 +61,7 @@ export async function warmNativeCaches( nodeTest: false, tls: false, cipher: false, + perfLoop: false, tlsCa: false, }, }); diff --git a/packages/compiler/src/executable/executable-cache.test.ts b/packages/compiler/src/executable/executable-cache.test.ts index e5887e855..b6bd90b06 100644 --- a/packages/compiler/src/executable/executable-cache.test.ts +++ b/packages/compiler/src/executable/executable-cache.test.ts @@ -57,6 +57,7 @@ const native: EarlyExecutableNativeFeatures = { tls: false, tlsCa: false, cipher: false, + perfLoop: false, }; async function fixture(): Promise<{ diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index ee9d84fcf..c020a1e1c 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -79,6 +79,7 @@ export interface EarlyExecutableNativeFeatures { nodeTest: boolean; tls: boolean; cipher: boolean; + perfLoop: boolean; tlsCa: boolean; } @@ -193,6 +194,7 @@ const BOOLEAN_NATIVE_KEYS = [ "tls", "tlsCa", "cipher", + "perfLoop", ] as const satisfies readonly (keyof EarlyExecutableNativeFeatures)[]; function validNativeFeatures(value: unknown): value is EarlyExecutableNativeFeatures { diff --git a/packages/compiler/src/frontend/lowering/builtins/performance.ts b/packages/compiler/src/frontend/lowering/builtins/performance.ts index 2a9962b0f..19b585e37 100644 --- a/packages/compiler/src/frontend/lowering/builtins/performance.ts +++ b/packages/compiler/src/frontend/lowering/builtins/performance.ts @@ -134,6 +134,12 @@ export function lowerPerfHooksCall( loc, }; } + if ( + access.name.text === "eventLoopUtilization" && + isPerfHooksPerformanceExpr(lowerer, access.expression) + ) { + return lowerEventLoopUtilization(lowerer, expr); + } const timeline = PERF_TIMELINE_CALLS.get(access.name.text); if (timeline && isPerfHooksPerformanceExpr(lowerer, access.expression)) { return lowerPerfTimelineCall(lowerer, expr, access.name.text, timeline, loc); @@ -142,7 +148,7 @@ export function lowerPerfHooksCall( lowerer.noLowering( `perf_hooks performance.${access.name.text}`, expr, - "performance.now() (and its .bind(performance) function value), mark, measure, the getEntries family, clearMarks and clearMeasures are the lowered surface", + "performance.now() (and its .bind(performance) function value), mark, measure, the getEntries family, clearMarks, clearMeasures and eventLoopUtilization are the lowered surface", ); } return null; @@ -513,3 +519,308 @@ export function fencePerfEntryKeyWalk( `${what} over performance entries (Node keeps their fields as prototype getters, so own-key walks see none; read the fields or call toJSON())`, ); } + +/* ── event-loop metrics: eventLoopUtilization and monitorEventLoopDelay ── */ + +/** The stdlib IntervalHistogram (monitorEventLoopDelay's result), by the + * receiver's checker type. Its value is the runtime histogram handle. */ +function isIntervalHistogram(lowerer: Lowerer, node: ts.Expression): boolean { + const symbol = lowerer.typeOf(node).getSymbol(); + return symbol?.name === "IntervalHistogram" && lowerer.isStdlibSymbol(symbol); +} + +/** IntervalHistogram's numeric members, as perf.eldStat selectors. */ +const HISTOGRAM_STATS: ReadonlyMap = new Map([ + ["min", 0], + ["max", 1], + ["mean", 2], + ["stddev", 3], + ["count", 4], + ["exceeds", 5], +]); + +/** `h.min`, `h.max`, … on an IntervalHistogram: the runtime's current + * statistic (Node's hdr_histogram values; nanoseconds). Null for every + * other read. */ +export function lowerHistogramProperty( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, +): IrExpr | null { + const which = HISTOGRAM_STATS.get(expr.name.text); + if ( + which === undefined || + expr.questionDotToken || + !isIntervalHistogram(lowerer, expr.expression) + ) + return null; + const loc = locOf(expr); + return { + kind: "libCall", + fn: "perf.eldStat", + args: [lowerer.lowerExprExpecting(expr.expression, F64), numLit(which, loc)], + type: F64, + loc, + }; +} + +/** enable(), disable(), reset() and percentile(p) on an IntervalHistogram. + * Other members (the BigInt twins, the percentiles Map) fence by name. */ +export function lowerHistogramMethodCall( + lowerer: Lowerer, + expr: ts.CallExpression, + access: ts.PropertyAccessExpression, +): IrExpr | null { + if (access.questionDotToken || !isIntervalHistogram(lowerer, access.expression)) return null; + const loc = locOf(expr); + const name = access.name.text; + const handle = (): IrExpr => lowerer.lowerExprExpecting(access.expression, F64); + const args = expr.arguments.nodes; + if ((name === "enable" || name === "disable" || name === "reset") && args.length === 0) { + const fn = + name === "enable" + ? "perf.eldEnable" + : name === "disable" + ? "perf.eldDisable" + : "perf.eldReset"; + return { kind: "libCall", fn, args: [handle()], type: name === "reset" ? VOID : BOOL, loc }; + } + if (name === "percentile" && args.length === 1 && !ts.isSpreadElement(args[0]!)) { + // Node validates first: NaN, p <= 0 and p > 100 throw ERR_OUT_OF_RANGE. + const h = lowerer.declareHiddenLocal("%histogram", F64); + const p = lowerer.declareHiddenLocal("%percentile", F64); + const pRef = (): IrExpr => varRef(p.id, F64, loc); + const cmp = (op: "!==" | "<=" | ">", left: IrExpr, right: IrExpr): IrExpr => ({ + kind: "bin", + op, + left, + right, + type: BOOL, + loc, + }); + const or = (left: IrExpr, right: IrExpr): IrExpr => ({ + kind: "logical", + op: "||", + left, + right, + type: BOOL, + loc, + }); + const outOfRange = or( + or(cmp("!==", pRef(), pRef()), cmp("<=", pRef(), numLit(0, loc))), + cmp(">", pRef(), numLit(100, loc)), + ); + const message: IrExpr = { + kind: "strConcat", + left: { + kind: "strLit", + value: 'The value of "percentile" is out of range. It must be > 0 && <= 100. Received ', + type: STRING, + loc, + }, + right: { kind: "toString", operand: pRef(), type: STRING, loc }, + type: STRING, + loc, + }; + const thrown: IrExpr = { + kind: "libCall", + fn: "error.nodeThrow", + args: [ + numLit(2, loc), + { kind: "strLit", value: "ERR_OUT_OF_RANGE", type: STRING, loc }, + message, + ], + type: F64, + loc, + }; + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: h.id, init: handle(), loc }, + { kind: "varDecl", localId: p.id, init: lowerer.lowerExprExpecting(args[0]!, F64), loc }, + ], + result: { + kind: "ternary", + cond: outOfRange, + then: thrown, + else_: { + kind: "libCall", + fn: "perf.eldPercentile", + args: [varRef(h.id, F64, loc), pRef()], + type: F64, + loc, + }, + type: F64, + loc, + }, + type: F64, + loc, + }; + } + lowerer.noLowering( + `IntervalHistogram.${name}`, + expr, + "enable(), disable(), reset(), percentile(p) and the min, max, mean, stddev, count and exceeds readings are the lowered surface", + ); +} + +/** `monitorEventLoopDelay(options?)` from node:perf_hooks (a named import + * or a namespace member): a new sampling histogram handle. The resolution + * must be a literal-free number expression or an object literal option. */ +export function lowerMonitorEventLoopDelayCall( + lowerer: Lowerer, + expr: ts.CallExpression, +): IrExpr | null { + const callee = expr.expression; + const bi = ts.isIdentifier(callee) + ? builtinImportOf(lowerer, callee) + : ts.isPropertyAccessExpression(callee) && !callee.questionDotToken + ? lowerer.builtinMemberOf(callee) + : null; + if (bi === null || bi.module !== "perf_hooks" || bi.member !== "monitorEventLoopDelay") + return null; + const loc = locOf(expr); + const args = expr.arguments.nodes; + let resolution: IrExpr = numLit(10, loc); + if (args.length > 1 || args.some(ts.isSpreadElement)) { + lowerer.noLowering( + "this monitorEventLoopDelay call form", + expr, + "pass at most one options object", + ); + } + if (args.length === 1) { + const options = args[0]!; + if (!ts.isObjectLiteralExpression(options)) { + lowerer.noLowering( + "monitorEventLoopDelay with a non-literal options object", + options, + "spell the options inline: monitorEventLoopDelay({ resolution: 20 })", + ); + } + for (const property of options.properties.nodes) { + if ( + !ts.isPropertyAssignment(property) || + !ts.isIdentifier(property.name) || + property.name.text !== "resolution" + ) { + lowerer.noLowering( + "monitorEventLoopDelay options other than resolution", + property, + "resolution is the one option", + ); + } + resolution = lowerer.lowerExprExpecting(property.initializer, F64); + } + } + return { kind: "libCall", fn: "perf.eldNew", args: [resolution], type: F64, loc }; +} + +/** performance.eventLoopUtilization(u1?, u2?) — Node's + * lib/internal/perf/event_loop_utilization.js over the runtime's loop + * start and idle time, one lifted helper per arity. */ +function lowerEventLoopUtilization(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { + const loc = locOf(expr); + const args = expr.arguments.nodes; + const resultT = lowerer.mapTypeOf(lowerer.typeOf(expr)); + if (resultT?.kind !== "record" || args.length > 2 || args.some(ts.isSpreadElement)) { + lowerer.noLowering("this performance.eventLoopUtilization call form", expr); + } + const helper = eluHelperOf(lowerer, args.length, resultT); + return { + kind: "call", + callee: helper, + args: args.map((arg) => lowerer.lowerExprExpecting(arg, resultT)), + type: resultT, + loc, + }; +} + +function eluHelperOf(lowerer: Lowerer, arity: number, recT: IrType & { kind: "record" }): string { + const name = `%perf.elu.${arity}`; + if (lowerer.liftedFns.some((f) => f.name === name)) return name; + const loc: SrcLoc = { file: "", start: 0, end: 0 }; + const f64Local = (id: string) => ({ id, name: id, type: F64, mutable: false }); + const params = Array.from({ length: arity }, (_, i) => ({ + localId: `u${i + 1}.0`, + name: `u${i + 1}`, + type: recT as IrType, + })); + const locals = [ + ...params.map((p) => ({ id: p.localId, name: p.name, type: recT as IrType, mutable: false })), + f64Local("ls.0"), + f64Local("idle.0"), + f64Local("active.0"), + ]; + const ref = (id: string): IrExpr => varRef(id, F64, loc); + const field = (param: string, f: string): IrExpr => ({ + kind: "recordGet", + obj: varRef(param, recT, loc), + shapeId: recT.shapeId, + field: f, + type: F64, + loc, + }); + const bin = (op: "+" | "-" | "/" | "<=", left: IrExpr, right: IrExpr): IrExpr => ({ + kind: "bin", + op, + left, + right, + type: op === "<=" ? BOOL : F64, + loc, + }); + const record = (idle: IrExpr, active: IrExpr, utilization: IrExpr): IrExpr => ({ + kind: "recordLit", + fields: [ + { name: "idle", value: idle }, + { name: "active", value: active }, + { name: "utilization", value: utilization }, + ], + type: recT, + loc, + }); + const result = (idle: IrExpr, active: IrExpr): IrStmt => ({ + kind: "return", + value: record(idle, active, bin("/", active, bin("+", idle, active))), + loc, + }); + const decl = (id: string, init: IrExpr): IrStmt => ({ kind: "varDecl", localId: id, init, loc }); + const body: IrStmt[] = [ + decl("ls.0", { kind: "libCall", fn: "perf.loopStart", args: [], type: F64, loc }), + { + kind: "if", + cond: bin("<=", ref("ls.0"), numLit(0, loc)), + then: [ + { kind: "return", value: record(numLit(0, loc), numLit(0, loc), numLit(0, loc)), loc }, + ], + else_: null, + loc, + }, + ]; + if (arity === 2) { + body.push( + decl("idle.0", bin("-", field("u1.0", "idle"), field("u2.0", "idle"))), + decl("active.0", bin("-", field("u1.0", "active"), field("u2.0", "active"))), + result(ref("idle.0"), ref("active.0")), + ); + } else { + body.push( + decl("idle.0", { kind: "libCall", fn: "perf.idleTime", args: [], type: F64, loc }), + decl( + "active.0", + bin( + "-", + bin("-", { kind: "libCall", fn: "perf.now", args: [], type: F64, loc }, ref("ls.0")), + ref("idle.0"), + ), + ), + arity === 0 + ? result(ref("idle.0"), ref("active.0")) + : result( + bin("-", ref("idle.0"), field("u1.0", "idle")), + bin("-", ref("active.0"), field("u1.0", "active")), + ), + ); + } + lowerer.liftedFns.push({ name, params, returnType: recT, locals, body, loc }); + return name; +} diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts index ef2900a7c..533ff79b0 100644 --- a/packages/compiler/src/frontend/lowering/call-signatures.ts +++ b/packages/compiler/src/frontend/lowering/call-signatures.ts @@ -443,7 +443,12 @@ export function paramShapes( declParam, lowerer.runtimeOptionalBindingType( declParam.name, - lowerer.mapTypeOf(tsType) ?? lowerer.irTypeOf(declParam.name), + lowerer.mapTypeOf(tsType) ?? + // A TypeScript instantiation at `any` (a type argument inferred + // from an untyped value) takes the checked-dynamic slot. + ((tsType.flags & ts.TypeFlags.Any) !== 0 && !isJsSourceFile(declParam.getSourceFile()) + ? DYN + : lowerer.irTypeOf(declParam.name)), ), lowerer.shapes, lowerer.unions, diff --git a/packages/compiler/src/frontend/lowering/class-protocol-dispatch.ts b/packages/compiler/src/frontend/lowering/class-protocol-dispatch.ts index c8e56f3cc..1b881ed43 100644 --- a/packages/compiler/src/frontend/lowering/class-protocol-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-protocol-dispatch.ts @@ -188,7 +188,8 @@ function protocolSignature( call: ts.CallExpression, access: ts.PropertyAccessExpression, ): ProtocolSignature | null { - const recvTs = lowerer.typeOf(access.expression); + // A guarded optional step's receiver still carries its nullish arms. + const recvTs = lowerer.checker.getNonNullableType(lowerer.typeOf(access.expression)); const property = lowerer.checker.getPropertyOfType(recvTs, access.name.text); if (!property) return null; const declarations = lowerer.checker.declarationsOf(property); @@ -240,10 +241,11 @@ export function lowerProtocolMethodCall( access: ts.PropertyAccessExpression, recv: IrExpr, ): IrExpr | null { + // An optional chain that already guarded this step (`s?.m(x)`, the chain + // re-dispatching with the narrowed receiver) calls like a plain one. if ( recv.type.kind !== "dyn" || - call.questionDotToken || - access.questionDotToken || + lowerer.chainBlocked(call, access) || call.arguments.nodes.some(ts.isSpreadElement) ) return null; diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index e1fa5994d..ac611a7df 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -302,7 +302,13 @@ export function genericCallInstance( const retTs = lowerer.checker.getReturnTypeOfSignature(rsig); const local = localClassResultInstance(lowerer, expr, info, rsig, params, retTs); if (local) return local; - const mappedReturn = lowerer.mapTypeOf(retTs); + // A result instantiated at `any` (a type argument inferred from an + // untyped value, such as an untyped forwarding call) is checked-dynamic. + const mappedReturn = + lowerer.mapTypeOf(retTs) ?? + ((retTs.flags & ts.TypeFlags.Any) !== 0 && !isJsSourceFile(info.decl.getSourceFile()) + ? DYN + : null); if (!mappedReturn) { fenceGenericSignatureResult(lowerer, expr, retTs); lowerer.badType(expr, retTs); @@ -691,7 +697,11 @@ function unifySignatureBindings( tsBindings.set(sym, lowerer.typeParamTsResolver(inst) ?? inst); } if (!bindings.has(sym)) { - const mapped = lowerer.mapTypeOf(inst); + const mapped = + lowerer.mapTypeOf(inst) ?? + ((inst.flags & ts.TypeFlags.Any) !== 0 && !isJsSourceFile(info.decl.getSourceFile()) + ? DYN + : null); // A visitor's inferred T can be void. It still binds the // return convention of `(node) => T`; discarding it leaves // the callback's return type spuriously uninstantiated. diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 3bebfd2af..23629a7ba 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -143,6 +143,8 @@ import { import { fencePerfEntryKeyWalk, isPerfEntryType, + lowerHistogramMethodCall, + lowerMonitorEventLoopDelayCall, lowerPerfHooksCall, perfEntryTag, } from "./builtins/performance.js"; @@ -241,6 +243,15 @@ const ZLIB_STREAM_METHODS: ReadonlySet = new Set(["flush", "params", "cl export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { const loc = locOf(expr); + // node:perf_hooks monitorEventLoopDelay (named import or namespace member). + if ( + (ts.isIdentifier(expr.expression) && expr.expression.text === "monitorEventLoopDelay") || + (ts.isPropertyAccessExpression(expr.expression) && + expr.expression.name.text === "monitorEventLoopDelay") + ) { + const histogram = lowerMonitorEventLoopDelayCall(lowerer, expr); + if (histogram) return histogram; + } // Object's checker return type is `any`. Claim known object inputs // before generic checked-dynamic dispatch tries to load the constructor. @@ -1379,6 +1390,7 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { // .bind(performance) function value over the runtime's // process-start-anchored monotonic clock. lowerPerfHooksCall(lowerer, expr, expr.expression) ?? + lowerHistogramMethodCall(lowerer, expr, expr.expression) ?? // The composed crypto pattern (randomBytes(n).toString(enc)) // — its receiver is a Buffer-typed CALL no other lowering claims. lowerer.lowerCryptoComposedCall(expr, expr.expression) ?? @@ -3289,7 +3301,13 @@ function lowerDynReceiverMethodCall( // array — scalar narrowings bridge through maybeNarrow's dynCheck and // take the ordinary typed paths, but there is no static home for an // any-elemented array). Typed receivers keep their own lowerings. - const recvTs = lowerer.typeOf(access.expression); + // An optional step the chain already guarded (`s?.m(x)`) has a receiver + // proven non-nullish. + const rawRecvTs = lowerer.typeOf(access.expression); + const recvTs = + access.questionDotToken !== undefined && lowerer.chainHandled.has(access) + ? lowerer.checker.getNonNullableType(rawRecvTs) + : rawRecvTs; if (lowerer.classImplementedProtocol(recvTs)) { const property = lowerer.checker.getPropertyOfType(recvTs, access.name.text); if ( diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 80b5bb333..aa597d8a2 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1,3 +1,5 @@ +import { lowerHistogramProperty } from "./builtins/performance.js"; +import { cleanFuncAdaptable } from "./coercions/function-adapters.js"; import { staticFunctionNameRead } from "./static-function-names.js"; import { argumentsOwner, typedArgumentsArity, typedArgumentsCandidate } from "./typed-arguments.js"; import { dateJsonType, isDateJsonSource } from "./builtins/dates.js"; @@ -1948,6 +1950,11 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { const staticName = staticFunctionNameRead(lowerer, expr); if (staticName !== null) return { kind: "strLit", value: staticName, type: STRING, loc }; } + // An IntervalHistogram's readings (min, max, mean, stddev, count, exceeds). + { + const reading = lowerHistogramProperty(lowerer, expr); + if (reading) return reading; + } // `.length` of a forwarded fixed tuple (a typed `arguments`, a tuple // rest parameter, an alias of either): the signature's arity. if (expr.name.text === "length" && !expr.questionDotToken) { @@ -4715,6 +4722,22 @@ function lowerNullishPair( ); } } + // An optional callback defaulted to a function of another signature + // (`o.tags ?? (() => ({}))`, fewer parameters or a narrower result): the + // result is the callback's own function type, and the default adapts into + // it (JavaScript ignores surplus arguments). The checker's merged union + // signature would lose the callback's result shape. + if (present?.kind === "func" && present.rest !== true) { + const raw = lowerer.lowerExpr(expr.right); + if ( + raw.type.kind === "func" && + (typeEquals(raw.type, present) || cleanFuncAdaptable(lowerer, raw.type, present)) + ) { + const right = lowerer.coerceInto(expr.right, raw, present); + lowerer.nullishFuncResults.add(expr); + return { kind: "nullish", left, right, type: present, loc }; + } + } lowerer.unsupported( "SC1090", expr, @@ -12688,6 +12711,19 @@ export function caughtRead( if (narrowed?.kind === "dyn") { return { kind: "caughtToDyn", value: ref, type: DYN, loc }; } + // A binding a user type predicate (or an assertion) narrowed to a + // structural type (`isErrorLike(e)` → `{ message: string; … }`, an + // intersection like `TypeError & { code: unknown }`): the thrown value is + // still whatever was thrown, so it crosses as a checked-dynamic value and + // each member read validates its own result, as JavaScript reads it. + const checked = lowerer.typeOf(node); + if ( + narrowed?.kind === "record" || + narrowed?.kind === "union" || + (narrowed === null && (checked.flags & (ts.TypeFlags.Object | ts.TypeFlags.Intersection)) !== 0) + ) { + return { kind: "caughtToDyn", value: ref, type: DYN, loc }; + } lowerer.unsupported("SC1063", node); } @@ -14000,6 +14036,36 @@ function lowerInExpression(lowerer: Lowerer, expr: ts.BinaryExpression, loc: Src ) { return { kind: "boolLit", value: true, type: BOOL, loc }; } + // Any other key: a member a program class declares. The receiver's + // runtime class is its static class or a descendant; the key is + // present exactly when that class (or an ancestor) declares it. + if (recv.kind === "varRef" || recv.kind === "caughtNarrow") { + const declares = (c: ClassInfo): boolean => + c.fields.has(key) || + lowerer.findMethodOn(c, key) !== null || + lowerer.findMethodOn(c, `get:${key}`) !== null || + lowerer.findMethodOn(c, `set:${key}`) !== null; + const staticName = recv.type.className; + const staticInfo = lowerer.classes.get(staticName); + if (staticInfo && declares(staticInfo)) + return { kind: "boolLit", value: true, type: BOOL, loc }; + const tests: IrExpr[] = []; + for (const c of lowerer.classes.values()) { + const name = c.def.name; + if (name === staticName || !lowerer.isSubclassOf(name, staticName) || !declares(c)) + continue; + // A declaring ancestor below the static class already covers it. + if (c.base && c.base.def.name !== staticName && declares(c.base)) continue; + tests.push({ kind: "instanceOf", value: recv, className: name, type: BOOL, loc }); + } + return tests.reduce( + (acc, test) => + acc.kind === "boolLit" + ? test + : { kind: "logical", op: "||", left: acc, right: test, type: BOOL, loc }, + { kind: "boolLit", value: false, type: BOOL, loc }, + ); + } lowerer.unsupported( "SC1090", expr, diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 01ff5f5c1..3af47f614 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -4887,7 +4887,8 @@ export function lowerVarDecl( if ( !decl.type && init.type.kind === "func" && - lowerer.typedArgumentsLambdas.has(stripTypeWrappers(decl.initializer)) + (lowerer.typedArgumentsLambdas.has(stripTypeWrappers(decl.initializer)) || + lowerer.nullishFuncResults.has(stripTypeWrappers(decl.initializer))) ) type = init.type; // A TypeScript lambda whose closure returns the runtime result (a missing diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 26405865c..8913b9eeb 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1917,6 +1917,9 @@ export class Lowerer { /** Const bindings holding a forwarded fixed tuple (`const args = * arguments as any` in a typed-arguments function) → the tuple type. */ readonly tupleAliasSymbols = new Map(); + /** `??` expressions whose result is the optional callback's own function + * type (an adapted function default); their bindings take that type. */ + readonly nullishFuncResults = new Set(); /** The static names of the current generic instance's name-keyed * function parameters (static-function-names.ts). */ staticFnNames: Map | null = null; diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index e43748b39..c819bf80c 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -2397,6 +2397,27 @@ export const AMBIENT_SURFACE_FNS: readonly AmbientSurfaceRow[] = [ name: "perf_hooks.performance.clearMeasures", fns: ["perf.clearMeasures"], }, + { + id: "node-builtin.perf_hooks.performance.eventLoopUtilization", + kind: "node-builtin", + name: "perf_hooks.performance.eventLoopUtilization", + fns: ["perf.loopStart", "perf.idleTime"], + note: "active time is the main thread's CPU time since the loop started; the one- and two-argument difference forms are supported", + }, + { + id: "node-builtin.perf_hooks.monitorEventLoopDelay", + kind: "node-builtin", + name: "perf_hooks.monitorEventLoopDelay", + fns: [ + "perf.eldNew", + "perf.eldEnable", + "perf.eldDisable", + "perf.eldReset", + "perf.eldPercentile", + "perf.eldStat", + ], + note: "an unref'd sampling interval records the delay histogram; enable, disable, reset, percentile and the min, max, mean, stddev, count and exceeds readings are supported", + }, // ── the process global's ambient reads and authority calls // (lowerProcessProperty/lowerProcessMethodCall — process is a // provenance-checked global here, not an importable module). diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 19e7775cc..1e3954586 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1556,7 +1556,8 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // parameter (variadic higher-order wrappers): the checker keeps both // conditionals symbolic inside the body, and their `infer` positions // match a call signature, which matchInferReference cannot decide. - const viaSignature = mapBoundSignatureAlias(type, ctx); + const viaSignature = + mapBoundSignatureAlias(type, ctx) ?? mapBoundKeyofNeverConditional(type, ctx); if (viaSignature !== null) { contextResolutions++; return viaSignature; @@ -3809,6 +3810,27 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { return { kind: "record", shapeId: perfEntryShape(ctx.shapes, ctx.unions) }; } // readline's getCursorPos() record, in Node's key order ({ cols, rows }). + // perf_hooks' EventLoopUtilization (performance.eventLoopUtilization's + // result): a plain data record in Node's key order. + if (isStdlibInterface("EventLoopUtilization")) { + return { + kind: "record", + shapeId: ctx.shapes.intern( + [ + { name: "active", type: F64 }, + { name: "idle", type: F64 }, + { name: "utilization", type: F64 }, + ], + false, + undefined, + ["idle", "active", "utilization"], + ), + }; + } + // monitorEventLoopDelay's IntervalHistogram: a numeric handle into the + // runtime's histogram table, like a timer handle. Its members lower over + // the handle (builtins/performance.ts). + if (isStdlibInterface("IntervalHistogram")) return F64; if (isStdlibInterface("CursorPos")) { return { kind: "record", @@ -4348,6 +4370,22 @@ function mapCallSignature(sig: ts.Signature, ctx: TypeMapperCtx): IrType | null ts.isParameter(decl) && (decl.questionToken !== undefined || decl.initializer !== undefined); let pt = mapType(checker.getTypeOfSymbol(p), ctx); + // A parameter a TypeScript program types `any` takes whatever the + // caller passes: the checked-dynamic slot, as an `any`-typed binding + // already is (dynFallbackType). Declaration files keep their paths. + // Only function declarations and spelled function types: lambdas and + // methods in object literals keep their existing checked-dynamic story. + if ( + !pt && + !rest && + decl !== undefined && + ts.isParameter(decl) && + decl.parent !== undefined && + (ts.isFunctionDeclaration(decl.parent) || ts.isFunctionTypeNode(decl.parent)) && + !decl.getSourceFile().isDeclarationFile && + (checker.getTypeOfSymbol(p).flags & ts.TypeFlags.Any) !== 0 + ) + pt = DYN; if ( decl && ts.isParameter(decl) && @@ -5122,7 +5160,51 @@ function mapGenericIndexedAccess(type: ts.Type, ctx: TypeMapperCtx): IrType | nu } if (!covered || covered.length === 0) return null; const first = covered[0]!; - return covered.every((t) => typeEquals(t, first)) ? first : null; + if (covered.every((t) => typeEquals(t, first))) return first; + // Only the `keyof T` index itself covers every field at once; a key + // parameter bound to string keeps its literal-key resolution below. + if (!(idx.isIndexType() && idx.getTarget() === obj)) return null; + // Fields of different scalar types (`Object.entries` over a bound record + // reads `T[keyof T]`): any one of them, the union of their types. + const arms: IrType[] = []; + for (const t of covered) { + const parts = t.kind === "union" ? (ctx.unions.get(t.unionId)?.arms ?? null) : [t]; + if (!parts) return null; + for (const part of parts) { + if ( + part.kind !== "f64" && + part.kind !== "string" && + part.kind !== "bool" && + part.kind !== "undefinedT" && + part.kind !== "nullT" + ) + return null; + if (!arms.some((a) => typeEquals(a, part))) arms.push(part); + } + } + if (arms.length === 1) return arms[0]!; + arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + return { kind: "union", unionId: ctx.unions.intern(arms) }; +} + +/** `keyof T extends never ? A : B` over a bound record T (the lib's + * Object.entries element spelling): decided by whether the binding has any + * key. */ +function mapBoundKeyofNeverConditional(type: ts.Type, ctx: TypeMapperCtx): IrType | null { + const { resolveTypeParam, shapes } = ctx; + if (!resolveTypeParam || !type.isConditionalType()) return null; + const check = type.getCheckType(); + const ext = type.getExtendsType(); + if (!check || !ext || !(ext.flags & ts.TypeFlags.Never) || !check.isIndexType()) return null; + const target = check.getTarget(); + if (!target || !(target.flags & ts.TypeFlags.TypeParameter)) return null; + const bound = resolveTypeParam(target); + if (bound?.kind !== "record") return null; + const shape = shapes.get(bound.shapeId); + if (!shape || shape.tuple) return null; + const hasKeys = shape.fields.length > 0 || shape.indexValue !== undefined; + const branch = hasKeys ? type.getFalseType() : type.getTrueType(); + return branch ? mapType(branch, ctx) : null; } /** A mapped type over a type parameter of the instance being lowered (`{ diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index ca9dc4d18..92643c7b9 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -554,6 +554,7 @@ async function compileExecutableNative( tls: features.tls, tlsCa: features.tlsCa, cipher: features.cipher, + perfLoop: features.perfLoop, ...(onArtifactReady === undefined ? {} : { onArtifactReady }), ...(ffi === null ? {} diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index f3ad4db28..02943ae1b 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -361,6 +361,20 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "perf.entryDetail": { argTypes: [F64], result: DYN }, "perf.entryKeep": { argTypes: [DYN], result: VOID }, "perf.entryRef": { argTypes: [F64], result: DYN }, + /** perf_hooks event-loop metrics (scr_perf_loop.c): loopStart is the + * loop's start in performance.now() ms (0 before it), idleTime the idle + * ms since. eldNew(resolution ms) creates a monitorEventLoopDelay + * histogram handle; enable/disable answer whether the state changed; + * eldPercentile expects Node's validated range; eldStat(which) reads + * 0 min, 1 max, 2 mean, 3 stddev, 4 count, 5 exceeds. */ + "perf.loopStart": { argTypes: [], result: F64 }, + "perf.idleTime": { argTypes: [], result: F64 }, + "perf.eldNew": { argTypes: [F64], result: F64 }, + "perf.eldEnable": { argTypes: [F64], result: BOOL }, + "perf.eldDisable": { argTypes: [F64], result: BOOL }, + "perf.eldReset": { argTypes: [F64], result: VOID }, + "perf.eldPercentile": { argTypes: [F64, F64], result: F64 }, + "perf.eldStat": { argTypes: [F64, F64], result: F64 }, /** process.memoryUsage(): memoryUsageTake samples the resident set and * the runtime heap once (MAY THROW Node's uv_resident_set_memory error) * and memoryUsageField(k) reads that sample (0 rss, 1 heapTotal, 2 diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 6e71fe91c..b2628fa10 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -49,6 +49,9 @@ export interface RuntimeFeatures { tls: boolean; tlsCa: boolean; cipher: boolean; + /** perf_hooks event-loop metrics (eventLoopUtilization, + * monitorEventLoopDelay): scr_perf_loop.c. */ + perfLoop: boolean; } const DYN_ASYNC_LIB_FNS: ReadonlySet = new Set([ @@ -407,6 +410,7 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run tls: false, tlsCa: false, cipher: false, + perfLoop: false, }; const keepGoing = (): boolean => stopAt === undefined || !features[stopAt]; let tables: TypeTables | undefined; @@ -505,6 +509,8 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run // scr_cipher.c over the mbedTLS archive; DEP0182 reports through the // process-warning unit, and unknown output encodings render through // the inspect unit. + if (fn.startsWith("perf.loop") || fn === "perf.idleTime" || fn.startsWith("perf.eld")) + features.perfLoop = true; if (fn === "crypto.cipherNew") { features.cipher = true; features.dynAsync = true; diff --git a/packages/compiler/src/native/cli.ts b/packages/compiler/src/native/cli.ts index c0d89bbbf..cf0c32ee3 100644 --- a/packages/compiler/src/native/cli.ts +++ b/packages/compiler/src/native/cli.ts @@ -100,6 +100,7 @@ async function main(): Promise { nodeTest: false, tls: false, cipher: false, + perfLoop: false, tlsCa: false, }, options.optimization ?? "release", diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts index bb901a674..90f4ca562 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -155,6 +155,7 @@ function validFeatures(value: unknown): value is NativeLinkFeatures { "tls", "tlsCa", "cipher", + "perfLoop", ].every((name) => typeof features[name] === "boolean"); } diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index 79984d6c5..3f7d96226 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -104,6 +104,7 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( tls: false, tlsCa: false, cipher: false, + perfLoop: false, nativeFetch: false, netEffective: false, httpEffective: false, diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 5cea78586..dc8ff48f8 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -1847,6 +1847,13 @@ "status": "static", "note": "recognized module (bare and node:-prefixed specifiers)" }, + { + "id": "node-builtin.perf_hooks.monitorEventLoopDelay", + "kind": "node-builtin", + "name": "perf_hooks.monitorEventLoopDelay", + "status": "static", + "note": "an unref'd sampling interval records the delay histogram; enable, disable, reset, percentile and the min, max, mean, stddev, count and exceeds readings are supported" + }, { "id": "node-builtin.perf_hooks.performance.clearMarks", "kind": "node-builtin", @@ -1859,6 +1866,13 @@ "name": "perf_hooks.performance.clearMeasures", "status": "static" }, + { + "id": "node-builtin.perf_hooks.performance.eventLoopUtilization", + "kind": "node-builtin", + "name": "perf_hooks.performance.eventLoopUtilization", + "status": "static", + "note": "active time is the main thread's CPU time since the loop started; the one- and two-argument difference forms are supported" + }, { "id": "node-builtin.perf_hooks.performance.getEntries", "kind": "node-builtin", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6390-catch-binding-predicate.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6390-catch-binding-predicate.ts.json new file mode 100644 index 000000000..fcfb2852f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6390-catch-binding-predicate.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6390-catch-binding-predicate.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6391-nullish-function-default.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6391-nullish-function-default.ts.json new file mode 100644 index 000000000..8200d301e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6391-nullish-function-default.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6391-nullish-function-default.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6392-optional-chain-protocol-calls.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6392-optional-chain-protocol-calls.ts.json new file mode 100644 index 000000000..f2343714c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6392-optional-chain-protocol-calls.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6392-optional-chain-protocol-calls.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6393-function-types-any-params.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6393-function-types-any-params.ts.json new file mode 100644 index 000000000..af0a9abe7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6393-function-types-any-params.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6393-function-types-any-params.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6394-in-key-error-subclasses.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6394-in-key-error-subclasses.ts.json new file mode 100644 index 000000000..9df1e1014 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6394-in-key-error-subclasses.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6394-in-key-error-subclasses.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6395-generic-object-entries.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6395-generic-object-entries.ts.json new file mode 100644 index 000000000..879a09388 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6395-generic-object-entries.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6395-generic-object-entries.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6396-generic-any-instantiation.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6396-generic-any-instantiation.ts.json new file mode 100644 index 000000000..da41452ad --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6396-generic-any-instantiation.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6396-generic-any-instantiation.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6397-event-loop-metrics.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6397-event-loop-metrics.ts.json new file mode 100644 index 000000000..0a74534b0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6397-event-loop-metrics.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6397-event-loop-metrics.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/event-loop-metrics.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/event-loop-metrics.ts.json new file mode 100644 index 000000000..66246403d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/event-loop-metrics.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/event-loop-metrics.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/timeout-type.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/timeout-type.ts.json new file mode 100644 index 000000000..46f0b6e38 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/timeout-type.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/timeout-type.ts" + ], + "diags": [] +} diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index eab680a55..b603f37ee 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -62,6 +62,8 @@ const OPTIONAL = [ ["scr_async_dyn.c", any("dynAsync", "dynInvoke", "dc", "fileHandle", "nativeFetch")], // The performance timeline emits its buffer warning through scr_async_dyn.c. ["scr_perf.c", "dynAsync"], + // perf_hooks event-loop metrics, only for programs that read them. + ["scr_perf_loop.c", "perfLoop"], // Blob and File box their reads as dyn promises through scr_async_dyn.c. ["scr_blob.c", "dynAsync"], ["scr_zlib.c", "zlib"], diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index 4c973e3e5..4b1094d54 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -169,6 +169,21 @@ double scr_process_uptime(void); /* perf_hooks performance.now(): fractional ms since process start (the * uptime anchor — Node's timeOrigin for a compiled program). */ double scr_perf_now(void); +/* perf_hooks event-loop metrics (scr_perf_loop.c). loop_mark records the + * loop's start (the emitted main calls it before the loop runs, only in + * programs that read the metric); loop_start answers it (0 before) and + * idle_time the idle ms since. The eld_* family is monitorEventLoopDelay's + * IntervalHistogram over a handle; eld_percentile expects 0 < p <= 100; eld_stat which: 0 min, 1 max, 2 mean, 3 stddev, + * 4 count, 5 exceeds. */ +void scr_perf_loop_mark(void); +double scr_perf_loop_start(void); +double scr_perf_idle_time(void); +double scr_eld_new(double resolution); +bool scr_eld_enable(double handle); +bool scr_eld_disable(double handle); +void scr_eld_reset(double handle); +double scr_eld_percentile(double handle, double percentile); +double scr_eld_stat(double handle, double which); /* perf_hooks user timing (scr_perf.c): mark, measure, the getEntries * family and the clears take the call's dyn argument vector (borrowed) * and apply Node's validation and errors. mark and measure answer 1 and diff --git a/packages/runtime/src/scr_perf_loop.c b/packages/runtime/src/scr_perf_loop.c new file mode 100644 index 000000000..29d98ea29 --- /dev/null +++ b/packages/runtime/src/scr_perf_loop.c @@ -0,0 +1,305 @@ +/* node:perf_hooks event-loop metrics: performance.eventLoopUtilization() + * and monitorEventLoopDelay()'s IntervalHistogram. + * + * Event-loop utilization. Node measures idle time as the time its loop + * spends blocked in the poll phase. A compiled program's main thread is + * busy exactly while it runs on the CPU (or blocks in a synchronous call), + * so the thread's CPU time stands in for Node's active time: idle is the + * wall time since the loop started minus that CPU time. The loop start is + * marked by the emitted main just before the loop runs, and only in + * programs that ask for the metric; before it, utilization is all zeros, + * like Node during the main module's synchronous evaluation. + * + * Event-loop delay. Each histogram samples on an unref'd interval at its + * resolution and records the time between samples in nanoseconds, like + * Node's ELDHistogram (Histogram::RecordDelta). Values land in an + * HdrHistogram-shaped counts array (lowest 1, three significant figures: + * 2048 sub-buckets per power of two), so min, max, mean, stddev and + * percentiles answer the bucket values Node's hdr_histogram answers. */ + +#include "scr_runtime.h" + +#include +#include +#include +#include +#ifdef _WIN32 +#include +#endif + +/* ── event-loop utilization ─────────────────────────────────────────── */ + +static SCR_TL double scr_perf_loop_start_ms = 0; /* performance.now() at loop start */ +static SCR_TL double scr_perf_loop_cpu0_ms = 0; /* thread CPU time at loop start */ + +/* The calling thread's CPU time in milliseconds; 0 where unavailable. */ +static double scr_perf_thread_cpu_ms(void) { +#if defined(_WIN32) + FILETIME created, exited, kernel, user; + if (!GetThreadTimes(GetCurrentThread(), &created, &exited, &kernel, &user)) return 0; + ULARGE_INTEGER k, u; + k.LowPart = kernel.dwLowDateTime; + k.HighPart = kernel.dwHighDateTime; + u.LowPart = user.dwLowDateTime; + u.HighPart = user.dwHighDateTime; + return (double)(k.QuadPart + u.QuadPart) / 1e4; /* 100ns units */ +#elif defined(CLOCK_THREAD_CPUTIME_ID) && !defined(__wasi__) + struct timespec ts; + if (clock_gettime(CLOCK_THREAD_CPUTIME_ID, &ts) != 0) return 0; + return (double)ts.tv_sec * 1000.0 + (double)ts.tv_nsec / 1e6; +#else + return 0; +#endif +} + +void scr_perf_loop_mark(void) { + if (scr_perf_loop_start_ms > 0) return; + scr_perf_loop_cpu0_ms = scr_perf_thread_cpu_ms(); + double now = scr_perf_now(); + /* Node's loopStart is positive once set; a mark at the very first + * microsecond still counts as started. */ + scr_perf_loop_start_ms = now > 0 ? now : 1e-6; +} + +double scr_perf_loop_start(void) { return scr_perf_loop_start_ms; } + +double scr_perf_idle_time(void) { + if (scr_perf_loop_start_ms <= 0) return 0; + double wall = scr_perf_now() - scr_perf_loop_start_ms; + double busy = scr_perf_thread_cpu_ms() - scr_perf_loop_cpu0_ms; + double idle = wall - busy; + if (idle < 0) idle = 0; + if (idle > wall) idle = wall; + return idle; +} + +/* ── HdrHistogram-shaped counts (lowest 1, 3 significant figures) ───── */ + +#define SCR_HDR_SUB_BUCKET_HALF_MAG 10 +#define SCR_HDR_SUB_BUCKET_HALF (1 << SCR_HDR_SUB_BUCKET_HALF_MAG) /* 1024 */ +#define SCR_HDR_SUB_BUCKET_COUNT (SCR_HDR_SUB_BUCKET_HALF * 2) /* 2048 */ +#define SCR_HDR_SUB_BUCKET_MASK ((int64_t)SCR_HDR_SUB_BUCKET_COUNT - 1) + +typedef struct { + int64_t *counts; /* owned, grown on demand */ + size_t counts_len; + int64_t total; + int64_t min_value; /* INT64_MAX while empty */ + int64_t max_value; /* 0 while empty */ + double count; /* Node's count_ (successful records) */ + uint64_t prev_ns; /* RecordDelta's previous sample; 0 = none */ + double resolution; /* ms */ + double timer; /* the sampling interval's handle while enabled, else 0 */ + ScrClosure *tick; /* owned: the sampling callback, identifies this histogram */ + bool enabled; +} ScrEld; + +static int scr_hdr_bucket_index(int64_t value) { + int pow2ceiling = 64 - __builtin_clzll((uint64_t)(value | SCR_HDR_SUB_BUCKET_MASK)); + return pow2ceiling - (SCR_HDR_SUB_BUCKET_HALF_MAG + 1); +} + +static size_t scr_hdr_counts_index(int64_t value) { + int bucket = scr_hdr_bucket_index(value); + int64_t sub = value >> bucket; + return (size_t)(((int64_t)(bucket + 1) << SCR_HDR_SUB_BUCKET_HALF_MAG) + (sub - SCR_HDR_SUB_BUCKET_HALF)); +} + +static int64_t scr_hdr_value_at_index(size_t index) { + int bucket = (int)(index >> SCR_HDR_SUB_BUCKET_HALF_MAG) - 1; + int64_t sub = (int64_t)(index & (SCR_HDR_SUB_BUCKET_HALF - 1)) + SCR_HDR_SUB_BUCKET_HALF; + if (bucket < 0) { + sub -= SCR_HDR_SUB_BUCKET_HALF; + bucket = 0; + } + return sub << bucket; +} + +static int64_t scr_hdr_range_size(int64_t value) { + int bucket = scr_hdr_bucket_index(value); + int64_t sub = value >> bucket; + int adjusted = sub >= SCR_HDR_SUB_BUCKET_COUNT ? bucket + 1 : bucket; + return (int64_t)1 << adjusted; +} + +static int64_t scr_hdr_lowest_equivalent(int64_t value) { + int bucket = scr_hdr_bucket_index(value); + int64_t sub = value >> bucket; + return sub << bucket; +} + +static int64_t scr_hdr_highest_equivalent(int64_t value) { + return scr_hdr_lowest_equivalent(value) + scr_hdr_range_size(value) - 1; +} + +static int64_t scr_hdr_median_equivalent(int64_t value) { + return scr_hdr_lowest_equivalent(value) + (scr_hdr_range_size(value) >> 1); +} + +/* Newton's square root: keeps this unit free of a libm reference. */ +static double scr_eld_sqrt(double x) { + if (!(x > 0)) return x == 0 ? 0 : NAN; + double r = x >= 1 ? x : 1; + for (int i = 0; i < 64; i++) { + double next = 0.5 * (r + x / r); + if (next == r) break; + r = next; + } + return r; +} + +static void scr_eld_record(ScrEld *h, int64_t value) { + if (value < 0) return; + size_t index = scr_hdr_counts_index(value); + if (index >= h->counts_len) { + size_t len = h->counts_len ? h->counts_len : SCR_HDR_SUB_BUCKET_COUNT; + while (len <= index) len *= 2; + int64_t *grown = realloc(h->counts, len * sizeof(int64_t)); + if (grown == NULL) return; + for (size_t i = h->counts_len; i < len; i++) grown[i] = 0; + h->counts = grown; + h->counts_len = len; + } + h->counts[index]++; + h->total++; + if (value != 0 && value < h->min_value) h->min_value = value; + if (value > h->max_value) h->max_value = value; + h->count += 1; +} + +static void scr_eld_clear(ScrEld *h) { + for (size_t i = 0; i < h->counts_len; i++) h->counts[i] = 0; + h->total = 0; + h->min_value = INT64_MAX; + h->max_value = 0; + h->count = 0; + h->prev_ns = 0; +} + +/* ── monitorEventLoopDelay ──────────────────────────────────────────── */ + +static SCR_TL ScrEld **scr_elds; +static SCR_TL size_t scr_elds_len; + +static ScrEld *scr_eld_of(double handle) { + if (!(handle >= 1) || handle > (double)scr_elds_len) return NULL; + return scr_elds[(size_t)handle - 1]; +} + +static void scr_eld_tick(ScrClosure *self) { + for (size_t i = 0; i < scr_elds_len; i++) { + ScrEld *h = scr_elds[i]; + if (h->tick != self) continue; + uint64_t now = scr_hrtime_ns(); + if (h->prev_ns > 0 && now > h->prev_ns) scr_eld_record(h, (int64_t)(now - h->prev_ns)); + h->prev_ns = now; + return; + } +} + +/* Histograms live for the thread's lifetime (Node frees them with their + * JS object); release them when the script ends so their callbacks and + * counts do not outlive it. */ +static void scr_eld_cleanup(void) { + for (size_t i = 0; i < scr_elds_len; i++) { + ScrEld *h = scr_elds[i]; + scr_closure_release(h->tick); + free(h->counts); + free(h); + } + free(scr_elds); + scr_elds = NULL; + scr_elds_len = 0; +} + +double scr_eld_new(double resolution) { + if (scr_elds_len == 0 && scr_elds == NULL) scr_atexit(scr_eld_cleanup); + ScrEld *h = calloc(1, sizeof(ScrEld)); + ScrEld **grown = realloc(scr_elds, (scr_elds_len + 1) * sizeof(ScrEld *)); + if (h == NULL || grown == NULL) { + free(h); + if (grown != NULL) scr_elds = grown; + return 0; + } + scr_elds = grown; + h->min_value = INT64_MAX; + h->resolution = resolution >= 1 ? resolution : 10; + h->tick = scr_closure_new((void *)scr_eld_tick, 0); + scr_elds[scr_elds_len++] = h; + return (double)scr_elds_len; +} + +bool scr_eld_enable(double handle) { + ScrEld *h = scr_eld_of(handle); + if (h == NULL || h->enabled) return false; + h->enabled = true; + h->timer = scr_set_interval(scr_closure_retain(h->tick), h->resolution); + scr_timer_unref(h->timer); + return true; +} + +bool scr_eld_disable(double handle) { + ScrEld *h = scr_eld_of(handle); + if (h == NULL || !h->enabled) return false; + h->enabled = false; + scr_clear_interval(h->timer); + h->timer = 0; + return true; +} + +void scr_eld_reset(double handle) { + ScrEld *h = scr_eld_of(handle); + if (h != NULL) scr_eld_clear(h); +} + +/* hdr_value_at_percentile; the compiler checked Node's 0 < p <= 100 range. */ +double scr_eld_percentile(double handle, double percentile) { + ScrEld *h = scr_eld_of(handle); + if (h == NULL || h->total == 0) return 0; + double requested = percentile < 100.0 ? percentile : 100.0; + int64_t wanted = (int64_t)((requested / 100.0) * (double)h->total + 0.5); + if (wanted < 1) wanted = 1; + int64_t seen = 0; + for (size_t i = 0; i < h->counts_len; i++) { + seen += h->counts[i]; + if (seen >= wanted) return (double)scr_hdr_highest_equivalent(scr_hdr_value_at_index(i)); + } + return 0; +} + +/* which: 0 min, 1 max, 2 mean, 3 stddev, 4 count, 5 exceeds. */ +double scr_eld_stat(double handle, double which) { + ScrEld *h = scr_eld_of(handle); + int w = (int)which; + if (h == NULL) return w == 0 ? (double)INT64_MAX : w >= 2 && w <= 3 ? NAN : 0; + switch (w) { + case 0: + if (h->counts_len > 0 && h->counts[0] > 0) return 0; + return h->min_value == INT64_MAX ? (double)INT64_MAX + : (double)scr_hdr_lowest_equivalent(h->min_value); + case 1: + return h->max_value == 0 ? 0 : (double)scr_hdr_highest_equivalent(h->max_value); + case 2: + case 3: { + if (h->total == 0) return NAN; + double sum = 0; + for (size_t i = 0; i < h->counts_len; i++) { + if (h->counts[i] == 0) continue; + sum += (double)h->counts[i] * (double)scr_hdr_median_equivalent(scr_hdr_value_at_index(i)); + } + double mean = sum / (double)h->total; + if (w == 2) return mean; + double geo = 0; + for (size_t i = 0; i < h->counts_len; i++) { + if (h->counts[i] == 0) continue; + double dev = (double)scr_hdr_median_equivalent(scr_hdr_value_at_index(i)) - mean; + geo += dev * dev * (double)h->counts[i]; + } + return scr_eld_sqrt(geo / (double)h->total); + } + case 4: + return h->count; + default: + return 0; + } +} diff --git a/tests/corpus/6390-catch-binding-predicate.ts b/tests/corpus/6390-catch-binding-predicate.ts new file mode 100644 index 000000000..82c8797b5 --- /dev/null +++ b/tests/corpus/6390-catch-binding-predicate.ts @@ -0,0 +1,42 @@ +// A catch binding narrowed by a user type predicate reads its fields. +interface ErrorLike { + message: string; + name: string; + code?: string; +} +function isErrorLike(value: unknown): value is ErrorLike { + return typeof value === "object" && value !== null && "message" in value && "name" in value; +} +interface Coded { + code: string; +} +function isCoded(value: unknown): value is Coded { + return typeof value === "object" && value !== null && typeof (value as Coded).code === "string"; +} +function report(fn: () => void): void { + try { + fn(); + } catch (err) { + if (isErrorLike(err)) { + console.log("error", { message: err ? err.message : "none", name: err.name, code: err.code ?? "-" }); + if (err.message.includes("disk")) console.log("disk problem"); + } else { + console.log("not error-like", String(err)); + } + if (isCoded(err) && err.code === "ENOSPC") console.log("coded", err.code); + } +} +report(() => { + throw new Error("disk full"); +}); +report(() => { + const e = new Error("no space") as Error & { code?: string }; + e.code = "ENOSPC"; + throw e; +}); +report(() => { + throw "plain"; +}); +report(() => { + throw { message: "obj", name: "Custom", code: "ENOSPC" }; +}); diff --git a/tests/corpus/6391-nullish-function-default.ts b/tests/corpus/6391-nullish-function-default.ts new file mode 100644 index 000000000..6721d97bf --- /dev/null +++ b/tests/corpus/6391-nullish-function-default.ts @@ -0,0 +1,13 @@ +// `??` with a function fallback whose signature differs from the optional +// callback's (fewer parameters, narrower result). +interface Opts { + tags?: (a: number, b: number) => Record; + label?: (n: number) => string; +} +function run(o: Opts, x: number, y: number): string { + const tags = o.tags ?? (() => ({})); + const label = o.label ?? ((n: number) => `#${n}`); + return `${label(x)} ${JSON.stringify(tags(x, y))}`; +} +console.log(run({}, 1, 2)); +console.log(run({ tags: (a, b) => ({ a, b }), label: (n) => `n${n}` }, 3, 4)); diff --git a/tests/corpus/6392-optional-chain-protocol-calls.ts b/tests/corpus/6392-optional-chain-protocol-calls.ts new file mode 100644 index 000000000..a08a4ee85 --- /dev/null +++ b/tests/corpus/6392-optional-chain-protocol-calls.ts @@ -0,0 +1,47 @@ +// Optional calls on an interface-typed method chain (a do-nothing tracer). +interface Span { + addTags(tags: Record): Span; +} +interface Scope { + active(): Span | null; +} +interface Tracer { + scope(): Scope; +} +class NoopSpan implements Span { + addTags(tags: Record): Span { + console.log("tags", JSON.stringify(tags)); + return this; + } +} +class NoopScope implements Scope { + span: Span | null; + constructor(span: Span | null) { + this.span = span; + } + active(): Span | null { + return this.span; + } +} +class NoopTracer implements Tracer { + s: Scope; + constructor(s: Scope) { + this.s = s; + } + scope(): Scope { + return this.s; + } +} +const quiet: Tracer = new NoopTracer(new NoopScope(null)); +const loud: Tracer = new NoopTracer(new NoopScope(new NoopSpan())); +function tagActiveSpan(tracer: Tracer, tags: Record): void { + tracer.scope().active()?.addTags(tags); +} +tagActiveSpan(quiet, { a: 1 }); +tagActiveSpan(loud, { b: 2 }); + +// Narrowed locals and non-null assertions reach the same protocol calls. +const scope2: Scope = new NoopScope(new NoopSpan()); +const current = scope2.active(); +current?.addTags({ c: 3 }); +scope2.active()!.addTags({ d: 4 }); diff --git a/tests/corpus/6393-function-types-any-params.ts b/tests/corpus/6393-function-types-any-params.ts new file mode 100644 index 000000000..ec7799c9d --- /dev/null +++ b/tests/corpus/6393-function-types-any-params.ts @@ -0,0 +1,8 @@ +// Function values whose parameters are typed any (optional or not). +function build(limit?: any): string { + return `limit=${limit ?? "none"}`; +} +const fns: Array<(limit?: any) => string> = [build]; +console.log(fns[0]!(), fns[0]!(5)); +const take: (v: any) => number = (v) => (typeof v === "number" ? v : -1); +console.log(take(3), take("x")); diff --git a/tests/corpus/6394-in-key-error-subclasses.ts b/tests/corpus/6394-in-key-error-subclasses.ts new file mode 100644 index 000000000..cdb28a5a3 --- /dev/null +++ b/tests/corpus/6394-in-key-error-subclasses.ts @@ -0,0 +1,25 @@ +// `'key' in err` on Error receivers, including subclasses that declare it. +class HttpError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.status = status; + } +} +function describe(err: Error): string { + if ("status" in err) return `status ${(err as HttpError).status}`; + if ("code" in err) return "has code"; + return `plain ${err.message}`; +} +console.log(describe(new Error("x"))); +console.log(describe(new HttpError("nf", 404))); +class Timeout extends HttpError { + get retryable(): boolean { + return true; + } +} +function retryable(err: Error): boolean { + return "retryable" in err; +} +console.log(retryable(new Error("x")), retryable(new HttpError("a", 500)), retryable(new Timeout("t", 504))); +console.log(describe(new Timeout("t", 504))); diff --git a/tests/corpus/6395-generic-object-entries.ts b/tests/corpus/6395-generic-object-entries.ts new file mode 100644 index 000000000..772367a3a --- /dev/null +++ b/tests/corpus/6395-generic-object-entries.ts @@ -0,0 +1,7 @@ +// Object.entries over a generic record parameter. +function maskSecrets>(tags: T): Record { + return Object.fromEntries( + Object.entries(tags).map(([k, v]) => [k, k.includes("secret") ? "[redacted]" : v]), + ); +} +console.log(JSON.stringify(maskSecrets({ user: "ada", secretToken: "x", n: 1 }))); diff --git a/tests/corpus/6396-generic-any-instantiation.ts b/tests/corpus/6396-generic-any-instantiation.ts new file mode 100644 index 000000000..c3c9da060 --- /dev/null +++ b/tests/corpus/6396-generic-any-instantiation.ts @@ -0,0 +1,18 @@ +// A generic method instantiated at `any` from an untyped forwarding call. +type AnyFn = (...args: any) => any; +class Tracer { + trace(name: string, fn: () => T): T { + console.log("span", name); + return fn(); + } +} +const tracer = new Tracer(); +function traced(name: string, fn: F): F { + return function (this: unknown) { + const args = arguments; + const result = tracer.trace(name, () => fn.apply(this, args as any)); + return result; + } as F; +} +const add = traced("add", (a: number, b: number) => a + b); +console.log(add(1, 2)); diff --git a/tests/corpus/6397-event-loop-metrics.ts b/tests/corpus/6397-event-loop-metrics.ts new file mode 100644 index 000000000..e1c3829ac --- /dev/null +++ b/tests/corpus/6397-event-loop-metrics.ts @@ -0,0 +1,49 @@ +// Event-loop monitoring under @types/node: monitorEventLoopDelay's histogram, +// eventLoopUtilization deltas, and an unref'd interval typed NodeJS.Timeout. +import { monitorEventLoopDelay, performance } from "node:perf_hooks"; + +const histogram = monitorEventLoopDelay({ resolution: 10 }); +console.log("enable", histogram.enable(), histogram.enable()); +const first = performance.eventLoopUtilization(); +console.log("before loop", first.idle, first.active, first.utilization); + +let last = performance.eventLoopUtilization(); +let ticks = 0; +// The unref'd interval keeps nothing alive; this timer holds the loop open +// until the third sample. +const keepAlive = setTimeout(() => console.log("never"), 10_000); +const interval = setInterval(() => { + const now = performance.eventLoopUtilization(); + const delta = performance.eventLoopUtilization(now, last); + last = now; + ticks++; + const p50 = histogram.percentile(50); + const p99 = histogram.percentile(99); + console.log( + "tick", + ticks, + delta.utilization >= 0 && delta.utilization <= 1, + delta.idle >= 0, + p50 > 0, + p99 >= p50, + histogram.max >= p99, + histogram.min <= p50, + histogram.count > 0, + ); + histogram.reset(); + console.log("after reset", histogram.count, histogram.max, histogram.min, Number.isNaN(histogram.mean)); + if (ticks === 3) { + clearInterval(interval); + clearTimeout(keepAlive); + console.log("disable", histogram.disable(), histogram.disable()); + const since = performance.eventLoopUtilization(first); + console.log("since", since.utilization > 0 && since.utilization <= 1, since.idle > 0); + try { + histogram.percentile(0); + } catch (e) { + console.log((e as Error).message, (e as { code?: string }).code); + } + } +}, 50); +interval.unref(); +console.log("hasRef", interval.hasRef()); diff --git a/tests/fixtures/node-types/README.md b/tests/fixtures/node-types/README.md index 0784c57fe..243ce0b81 100644 --- a/tests/fixtures/node-types/README.md +++ b/tests/fixtures/node-types/README.md @@ -21,6 +21,7 @@ What the fixture pins (see tests/harness/project-config.test.ts): (process.uptime, Buffer.from, setInterval) reports the SC2020-family fence naming @types/node, instead of typechecking its way into a broken binary — and never a raw "Cannot find name" error. +- `event-loop-metrics.ts` and `timeout-type.ts` — performance.eventLoopUtilization, monitorEventLoopDelay's IntervalHistogram and NodeJS.Timeout as declared types lower under @types/node exactly as under the fallback declarations. - `source-import/` — an imported TypeScript source may use the `RequestInfo` global supplied by TypeScript's default DOM lib while scriptc adopts the project's @types/node declarations. Projects WITHOUT @types/node (every other fixture and the whole corpus) keep diff --git a/tests/fixtures/node-types/event-loop-metrics.ts b/tests/fixtures/node-types/event-loop-metrics.ts new file mode 100644 index 000000000..f5c97c41b --- /dev/null +++ b/tests/fixtures/node-types/event-loop-metrics.ts @@ -0,0 +1,49 @@ +// Event-loop monitoring under @types/node: monitorEventLoopDelay's histogram, +// eventLoopUtilization deltas, and an unref'd interval typed NodeJS.Timeout. +import { monitorEventLoopDelay, performance } from "node:perf_hooks"; + +const histogram = monitorEventLoopDelay({ resolution: 10 }); +console.log("enable", histogram.enable(), histogram.enable()); +const first = performance.eventLoopUtilization(); +console.log("before loop", first.idle, first.active, first.utilization); + +let last = performance.eventLoopUtilization(); +let ticks = 0; +// The unref'd interval keeps nothing alive; this timer holds the loop open +// until the third sample. +const keepAlive = setTimeout(() => console.log("never"), 10_000); +const interval: NodeJS.Timeout = setInterval(() => { + const now = performance.eventLoopUtilization(); + const delta = performance.eventLoopUtilization(now, last); + last = now; + ticks++; + const p50 = histogram.percentile(50); + const p99 = histogram.percentile(99); + console.log( + "tick", + ticks, + delta.utilization >= 0 && delta.utilization <= 1, + delta.idle >= 0, + p50 > 0, + p99 >= p50, + histogram.max >= p99, + histogram.min <= p50, + histogram.count > 0, + ); + histogram.reset(); + console.log("after reset", histogram.count, histogram.max, histogram.min, Number.isNaN(histogram.mean)); + if (ticks === 3) { + clearInterval(interval); + clearTimeout(keepAlive); + console.log("disable", histogram.disable(), histogram.disable()); + const since = performance.eventLoopUtilization(first); + console.log("since", since.utilization > 0 && since.utilization <= 1, since.idle > 0); + try { + histogram.percentile(0); + } catch (e) { + console.log((e as Error).message, (e as { code?: string }).code); + } + } +}, 50); +interval.unref(); +console.log("hasRef", interval.hasRef()); diff --git a/tests/fixtures/node-types/timeout-type.ts b/tests/fixtures/node-types/timeout-type.ts new file mode 100644 index 000000000..42daee86d --- /dev/null +++ b/tests/fixtures/node-types/timeout-type.ts @@ -0,0 +1,30 @@ +// NodeJS.Timeout as a declared type under @types/node: fields, clearTimeout, +// ref/unref/hasRef, refresh. +class Poller { + private timer: NodeJS.Timeout | undefined; + private count = 0; + start(): void { + this.timer = setInterval(() => { + this.count++; + console.log("poll", this.count); + if (this.count === 3) this.stop(); + }, 5); + } + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + console.log("stopped"); + } + } +} +new Poller().start(); +const t: NodeJS.Timeout = setTimeout(() => console.log("never"), 1000); +console.log(t.hasRef()); +t.unref(); +console.log(t.hasRef()); +t.ref(); +console.log(t.hasRef()); +clearTimeout(t); +const timers: NodeJS.Timeout[] = [setTimeout(() => console.log("a"), 30), setTimeout(() => console.log("b"), 31)]; +console.log(timers.length); diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index bf57a830a..4b307b1d9 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -52,6 +52,32 @@ test("node-types: filesystem capacity supports typed number and BigInt results u expect(comparableStderr(native.stderr)).toBe(node.stderr); }); +for (const [name, title] of [ + [ + "event-loop-metrics", + "eventLoopUtilization and monitorEventLoopDelay follow @types/node's types", + ], + ["timeout-type", "NodeJS.Timeout fields, ref/unref/hasRef and clearInterval under @types/node"], +] as const) { + test(`node-types: ${title}`, async () => { + const outDir = outDirFor(name); + const entry = join(nodeTypesDir, `${name}.ts`); + const result = await compile(entry, { + outPath: join(outDir, process.platform === "win32" ? `${name}.exe` : name), + outDir, + sanitize, + }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const [native, node] = await Promise.all([ + execFileAsync(result.binaryPath), + execFileAsync(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", entry]), + ]); + expect(native.stdout).toBe(node.stdout); + expect(comparableStderr(native.stderr)).toBe(node.stderr); + }); +} + test("node-types: http header arrays and server.address() follow @types/node's types", async () => { const outDir = outDirFor("http-headers-address"); const entry = join(nodeTypesDir, "http-headers-address.ts"); diff --git a/tests/harness/self-hosting-native-toolchain.test.ts b/tests/harness/self-hosting-native-toolchain.test.ts index c22e8a839..39edd5567 100644 --- a/tests/harness/self-hosting-native-toolchain.test.ts +++ b/tests/harness/self-hosting-native-toolchain.test.ts @@ -42,6 +42,7 @@ const features: NativeLinkFeatures = { tls: false, tlsCa: false, cipher: false, + perfLoop: false, }; test("native toolchain configuration, staging and helper validation are static", async () => { diff --git a/tests/harness/self-hosting-runtime-pack.test.ts b/tests/harness/self-hosting-runtime-pack.test.ts index 08eb45754..7aee06559 100644 --- a/tests/harness/self-hosting-runtime-pack.test.ts +++ b/tests/harness/self-hosting-runtime-pack.test.ts @@ -43,6 +43,7 @@ const base: NativeLinkFeatures = { tls: false, tlsCa: false, cipher: false, + perfLoop: false, }; test("runtime manifest validation and selection are entirely static", async () => { From 33588981ae38c6a7d6380024e014dad5cc07abd0 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 19:59:40 -0700 Subject: [PATCH 06/31] Bake mixed JSON tables and destructure unions of arrays and tuples JSON documents whose rows mix strings and numbers (`(string | number)[][]`, `(string | number[])[][]`, the character-set and code-point tables of iconv-lite and tr46) now bake: comptime literals take union-typed elements and pick each value's arm from its JSON kind and contents. Array destructuring of a union of array and tuple types (`const [m, a, b] = re.exec(s) || ["", "", ""]`) reads each position from the arm the value holds: a tuple arm answers its field or undefined past its end, an array arm its element or undefined past its end. Unions of tuple types no longer pick the checker's `length` literal as a discriminant: tuple shapes have no such slot. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/array-values.ts | 60 ++++++++++++--- .../src/frontend/lowering/lower-comptime.ts | 73 ++++++++++++++++++- .../src/frontend/lowering/lower-stmts.ts | 12 ++- packages/compiler/src/frontend/type-mapper.ts | 3 + .../main.ts.json | 6 ++ ...11-union-array-tuple-destructuring.ts.json | 6 ++ .../6410-json-mixed-scalar-tables/extra.json | 1 + .../6410-json-mixed-scalar-tables/main.ts | 21 ++++++ .../node_modules/code-table/index.d.ts | 2 + .../node_modules/code-table/index.js | 10 +++ .../node_modules/code-table/package.json | 1 + .../node_modules/code-table/table.json | 1 + .../node_modules/range-map/index.d.ts | 2 + .../node_modules/range-map/index.js | 37 ++++++++++ .../node_modules/range-map/package.json | 1 + .../node_modules/range-map/table.json | 1 + .../6411-union-array-tuple-destructuring.ts | 34 +++++++++ 17 files changed, 255 insertions(+), 16 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6410-json-mixed-scalar-tables/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6411-union-array-tuple-destructuring.ts.json create mode 100644 tests/corpus/6410-json-mixed-scalar-tables/extra.json create mode 100644 tests/corpus/6410-json-mixed-scalar-tables/main.ts create mode 100644 tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/index.d.ts create mode 100644 tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/index.js create mode 100644 tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/package.json create mode 100644 tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/table.json create mode 100644 tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/index.d.ts create mode 100644 tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/index.js create mode 100644 tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/package.json create mode 100644 tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/table.json create mode 100644 tests/corpus/6411-union-array-tuple-destructuring.ts diff --git a/packages/compiler/src/frontend/lowering/array-values.ts b/packages/compiler/src/frontend/lowering/array-values.ts index 8f2b28a3f..e9148b244 100644 --- a/packages/compiler/src/frontend/lowering/array-values.ts +++ b/packages/compiler/src/frontend/lowering/array-values.ts @@ -84,8 +84,11 @@ export function arrayValueRead( }; } -/** Read a stabilized union of native arrays without copying either the - * receiver or its elements. Every arm contributes its missing-value case. */ +/** Read one position of a stabilized union of native arrays and tuples + * (`const [a, b] = re.exec(s) || ["", ""]`) without copying the receiver + * or its elements. Every array arm contributes its missing-value case; a + * tuple arm answers its field, or undefined past its end. Tuple arms need + * a constant position. */ export function unionArrayValueRead( lowerer: Lowerer, value: IrExpr, @@ -96,17 +99,32 @@ export function unionArrayValueRead( const unionId = value.type.unionId; const arms = lowerer.unions.get(unionId)?.arms; if (!arms?.length) return null; + const position = index.kind === "numLit" ? String(index.value) : null; + // A tuple arm's answer at the position: its field, or undefined past + // its end. Null when the arm is neither an array nor a tuple. + const tupleField = (arm: IrType): { name: string; type: IrType } | "past" | null => { + if (arm.kind !== "record" || position === null) return null; + const shape = lowerer.shapes.get(arm.shapeId); + if (!shape?.tuple) return null; + return shape.fields.find((f) => f.name === position) ?? "past"; + }; // Keep the collection's IrType union layout and narrow each element at // its use; an inferred array predicate changes the collection's ABI. - for (const arm of arms) if (arm.kind !== "array") return null; + for (const arm of arms) if (arm.kind !== "array" && tupleField(arm) === null) return null; const answers = new Map(); for (const arm of arms) { - if (arm.kind !== "array") return null; - const answer = arrayValueType(lowerer, arm.elem); + const field = arm.kind === "array" ? null : tupleField(arm); + const answer = + arm.kind === "array" + ? arrayValueType(lowerer, arm.elem) + : field === "past" + ? UNDEFINED_T + : field!.type; const parts = answer.kind === "union" ? lowerer.unions.get(answer.unionId)!.arms : [answer]; for (const part of parts) answers.set(typeKey(part), part); } const joined = [...answers.values()].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + if (joined.length === 1 && joined[0]!.kind === "undefinedT") return null; if ( joined.length > 1 && (!unionContainerArmsOk(joined) || @@ -118,12 +136,34 @@ export function unionArrayValueRead( let result: IrExpr | null = null; for (let tag = arms.length - 1; tag >= 0; tag--) { const arm = arms[tag]!; - if (arm.kind !== "array") return null; const receiver: IrExpr = { kind: "unionNarrow", unionId, tag, value, type: arm, loc }; - const read = lowerer.coerceToExpected( - arrayValueRead(lowerer, receiver, index, arm.elem, loc), - type, - ); + let read: IrExpr; + if (arm.kind === "array") { + read = lowerer.coerceToExpected( + arrayValueRead(lowerer, receiver, index, arm.elem, loc), + type, + ); + } else { + const field = tupleField(arm); + if (field === null) return null; + if (field === "past") { + const missing = lowerer.wrappedUndefined(type, loc); + if (!missing) return null; + read = missing; + } else { + read = lowerer.coerceToExpected( + { + kind: "recordGet", + obj: receiver, + shapeId: (arm as IrType & { kind: "record" }).shapeId, + field: field.name, + type: field.type, + loc, + }, + type, + ); + } + } result = result === null ? read diff --git a/packages/compiler/src/frontend/lowering/lower-comptime.ts b/packages/compiler/src/frontend/lowering/lower-comptime.ts index 41c6704c5..31fa5afe0 100644 --- a/packages/compiler/src/frontend/lowering/lower-comptime.ts +++ b/packages/compiler/src/frontend/lowering/lower-comptime.ts @@ -4,7 +4,7 @@ import { InternalCompilerError } from "../../errors.js"; * literals — records/arrays/unions included, subject to comptimeBakeable. */ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; -import { type IrExpr, type IrType } from "../../ir/ir.js"; +import { isUnitType, type IrExpr, type IrType } from "../../ir/ir.js"; import { comptimeFailedDiag } from "../../diagnostics/diagnostic.js"; import { locOf } from "../program.js"; import { PoisonError } from "./lowerer.js"; @@ -110,8 +110,9 @@ function comptimeFailureDetail(error: unknown): string { /** True when a comptime result of this type can be written as a literal * expression: number/string/boolean, arrays and record shapes of those, - * nested. Closures, class instances, unions, promises, dyn, and void - * cannot be baked. */ + * and unions of those with null or undefined (the mixed rows of a JSON + * table: `(string | number)[][]`), nested. Closures, class instances, + * promises, dyn, and void cannot be baked. */ export function comptimeBakeable(lowerer: Lowerer, t: IrType): boolean { switch (t.kind) { case "f64": @@ -124,6 +125,52 @@ export function comptimeBakeable(lowerer: Lowerer, t: IrType): boolean { const shape = lowerer.shapes.get(t.shapeId); return !!shape && shape.fields.every((f) => lowerer.comptimeBakeable(f.type)); } + case "union": { + const arms = lowerer.unions.get(t.unionId)?.arms; + return ( + arms !== undefined && + arms.every( + (arm) => isUnitType(arm) || (arm.kind !== "union" && comptimeBakeable(lowerer, arm)), + ) + ); + } + default: + return false; + } +} + +/** Whether `value` bakes into the bakeable type `t` without a mismatch: + * the arm choice of a union literal. Records are exact, as in + * comptimeValueToIr; an absent field reads as undefined. */ +function comptimeFits(lowerer: Lowerer, value: unknown, t: IrType): boolean { + switch (t.kind) { + case "f64": + return typeof value === "number" && Number.isFinite(value); + case "string": + return typeof value === "string"; + case "bool": + return typeof value === "boolean"; + case "nullT": + return value === null; + case "undefinedT": + return value === undefined; + case "array": + return Array.isArray(value) && value.every((el) => comptimeFits(lowerer, el, t.elem)); + case "record": { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const shape = lowerer.shapes.get(t.shapeId); + if (!shape) return false; + const obj = value as Record; + const declared = new Set(shape.fields.map((f) => f.name)); + return ( + Object.keys(obj).every((key) => declared.has(key)) && + shape.fields.every((f) => comptimeFits(lowerer, obj[f.name], f.type)) + ); + } + case "union": + return (lowerer.unions.get(t.unionId)?.arms ?? []).some((arm) => + comptimeFits(lowerer, value, arm), + ); default: return false; } @@ -265,6 +312,26 @@ export function comptimeValueToIr( })); return { kind: "recordLit", fields, type: expected, loc }; } + case "union": { + // The first arm the value fits, in the union's arm order: a JSON + // value fits exactly one scalar arm, and array or record arms are + // told apart by their contents. + const arms = lowerer.unions.get(expected.unionId)!.arms; // bakeable-checked + const tag = arms.findIndex((arm) => comptimeFits(lowerer, value, arm)); + if (tag < 0) fail(describeComptimeValue(value)); + const arm = arms[tag]!; + const payload: IrExpr = isUnitType(arm) + ? { kind: "unitLit", unit: arm.kind === "nullT" ? "null" : "undefined", type: arm, loc } + : lowerer.comptimeValueToIr(value, arm, path, blame); + return { + kind: "unionWrap", + unionId: expected.unionId, + tag, + value: payload, + type: expected, + loc, + }; + } default: // comptimeBakeable already fenced these off. throw new InternalCompilerError(`lowerer bug: unbakeable comptime target '${expected.kind}'`); diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 2c36e4973..444fb899c 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -2379,20 +2379,26 @@ export function lowerBindingPattern( lowerer.fenceStaticHeadersIteration(pattern); if ( srcType.kind === "union" && - lowerer.unions.get(srcType.unionId)?.arms.every((arm) => arm.kind === "array") + lowerer.unions + .get(srcType.unionId) + ?.arms.every( + (arm) => + arm.kind === "array" || + (arm.kind === "record" && lowerer.shapes.get(arm.shapeId)?.tuple === true), + ) ) { for (let i = 0; i < pattern.elements.nodes.length; i++) { const el = pattern.elements.nodes[i]!; if (ts.isOmittedExpression(el) || el.name === undefined) continue; if (el.dotDotDotToken) - lowerer.unsupported("SC1031", el, "rest elements over a union of array layouts"); + lowerer.unsupported("SC1031", el, "rest elements over a union of array or tuple layouts"); const loc = locOf(el); let value = unionArrayValueRead(lowerer, srcRef(), numLit(i, loc), loc); if (!value) lowerer.unsupported( "SC1031", el, - "array union elements without a common native representation", + "array or tuple union elements without a common native representation", ); if (el.initializer) value = applyBindingDefault(lowerer, el, value); lowerer.bindPatternTarget(el.name, value, isLet, out); diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 437aba2b8..eea7dd976 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -4411,6 +4411,9 @@ function unionDiscriminant( ): IrUnionDiscriminant | undefined { if (arms.filter((arm) => arm.kind === "record").length < 2 || parts.length < 2) return undefined; const { checker } = ctx; + // Tuple shapes keep positions only: their checker `length` literal has + // no slot to compare (discriminantField refuses tuple shapes too). + if (parts.some((part) => ctx.shapes.get(part.mapped.shapeId)?.tuple)) return undefined; for (const candidate of checker.getPropertiesOfType(parts[0]!.source)) { if (candidate.name.startsWith("__@")) continue; const byTag = new Map(); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6410-json-mixed-scalar-tables/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6410-json-mixed-scalar-tables/main.ts.json new file mode 100644 index 000000000..c7ec1ab56 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6410-json-mixed-scalar-tables/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6410-json-mixed-scalar-tables/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6411-union-array-tuple-destructuring.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6411-union-array-tuple-destructuring.ts.json new file mode 100644 index 000000000..94d8b78e7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6411-union-array-tuple-destructuring.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6411-union-array-tuple-destructuring.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6410-json-mixed-scalar-tables/extra.json b/tests/corpus/6410-json-mixed-scalar-tables/extra.json new file mode 100644 index 000000000..927c5d968 --- /dev/null +++ b/tests/corpus/6410-json-mixed-scalar-tables/extra.json @@ -0,0 +1 @@ +[["x", 1, "two", 3], [4, "five"], ["six"]] diff --git a/tests/corpus/6410-json-mixed-scalar-tables/main.ts b/tests/corpus/6410-json-mixed-scalar-tables/main.ts new file mode 100644 index 000000000..54e337b02 --- /dev/null +++ b/tests/corpus/6410-json-mixed-scalar-tables/main.ts @@ -0,0 +1,21 @@ +// JSON tables whose rows mix strings and numbers, the shape of a +// character-set table (`[["8140", 12288, 65292], ...]`) and of a +// code-point range map (`[[[0, 44], "disallowed"], [[65, 65], "mapped", +// [97]], ...]`). A package requires them at load or inside a function, and +// an ES module imports one; each row keeps its strings and numbers. +import { lookup, rowCount } from "code-table"; +import { mapChars, status } from "range-map"; +import extra from "./extra.json" with { type: "json" }; + +console.log(lookup("8150"), lookup("a1"), lookup("zzzz"), rowCount()); +const mapped = mapChars("AB-c9"); +console.log(mapped.text, mapped.error); +console.log(JSON.stringify(mapChars("a,b")), JSON.stringify(mapChars("{"))); +console.log(status(10), status(46), status(66), status(1000)); + +for (const row of extra) { + const head = row[0]; + const rest = row.slice(1); + console.log(typeof head, head, rest.length, rest.map((v) => (typeof v === "number" ? v + 1 : v.toUpperCase())).join(",")); +} +console.log(JSON.stringify(extra)); diff --git a/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/index.d.ts b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/index.d.ts new file mode 100644 index 000000000..ae7db1d98 --- /dev/null +++ b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/index.d.ts @@ -0,0 +1,2 @@ +export declare function lookup(code: string): number | string; +export declare function rowCount(): number; diff --git a/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/index.js b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/index.js new file mode 100644 index 000000000..dc10bb817 --- /dev/null +++ b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/index.js @@ -0,0 +1,10 @@ +'use strict'; +const table = require('./table.json'); +exports.lookup = function (code) { + for (const row of table) if (row[0] === code) return row[1]; + return -1; +}; +exports.rowCount = function () { + // A second require of the same document answers the same rows. + return require('./table.json').length; +}; diff --git a/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/package.json b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/package.json new file mode 100644 index 000000000..e15622d87 --- /dev/null +++ b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/package.json @@ -0,0 +1 @@ +{ "name": "code-table", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/table.json b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/table.json new file mode 100644 index 000000000..5abaa6772 --- /dev/null +++ b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/table.json @@ -0,0 +1 @@ +[["8140", 12288, 65292], ["8150", 65507, 65340], ["a1", "。", 62]] diff --git a/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/index.d.ts b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/index.d.ts new file mode 100644 index 000000000..322bd20e1 --- /dev/null +++ b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/index.d.ts @@ -0,0 +1,2 @@ +export declare function mapChars(s: string): { text: string; error: boolean }; +export declare function status(cp: number): string; diff --git a/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/index.js b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/index.js new file mode 100644 index 000000000..481d93ad4 --- /dev/null +++ b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/index.js @@ -0,0 +1,37 @@ +'use strict'; +var table = require('./table.json'); +function findStatus(val) { + var start = 0; + var end = table.length - 1; + while (start <= end) { + var mid = Math.floor((start + end) / 2); + var target = table[mid]; + if (target[0][0] <= val && target[0][1] >= val) { + return target; + } else if (target[0][0] > val) { + end = mid - 1; + } else { + start = mid + 1; + } + } + return null; +} +exports.status = function (cp) { + var found = findStatus(cp); + return found === null ? 'unknown' : found[1]; +}; +exports.mapChars = function (s) { + var error = false; + var out = ''; + for (var i = 0; i < s.length; ++i) { + var cp = s.codePointAt(i); + var found = findStatus(cp); + if (found === null) { error = true; continue; } + switch (found[1]) { + case 'disallowed': error = true; out += String.fromCodePoint(cp); break; + case 'mapped': out += String.fromCodePoint.apply(String, found[2]); break; + case 'valid': out += String.fromCodePoint(cp); break; + } + } + return { text: out, error: error }; +}; diff --git a/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/package.json b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/package.json new file mode 100644 index 000000000..bc877e1e8 --- /dev/null +++ b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/package.json @@ -0,0 +1 @@ +{ "name": "range-map", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/table.json b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/table.json new file mode 100644 index 000000000..900f9e316 --- /dev/null +++ b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/table.json @@ -0,0 +1 @@ +[[[0,44],"disallowed"],[[45,46],"valid"],[[47,47],"disallowed"],[[48,57],"valid"],[[65,65],"mapped",[97]],[[66,67],"mapped",[98,99]],[[97,122],"valid"]] diff --git a/tests/corpus/6411-union-array-tuple-destructuring.ts b/tests/corpus/6411-union-array-tuple-destructuring.ts new file mode 100644 index 000000000..1dd88fa92 --- /dev/null +++ b/tests/corpus/6411-union-array-tuple-destructuring.ts @@ -0,0 +1,34 @@ +// Array destructuring of a value whose type is a union of array and tuple +// types: a regex match or a default row (`re.exec(s) || ["", "", ""]`), and +// functions answering either a fixed tuple or a list. Each position reads +// the arm the value holds; positions past a tuple's end and past an array's +// end read undefined, and defaults fire exactly there. +function scan(text: string): string { + const [match, head, word] = /(\w*)-(\w+)/.exec(text) || ["", "", ""]; + return `${match}|${head}|${word.toUpperCase()}`; +} +console.log(scan("ab-cd"), scan("none"), scan("-x")); + +function pick(flag: boolean): [string, number] | string[] { + return flag ? ["a", 1] : ["b", "c"]; +} +const [p, q] = pick(true); +const [r, s = "dflt", t = "z"] = pick(false); +const [u, , w = "w"] = pick(true); +console.log(p, q, r, s, t, u, w); +console.log(typeof q, typeof s); + +function pair(n: number): number[] | [number, number] { + return n > 1 ? [n, n * 2] : [n]; +} +const [x, y] = pair(7); +const [x1, y1 = -1] = pair(1); +console.log(x, y, x1, y1); + +function row(kind: string): [string, boolean] | [string, boolean, number] { + return kind === "long" ? [kind, true, 3] : [kind, false]; +} +for (const k of ["long", "short"]) { + const [name, flag, count = 0] = row(k); + console.log(name, flag, count); +} From 52f9bf284f7174c3e0f0163aa676f57591ec25b4 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 20:40:56 -0700 Subject: [PATCH 07/31] Destructure a match-or-empty-string union by code point `const [hit] = re.exec(s) || ""` destructures `RegExpExecArray | string`: the string arm answers its code point at each position, or undefined past its end, as the string iterator does. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/array-values.ts | 25 ++++++++++++++----- .../src/frontend/lowering/lower-stmts.ts | 8 ++++-- .../6411-union-array-tuple-destructuring.ts | 8 ++++++ 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/array-values.ts b/packages/compiler/src/frontend/lowering/array-values.ts index e9148b244..0ca8503df 100644 --- a/packages/compiler/src/frontend/lowering/array-values.ts +++ b/packages/compiler/src/frontend/lowering/array-values.ts @@ -8,6 +8,7 @@ import { type IrType, isIntegerBytesElem, type SrcLoc, + STRING, UNDEFINED_T, typeEquals, typeKey, @@ -88,12 +89,15 @@ export function arrayValueRead( * (`const [a, b] = re.exec(s) || ["", ""]`) without copying the receiver * or its elements. Every array arm contributes its missing-value case; a * tuple arm answers its field, or undefined past its end. Tuple arms need - * a constant position. */ + * a constant position. With `stringChars` (the string iterator's code + * points as a fresh string[]), a string arm (`re.exec(s) || ""`) answers + * its code point at the position, or undefined past its end. */ export function unionArrayValueRead( lowerer: Lowerer, value: IrExpr, index: IrExpr, loc: SrcLoc, + stringChars?: (text: IrExpr) => IrExpr, ): IrExpr | null { if (value.type.kind !== "union") return null; const unionId = value.type.unionId; @@ -110,16 +114,20 @@ export function unionArrayValueRead( }; // Keep the collection's IrType union layout and narrow each element at // its use; an inferred array predicate changes the collection's ABI. - for (const arm of arms) if (arm.kind !== "array" && tupleField(arm) === null) return null; + const stringArm = (arm: IrType): boolean => arm.kind === "string" && stringChars !== undefined; + for (const arm of arms) + if (arm.kind !== "array" && !stringArm(arm) && tupleField(arm) === null) return null; const answers = new Map(); for (const arm of arms) { - const field = arm.kind === "array" ? null : tupleField(arm); + const field = arm.kind === "array" || stringArm(arm) ? null : tupleField(arm); const answer = arm.kind === "array" ? arrayValueType(lowerer, arm.elem) - : field === "past" - ? UNDEFINED_T - : field!.type; + : stringArm(arm) + ? arrayValueType(lowerer, STRING) + : field === "past" + ? UNDEFINED_T + : field!.type; const parts = answer.kind === "union" ? lowerer.unions.get(answer.unionId)!.arms : [answer]; for (const part of parts) answers.set(typeKey(part), part); } @@ -143,6 +151,11 @@ export function unionArrayValueRead( arrayValueRead(lowerer, receiver, index, arm.elem, loc), type, ); + } else if (stringArm(arm)) { + read = lowerer.coerceToExpected( + arrayValueRead(lowerer, stringChars!(receiver), index, STRING, loc), + type, + ); } else { const field = tupleField(arm); if (field === null) return null; diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 444fb899c..737c2e9ae 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -2384,8 +2384,10 @@ export function lowerBindingPattern( ?.arms.every( (arm) => arm.kind === "array" || + arm.kind === "string" || (arm.kind === "record" && lowerer.shapes.get(arm.shapeId)?.tuple === true), - ) + ) && + lowerer.unions.get(srcType.unionId)!.arms.some((arm) => arm.kind !== "string") ) { for (let i = 0; i < pattern.elements.nodes.length; i++) { const el = pattern.elements.nodes[i]!; @@ -2393,7 +2395,9 @@ export function lowerBindingPattern( if (el.dotDotDotToken) lowerer.unsupported("SC1031", el, "rest elements over a union of array or tuple layouts"); const loc = locOf(el); - let value = unionArrayValueRead(lowerer, srcRef(), numLit(i, loc), loc); + let value = unionArrayValueRead(lowerer, srcRef(), numLit(i, loc), loc, (text) => + strCharsCall(lowerer, text, loc), + ); if (!value) lowerer.unsupported( "SC1031", diff --git a/tests/corpus/6411-union-array-tuple-destructuring.ts b/tests/corpus/6411-union-array-tuple-destructuring.ts index 1dd88fa92..f62eba996 100644 --- a/tests/corpus/6411-union-array-tuple-destructuring.ts +++ b/tests/corpus/6411-union-array-tuple-destructuring.ts @@ -32,3 +32,11 @@ for (const k of ["long", "short"]) { const [name, flag, count = 0] = row(k); console.log(name, flag, count); } + +// A match or an empty string (`re.exec(s) || ""`): a string destructures +// by code point, so the empty string binds undefined. +function firstHit(text: string): string { + const [hit] = /\d+/.exec(text) || ""; + return hit ? `hit ${hit}` : "none"; +} +console.log(firstHit("ab12"), firstHit("ab")); From c208973d752bd72c72b6497358e7e4f2a3860342 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 20:35:23 -0700 Subject: [PATCH 08/31] Read T[keyof T] as unknown for T bound to an unknown-valued record Inside a generic body Object.entries(t) over T extends Record types its values 'keyof T extends never ? unknown : T[keyof T]'. An instance whose T maps to the checked-dynamic value now reads both branches as unknown. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/type-mapper.ts | 19 +++++++++-- ...468-generic-unknown-record-entries.ts.json | 6 ++++ .../6468-generic-unknown-record-entries.ts | 33 +++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6468-generic-unknown-record-entries.ts.json create mode 100644 tests/corpus/6468-generic-unknown-record-entries.ts diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 1e3954586..7a40d4e24 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -5139,11 +5139,15 @@ function mapGenericIndexedAccess(type: ts.Type, ctx: TypeMapperCtx): IrType | nu const obj = type.getObjectType(); if (obj === undefined || !(obj.flags & ts.TypeFlags.TypeParameter)) return null; const bound = resolveTypeParam(obj); + const idx = type.getIndexType(); + if (idx === undefined) return null; + // `T[keyof T]` over a T bound to an unknown-valued record view + // (`Record` maps to the checked-dynamic value, see + // mapRecordType): every member reads as unknown. + if (bound?.kind === "dyn" && idx.isIndexType() && idx.getTarget() === obj) return DYN; if (!bound || bound.kind !== "record") return null; const shape = shapes.get(bound.shapeId); if (!shape || shape.tuple) return null; - const idx = type.getIndexType(); - if (idx === undefined) return null; let covered: IrType[] | null = null; if (idx.isStringLiteralType()) { const f = shape.fields.find((x) => x.name === idx.value); @@ -5199,6 +5203,17 @@ function mapBoundKeyofNeverConditional(type: ts.Type, ctx: TypeMapperCtx): IrTyp const target = check.getTarget(); if (!target || !(target.flags & ts.TypeFlags.TypeParameter)) return null; const bound = resolveTypeParam(target); + // A T bound to the checked-dynamic value (`Record`, an + // unknown-valued record view) has no static key set to decide the + // branch, but both branches of the lib's `keyof T extends never ? + // unknown : T[keyof T]` read as unknown there: one answer either way. + if (bound?.kind === "dyn") { + const whenEmpty = type.getTrueType(); + const otherwise = type.getFalseType(); + const a = whenEmpty ? mapType(whenEmpty, ctx) : null; + const b = otherwise ? mapType(otherwise, ctx) : null; + return a && b && typeEquals(a, b) ? a : null; + } if (bound?.kind !== "record") return null; const shape = shapes.get(bound.shapeId); if (!shape || shape.tuple) return null; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6468-generic-unknown-record-entries.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6468-generic-unknown-record-entries.ts.json new file mode 100644 index 000000000..0b4118c70 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6468-generic-unknown-record-entries.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6468-generic-unknown-record-entries.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6468-generic-unknown-record-entries.ts b/tests/corpus/6468-generic-unknown-record-entries.ts new file mode 100644 index 000000000..f0e57756a --- /dev/null +++ b/tests/corpus/6468-generic-unknown-record-entries.ts @@ -0,0 +1,33 @@ +// Inside a generic body, `Object.entries(tags)` over `T extends +// Record` reads `[string, T[keyof T]]` elements. An +// instance whose T is the unknown-valued record view reads every member +// as unknown; a record instance reads the union of its member types. +function upper(text: string): string { + return text.toUpperCase(); +} + +function scrub(text: string): string { + return text.replace(/secret/g, "***"); +} + +function describe>(tags: T): string[] { + return Object.entries(tags).map( + ([key, value]) => `${key}=${typeof value === "string" ? upper(value) : String(value)}`, + ); +} + +function redact>(tags: T): T { + return Object.fromEntries( + Object.entries(tags).map(([key, value]) => [key, typeof value === "string" ? scrub(value) : value]), + ) as T; +} + +function flatten(value: unknown): Record { + return typeof value === "object" && value !== null ? (value as Record) : { value }; +} + +console.log(describe(flatten({ b: 1, a: "x", c: true }))); +console.log(describe(flatten(5))); +console.log(describe({ name: "n", size: 3 })); +const cleaned = redact(flatten({ token: "secret", count: 2 })); +console.log(cleaned, Object.keys(cleaned)); From fe322ae901cbc4914f527c756d8f71235f2454f4 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 21:16:25 -0700 Subject: [PATCH 09/31] Keep the JSON-shape diagnostics fixture refused now that null fields bake A null-valued JSON field bakes as null, so the fixture's document now compiles. It keeps its purpose with a value no literal spells (1e400 parses to Infinity), and the mixed-table corpus program covers null elements. Co-Authored-By: Claude Opus 5.5 --- tests/corpus/6410-json-mixed-scalar-tables/extra.json | 2 +- tests/corpus/6410-json-mixed-scalar-tables/main.ts | 7 ++++++- tests/diagnostics/json-shape/main.ts | 8 ++++---- tests/diagnostics/json-shape/nulled.json | 2 +- tests/harness/__snapshots__/json-shape/main.ts.txt | 4 ++-- 5 files changed, 14 insertions(+), 9 deletions(-) diff --git a/tests/corpus/6410-json-mixed-scalar-tables/extra.json b/tests/corpus/6410-json-mixed-scalar-tables/extra.json index 927c5d968..7af99c22f 100644 --- a/tests/corpus/6410-json-mixed-scalar-tables/extra.json +++ b/tests/corpus/6410-json-mixed-scalar-tables/extra.json @@ -1 +1 @@ -[["x", 1, "two", 3], [4, "five"], ["six"]] +[["x", 1, "two", 3], [4, "five", null], ["six"], [null]] diff --git a/tests/corpus/6410-json-mixed-scalar-tables/main.ts b/tests/corpus/6410-json-mixed-scalar-tables/main.ts index 54e337b02..c60f962dc 100644 --- a/tests/corpus/6410-json-mixed-scalar-tables/main.ts +++ b/tests/corpus/6410-json-mixed-scalar-tables/main.ts @@ -16,6 +16,11 @@ console.log(status(10), status(46), status(66), status(1000)); for (const row of extra) { const head = row[0]; const rest = row.slice(1); - console.log(typeof head, head, rest.length, rest.map((v) => (typeof v === "number" ? v + 1 : v.toUpperCase())).join(",")); + console.log( + typeof head, + head, + rest.length, + rest.map((v) => (v === null ? "null" : typeof v === "number" ? v + 1 : v.toUpperCase())).join(","), + ); } console.log(JSON.stringify(extra)); diff --git a/tests/diagnostics/json-shape/main.ts b/tests/diagnostics/json-shape/main.ts index 1d9552506..a46338d52 100644 --- a/tests/diagnostics/json-shape/main.ts +++ b/tests/diagnostics/json-shape/main.ts @@ -1,7 +1,7 @@ -/* A JSON document whose shape doesn't bake: a null-valued field has no - * standalone STATIC type, so the import reports the dynamic-family type - * diagnostic (SC2011 at the import site — the shape maps in the embedded - * engine) and uses of the binding poison. */ +/* A JSON document whose value doesn't bake: `1e400` parses to Infinity, + * which no number literal spells, so the import reports the bake failure + * at the import site and uses of the binding poison. (Its null-valued + * field bakes as null.) */ import bad from "./nulled.json" with { type: "json" }; console.log(bad.name); diff --git a/tests/diagnostics/json-shape/nulled.json b/tests/diagnostics/json-shape/nulled.json index fa4738449..5e506a6af 100644 --- a/tests/diagnostics/json-shape/nulled.json +++ b/tests/diagnostics/json-shape/nulled.json @@ -1 +1 @@ -{ "name": "x", "deprecated": null } +{ "name": "x", "deprecated": null, "limit": 1e400 } diff --git a/tests/harness/__snapshots__/json-shape/main.ts.txt b/tests/harness/__snapshots__/json-shape/main.ts.txt index b03f098a6..85f5d2917 100644 --- a/tests/harness/__snapshots__/json-shape/main.ts.txt +++ b/tests/harness/__snapshots__/json-shape/main.ts.txt @@ -1,6 +1,6 @@ -json-shape/main.ts:5:8 - error SC2001: values of type '{ name: string; deprecated: null; }' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +json-shape/main.ts:5:8 - error SC1110: comptime evaluation failed: expected 'number' at $.limit, got Infinity (only finite numbers can be written as literals) - 4 | * engine) and uses of the binding poison. */ + 4 | * field bakes as null.) */ 5 | import bad from "./nulled.json" with { type: "json" }; | ^~~ 6 | From 85a2567ecdcacfcbf1004af9cc248b7fc04b8ffc Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 20:35:22 -0700 Subject: [PATCH 10/31] Treat optional members an index signature covers as keys, erase 'as never', and keep fromEntries objects asserted to records A record type whose declared members are all optional and typed as its index signature's slot (ProcessEnv's TZ?, '{ CI?: string } & Dictionary') interns as the pure index-signature record, its slot admitting undefined: spread, delete and enumeration then follow one insertion order. 'value as never' erases, so the consuming slot checks an unknown value like any other. 'Object.fromEntries(...) as T' for a declared record T goes through the checked-dynamic bridge, which keeps the entries' keys and order. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/narrowing.ts | 52 +++++++++++++ packages/compiler/src/frontend/type-mapper.ts | 47 ++++++++++++ ...ional-members-over-index-signature.ts.json | 6 ++ .../6466-never-assertion-argument.ts.json | 6 ++ .../6467-fromentries-record-assertion.ts.json | 6 ++ ...5-optional-members-over-index-signature.ts | 73 +++++++++++++++++++ tests/corpus/6466-never-assertion-argument.ts | 31 ++++++++ .../6467-fromentries-record-assertion.ts | 29 ++++++++ 8 files changed, 250 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6465-optional-members-over-index-signature.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6466-never-assertion-argument.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6467-fromentries-record-assertion.ts.json create mode 100644 tests/corpus/6465-optional-members-over-index-signature.ts create mode 100644 tests/corpus/6466-never-assertion-argument.ts create mode 100644 tests/corpus/6467-fromentries-record-assertion.ts diff --git a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts index 207000717..09bb4b4d1 100644 --- a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts +++ b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts @@ -1151,6 +1151,50 @@ export function withRuntimeOptionalNarrowed( * memory); * - dyn → anything else (closures, class instances, void) is rejected: * those types cannot be found inside a JSON dyn. */ +/** `Object.fromEntries(...) as T` with T a declared record (the generic + * `transform>(t: T): T` idiom): the + * entries build a pure index-signature record, which has no layout in + * common with T's struct. Node keeps the object itself, with exactly the + * keys and the order the entries gave it. The result of fromEntries is a + * fresh object nothing else references, so the checked-dynamic bridge, + * which keeps keys and their order the same way `JSON.parse(text) as T` + * does, cannot be told apart from the original object; a value outside + * T throws the catchable TypeError of every checked cast. Other sources + * may be aliased, where a rebuilt object would sever mutation, so they + * keep the exact-shape fence. */ +function freshEntriesRecordAssertion( + lowerer: Lowerer, + expr: ts.AsExpression | ts.TypeAssertion, + inner: IrExpr, + target: IrType | null, +): IrExpr | null { + if (inner.type.kind !== "record" || target?.kind !== "record") return null; + const source = lowerer.shapes.get(inner.type.shapeId); + const dest = lowerer.shapes.get(target.shapeId); + if (!source?.indexValue || source.tuple || source.fields.length > 0) return null; + if (!dest || dest.tuple || dest.indexValue || shapeHasAccessorSlots(dest)) return null; + let operand: ts.Expression = expr.expression; + while (ts.isParenthesizedExpression(operand)) operand = operand.expression; + if ( + !ts.isCallExpression(operand) || + !ts.isPropertyAccessExpression(operand.expression) || + operand.expression.name.text !== "fromEntries" || + !lowerer.isStdlibGlobal(operand.expression.expression, "Object") + ) + return null; + if (!lowerer.dynConvertible(inner.type)) return null; + if ( + !canDynCheckTo( + target, + (id) => lowerer.shapes.get(id), + (id) => lowerer.unions.get(id), + ) + ) + return null; + const loc = locOf(expr); + return { kind: "dynCheck", value: lowerer.coerceToExpected(inner, DYN), type: target, loc }; +} + /** `e as T` and the old-style assertion `e` — one node shape (both * carry `.type` and `.expression`), one lowering. */ export function lowerAsExpression( @@ -1286,6 +1330,8 @@ export function lowerAsExpression( ) { return lowerer.coerceInto(expr, inner, target); } + const rebuilt = freshEntriesRecordAssertion(lowerer, expr, inner, target); + if (rebuilt) return rebuilt; // Static assertions normally erase, but record layouts are // monomorphic: a consumer selected from the asserted shape must see // that shape physically. Reuse the ordinary slot coercion so plain @@ -1327,6 +1373,12 @@ export function lowerAsExpression( return inner; // erasure, unchanged } + // `value as never` is the escape hatch that makes an unknown value fit + // any slot (`tagSuccess(value as never)`). No value has type never, so + // checking against its placeholder layout would reject every value. + // Erase it instead: the consuming slot applies the same validated + // extraction an unknown value gets wherever the checker admits it. + if (expr.type.kind === ts.SyntaxKind.NeverKeyword) return inner; const targetTs = lowerer.checker.getTypeFromTypeNode(expr.type); const target = lowerer.mapTypeOf(targetTs) ?? dynFallbackType(lowerer, expr.type, targetTs); if (!target) lowerer.badType(expr.type, targetTs); diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 437aba2b8..2b47c8ff3 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -5860,6 +5860,8 @@ function mapRecordType(widened: ts.Type, ctx: TypeMapperCtx): IrType | null { (inner.indexValue === undefined || inner.indexValue.kind === "dyn") ) return DYN; + const covered = coveredOptionalFieldsShape(widened, inner, ctx); + if (covered !== null) return { kind: "record", shapeId: covered }; const refined = refinedConstituentShape(widened, inner, ctx); if (refined !== null) return { kind: "record", shapeId: refined }; return { @@ -5871,6 +5873,51 @@ function mapRecordType(widened: ts.Type, ctx: TypeMapperCtx): IrType | null { } } +/** An index signature already describes every OPTIONAL declared member + * whose type is its slot type: @types/node's `ProcessEnv extends + * Dict { TZ?: string }` and every spread of it, or a declared + * environment `{ CI?: string } & Dictionary`. At runtime such a + * member is just another key, so the shape interns as the pure + * index-signature record. Its slot admits undefined, as the optional + * members do (`env.CI = undefined` keeps the key). A hybrid layout would + * give each member a struct slot, which enumerates before the overflow + * keys (Node keeps one insertion order) and makes `delete` and spread + * answer differently for declared and undeclared keys. Required members, + * and members typed otherwise than the slot (whose reads the slot type + * could not give back), keep the hybrid shape. */ +function coveredOptionalFieldsShape( + widened: ts.Type, + parts: RecordShapeParts, + ctx: TypeMapperCtx, +): string | null { + const slot = parts.indexValue; + if (!slot || parts.fields.length === 0) return null; + const { checker, shapes, unions } = ctx; + // The members' common type must be the slot plus undefined. Read it off + // the members (already interned) rather than building it: a hybrid that + // does not qualify must not intern anything new. + const optionalSlot = parts.fields[0]!.type; + if (!parts.fields.every((field) => typeEquals(field.type, optionalSlot))) return null; + if (optionalSlot.kind !== "union" || unions.armTag(optionalSlot.unionId, UNDEFINED_T) < 0) + return null; + const memberArms = unions.get(optionalSlot.unionId)?.arms ?? []; + const slotArms = + slot.kind === "union" ? (unions.get(slot.unionId)?.arms ?? []) : isUnitType(slot) ? [] : [slot]; + const definedArms = memberArms.filter((arm) => arm.kind !== "undefinedT"); + const slotDefined = slotArms.filter((arm) => arm.kind !== "undefinedT"); + if ( + definedArms.length === 0 || + definedArms.length !== slotDefined.length || + !slotDefined.every((arm) => definedArms.some((other) => typeEquals(arm, other))) + ) + return null; + if (!isSupportedIndexValue(optionalSlot)) return null; + const props = checker.getPropertiesOfType(widened); + if (props.length !== parts.fields.length) return null; + if (!props.every((p) => (p.flags & ts.SymbolFlags.Optional) !== 0)) return null; + return shapes.intern([], false, optionalSlot, []); +} + /** A refinement intersection (`Expr & { kind: "mul" }`, which the checker * reduces to `Mul & { kind: "mul" }`) keeps the shape of the constituent it * refines when the refinement changes no field's representation. Recursive diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6465-optional-members-over-index-signature.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6465-optional-members-over-index-signature.ts.json new file mode 100644 index 000000000..91a157207 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6465-optional-members-over-index-signature.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6465-optional-members-over-index-signature.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6466-never-assertion-argument.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6466-never-assertion-argument.ts.json new file mode 100644 index 000000000..17f25aa22 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6466-never-assertion-argument.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6466-never-assertion-argument.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6467-fromentries-record-assertion.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6467-fromentries-record-assertion.ts.json new file mode 100644 index 000000000..21ca66953 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6467-fromentries-record-assertion.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6467-fromentries-record-assertion.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6465-optional-members-over-index-signature.ts b/tests/corpus/6465-optional-members-over-index-signature.ts new file mode 100644 index 000000000..df957c8d9 --- /dev/null +++ b/tests/corpus/6465-optional-members-over-index-signature.ts @@ -0,0 +1,73 @@ +// An index signature whose value admits undefined already describes an +// optional member of the same type (`interface Env extends Dict +// { TZ?: string }`, @types/node's ProcessEnv): such a record is one key +// set in insertion order, so spread, delete, `in` and every enumeration +// treat the declared member like any other key. +interface Env { + TZ?: string; + [key: string]: string | undefined; +} + +const base: Env = { A: "1", TZ: "UTC", B: "2" }; +const env = { ...base }; +env.C = "3"; +for (const key of ["A", "TZ", "MISSING"]) { + delete env[key]; +} +console.log(Object.entries(env), Object.keys(base)); +const probe = (key: string): boolean => key in env; +console.log(probe("TZ"), probe("B"), env.TZ, env.B); +env.TZ = "Europe/Berlin"; +console.log(JSON.stringify(env), env); + +const extended: Env = { ...base, D: "4" }; +delete extended.TZ; +extended.TZ = "Asia/Tokyo"; +console.log(extended, Object.keys(extended).join(",")); + +// A declared member written after another key comes after it. +interface Scores { + best?: number; + [name: string]: number | undefined; +} +const scores: Scores = {}; +scores["x"] = 1; +scores.best = 2; +console.log(Object.keys(scores), JSON.stringify(scores)); + +// The process environment has the same shape; a child environment built +// from it keeps the parent's keys in order. +process.env.SCX_FIRST = "first"; +process.env.SCX_DROP = "drop"; +process.env.SCX_LAST = "last"; +const DROP = ["SCX_DROP", "SCX_NOT_SET"]; +function childEnv(): string[] { + const runEnv = { ...process.env }; + runEnv.SCX_ADDED = "added"; + for (const key of DROP) { + delete runEnv[key]; + } + runEnv.SCX_FIRST = runEnv.SCX_FIRST ? `${runEnv.SCX_FIRST}!` : "none"; + return Object.entries(runEnv) + .filter(([key]) => key.startsWith("SCX_")) + .map(([key, value]) => `${key}=${value || ""}`); +} +console.log(childEnv()); + +// A record of declared optional members over a slot without undefined is +// the same key set: merging it over the process environment keeps both. +type Dictionary = { [key: string]: T }; +type BuildEnv = { CI?: string; DEBUG?: string } & Dictionary; +function merged(buildEnv: BuildEnv): string[] { + const env = { ...process.env, ...buildEnv }; + return [ + String(env.CI === "1" || env.SCX_FIRST === "first"), + `${env.DEBUG ?? "-"}`, + Object.keys(env) + .filter((key) => key.startsWith("SCX_") || key === "CI" || key === "DEBUG") + .join(","), + ]; +} +const buildEnv: BuildEnv = { SCX_BUILD: "b", CI: "1" }; +buildEnv.DEBUG = "yes"; +console.log(merged(buildEnv), Object.keys(buildEnv), buildEnv.CI, buildEnv["SCX_BUILD"]); diff --git a/tests/corpus/6466-never-assertion-argument.ts b/tests/corpus/6466-never-assertion-argument.ts new file mode 100644 index 000000000..8d2c1f2bf --- /dev/null +++ b/tests/corpus/6466-never-assertion-argument.ts @@ -0,0 +1,31 @@ +// `value as never` makes an unknown value fit any parameter: the argument +// is checked against the parameter's type like any unknown value the +// checker admits there, and a typed value passes unchanged. +type Out = { routes: { src: string; dest?: string }[]; count: number }; + +function show(tags: Record): void { + console.log(JSON.stringify(tags)); +} + +function report(tag: (result: Out) => Record, value: unknown): void { + show(tag(value as never)); +} + +const describe = (r: Out): Record => ({ count: r.count, first: r.routes[0]?.src }); +report(describe, JSON.parse('{"routes":[{"src":"/a"},{"src":"/b","dest":"/c"}],"count":2}')); + +// Through a promise of unknown, the way a wrapper tags an async result. +function later(value: Out): Promise { + return Promise.resolve(value); +} +later({ routes: [], count: 7 }).then((value) => { + show(describe(value as never)); +}); + +// A typed value keeps its identity through the assertion. +function bump(target: Out): void { + target.count++; +} +const typed: Out = { routes: [{ src: "/x" }], count: 1 }; +bump(typed as never); +console.log(typed.count); diff --git a/tests/corpus/6467-fromentries-record-assertion.ts b/tests/corpus/6467-fromentries-record-assertion.ts new file mode 100644 index 000000000..1496292b3 --- /dev/null +++ b/tests/corpus/6467-fromentries-record-assertion.ts @@ -0,0 +1,29 @@ +// `Object.fromEntries(...) as T` for a declared record T keeps exactly +// the keys the entries created, in their order, as Node keeps the object +// itself. +type Tags = { "error.type": string; "error.msg": string; "error.cause"?: string }; + +function scrub(input: string): string { + return input.replace(/secret/g, "***"); +} + +function redact(tags: Tags): Tags { + return Object.fromEntries( + Object.entries(tags).map(([key, value]) => [key, typeof value === "string" ? scrub(value) : value]), + ) as Tags; +} + +const e = new Error("a secret here"); +const plain = redact({ "error.type": e.name, "error.msg": e.message }); +console.log(plain, Object.keys(plain), "error.cause" in plain, plain["error.msg"].length); +const caused = redact({ "error.type": "TypeError", "error.msg": "m", "error.cause": "secret cause" }); +console.log(caused, JSON.stringify(caused)); + +// Keys the record does not declare stay with the object. +type Point = { x: number; y: number }; +const p = Object.fromEntries([ + ["y", 2], + ["x", 1], + ["z", 3], +]) as Point; +console.log(p, p.x + p.y, Object.keys(p), JSON.stringify(p)); From b3bcbaaf7cea09e9a17582ad9249f887db081f2a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 01:46:36 -0500 Subject: [PATCH 11/31] Decode memargs the same way in validation and translation Validation, translation and instruction skipping now read memarg flags through one function, readMemargFlags in module.ts. Flags with bit 6 set are followed by an explicit memory index, which may be 0; flags of 128 or more are malformed. The translator refuses a module (WasmTranslateError) when a memarg, memory.size, memory.grow, memory.init, memory.copy or memory.fill names a memory other than 0, and when the operand of local.set, local.tee, global.set, table.set, a load or a store does not have the type of its destination. Locals are stored with their declared type. --- packages/compiler/src/wasm/binary.ts | 23 +++---- packages/compiler/src/wasm/module.ts | 18 +++++- packages/compiler/src/wasm/translate.ts | 83 ++++++++++++++++++------- 3 files changed, 85 insertions(+), 39 deletions(-) diff --git a/packages/compiler/src/wasm/binary.ts b/packages/compiler/src/wasm/binary.ts index 5ddc28cc9..ad5297739 100644 --- a/packages/compiler/src/wasm/binary.ts +++ b/packages/compiler/src/wasm/binary.ts @@ -20,6 +20,7 @@ * The decoder never evaluates anything and keeps no module contents beyond * the counts and the feature set it reports. It is plain TypeScript that * the native compiler also compiles. */ +import { readMemargFlags } from "./module.js"; /** Proposals the summary can name, in report order. The first group is the * translatable set; the rest are reported as unsupported. */ @@ -1244,19 +1245,9 @@ class FunctionValidator { /** A memarg. A memory other than 0 stops typing (multi-memory). */ memarg(naturalAlign: number): void { const r = this.r; - let align = r.u32(); - let multi = false; - if (align >= 64 && align < 128) { - align -= 64; - multi = true; - } else if (align >= 64) { - r.malformed("malformed memop flags"); - } - let memory = 0; - if (multi) { - memory = r.u32(); - if (memory !== 0) this.otherMemory(memory); - } + const { align, memory } = readMemargFlags(r); + if (align < 0) r.malformed("malformed memop flags"); + if (memory !== 0) this.otherMemory(memory); if (this.m.memory64) r.u64(); else r.u32(); if (!this.typed) return; @@ -1840,9 +1831,9 @@ class FunctionValidator { scanMemarg(): void { const r = this.r; - const align = r.u32(); - if (align >= 128) r.malformed("malformed memop flags"); - if (align >= 64 && r.u32() !== 0) this.m.feature("multi-memory"); + const { align, memory } = readMemargFlags(r); + if (align < 0) r.malformed("malformed memop flags"); + if (memory !== 0) this.m.feature("multi-memory"); if (this.m.memory64) r.u64(); else r.u32(); } diff --git a/packages/compiler/src/wasm/module.ts b/packages/compiler/src/wasm/module.ts index 3b0dd54c0..d7ec38701 100644 --- a/packages/compiler/src/wasm/module.ts +++ b/packages/compiler/src/wasm/module.ts @@ -283,6 +283,20 @@ function decodeUtf8(bytes: Uint8Array, start: number, end: number): string { return out; } +/** The flags of a memarg (the immediates of loads, stores and atomics): + * the alignment exponent and the memory index. Flags with bit 6 set + * (multi-memory) are followed by an explicit memory index, which may be + * 0; flags of 128 or more are malformed and answer align -1 without + * reading further. The offset follows. Validation (binary.ts), translation + * and skipImmediates all read memargs through this function, so they agree + * on where each instruction ends. */ +export function readMemargFlags(r: { u32(): number }): { align: number; memory: number } { + const flags = r.u32(); + if (flags >= 128) return { align: -1, memory: 0 }; + if (flags >= 64) return { align: flags - 64, memory: r.u32() }; + return { align: flags, memory: 0 }; +} + /** Reads the immediates of opcode `op` (already consumed). Covers the * translatable feature level; anything else was refused before * translation. */ @@ -372,7 +386,9 @@ export function skipImmediates(c: WasmCursor, op: number): void { } default: if (op >= 0x28 && op <= 0x3e) { - c.u32(); + const flags = readMemargFlags(c); + if (flags.align < 0 || flags.memory !== 0) + throw new Error(`wasm: memarg of memory ${flags.memory} is not supported`); c.u32(); } return; diff --git a/packages/compiler/src/wasm/translate.ts b/packages/compiler/src/wasm/translate.ts index e0ce27be5..813dab84a 100644 --- a/packages/compiler/src/wasm/translate.ts +++ b/packages/compiler/src/wasm/translate.ts @@ -48,6 +48,7 @@ import { VAL_I64, WasmCursor, parseWasmModule, + readMemargFlags, skipImmediates, type WasmFuncType, type WasmModule, @@ -830,6 +831,39 @@ class FunctionTranslator { return v; } + /** Pops a value that must have type `t`: the type a local, global, + * table or memory store writes. Validation guarantees it; a mismatch + * means the translator and the validator disagree about the code, so + * the module is not translated. */ + popType(t: number, what: string): StackValue { + const v = this.pop(); + if (v.t !== t) throw new WasmTranslateError(`${what} operand type does not match`); + return v; + } + + /** The declared type of local `i` (parameters first). */ + localType(i: number): number { + const t = this.localTypes[i]; + if (t === undefined) throw new WasmTranslateError(`unknown local ${i}`); + return t; + } + + /** The offset of a memarg over memory 0 (the only memory a translatable + * module has). */ + memargOffset(c: WasmCursor): number { + const flags = readMemargFlags(c); + if (flags.align < 0 || flags.memory !== 0) + throw new WasmTranslateError(`memarg of memory ${flags.memory} is not supported`); + return c.u32(); + } + + /** A memory index immediate (memory.size, memory.grow and the bulk + * memory instructions), which must be 0. */ + memoryIndex(c: WasmCursor): void { + const index = c.u32(); + if (index !== 0) throw new WasmTranslateError(`memory ${index} is not supported`); + } + popN(n: number): StackValue[] { const out = this.stack.splice(this.stack.length - n, n); if (out.length !== n) throw new Error("wasm translator: operand stack underflow"); @@ -1540,20 +1574,23 @@ class FunctionTranslator { } case 0x20: { const i = c.u32(); - const t = this.localTypes[i]!; + const t = this.localType(i); this.push(this.emit(`load ${llType(t)}, ptr %l${i}`), t); return; } case 0x21: { const i = c.u32(); - const v = this.pop(); - this.add(` store ${llType(v.t)} ${v.v}, ptr %l${i}`); + const t = this.localType(i); + const v = this.popType(t, "local.set"); + this.add(` store ${llType(t)} ${v.v}, ptr %l${i}`); return; } case 0x22: { const i = c.u32(); - const v = this.stack[this.stack.length - 1]!; - this.add(` store ${llType(v.t)} ${v.v}, ptr %l${i}`); + const t = this.localType(i); + const v = this.popType(t, "local.tee"); + this.stack.push(v); + this.add(` store ${llType(t)} ${v.v}, ptr %l${i}`); return; } case 0x23: { @@ -1565,7 +1602,9 @@ class FunctionTranslator { } case 0x24: { const g = c.u32(); - const v = this.pop(); + const global = this.m.globals[g]; + if (global === undefined) throw new WasmTranslateError(`unknown global ${g}`); + const v = this.popType(global.type, "global.set"); const cell = this.loadPtrSlot(this.mt.globalSlot(g)); if (v.t === VAL_EXTERNREF) this.add(` call void @scr_wasm_global_set_ref(ptr ${cell}, ptr ${v.v})`); @@ -1583,13 +1622,15 @@ class FunctionTranslator { } case 0x26: { const t = c.u32(); - const v = this.pop(); - const i = this.pop(); + const table = this.m.tables[t]; + if (table === undefined) throw new WasmTranslateError(`unknown table ${t}`); + const v = this.popType(table.elemType, "table.set"); + const i = this.popType(VAL_I32, "table.set"); this.add(` call void @scr_wasm_table_set(ptr %inst, i32 ${t}, i32 ${i.v}, ptr ${v.v})`); return; } case 0x3f: { - c.u32(); + this.memoryIndex(c); const mem = this.loadPtrSlot(OFF_MEMORY); const sizePtr = this.emit(`getelementptr inbounds i8, ptr ${mem}, i64 8`); const size = this.emit(`load i64, ptr ${sizePtr}, align 8`); @@ -1598,7 +1639,7 @@ class FunctionTranslator { return; } case 0x40: { - c.u32(); + this.memoryIndex(c); const delta = this.pop(); this.push(this.emit(`call i32 @scr_wasm_memory_grow(ptr %inst, i32 ${delta.v})`), VAL_I32); this.refreshMemory(); @@ -1641,10 +1682,9 @@ class FunctionTranslator { break; } if (op >= 0x28 && op <= 0x35) { - c.u32(); - const offset = c.u32(); + const offset = this.memargOffset(c); const [vt, mt, bytes, ext] = LOAD_INFO[op]!; - const addr = this.pop(); + const addr = this.popType(VAL_I32, "load address"); const p = this.address(addr.v, offset, bytes); const line = this.lines.lines.length; const loaded = this.emit(`load ${mt}, ptr ${p}, align 1`); @@ -1656,11 +1696,10 @@ class FunctionTranslator { return; } if (op >= 0x36 && op <= 0x3e) { - c.u32(); - const offset = c.u32(); - const [, mt, bytes] = STORE_INFO[op]!; - const v = this.pop(); - const addr = this.pop(); + const offset = this.memargOffset(c); + const [vt, mt, bytes] = STORE_INFO[op]!; + const v = this.popType(vt, "store"); + const addr = this.popType(VAL_I32, "store address"); const p = this.address(addr.v, offset, bytes); let value = v.v; if (mt === "i8" || mt === "i16" || (mt === "i32" && v.t === VAL_I64)) { @@ -1960,7 +1999,7 @@ class FunctionTranslator { case 8: { // memory.init const seg = c.u32(); - c.u32(); + this.memoryIndex(c); const n = this.pop(); const s = this.pop(); const d = this.pop(); @@ -1973,8 +2012,8 @@ class FunctionTranslator { this.add(` call void @scr_wasm_data_drop(ptr %inst, i32 ${c.u32()})`); return; case 10: { - c.u32(); - c.u32(); + this.memoryIndex(c); + this.memoryIndex(c); const n = this.pop(); const s = this.pop(); const d = this.pop(); @@ -1984,7 +2023,7 @@ class FunctionTranslator { return; } case 11: { - c.u32(); + this.memoryIndex(c); const n = this.pop(); const v = this.pop(); const d = this.pop(); From 45431aec320c53e12e9b664e80d8d48ffbb4f547 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 02:01:48 -0500 Subject: [PATCH 12/31] Choose comptime union arms by discriminant and refuse recursive records A comptime record value whose union has a discriminant takes the arm its discriminant field names, so a value that fits two arms with overlapping shapes gets that arm's tag and layout instead of the first arm it fits. comptimeBakeable reports a record type reachable from its own fields as not bakeable instead of recursing without end, so such a comptime result reports SC1090, and the message names unions and that limit. --- .../src/frontend/lowering/lower-comptime.ts | 54 ++++++++++++++----- tests/corpus/1052-comptime-records.ts | 17 ++++++ tests/diagnostics/comptime.ts | 7 +++ tests/harness/__snapshots__/comptime.ts.txt | 9 +++- 4 files changed, 74 insertions(+), 13 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-comptime.ts b/packages/compiler/src/frontend/lowering/lower-comptime.ts index 31fa5afe0..bdd020127 100644 --- a/packages/compiler/src/frontend/lowering/lower-comptime.ts +++ b/packages/compiler/src/frontend/lowering/lower-comptime.ts @@ -76,7 +76,7 @@ export function lowerComptime(lowerer: Lowerer, expr: ts.CallExpression): IrExpr "SC1090", expr, `baking comptime results of type '${lowerer.fmt(target)}' into the binary ` + - `(only number, string, boolean, arrays, and records lower to literals)`, + `(number, string, boolean, and arrays, records and unions of those lower to literals; a record type reachable from its own fields does not)`, ); } // 5. The host executes the extracted callback under a finite budget. @@ -112,25 +112,36 @@ function comptimeFailureDetail(error: unknown): string { * expression: number/string/boolean, arrays and record shapes of those, * and unions of those with null or undefined (the mixed rows of a JSON * table: `(string | number)[][]`), nested. Closures, class instances, - * promises, dyn, and void cannot be baked. */ + * promises, dyn, void, and recursive record types (a record reachable + * from its own fields) cannot be baked. */ export function comptimeBakeable(lowerer: Lowerer, t: IrType): boolean { + return bakeableWithin(lowerer, t, new Set()); +} + +/** comptimeBakeable below the records in `open`, whose fields are being + * checked: reaching one of them again means the type is recursive. */ +function bakeableWithin(lowerer: Lowerer, t: IrType, open: Set): boolean { switch (t.kind) { case "f64": case "string": case "bool": return true; case "array": - return lowerer.comptimeBakeable(t.elem); + return bakeableWithin(lowerer, t.elem, open); case "record": { const shape = lowerer.shapes.get(t.shapeId); - return !!shape && shape.fields.every((f) => lowerer.comptimeBakeable(f.type)); + if (!shape || open.has(t.shapeId)) return false; + open.add(t.shapeId); + const bakeable = shape.fields.every((f) => bakeableWithin(lowerer, f.type, open)); + open.delete(t.shapeId); + return bakeable; } case "union": { const arms = lowerer.unions.get(t.unionId)?.arms; return ( arms !== undefined && arms.every( - (arm) => isUnitType(arm) || (arm.kind !== "union" && comptimeBakeable(lowerer, arm)), + (arm) => isUnitType(arm) || (arm.kind !== "union" && bakeableWithin(lowerer, arm, open)), ) ); } @@ -139,6 +150,30 @@ export function comptimeBakeable(lowerer: Lowerer, t: IrType): boolean { } } +/** The tag of the arm of union `unionId` that `value` bakes into, or -1. + * A record value whose discriminant field holds one of the union's + * discriminant values takes that case's arm, since arms with overlapping + * shapes can both fit it. Any other value takes the first arm it fits, in + * the union's arm order: a JSON value fits exactly one scalar arm, and + * array or record arms are told apart by their contents. */ +function comptimeUnionArm(lowerer: Lowerer, unionId: string, value: unknown): number { + const def = lowerer.unions.get(unionId); + if (!def) return -1; + const discriminant = def.discriminant; + if ( + discriminant !== undefined && + typeof value === "object" && + value !== null && + !Array.isArray(value) + ) { + const key = (value as Record)[discriminant.field]; + const named = discriminant.cases.find((c) => c.values.some((v) => v === key)); + if (named !== undefined) + return comptimeFits(lowerer, value, def.arms[named.tag]!) ? named.tag : -1; + } + return def.arms.findIndex((arm) => comptimeFits(lowerer, value, arm)); +} + /** Whether `value` bakes into the bakeable type `t` without a mismatch: * the arm choice of a union literal. Records are exact, as in * comptimeValueToIr; an absent field reads as undefined. */ @@ -168,9 +203,7 @@ function comptimeFits(lowerer: Lowerer, value: unknown, t: IrType): boolean { ); } case "union": - return (lowerer.unions.get(t.unionId)?.arms ?? []).some((arm) => - comptimeFits(lowerer, value, arm), - ); + return comptimeUnionArm(lowerer, t.unionId, value) >= 0; default: return false; } @@ -313,11 +346,8 @@ export function comptimeValueToIr( return { kind: "recordLit", fields, type: expected, loc }; } case "union": { - // The first arm the value fits, in the union's arm order: a JSON - // value fits exactly one scalar arm, and array or record arms are - // told apart by their contents. const arms = lowerer.unions.get(expected.unionId)!.arms; // bakeable-checked - const tag = arms.findIndex((arm) => comptimeFits(lowerer, value, arm)); + const tag = comptimeUnionArm(lowerer, expected.unionId, value); if (tag < 0) fail(describeComptimeValue(value)); const arm = arms[tag]!; const payload: IrExpr = isUnitType(arm) diff --git a/tests/corpus/1052-comptime-records.ts b/tests/corpus/1052-comptime-records.ts index 47cccf7b3..658aaed32 100644 --- a/tests/corpus/1052-comptime-records.ts +++ b/tests/corpus/1052-comptime-records.ts @@ -43,3 +43,20 @@ const meta = comptime(() => { return { kind: "meta", tags, count: tags.length }; }); console.log(meta.kind, meta.tags.join("|"), meta.count); + +// A discriminated union whose arms both fit a "labeled" value without a +// label: the discriminant picks the arm. +type Item = { kind: "plain"; v: number } | { kind: "labeled"; v: number; label?: string }; +const items: Item[] = comptime( + () => + [ + { kind: "plain", v: 1 }, + { kind: "labeled", v: 2 }, + { kind: "labeled", v: 3, label: "x" }, + ] as Item[], +); +for (const item of items) { + if (item.kind === "labeled") console.log("labeled", item.v, item.label); + else console.log("plain", item.v); +} +console.log(JSON.stringify(items)); diff --git a/tests/diagnostics/comptime.ts b/tests/diagnostics/comptime.ts index 9ba882aaf..9c8239464 100644 --- a/tests/diagnostics/comptime.ts +++ b/tests/diagnostics/comptime.ts @@ -20,3 +20,10 @@ const nested = comptime(() => comptime(() => 1)); const eventually = comptime(async () => 1); const lied = comptime(() => 1 as unknown as string); + +// A record type reachable from its own fields has no literal form. +interface Chain { + value: number; + next: Chain | null; +} +const chain: Chain = comptime(() => ({ value: 1, next: null }) as Chain); diff --git a/tests/harness/__snapshots__/comptime.ts.txt b/tests/harness/__snapshots__/comptime.ts.txt index 2b1411ae5..603c297a8 100644 --- a/tests/harness/__snapshots__/comptime.ts.txt +++ b/tests/harness/__snapshots__/comptime.ts.txt @@ -45,4 +45,11 @@ comptime.ts:22:23 - error SC1110: comptime evaluation failed: expected 'string' 21 | 22 | const lied = comptime(() => 1 as unknown as string); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 23 | \ No newline at end of file + 23 | + +comptime.ts:29:22 - error SC1090: baking comptime results of type '{ next: null | ...; value: number }' into the binary (number, string, boolean, and arrays, records and unions of those lower to literals; a record type reachable from its own fields does not) is not supported yet + + 28 | } + 29 | const chain: Chain = comptime(() => ({ value: 1, next: null }) as Chain); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 30 | \ No newline at end of file From 069545a126a0d48e1a7c0bd39130906fd4ab4f86 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 02:07:46 -0500 Subject: [PATCH 13/31] Address review: monitorEventLoopDelay spellings and validation, Error 'in' keys, event-loop metrics in libraries - monitorEventLoopDelay is lowered from the builtin call dispatch, by module and member, so aliased named imports, namespace members and destructured requires all reach it. Shorthand and possibly undefined resolutions take Node's default. - The runtime validates the resolution as Node does (an integer of at least 1) and throws a catchable RangeError with ERR_OUT_OF_RANGE. - `'stack' in err` and Object.prototype's keys are true for every Error. The declared-member fold applies only to keys some program class declares and that built-in errors don't carry in runtime slots; other keys stay fenced. - Library mode's async_free gate refuses the event-loop metrics, which library links leave out. They join the perf_hooks fence family. --- .../src/frontend/lowering/builtin-calls.ts | 4 ++ .../frontend/lowering/builtins/performance.ts | 32 ++++++------- .../src/frontend/lowering/lower-calls.ts | 11 ----- .../src/frontend/lowering/lower-exprs.ts | 46 ++++++++++++++++--- packages/compiler/src/ir/builtin-effects.ts | 2 + .../compiler/src/ir/builtin-signatures.ts | 2 +- packages/compiler/src/ir/runtime-features.ts | 4 +- packages/compiler/src/library/ir-policy.ts | 6 +++ packages/runtime/src/scr_perf_loop.c | 24 +++++++++- tests/corpus/6394-in-key-error-subclasses.ts | 6 +++ tests/corpus/6397-event-loop-metrics.ts | 21 +++++++++ tests/harness/library-asyncfree.test.ts | 15 ++++++ tests/harness/library-profile.test.ts | 5 +- 13 files changed, 138 insertions(+), 40 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/builtin-calls.ts b/packages/compiler/src/frontend/lowering/builtin-calls.ts index 7e1852600..7d3a5d41a 100644 --- a/packages/compiler/src/frontend/lowering/builtin-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtin-calls.ts @@ -1,4 +1,5 @@ import { lowerReadlineCursorCall } from "./builtins/readline.js"; +import { lowerMonitorEventLoopDelayCall } from "./builtins/performance.js"; import { lowerEnvironmentData, lowerReceiveMessageOnPort, @@ -69,6 +70,9 @@ export function lowerBuiltinCall( return lowerAddAbortListener(lowerer, expr, loc); } if (bi.module === "events" && bi.member === "once") return lowerEventsOnce(lowerer, expr, loc); + if (bi.module === "perf_hooks" && bi.member === "monitorEventLoopDelay") { + return lowerMonitorEventLoopDelayCall(lowerer, expr); + } if (bi.module === "events") { const helper = lowerEventsEmitterHelper(lowerer, expr, bi.member, loc); if (helper) return helper; diff --git a/packages/compiler/src/frontend/lowering/builtins/performance.ts b/packages/compiler/src/frontend/lowering/builtins/performance.ts index 19b585e37..7aac3b80c 100644 --- a/packages/compiler/src/frontend/lowering/builtins/performance.ts +++ b/packages/compiler/src/frontend/lowering/builtins/performance.ts @@ -18,6 +18,7 @@ import { } from "../../../ir/ir.js"; import { PERF_ENTRY_FIELDS, PERF_ENTRY_MARKER, perfEntryJsonShape } from "../../type-mapper.js"; import { builtinImportOf } from "./module-bindings.js"; +import { lowerOptionalArgument } from "../optional-arguments.js"; import { checkedIterableSpread } from "../checked-iterable-spread.js"; /** True for any spelling of node:perf_hooks' `performance` object — the @@ -663,21 +664,11 @@ export function lowerHistogramMethodCall( ); } -/** `monitorEventLoopDelay(options?)` from node:perf_hooks (a named import - * or a namespace member): a new sampling histogram handle. The resolution - * must be a literal-free number expression or an object literal option. */ -export function lowerMonitorEventLoopDelayCall( - lowerer: Lowerer, - expr: ts.CallExpression, -): IrExpr | null { - const callee = expr.expression; - const bi = ts.isIdentifier(callee) - ? builtinImportOf(lowerer, callee) - : ts.isPropertyAccessExpression(callee) && !callee.questionDotToken - ? lowerer.builtinMemberOf(callee) - : null; - if (bi === null || bi.module !== "perf_hooks" || bi.member !== "monitorEventLoopDelay") - return null; +/** `monitorEventLoopDelay(options?)` from node:perf_hooks, in any import + * spelling (`lowerBuiltinCall`): a new sampling histogram handle. The + * options are an inline object literal; the runtime validates the + * resolution as Node does (an integer of at least 1). */ +export function lowerMonitorEventLoopDelayCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { const loc = locOf(expr); const args = expr.arguments.nodes; let resolution: IrExpr = numLit(10, loc); @@ -698,8 +689,9 @@ export function lowerMonitorEventLoopDelayCall( ); } for (const property of options.properties.nodes) { + const shorthand = ts.isShorthandPropertyAssignment(property); if ( - !ts.isPropertyAssignment(property) || + (!shorthand && !ts.isPropertyAssignment(property)) || !ts.isIdentifier(property.name) || property.name.text !== "resolution" ) { @@ -709,7 +701,13 @@ export function lowerMonitorEventLoopDelayCall( "resolution is the one option", ); } - resolution = lowerer.lowerExprExpecting(property.initializer, F64); + // An undefined resolution takes the default, as Node's destructuring. + resolution = lowerOptionalArgument( + lowerer, + shorthand ? property.name : property.initializer, + F64, + numLit(10, loc), + ); } } return { kind: "libCall", fn: "perf.eldNew", args: [resolution], type: F64, loc }; diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 23629a7ba..e60ee2367 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -144,7 +144,6 @@ import { fencePerfEntryKeyWalk, isPerfEntryType, lowerHistogramMethodCall, - lowerMonitorEventLoopDelayCall, lowerPerfHooksCall, perfEntryTag, } from "./builtins/performance.js"; @@ -243,16 +242,6 @@ const ZLIB_STREAM_METHODS: ReadonlySet = new Set(["flush", "params", "cl export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { const loc = locOf(expr); - // node:perf_hooks monitorEventLoopDelay (named import or namespace member). - if ( - (ts.isIdentifier(expr.expression) && expr.expression.text === "monitorEventLoopDelay") || - (ts.isPropertyAccessExpression(expr.expression) && - expr.expression.name.text === "monitorEventLoopDelay") - ) { - const histogram = lowerMonitorEventLoopDelayCall(lowerer, expr); - if (histogram) return histogram; - } - // Object's checker return type is `any`. Claim known object inputs // before generic checked-dynamic dispatch tries to load the constructor. if ( diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index aa597d8a2..684ffde58 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -13801,6 +13801,24 @@ const OBJECT_PROTOTYPE_KEYS: ReadonlySet = new Set([ * idiom over multiple shapes), index-signature keys, class instances, * and dyn/unknown stay fenced. Keys are literal strings — a computed key * over a shape would need the runtime key table. */ +/** Keys built-in errors carry outside their class fields: Node's system + * error properties, and AggregateError's, SuppressedError's and + * AssertionError's own members. */ +const BUILTIN_ERROR_SLOT_KEYS: ReadonlySet = new Set([ + "errno", + "syscall", + "path", + "dest", + "info", + "errors", + "error", + "suppressed", + "actual", + "expected", + "operator", + "generatedMessage", +]); + function lowerInExpression(lowerer: Lowerer, expr: ts.BinaryExpression, loc: SrcLoc): IrExpr { if ( lowerer.isStdlibGlobal(expr.right, "process") && @@ -14036,15 +14054,29 @@ function lowerInExpression(lowerer: Lowerer, expr: ts.BinaryExpression, loc: Src ) { return { kind: "boolLit", value: true, type: BOOL, loc }; } + // Every Error has its own `stack`, and Error.prototype's `toString` + // and `constructor` are among Object.prototype's keys. + if ( + (key === "stack" || OBJECT_PROTOTYPE_KEYS.has(key)) && + (recv.kind === "varRef" || recv.kind === "caughtNarrow") + ) { + return { kind: "boolLit", value: true, type: BOOL, loc }; + } // Any other key: a member a program class declares. The receiver's // runtime class is its static class or a descendant; the key is - // present exactly when that class (or an ancestor) declares it. - if (recv.kind === "varRef" || recv.kind === "caughtNarrow") { - const declares = (c: ClassInfo): boolean => - c.fields.has(key) || - lowerer.findMethodOn(c, key) !== null || - lowerer.findMethodOn(c, `get:${key}`) !== null || - lowerer.findMethodOn(c, `set:${key}`) !== null; + // present exactly when that class (or an ancestor) declares it. Keys + // built-in errors keep outside their class fields depend on the + // runtime error and stay fenced, as do keys no program class declares. + const declares = (c: ClassInfo): boolean => + c.fields.has(key) || + lowerer.findMethodOn(c, key) !== null || + lowerer.findMethodOn(c, `get:${key}`) !== null || + lowerer.findMethodOn(c, `set:${key}`) !== null; + if ( + (recv.kind === "varRef" || recv.kind === "caughtNarrow") && + !BUILTIN_ERROR_SLOT_KEYS.has(key) && + [...lowerer.classes.values()].some((c) => c.builtinError !== true && declares(c)) + ) { const staticName = recv.type.className; const staticInfo = lowerer.classes.get(staticName); if (staticInfo && declares(staticInfo)) diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index f20d0945e..9a7ca178b 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -363,6 +363,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "perf.entries", "perf.clearMarks", "perf.clearMeasures", + // monitorEventLoopDelay's resolution: Node's ERR_OUT_OF_RANGE. + "perf.eldNew", // Non-TTY stdin: Node's stdin has no setRawMode member, so the call is // the exact catchable TypeError. "process.stdinSetRawMode", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 02943ae1b..de50aab1c 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -364,7 +364,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ /** perf_hooks event-loop metrics (scr_perf_loop.c): loopStart is the * loop's start in performance.now() ms (0 before it), idleTime the idle * ms since. eldNew(resolution ms) creates a monitorEventLoopDelay - * histogram handle; enable/disable answer whether the state changed; + * histogram handle (MAY THROW Node's resolution RangeError); enable/disable answer whether the state changed; * eldPercentile expects Node's validated range; eldStat(which) reads * 0 min, 1 max, 2 mean, 3 stddev, 4 count, 5 exceeds. */ "perf.loopStart": { argTypes: [], result: F64 }, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index b2628fa10..1946faeb3 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -506,11 +506,11 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run } if (fn.startsWith("dgram.") || fn.startsWith("dns.")) features.dgram = true; if (fn.startsWith("tlsca.")) features.tlsCa = true; + if (fn.startsWith("perf.loop") || fn === "perf.idleTime" || fn.startsWith("perf.eld")) + features.perfLoop = true; // scr_cipher.c over the mbedTLS archive; DEP0182 reports through the // process-warning unit, and unknown output encodings render through // the inspect unit. - if (fn.startsWith("perf.loop") || fn === "perf.idleTime" || fn.startsWith("perf.eld")) - features.perfLoop = true; if (fn === "crypto.cipherNew") { features.cipher = true; features.dynAsync = true; diff --git a/packages/compiler/src/library/ir-policy.ts b/packages/compiler/src/library/ir-policy.ts index 3c3855e5c..a9c9609f5 100644 --- a/packages/compiler/src/library/ir-policy.ts +++ b/packages/compiler/src/library/ir-policy.ts @@ -53,6 +53,11 @@ const LIB_MODE_REFUSED_PREFIXES: readonly [string, string][] = [ ["crypto.cipherNew", "crypto.createCipheriv and createDecipheriv"], ["fs.watch", "fs.watch"], ["watcher.", "fs.watch"], + // The event-loop metrics sample the loop and its timers (scr_perf_loop.c), + // which library links exclude. + ["perf.loopStart", "performance.eventLoopUtilization"], + ["perf.idleTime", "performance.eventLoopUtilization"], + ["perf.eld", "perf_hooks.monitorEventLoopDelay"], ["test.", "the node:test surface"], ["readable.", "the node:stream surface"], ["writable.", "the node:stream surface"], @@ -157,6 +162,7 @@ export function moduleLibAsyncSurface(mod: IrModule): { surface: string; loc: Sr [features.nodeTest, "the node:test surface"], [features.dynAsync, "the checked-dynamic async surface"], [features.dc, "the diagnostics_channel surface"], + [features.perfLoop, "the perf_hooks event-loop metrics"], ]; for (const [on, surface] of coarse) { if (on) return { surface, loc: entryLoc }; diff --git a/packages/runtime/src/scr_perf_loop.c b/packages/runtime/src/scr_perf_loop.c index 29d98ea29..68d3bf10f 100644 --- a/packages/runtime/src/scr_perf_loop.c +++ b/packages/runtime/src/scr_perf_loop.c @@ -21,7 +21,9 @@ #include #include +#include #include +#include #include #ifdef _WIN32 #include @@ -212,7 +214,27 @@ static void scr_eld_cleanup(void) { scr_elds_len = 0; } +/* Node's validateInteger(resolution, 'options.resolution', 1): a catchable + * RangeError (ERR_OUT_OF_RANGE) for a non-integer or a value out of range, + * with the value as util.inspect renders it ("-0" included). */ +static bool scr_eld_resolution_ok(double resolution) { + bool integer = isfinite(resolution) && trunc(resolution) == resolution; + if (integer && resolution >= 1 && resolution <= 9007199254740991.0) return true; + char received[48]; + if (resolution == 0 && signbit(resolution)) + memcpy(received, "-0", 3); + else + scr_num_received(resolution, received); + char msg[192]; + int len = snprintf(msg, sizeof msg, + "The value of \"options.resolution\" is out of range. It must be %s. Received %s", + integer ? ">= 1 && <= 9007199254740991" : "an integer", received); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); + return false; +} + double scr_eld_new(double resolution) { + if (!scr_eld_resolution_ok(resolution)) return 0; if (scr_elds_len == 0 && scr_elds == NULL) scr_atexit(scr_eld_cleanup); ScrEld *h = calloc(1, sizeof(ScrEld)); ScrEld **grown = realloc(scr_elds, (scr_elds_len + 1) * sizeof(ScrEld *)); @@ -223,7 +245,7 @@ double scr_eld_new(double resolution) { } scr_elds = grown; h->min_value = INT64_MAX; - h->resolution = resolution >= 1 ? resolution : 10; + h->resolution = resolution; h->tick = scr_closure_new((void *)scr_eld_tick, 0); scr_elds[scr_elds_len++] = h; return (double)scr_elds_len; diff --git a/tests/corpus/6394-in-key-error-subclasses.ts b/tests/corpus/6394-in-key-error-subclasses.ts index cdb28a5a3..5ade6bcd1 100644 --- a/tests/corpus/6394-in-key-error-subclasses.ts +++ b/tests/corpus/6394-in-key-error-subclasses.ts @@ -23,3 +23,9 @@ function retryable(err: Error): boolean { } console.log(retryable(new Error("x")), retryable(new HttpError("a", 500)), retryable(new Timeout("t", 504))); console.log(describe(new Timeout("t", 504))); +// Keys every Error has: its own stack, and Error.prototype's and +// Object.prototype's members. +function inherent(err: Error): boolean[] { + return ["stack" in err, "toString" in err, "constructor" in err, "hasOwnProperty" in err]; +} +console.log(inherent(new Error("s")), inherent(new HttpError("h", 400)), inherent(new TypeError("t"))); diff --git a/tests/corpus/6397-event-loop-metrics.ts b/tests/corpus/6397-event-loop-metrics.ts index e1c3829ac..8477dcc48 100644 --- a/tests/corpus/6397-event-loop-metrics.ts +++ b/tests/corpus/6397-event-loop-metrics.ts @@ -1,6 +1,8 @@ // Event-loop monitoring under @types/node: monitorEventLoopDelay's histogram, // eventLoopUtilization deltas, and an unref'd interval typed NodeJS.Timeout. import { monitorEventLoopDelay, performance } from "node:perf_hooks"; +import * as perfHooks from "node:perf_hooks"; +import { monitorEventLoopDelay as eld } from "node:perf_hooks"; const histogram = monitorEventLoopDelay({ resolution: 10 }); console.log("enable", histogram.enable(), histogram.enable()); @@ -47,3 +49,22 @@ const interval = setInterval(() => { }, 50); interval.unref(); console.log("hasRef", interval.hasRef()); + +// Other import spellings, and Node's resolution validation. +const aliased = eld(); +const namespaced = perfHooks.monitorEventLoopDelay({ resolution: 20 }); +console.log("spellings", aliased.enable(), namespaced.enable(), aliased.disable(), namespaced.disable()); +for (const resolution of [0, -0, -1, 1.5, NaN, Infinity, 1e16, 1]) { + try { + eld({ resolution }); + console.log("created", resolution); + } catch (e) { + console.log((e as Error).name, (e as { code?: string }).code, (e as Error).message); + } +} +// An undefined resolution takes Node's default. +function withResolution(resolution?: number): boolean { + const h = monitorEventLoopDelay({ resolution }); + return h.enable() && h.disable(); +} +console.log("defaults", withResolution(), withResolution(20), eld({ resolution: undefined }).enable()); diff --git a/tests/harness/library-asyncfree.test.ts b/tests/harness/library-asyncfree.test.ts index 0adbef7bc..7c4d029cc 100644 --- a/tests/harness/library-asyncfree.test.ts +++ b/tests/harness/library-asyncfree.test.ts @@ -73,6 +73,21 @@ describe("async_free detection over the IR", () => { ); }); + test("the event-loop metrics refuse", async () => { + expect( + await surfaceOf( + "eld", + `import { monitorEventLoopDelay } from "node:perf_hooks";\nconst h = monitorEventLoopDelay();\nconsole.log(h.enable());\n`, + ), + ).toContain("monitorEventLoopDelay"); + expect( + await surfaceOf( + "elu", + `import { performance } from "node:perf_hooks";\nconsole.log(performance.eventLoopUtilization().idle >= 0);\n`, + ), + ).toContain("eventLoopUtilization"); + }); + test("child_process refuses even in its synchronous spelling", async () => { expect( await surfaceOf( diff --git a/tests/harness/library-profile.test.ts b/tests/harness/library-profile.test.ts index c7cb13751..c83b31ada 100644 --- a/tests/harness/library-profile.test.ts +++ b/tests/harness/library-profile.test.ts @@ -569,10 +569,13 @@ describe("library profile fences", () => { expect(processIds).toContain("node-builtin.process.isTTY"); expect(r.profile.fences[1]!.surfaces.every((s) => s.detector !== undefined)).toBe(true); // The user-timing timeline reads the same clock (mark() and measure() - // default to performance.now()), so it joins the perf_hooks family. + // default to performance.now()), and so do the event-loop metrics, so + // they join the perf_hooks family. expect(r.profile.fences[2]!.surfaces.map((s) => s.id)).toEqual([ + "node-builtin.perf_hooks.monitorEventLoopDelay", "node-builtin.perf_hooks.performance.clearMarks", "node-builtin.perf_hooks.performance.clearMeasures", + "node-builtin.perf_hooks.performance.eventLoopUtilization", "node-builtin.perf_hooks.performance.getEntries", "node-builtin.perf_hooks.performance.mark", "node-builtin.perf_hooks.performance.measure", From 1fb1d9c829002f78b5449b1937897ad32647a490 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 23:16:01 -0700 Subject: [PATCH 14/31] Let constructor functions inherit from EventEmitter and the stream classes Bundled npm code (glob, yazl, from2, multistream) subclasses Node's classes with constructor functions: util.inherits(Ctor, EventEmitter) and EventEmitter.call(this). util.inherits received an identity token and threw ERR_INVALID_ARG_TYPE, EventEmitter.call(this) and EventEmitter.prototype were refused, and `this instanceof Ctor` against a function was refused. A new runtime unit (scr_dyn_builtin_class.c, linked by the new dynBuiltinClass feature) provides EventEmitter, the legacy Stream and the stream classes as untyped constructor functions with Node's prototype chain. EventEmitter's prototype ports lib/events.js over the object's own _events, _eventsCount and _maxListeners properties; Stream adds the legacy pipe. util.inherits, Base.call/apply(this, ...), Base.prototype, instanceof on untyped values and JavaScript value reads of these classes use them. Constructing over a stream class throws SC2020 where it runs. instanceof against a compiled constructor function compares its prototype with the value's prototype chain. Co-Authored-By: Claude Opus 5.5 --- .../limitations/builtin-function-values.mdx | 6 +- .../src/backend/executable-features.ts | 1 + packages/compiler/src/backend/library-pack.ts | 1 + .../compiler/src/backend/llvm/lib-shared.ts | 3 + .../src/backend/native-link-info-core.ts | 1 + .../compiler/src/backend/native/contracts.ts | 5 + .../compiler/src/backend/native/executable.ts | 4 + .../src/backend/runtime-pack-targets.test.ts | 1 + .../compiler/src/backend/runtime-pack.test.ts | 1 + packages/compiler/src/backend/warm-cache.ts | 1 + .../src/executable/executable-cache.test.ts | 1 + .../src/executable/executable-cache.ts | 2 + .../lowering/builtins/builtin-class-dyn.ts | 149 ++ .../src/frontend/lowering/class-prototypes.ts | 14 + .../lowering/expressions/identifiers.ts | 5 + .../lowering/expressions/relational.ts | 25 + .../src/frontend/lowering/lower-calls.ts | 6 + .../src/frontend/lowering/lower-inspect.ts | 19 +- .../compiler/src/frontend/lowering/lowerer.ts | 7 + packages/compiler/src/index.ts | 1 + packages/compiler/src/ir/builtin-effects.ts | 3 + .../compiler/src/ir/builtin-signatures.ts | 10 + packages/compiler/src/ir/runtime-features.ts | 20 +- packages/compiler/src/native/cli.ts | 1 + packages/compiler/src/native/prepare.ts | 1 + .../compiler/src/native/sanitizer.test.ts | 1 + ...6453-constructor-function-emitter.cjs.json | 6 + ...constructor-function-stream-bases.cjs.json | 6 + .../runtime-pack-matrix.mjs | 4 + packages/runtime/src/scr_dyn.h | 6 + packages/runtime/src/scr_dyn_builtin_class.c | 1469 +++++++++++++++++ .../6453-constructor-function-emitter.cjs | 114 ++ ...6454-constructor-function-stream-bases.cjs | 65 + .../self-hosting-native-toolchain.test.ts | 1 + .../self-hosting-runtime-features.test.ts | 1 + .../harness/self-hosting-runtime-pack.test.ts | 1 + 36 files changed, 1955 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/builtins/builtin-class-dyn.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6453-constructor-function-emitter.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6454-constructor-function-stream-bases.cjs.json create mode 100644 packages/runtime/src/scr_dyn_builtin_class.c create mode 100644 tests/corpus/6453-constructor-function-emitter.cjs create mode 100644 tests/corpus/6454-constructor-function-stream-bases.cjs diff --git a/docs/content/limitations/builtin-function-values.mdx b/docs/content/limitations/builtin-function-values.mdx index c08540c1a..f3902e75b 100644 --- a/docs/content/limitations/builtin-function-values.mdx +++ b/docs/content/limitations/builtin-function-values.mdx @@ -12,7 +12,11 @@ Other immutable aliases of table-backed Node builtins support direct calls, but Native calls accept standard process streams; custom native streams and mutations to `util.inspect.colors` remain unsupported. `util.inherits` links the prototypes of constructor functions, and `util.deprecate`, `util.callbackify`, and `util.debuglog` return native function values that forward receivers and arguments. -Class constructors passed to `util.inherits` or `util.deprecate` are refused. +In JavaScript, a constructor function can inherit from `EventEmitter`, the legacy `Stream` constructor, or a stream class through `util.inherits` or `Object.setPrototypeOf`. +`EventEmitter.call(this)` and `Stream.call(this)` initialize the object as Node does, and its listener methods keep their state in the object's own `_events`, `_eventsCount`, and `_maxListeners` properties. +The `captureRejections` option and `events.errorMonitor` listeners are not supported on such objects. +Calling `Readable`, `Writable`, `Duplex`, `Transform`, or `PassThrough` with a constructor function's receiver throws `SC2020` where the call runs. +Other class constructors passed to `util.inherits` or `util.deprecate` are refused. Deprecation warnings are emitted on the next tick and follow assignments to `process.noDeprecation` and `process.throwDeprecation`; compiled binaries take no Node command-line flags such as `--no-deprecation` or `--trace-deprecation`, and `process.traceDeprecation` is unsupported. A stored `util.deprecate` reports a `length` of 4, where Node reports 3. In TypeScript, a `util.callbackify` result must reach a callback whose error parameter accepts `null`; the @types/node overloads that type it as non-null are refused. diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index eeb6c6900..a6e228b2e 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -31,6 +31,7 @@ export function executableLinkFeatures(mod: IrModule): NativeLinkFeatures { bigint: features.bigint, searchParams: features.searchParams, qs: features.qs, + dynBuiltinClass: features.dynBuiltinClass, parseArgs: features.parseArgs, stream: features.stream, net: features.net, diff --git a/packages/compiler/src/backend/library-pack.ts b/packages/compiler/src/backend/library-pack.ts index 5070a47a8..efabdc9d4 100644 --- a/packages/compiler/src/backend/library-pack.ts +++ b/packages/compiler/src/backend/library-pack.ts @@ -42,6 +42,7 @@ export async function compilePackedLibrary( bigint: options.bigint ?? false, searchParams: options.searchParams ?? false, qs: false, + dynBuiltinClass: false, parseArgs: false, stream: false, net: false, diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 44741edc8..2b25cc92e 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1404,6 +1404,9 @@ export const LIB_FN_SYMS: Record = { "dyn.classPrototype": "scr_dyn_class_prototype", "dyn.classBasePrototype": "scr_dyn_class_base_prototype", "dyn.classInherit": "scr_dyn_class_inherit", + "dyn.builtinClass": "scr_dyn_builtin_class", + "dyn.builtinInstanceOf": "scr_dyn_builtin_instanceof", + "dyn.functionInstanceOf": "scr_dyn_function_instanceof", "dyn.classSuper": "scr_dyn_class_super", "dyn.assignPrototype": "scr_dyn_assign_prototype", "dyn.definePrototypeProps": "scr_dyn_define_prototype_props", diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 86146259d..d7cf78327 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -46,6 +46,7 @@ export interface NativeLinkFeatures { bigint: boolean; searchParams: boolean; qs: boolean; + dynBuiltinClass: boolean; parseArgs: boolean; stream: boolean; net: boolean; diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index 33b13960e..d978d4387 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -150,6 +150,11 @@ export interface CcOptions { * line (escape-only programs ride the always-linked component encoder * and never flip this). */ qs?: boolean; + /** The program uses EventEmitter or a stream class as an untyped + * constructor value (a constructor function's base): compiles + * scr_dyn_builtin_class.c, which also references the inspection, + * dyn-invoke and symbol units. */ + dynBuiltinClass?: boolean; /** The program uses native util parsing, comparison, or styling. Optional * utility units also require their symbol, inspection, and warning features. */ parseArgs?: boolean; diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 5ebb4796e..bbb720cc7 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -235,6 +235,10 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [ (u) => u.opts.parseArgs && u.opts.inspect && u.opts.dynAsync && u.opts.dynInvoke && u.regex, "scr_util_callable.c", ), + unit( + (u) => u.opts.dynBuiltinClass && u.opts.inspect && u.opts.dynInvoke && u.opts.symbol, + "scr_dyn_builtin_class.c", + ), unit((u) => u.opts.stream, "scr_stream.c", "scr_fs_stream.c"), // The zlib stream classes reference both the zlib engine and the // stream unit. diff --git a/packages/compiler/src/backend/runtime-pack-targets.test.ts b/packages/compiler/src/backend/runtime-pack-targets.test.ts index fd57cb49d..8b0689c51 100644 --- a/packages/compiler/src/backend/runtime-pack-targets.test.ts +++ b/packages/compiler/src/backend/runtime-pack-targets.test.ts @@ -107,6 +107,7 @@ const features = { bigint: false, searchParams: false, qs: false, + dynBuiltinClass: false, parseArgs: false, stream: false, net: false, diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts index 61d1dba5e..5af267d96 100644 --- a/packages/compiler/src/backend/runtime-pack.test.ts +++ b/packages/compiler/src/backend/runtime-pack.test.ts @@ -44,6 +44,7 @@ const BASE: NativeLinkFeatures = { bigint: false, searchParams: false, qs: false, + dynBuiltinClass: false, parseArgs: false, stream: false, net: false, diff --git a/packages/compiler/src/backend/warm-cache.ts b/packages/compiler/src/backend/warm-cache.ts index 2cd111060..b0736f102 100644 --- a/packages/compiler/src/backend/warm-cache.ts +++ b/packages/compiler/src/backend/warm-cache.ts @@ -50,6 +50,7 @@ export async function warmNativeCaches( bigint: false, searchParams: false, qs: false, + dynBuiltinClass: false, parseArgs: false, stream: false, net: false, diff --git a/packages/compiler/src/executable/executable-cache.test.ts b/packages/compiler/src/executable/executable-cache.test.ts index 1b42c3eca..cdd24ff7b 100644 --- a/packages/compiler/src/executable/executable-cache.test.ts +++ b/packages/compiler/src/executable/executable-cache.test.ts @@ -45,6 +45,7 @@ const native: EarlyExecutableNativeFeatures = { bigint: false, searchParams: false, qs: false, + dynBuiltinClass: false, parseArgs: false, stream: false, net: false, diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 8c67c1006..349c07d42 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -71,6 +71,7 @@ export interface EarlyExecutableNativeFeatures { bigint: boolean; searchParams: boolean; qs: boolean; + dynBuiltinClass: boolean; parseArgs: boolean; stream: boolean; net: boolean; @@ -185,6 +186,7 @@ const BOOLEAN_NATIVE_KEYS = [ "bigint", "searchParams", "qs", + "dynBuiltinClass", "parseArgs", "stream", "net", diff --git a/packages/compiler/src/frontend/lowering/builtins/builtin-class-dyn.ts b/packages/compiler/src/frontend/lowering/builtins/builtin-class-dyn.ts new file mode 100644 index 000000000..ac73924ad --- /dev/null +++ b/packages/compiler/src/frontend/lowering/builtins/builtin-class-dyn.ts @@ -0,0 +1,149 @@ +import { numLit } from "../../../ir/build.js"; +import { BOOL, DYN, RUNTIME_EMITTER_CLASS, type IrExpr, type SrcLoc } from "../../../ir/ir.js"; +import * as ts from "../../ts7/adapter.js"; +import { locOf } from "../../program.js"; +import type { Lowerer } from "../lowerer.js"; + +/* EventEmitter and the stream classes as untyped constructor values, the + * bases of JavaScript constructor functions: + * + * function Bus() { EventEmitter.call(this); } + * util.inherits(Bus, EventEmitter); + * + * A compiled `class Bus extends EventEmitter` gets a native emitter prefix, + * but `new Bus()` on a constructor function builds an untyped object, so + * the base is the runtime's dyn constructor (scr_dyn_builtin_class.c): a + * function whose prototype carries Node's EventEmitter methods over the + * object's own `_events` table. The stream classes have prototype chains + * for util.inherits and instanceof; constructing over them refuses where + * it runs. These values appear only where a constructor function uses its + * base: util.inherits, `Base.call(this, …)` and `Base.apply(this, …)`, + * `Base.prototype` and `instanceof Base` on an untyped value. */ + +/** scr_dyn_builtin_class's kind for a runtime class, or null. */ +export function builtinClassKind(className: string): number | null { + switch (className) { + case RUNTIME_EMITTER_CLASS: + return 0; + case "%Readable": + return 2; + case "%Writable": + return 3; + case "%Duplex": + return 4; + case "%Transform": + return 5; + case "%PassThrough": + return 6; + default: + return null; + } +} + +/** The classes by builtin module member. JavaScript sources bind + * `require("events").EventEmitter` as a module member rather than a class + * value, and `require("stream")` is the legacy Stream constructor. */ +const MODULE_CLASS_KINDS: Readonly>>> = { + events: { EventEmitter: 0, default: 0 }, + stream: { Stream: 1, Readable: 2, Writable: 3, Duplex: 4, Transform: 5, PassThrough: 6 }, +}; + +/** The kind of a builtin module member that is one of those classes. */ +export function builtinMemberClassKind(module: string, member: string): number | null { + return MODULE_CLASS_KINDS[module]?.[member] ?? null; +} + +function unparenthesized(node: ts.Expression): ts.Expression { + let current = node; + while (ts.isParenthesizedExpression(current)) current = current.expression; + return current; +} + +/** The kind of a node that names one of those classes: a class value, a + * builtin module member, or the events and stream modules themselves. */ +export function builtinClassKindOf(lowerer: Lowerer, node: ts.Expression): number | null { + const expr = unparenthesized(node); + const mapped = lowerer.mapTypeOf(lowerer.typeOf(expr)); + if (mapped?.kind === "classval") return builtinClassKind(mapped.className); + const member = ts.isIdentifier(expr) + ? lowerer.builtinImportOf(expr) + : ts.isPropertyAccessExpression(expr) + ? lowerer.builtinMemberOf(expr) + : null; + if (member) return MODULE_CLASS_KINDS[member.module]?.[member.member] ?? null; + const module = lowerer.builtinNamespaceModuleOf(expr); + return module === "events" ? 0 : module === "stream" ? 1 : null; +} + +/** Whether evaluating the node can be skipped: a binding or a member of + * one, or of a builtin require. */ +function referenceOnly(node: ts.Expression): boolean { + const expr = unparenthesized(node); + if (ts.isIdentifier(expr)) return true; + if (ts.isPropertyAccessExpression(expr) && !expr.questionDotToken) { + const base = unparenthesized(expr.expression); + return ( + ts.isIdentifier(base) || + (ts.isCallExpression(base) && + ts.isIdentifier(base.expression) && + base.expression.text === "require" && + base.arguments.nodes.length === 1 && + ts.isStringLiteral(base.arguments.nodes[0]!)) + ); + } + return false; +} + +/** The untyped constructor named by a node, or null when it names none of + * those classes. */ +export function builtinClassDynValueOf(lowerer: Lowerer, node: ts.Expression): IrExpr | null { + const kind = builtinClassKindOf(lowerer, node); + if (kind === null) return null; + const loc = locOf(node); + const value = builtinClassValue(kind, loc); + if (referenceOnly(node)) return value; + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: lowerer.lowerExpr(node), loc }], + result: value, + type: DYN, + loc, + }; +} + +export function builtinClassValue(kind: number, loc: SrcLoc): IrExpr { + return { kind: "libCall", fn: "dyn.builtinClass", args: [numLit(kind, loc)], type: DYN, loc }; +} + +/** `Base.call(this, …)` / `Base.apply(this, args)` on one of those class + * values: Function.prototype.call/apply over the untyped constructor. */ +export function lowerBuiltinClassCall(lowerer: Lowerer, call: ts.CallExpression): IrExpr | null { + const callee = call.expression; + if (!ts.isPropertyAccessExpression(callee) || callee.questionDotToken) return null; + const method = callee.name.text; + if (method !== "call" && method !== "apply") return null; + if (call.arguments.nodes.some(ts.isSpreadElement)) return null; + const receiver = builtinClassDynValueOf(lowerer, callee.expression); + if (receiver === null) return null; + const loc = locOf(call); + return { + kind: "dynInvoke", + recv: receiver, + method, + calleeName: callee.getText(), + args: call.arguments.nodes.map((arg) => lowerer.lowerExprExpecting(arg, DYN)), + type: DYN, + loc, + }; +} + +/** `value instanceof Base` for an untyped value. */ +export function builtinClassInstanceOf(value: IrExpr, kind: number, loc: SrcLoc): IrExpr { + return { + kind: "libCall", + fn: "dyn.builtinInstanceOf", + args: [value, numLit(kind, loc)], + type: BOOL, + loc, + }; +} diff --git a/packages/compiler/src/frontend/lowering/class-prototypes.ts b/packages/compiler/src/frontend/lowering/class-prototypes.ts index d337dc802..6c6c7811d 100644 --- a/packages/compiler/src/frontend/lowering/class-prototypes.ts +++ b/packages/compiler/src/frontend/lowering/class-prototypes.ts @@ -1,4 +1,5 @@ import { dynUndefinedExpr, varRef } from "../../ir/build.js"; +import { builtinClassKind, builtinClassValue } from "./builtins/builtin-class-dyn.js"; import * as ts from "../ts7/adapter.js"; import { BOOL, @@ -450,6 +451,19 @@ export function lowerClassPrototypeData( exactClassOfReceiver(lowerer, expr.expression) ?? (stored?.kind === "classval" ? lowerer.classes.get(stored.className) : undefined); if (!info) return null; + // EventEmitter.prototype and the stream prototypes: the untyped + // constructor's prototype object (builtin-class-dyn.ts). + const builtinKind = builtinClassKind(info.def.name); + if (builtinKind !== null) { + const loc = locOf(expr); + return { + kind: "dynKeyGet", + value: builtinClassValue(builtinKind, loc), + key: { kind: "strLit", value: "prototype", type: STRING, loc }, + type: DYN, + loc, + }; + } const parent = expr.parent; const descriptorKey = ts.isCallExpression(parent) && diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index 2ee56573c..83761d131 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -1,5 +1,6 @@ /* Identifier references in expression position: lowerExprInner's identifier branch and its helpers. */ import { cjsMemberAliasValueOf } from "../cjs-exports-object.js"; +import { builtinClassValue, builtinMemberClassKind } from "../builtins/builtin-class-dyn.js"; import * as ts from "../../ts7/adapter.js"; import * as posix from "node:path/posix"; import { type Lowerer, PoisonError } from "../lowerer.js"; @@ -524,6 +525,10 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: // the same identity-token story as stdlib globals above (the // harness adds worker_threads.Worker to its identity Set). if (isJsSourceFile(expr.getSourceFile())) { + // EventEmitter and the stream classes are the runtime's untyped + // constructors, so constructor functions can inherit from them. + const classKind = builtinMemberClassKind(bi.module, bi.member); + if (classKind !== null) return builtinClassValue(classKind, loc); // A function member is a function value that fences when called. if (builtinModuleFnOf(lowerer, bi.module, bi.member)) return builtinFunctionFenceValue(lowerer, bi, expr, loc); diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts index 619c0fe37..8accf2994 100644 --- a/packages/compiler/src/frontend/lowering/expressions/relational.ts +++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts @@ -1,5 +1,6 @@ /* instanceof and in, including private-name and runtime-key in. */ import * as ts from "../../ts7/adapter.js"; +import { builtinClassInstanceOf, builtinClassKind } from "../builtins/builtin-class-dyn.js"; import { checkedPrivateIn, localPrivateBrandClass, @@ -471,6 +472,22 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); return checkedClassInstanceOf(lowerer, value, constructor, loc); } + // `this instanceof Glob` in a JavaScript constructor function: the + // function's prototype against the value's prototype chain. + if ( + constructor?.type.kind === "func" && + isJsSourceFile(expr.getSourceFile()) && + lowerer.dynConvertible(constructor.type) + ) { + const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); + return { + kind: "libCall", + fn: "dyn.functionInstanceOf", + args: [value, { kind: "dynFrom", value: constructor, type: DYN, loc }], + type: BOOL, + loc, + }; + } lowerer.unsupported( "SC1090", expr.right, @@ -579,6 +596,14 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc loc, }; } + // `u instanceof EventEmitter` (or a stream class): an untyped object + // built by a constructor function inherits the untyped constructor's + // prototype (builtin-class-dyn.ts). Compiled emitters and streams have + // no untyped form, so the prototype chain answers for every value. + const builtinKind = builtinClassKind(target.def.name); + if (left.type.kind === "dyn" && builtinKind !== null) { + return builtinClassInstanceOf(left, builtinKind, loc); + } if (left.type.kind === "dyn") { lowerer.unsupported( "SC1090", diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index ed549e4ff..cc14c74ce 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -1,4 +1,5 @@ import { guardNullProto, nullProtoRecordShape } from "./null-prototype.js"; +import { lowerBuiltinClassCall } from "./builtins/builtin-class-dyn.js"; import { refuseModuleMemberThis } from "./module-member-this.js"; import { lowerUnionEquality, tagEqualityMayMissAlias } from "./strict-equality.js"; import { lowerHashStreamCall } from "./builtins/hash-stream.js"; @@ -263,6 +264,11 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { if (object) return object; } + // `EventEmitter.call(this)` in a constructor function: the untyped + // EventEmitter constructor initializes the receiver. + const builtinClassCall = lowerBuiltinClassCall(lowerer, expr); + if (builtinClassCall) return builtinClassCall; + // require() of a JSON document: the shared parsed object. const json = lowerJsonRequireCall(lowerer, expr); if (json) return json; diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index 245d1b676..ff5fb495d 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -1,4 +1,5 @@ import { dynUndefinedExpr, boolLit, helperLoc, numLit, strLit, varRef } from "../../ir/build.js"; +import { builtinClassDynValueOf, builtinClassKindOf } from "./builtins/builtin-class-dyn.js"; import { InternalCompilerError } from "../../errors.js"; import * as ts from "../ts7/adapter.js"; @@ -2544,8 +2545,15 @@ function lowerUtilCallableCall( const arity = member === "deprecate" ? 4 : member === "callbackify" ? 1 : 2; // Program classes keep static prototypes: there is no function object // whose prototype chain the runtime could relink or wrap. + // EventEmitter and the stream classes are the exception as superCtor: + // the runtime has untyped constructors for them (builtin-class-dyn.ts). const classArgs = member === "inherits" ? 2 : member === "deprecate" ? 1 : 0; - for (const arg of expr.arguments.nodes.slice(0, classArgs)) { + const builtinBase = + member === "inherits" && expr.arguments.nodes[1] !== undefined + ? builtinClassKindOf(lowerer, expr.arguments.nodes[1]) + : null; + for (const [index, arg] of expr.arguments.nodes.slice(0, classArgs).entries()) { + if (index === 1 && builtinBase !== null) continue; if (lowerer.mapTypeOf(lowerer.typeOf(arg))?.kind === "classval") lowerer.noLowering( `util.${member} over a class constructor`, @@ -2564,9 +2572,12 @@ function lowerUtilCallableCall( const stmts: IrStmt[] = []; const args: IrExpr[] = []; for (let i = 0; i < arity; i++) { - const input: IrExpr = expr.arguments.nodes[i] - ? lowerer.lowerExprExpecting(expr.arguments.nodes[i]!, DYN) - : dynUndefinedExpr(loc); + const node = expr.arguments.nodes[i]; + const input: IrExpr = !node + ? dynUndefinedExpr(loc) + : i === 1 && builtinBase !== null + ? (builtinClassDynValueOf(lowerer, node) ?? lowerer.lowerExprExpecting(node, DYN)) + : lowerer.lowerExprExpecting(node, DYN); const saved = lowerer.declareHiddenLocal("%utilInput", DYN); stmts.push({ kind: "varDecl", localId: saved.id, init: input, loc }); args.push(varRef(saved.id, DYN, loc)); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 7144e574e..1a050f6c4 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1,4 +1,5 @@ import { jsDynamicDeclarations } from "./js-dynamic-objects.js"; +import { builtinClassValue, builtinMemberClassKind } from "./builtins/builtin-class-dyn.js"; import { lifetimeRegionType, lowerLifetimeRegionCall, @@ -12535,6 +12536,12 @@ export class Lowerer { } // A bare `fs.constants` read (not one of the baked bits above). if (bi.member === "constants" && bi.module === "fs") return fsConstantsObject(loc); + // `stream.Readable` as a JavaScript value: the runtime's untyped + // constructor, the base a constructor function inherits. + if (isJsSourceFile(expr.getSourceFile())) { + const classKind = builtinMemberClassKind(bi.module, bi.member); + if (classKind !== null) return builtinClassValue(classKind, loc); + } if (builtinModuleFnOf(this, bi.module, bi.member)) { this.unsupported( "SC1090", diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 7d8d88889..05a6f7423 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -543,6 +543,7 @@ async function compileExecutableNative( bigint: features.bigint, searchParams: features.searchParams, qs: features.qs, + dynBuiltinClass: features.dynBuiltinClass, parseArgs: features.parseArgs, stream: features.stream, net: features.net, diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 826151009..1b84c5888 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -556,6 +556,9 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "dyn.objCreateWithProperties", "dyn.getPrototype", "dyn.setPrototype", + // a proxy in the prototype chain can throw + "dyn.builtinInstanceOf", + "dyn.functionInstanceOf", "dyn.getOwnPropertyNames", "dyn.ownKeys", "dyn.getOwnPropertySymbols", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index c55444e60..a3480eb2c 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3409,6 +3409,16 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "dyn.classPrototype": { argTypes: [DYN, DYN], result: DYN }, "dyn.classBasePrototype": { argTypes: [DYN], result: DYN }, "dyn.classInherit": { argTypes: [DYN, DYN], result: DYN }, + /** EventEmitter (0), Stream (1), Readable (2), Writable (3), Duplex (4), + * Transform (5) and PassThrough (6) as untyped constructor values, one + * per thread (scr_dyn_builtin_class.c). */ + "dyn.builtinClass": { argTypes: [F64], result: DYN }, + /** `value instanceof C` for those constructors, over the value's + * prototype chain. */ + "dyn.builtinInstanceOf": { argTypes: [DYN, F64], result: BOOL }, + /** `value instanceof f` for an untyped constructor function: the + * prototype chain against f.prototype. */ + "dyn.functionInstanceOf": { argTypes: [DYN, DYN], result: BOOL }, "dyn.classSuper": { argTypes: [DYN, DYN, DYN], result: VOID }, "dyn.assignPrototype": { argTypes: [DYN, DYN, DYN], result: DYN }, "dyn.definePrototypeProps": { argTypes: [DYN, DYN, DYN], result: DYN }, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 9abe15345..de91b4b78 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -222,6 +222,8 @@ const CHILD_PROCESS_LIB_PREFIXES: readonly string[] = ["child.", "cp.", "spawnRe /** Entry points that link the symbol unit, besides the sym. and disposable. families. */ const SYMBOL_LIB_FNS: ReadonlySet = new Set([ + // EventEmitter's kShapeMode and kCapture properties + "dyn.builtinClass", "dyn.usingDispose", "dyn.usingMethod", "util.isDeepStrictEqual", @@ -490,9 +492,16 @@ const RUNTIME_FEATURE_RULES = { (node.name === "promise.allSettled" || node.name === "promise.any")) || (node.kind === "dynFrom" && boxesPromise(node.value.type, tables(), new Set())), }), + /** EventEmitter and the stream classes as untyped constructor values + * (scr_dyn_builtin_class.c), which call listeners through the dyn-invoke + * unit, render unhandled 'error' payloads through the inspect unit and + * create EventEmitter's private symbols through the symbol unit. */ + dynBuiltinClass: rule({ + fns: ["dyn.builtinClass", "dyn.builtinInstanceOf", "dyn.functionInstanceOf"], + }), dynInvoke: rule({ initial: (mod) => mod.workers === true, - fns: [...DYN_INVOKE_LIB_FNS, ...UTIL_CALLABLE_LIB_FNS], + fns: [...DYN_INVOKE_LIB_FNS, ...UTIL_CALLABLE_LIB_FNS, "dyn.builtinClass"], expr: (node, _tables) => node.kind === "dynInvoke", }), /** The program has dynamic-tier jsval operations (js.* builtins, the @@ -534,7 +543,13 @@ const RUNTIME_FEATURE_RULES = { * terminal unit prints completion errors through it when there is a * completer. */ inspect: rule({ - fns: ["global.native", "util.styleText", "crypto.cipherNew", ...UTIL_CALLABLE_LIB_FNS], + fns: [ + "global.native", + "util.styleText", + "crypto.cipherNew", + "dyn.builtinClass", + ...UTIL_CALLABLE_LIB_FNS, + ], prefixes: ["insp.", "console.", "test."], libCall: (node) => node.fn === "rl.terminal" && !isDynUndefined(node.args[3]!), }), @@ -637,6 +652,7 @@ function noRuntimeFeatures(): RuntimeFeatures { dc: false, dgram: false, dynAsync: false, + dynBuiltinClass: false, dynInvoke: false, dynTier: false, emitter: false, diff --git a/packages/compiler/src/native/cli.ts b/packages/compiler/src/native/cli.ts index 7d00acbc8..ce11aadf6 100644 --- a/packages/compiler/src/native/cli.ts +++ b/packages/compiler/src/native/cli.ts @@ -89,6 +89,7 @@ async function main(): Promise { bigint: false, searchParams: false, qs: false, + dynBuiltinClass: false, parseArgs: false, stream: false, net: false, diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts index 54100a413..b95b978ff 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -143,6 +143,7 @@ function validFeatures(value: unknown): value is NativeLinkFeatures { "bigint", "searchParams", "qs", + "dynBuiltinClass", "parseArgs", "stream", "net", diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index 6ca84af6f..bbd07a6cd 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -95,6 +95,7 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( bigint: false, searchParams: false, qs: false, + dynBuiltinClass: false, parseArgs: false, stream: false, net: false, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6453-constructor-function-emitter.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6453-constructor-function-emitter.cjs.json new file mode 100644 index 000000000..ae5c0050e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6453-constructor-function-emitter.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6453-constructor-function-emitter.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6454-constructor-function-stream-bases.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6454-constructor-function-stream-bases.cjs.json new file mode 100644 index 000000000..bc48a7f35 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6454-constructor-function-stream-bases.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6454-constructor-function-stream-bases.cjs" + ], + "diags": [] +} diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 1e959eccc..a6b11f53e 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -88,6 +88,10 @@ export const OPTIONAL_RUNTIME_SOURCES = [ ["scr_util_compare.c", all("parseArgs", "symbol")], ["scr_util_style.c", all("parseArgs", "inspect", "dynAsync")], ["scr_util_callable.c", all("parseArgs", "inspect", "dynAsync", "dynInvoke", "regex")], + // Untyped EventEmitter and stream constructor values call listeners + // through the dyn-invoke unit, inspect unhandled 'error' payloads and + // create EventEmitter's private symbols. + ["scr_dyn_builtin_class.c", all("dynBuiltinClass", "inspect", "dynInvoke", "symbol")], ["scr_stream.c", "stream"], // The zlib stream classes reference both the zlib engine and the stream unit. ["scr_zlib_stream.c", all("zlib", "stream")], diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index a3d506a10..91919b641 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1116,6 +1116,12 @@ ScrDyn *scr_dyn_define_prototype_props(ScrDyn *target, ScrDyn *descs, ScrDyn *pr * must be static literals (the box never frees them; name may be NULL). */ ScrDyn *scr_dyn_new_func(ScrClosure *clo, ScrDynThunk thunk, uint32_t arity, const char *sig, const char *name); ScrDyn *scr_dyn_new_class(ScrClassObj *cls, const char *type_key); +/* EventEmitter and the stream constructors as untyped constructor values, + * and instanceof over untyped constructor functions + * (scr_dyn_builtin_class.c, linked with the dynBuiltinClass feature). */ +ScrDyn *scr_dyn_builtin_class(double kind); +bool scr_dyn_builtin_instanceof(ScrDyn *value, double kind); +bool scr_dyn_function_instanceof(ScrDyn *value, ScrDyn *callee); bool scr_dyn_class_is_key(const ScrDyn *value, const ScrStr *type_key); bool scr_dyn_class_is(const ScrDyn *value, const char *type_key); /* Calls a dyn value: a non-function kind throws the catchable TypeError diff --git a/packages/runtime/src/scr_dyn_builtin_class.c b/packages/runtime/src/scr_dyn_builtin_class.c new file mode 100644 index 000000000..76a7ab15e --- /dev/null +++ b/packages/runtime/src/scr_dyn_builtin_class.c @@ -0,0 +1,1469 @@ +/* EventEmitter and the node:stream constructors as checked-dynamic + * constructor values. JavaScript constructor functions inherit from them + * the pre-class way: + * + * function Bus() { EventEmitter.call(this); } + * util.inherits(Bus, EventEmitter); + * + * A compiled EventEmitter instance keeps its listeners in native state, but + * the receiver here is an ordinary dyn object built by `new Bus()`. So the + * EventEmitter value is a native function whose prototype carries Node's + * lib/events.js methods over that object's own `_events`, `_eventsCount` + * and `_maxListeners` properties, the state Node's own implementation + * keeps there. Calling it with a receiver runs EventEmitter.init. + * + * Stream is Node's legacy constructor: EventEmitter.call(this, opts) and a + * prototype that inherits EventEmitter's. Readable, Writable, Duplex, + * Transform and PassThrough have prototypes chained as in Node, so + * util.inherits onto them and instanceof work, but their native state has + * no dyn object form: calling one with a receiver throws the "not + * supported yet" Error where the construction runs. + * + * One constructor per kind and thread, so every boxing of the same class + * value is the same function. All arguments are borrowed. */ +#include "scr_runtime.h" + +#include +#include +#include + +#ifdef _WIN32 +#include +#define BC_PID() ((long)_getpid()) +#else +#include +#define BC_PID() ((long)getpid()) +#endif + +enum { + BC_EMITTER, + BC_STREAM, + BC_READABLE, + BC_WRITABLE, + BC_DUPLEX, + BC_TRANSFORM, + BC_PASSTHROUGH, + BC_COUNT +}; + +static const char *const bc_names[BC_COUNT] = { + "EventEmitter", "Stream", "Readable", "Writable", "Duplex", "Transform", "PassThrough", +}; + +static SCR_TL ScrDyn *bc_ctors[BC_COUNT]; +static SCR_TL bool bc_teardown_registered; +/* Node's private kShapeMode and kCapture symbols, which util.inspect shows + * on every emitter as Symbol(shapeMode) and Symbol(kCapture). */ +enum { BC_SHAPE_MODE, BC_CAPTURE, BC_SYMBOLS }; +static SCR_TL ScrDyn *bc_symbols[BC_SYMBOLS]; + +static ScrDyn *bc_class(int kind); + +/* ── property helpers ───────────────────────────────────────────────── */ + +static ScrDyn *bc_key(const char *name) { + ScrStr *text = scr_str_new(name, strlen(name)); + ScrDyn *key = scr_dyn_new_str(text); + scr_str_release(text); + return key; +} + +/* object[key] with object as the receiver: +1, or NULL with an exception + * pending. */ +static ScrDyn *bc_get_key(ScrDyn *object, ScrDyn *key) { + ScrDyn *value = scr_dyn_reflect_get(object, key, object); + if (!value && !scr_exc_pending()) value = scr_dyn_retain(scr_dyn_undefined()); + return value; +} + +static ScrDyn *bc_get(ScrDyn *object, const char *name) { + ScrDyn *key = bc_key(name); + ScrDyn *value = bc_get_key(object, key); + scr_dyn_release(key); + return value; +} + +/* object[key] = value; the value moves in. False with an exception + * pending. */ +static bool bc_set_key(ScrDyn *object, ScrDyn *key, ScrDyn *value) { + scr_dyn_key_set_computed(object, key, value); + scr_dyn_release(value); + return !scr_exc_pending(); +} + +static bool bc_set(ScrDyn *object, const char *name, ScrDyn *value) { + ScrDyn *key = bc_key(name); + bool ok = bc_set_key(object, key, value); + scr_dyn_release(key); + return ok; +} + +static bool bc_undefined(const ScrDyn *value) { + return value->kind == SCR_DYN_UNDEF; +} + +static double bc_number(const ScrDyn *value) { + return value->kind == SCR_DYN_NUM ? value->v.num : 0; +} + +/* receiver.method(...args), dispatched through the receiver's own lookup + * like Node's `this.emit(...)`. +1 or NULL. */ +static ScrDyn *bc_invoke(ScrDyn *receiver, const char *method, ScrDyn *const *args, size_t argc) { + ScrDyn *fn = bc_get(receiver, method); + if (!fn) return NULL; + scr_dyn_this_push_dyn(receiver); + ScrDyn *result = scr_dyn_call(fn, args, argc, method); + scr_dyn_this_pop(); + scr_dyn_release(fn); + return result; +} + +static ScrDyn *bc_apply(ScrDyn *fn, ScrDyn *receiver, ScrDyn *const *args, size_t argc) { + scr_dyn_this_push_dyn(receiver); + ScrDyn *result = scr_dyn_call(fn, args, argc, "listener"); + scr_dyn_this_pop(); + return result; +} + +/* Listener lists are replaced rather than mutated: a fresh array with item + * first, or without the item at index. Node mutates its array in place, + * which nothing outside the emitter observes (emit iterates a copy and + * listeners() answers one). A leak warning already given stays given. */ +static ScrDyn *bc_list_copy(ScrDyn *list, ScrDyn *first, double skip) { + ScrDyn *copy = scr_dyn_new_arr(); + if (first) scr_dyn_arr_push(copy, scr_dyn_retain(first)); + double length = scr_dyn_arr_len(list); + for (double i = 0; i < length; i++) { + if (i == skip) continue; + ScrDyn *item = scr_dyn_index_get(list, i); + if (!item) { + scr_dyn_release(copy); + return NULL; + } + scr_dyn_arr_push(copy, item); + } + ScrDyn *warned = bc_get(list, "warned"); + if (!warned || (scr_dyn_truthy(warned) && !bc_set(copy, "warned", scr_dyn_new_bool(true)))) { + if (warned) scr_dyn_release(warned); + scr_dyn_release(copy); + return NULL; + } + scr_dyn_release(warned); + return copy; +} + +static ScrDyn *bc_unused_args[1]; + +static ScrDyn *bc_arg(ScrDyn *const *args, size_t argc, size_t index) { + return index < argc ? args[index] : scr_dyn_undefined(); +} + +/* The receiver of a prototype method call, +1. Methods called without one + * (an extracted `emitter.on`) read `this` as undefined, as in strict code. */ +static ScrDyn *bc_this(void) { + return scr_dyn_this_get(); +} + +static bool bc_require_object(ScrDyn *self, const char *reading) { + if (self->kind != SCR_DYN_UNDEF && self->kind != SCR_DYN_NULL) return true; + char message[96]; + snprintf(message, sizeof message, "Cannot read properties of %s (reading '%s')", + self->kind == SCR_DYN_NULL ? "null" : "undefined", reading); + scr_throw_error_msg(SCR_ERR_TYPE, message, strlen(message)); + return false; +} + +static bool bc_check_listener(const ScrDyn *listener) { + if (scr_dyn_is_callable(listener)) return true; + scr_dyn_arg_type_fail("listener", "of type function", listener); + return false; +} + +static void bc_teardown(void); + +static ScrDyn *bc_symbol(int which) { + if (!bc_symbols[which]) { + if (!bc_teardown_registered) { + bc_teardown_registered = true; + scr_atexit(bc_teardown); + } + const char *name = which == BC_SHAPE_MODE ? "shapeMode" : "kCapture"; + ScrStr *description = scr_str_new(name, strlen(name)); + ScrSym *symbol = scr_sym_new(description); + scr_str_release(description); + bc_symbols[which] = scr_dyn_new_symbol(symbol); + scr_sym_release(symbol); + } + return bc_symbols[which]; +} + +/* ── EventEmitter.init ──────────────────────────────────────────────── */ + +/* `_events` is fresh unless the receiver already has its own (Node's + * shapeMode, a subclass that pre-populates it); `_maxListeners` keeps a + * value set before the call. */ +static bool bc_emitter_init(ScrDyn *self, ScrDyn *opts) { + if (!bc_require_object(self, "_events")) return false; + ScrDyn *events = bc_get(self, "_events"); + if (!events) return false; + bool fresh = bc_undefined(events); + if (!fresh) { + ScrDyn *proto = scr_dyn_get_prototype(self); + if (!proto) { + scr_dyn_release(events); + return false; + } + if (proto->kind != SCR_DYN_NULL) { + ScrDyn *inherited = bc_get(proto, "_events"); + if (!inherited) { + scr_dyn_release(proto); + scr_dyn_release(events); + return false; + } + fresh = scr_dyn_strict_eq(events, inherited); + scr_dyn_release(inherited); + } + scr_dyn_release(proto); + } + scr_dyn_release(events); + if (fresh) { + if (!bc_set(self, "_events", scr_dyn_new_obj_null_proto())) return false; + if (!bc_set(self, "_eventsCount", scr_dyn_new_num(0))) return false; + } + if (!bc_set_key(self, bc_symbol(BC_SHAPE_MODE), scr_dyn_new_bool(!fresh))) return false; + ScrDyn *max = bc_get(self, "_maxListeners"); + if (!max) return false; + bool keep = scr_dyn_truthy(max); + if (!bc_set(self, "_maxListeners", keep ? max : (scr_dyn_release(max), scr_dyn_retain(scr_dyn_undefined())))) + return false; + if (opts->kind != SCR_DYN_UNDEF && opts->kind != SCR_DYN_NULL) { + ScrDyn *capture = bc_get(opts, "captureRejections"); + if (!capture) return false; + bool requested = !bc_undefined(capture); + if (requested && capture->kind != SCR_DYN_BOOL) { + scr_dyn_arg_type_fail("options.captureRejections", "of type boolean", capture); + scr_dyn_release(capture); + return false; + } + bool enabled = requested && capture->v.b; + scr_dyn_release(capture); + if (enabled) { + static const char message[] = "EventEmitter captureRejections on a constructor function is not supported yet"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + return false; + } + } + return bc_set_key(self, bc_symbol(BC_CAPTURE), scr_dyn_new_bool(false)); +} + +/* ── listener bookkeeping (lib/events.js) ───────────────────────────── */ + +static double bc_max_listeners(ScrDyn *self) { + ScrDyn *max = bc_get(self, "_maxListeners"); + if (!max) return -1; + double value; + if (bc_undefined(max)) { + ScrDyn *fallback = bc_get(bc_class(BC_EMITTER), "defaultMaxListeners"); + value = fallback ? bc_number(fallback) : 10; + if (fallback) scr_dyn_release(fallback); + } else { + value = bc_number(max); + } + scr_dyn_release(max); + return value; +} + +static void bc_warn_tick(ScrClosure *self) { + ScrStr *message = (ScrStr *)scr_box_get_ref(self->caps[0]); + if (scr_warning_emit_hook) { + scr_warning_emit_hook("MaxListenersExceededWarning", NULL, message); + } else { + fflush(stdout); + fprintf(stderr, "(node:%ld) MaxListenersExceededWarning: %.*s\n", BC_PID(), + (int)message->len, message->data); + if (!scr_warning_trace_hint_shown) { + scr_warning_trace_hint_shown = true; + fputs("(Use `node --trace-warnings ...` to show where the warning was created)\n", stderr); + } + } + scr_str_release(message); +} + +/* The constructor name util.inspect prints at depth -1: `[Bus]`. */ +static ScrStr *bc_constructor_name(ScrDyn *self) { + ScrDyn *ctor = bc_get(self, "constructor"); + ScrStr *name = NULL; + if (ctor && scr_dyn_is_callable(ctor)) { + ScrDyn *text = bc_get(ctor, "name"); + if (text && text->kind == SCR_DYN_STR && text->v.str->len) name = scr_str_retain(text->v.str); + if (text) scr_dyn_release(text); + } + if (ctor) scr_dyn_release(ctor); + if (scr_exc_pending()) scr_exc_clear(); + return name ? name : scr_str_new("Object", 6); +} + +static void bc_warn_leak(ScrDyn *self, ScrDyn *type, double count, double max) { + char counted[32], limit[32]; + snprintf(counted, sizeof counted, "%g", count); + snprintf(limit, sizeof limit, "%g", max); + ScrStr *event = scr_dyn_string_coerce_js(type); + if (!event) { + scr_exc_clear(); + event = scr_str_new("", 0); + } + ScrStr *cls = bc_constructor_name(self); + ScrJsonBuf text; + scr_jb_init(&text); + scr_jb_puts(&text, "Possible EventEmitter memory leak detected. "); + scr_jb_puts(&text, counted); + scr_jb_putc(&text, ' '); + scr_jb_put_str(&text, event); + scr_jb_puts(&text, " listeners added to ["); + scr_jb_put_str(&text, cls); + scr_jb_puts(&text, "]. MaxListeners is "); + scr_jb_puts(&text, limit); + scr_jb_puts(&text, ". Use emitter.setMaxListeners() to increase limit"); + scr_str_release(event); + scr_str_release(cls); + ScrClosure *tick = scr_closure_new((void *)&bc_warn_tick, 1); + ScrBox *box = scr_box_new(SCR_BOX_STR); + scr_box_set_ref(box, scr_jb_finish(&text)); + tick->caps[0] = box; + scr_next_tick(tick); +} + +/* _addListener(target, type, listener, prepend): +1 target or NULL. */ +static ScrDyn *bc_add_listener(ScrDyn *self, ScrDyn *type, ScrDyn *listener, bool prepend) { + if (!bc_check_listener(listener)) return NULL; + ScrDyn *events = bc_get(self, "_events"); + if (!events) return NULL; + ScrDyn *existing = NULL; + if (bc_undefined(events)) { + scr_dyn_release(events); + events = scr_dyn_new_obj_null_proto(); + if (!bc_set(self, "_events", scr_dyn_retain(events)) || !bc_set(self, "_eventsCount", scr_dyn_new_num(0))) + goto fail; + } else { + ScrDyn *watcher = bc_get(events, "newListener"); + if (!watcher) goto fail; + bool watched = !bc_undefined(watcher); + scr_dyn_release(watcher); + if (watched) { + ScrDyn *inner = bc_get(listener, "listener"); + if (!inner) goto fail; + ScrDyn *name = bc_key("newListener"); + ScrDyn *args[3] = { name, type, bc_undefined(inner) || inner->kind == SCR_DYN_NULL ? listener : inner }; + ScrDyn *emitted = bc_invoke(self, "emit", args, 3); + scr_dyn_release(name); + scr_dyn_release(inner); + if (!emitted) goto fail; + scr_dyn_release(emitted); + /* the listener may have replaced _events */ + scr_dyn_release(events); + events = bc_get(self, "_events"); + if (!events) return NULL; + } + existing = bc_get_key(events, type); + if (!existing) goto fail; + } + if (!existing || bc_undefined(existing)) { + if (!bc_set_key(events, type, scr_dyn_retain(listener))) goto fail; + ScrDyn *count = bc_get(self, "_eventsCount"); + if (!count) goto fail; + double next = bc_number(count) + 1; + scr_dyn_release(count); + if (!bc_set(self, "_eventsCount", scr_dyn_new_num(next))) goto fail; + } else { + if (scr_dyn_is_callable(existing)) { + ScrDyn *list = scr_dyn_new_arr(); + scr_dyn_arr_push(list, scr_dyn_retain(prepend ? listener : existing)); + scr_dyn_arr_push(list, scr_dyn_retain(prepend ? existing : listener)); + if (!bc_set_key(events, type, scr_dyn_retain(list))) { + scr_dyn_release(list); + goto fail; + } + scr_dyn_release(existing); + existing = list; + } else if (prepend) { + ScrDyn *list = bc_list_copy(existing, listener, -1); + if (!list) goto fail; + if (!bc_set_key(events, type, scr_dyn_retain(list))) { + scr_dyn_release(list); + goto fail; + } + scr_dyn_release(existing); + existing = list; + } else if (existing->kind == SCR_DYN_ARR) { + scr_dyn_arr_push(existing, scr_dyn_retain(listener)); + } + double max = bc_max_listeners(self); + if (scr_exc_pending()) goto fail; + double length = existing->kind == SCR_DYN_ARR ? scr_dyn_arr_len(existing) : 0; + if (max > 0 && length > max) { + ScrDyn *warned = bc_get(existing, "warned"); + if (!warned) goto fail; + bool already = scr_dyn_truthy(warned); + scr_dyn_release(warned); + if (!already) { + if (!bc_set(existing, "warned", scr_dyn_new_bool(true))) goto fail; + bc_warn_leak(self, type, length, max); + } + } + } + if (existing) scr_dyn_release(existing); + scr_dyn_release(events); + return scr_dyn_retain(self); +fail: + if (existing) scr_dyn_release(existing); + scr_dyn_release(events); + return NULL; +} + +/* removeListener(type, listener): +1 self or NULL. */ +static ScrDyn *bc_remove_listener(ScrDyn *self, ScrDyn *type, ScrDyn *listener) { + if (!bc_check_listener(listener)) return NULL; + ScrDyn *events = bc_get(self, "_events"); + if (!events) return NULL; + if (bc_undefined(events)) { + scr_dyn_release(events); + return scr_dyn_retain(self); + } + ScrDyn *list = bc_get_key(events, type); + if (!list) goto fail_events; + if (bc_undefined(list)) { + scr_dyn_release(list); + scr_dyn_release(events); + return scr_dyn_retain(self); + } + ScrDyn *wrapped = scr_dyn_is_callable(list) ? bc_get(list, "listener") : NULL; + if (scr_exc_pending()) goto fail_list; + bool match = scr_dyn_is_callable(list) && + (scr_dyn_strict_eq(list, listener) || (wrapped && scr_dyn_strict_eq(wrapped, listener))); + if (match) { + ScrDyn *count = bc_get(self, "_eventsCount"); + if (!count) goto fail_wrapped; + double left = bc_number(count) - 1; + scr_dyn_release(count); + if (!bc_set(self, "_eventsCount", scr_dyn_new_num(left))) goto fail_wrapped; + ScrDyn *shaped = bc_get_key(self, bc_symbol(BC_SHAPE_MODE)); + if (!shaped) goto fail_wrapped; + bool shape_mode = scr_dyn_truthy(shaped); + scr_dyn_release(shaped); + if (shape_mode) { + if (!bc_set_key(events, type, scr_dyn_retain(scr_dyn_undefined()))) goto fail_wrapped; + } else if (left == 0) { + if (!bc_set(self, "_events", scr_dyn_new_obj_null_proto())) goto fail_wrapped; + } else { + scr_dyn_key_delete_computed(events, type, false); + if (scr_exc_pending()) goto fail_wrapped; + ScrDyn *watcher = bc_get(events, "removeListener"); + if (!watcher) goto fail_wrapped; + bool watched = scr_dyn_truthy(watcher); + scr_dyn_release(watcher); + if (watched) { + ScrDyn *name = bc_key("removeListener"); + ScrDyn *args[3] = { name, type, wrapped && scr_dyn_truthy(wrapped) ? wrapped : listener }; + ScrDyn *emitted = bc_invoke(self, "emit", args, 3); + scr_dyn_release(name); + if (!emitted) goto fail_wrapped; + scr_dyn_release(emitted); + } + } + } else if (list->kind == SCR_DYN_ARR) { + double length = scr_dyn_arr_len(list); + double position = -1; + for (double i = length - 1; i >= 0; i--) { + ScrDyn *item = scr_dyn_index_get(list, i); + if (!item) goto fail_wrapped; + bool same = scr_dyn_strict_eq(item, listener); + if (!same && scr_dyn_is_callable(item)) { + ScrDyn *inner = bc_get(item, "listener"); + if (!inner) { + scr_dyn_release(item); + goto fail_wrapped; + } + same = scr_dyn_strict_eq(inner, listener); + scr_dyn_release(inner); + } + scr_dyn_release(item); + if (same) { + position = i; + break; + } + } + if (position >= 0) { + ScrDyn *rest = bc_list_copy(list, NULL, position); + if (!rest) goto fail_wrapped; + ScrDyn *stored = scr_dyn_arr_len(rest) == 1 ? scr_dyn_index_get(rest, 0) : scr_dyn_retain(rest); + scr_dyn_release(rest); + if (!stored || !bc_set_key(events, type, stored)) goto fail_wrapped; + ScrDyn *watcher = bc_get(events, "removeListener"); + if (!watcher) goto fail_wrapped; + bool watched = !bc_undefined(watcher); + scr_dyn_release(watcher); + if (watched) { + ScrDyn *name = bc_key("removeListener"); + ScrDyn *args[3] = { name, type, listener }; + ScrDyn *emitted = bc_invoke(self, "emit", args, 3); + scr_dyn_release(name); + if (!emitted) goto fail_wrapped; + scr_dyn_release(emitted); + } + } + } + if (wrapped) scr_dyn_release(wrapped); + scr_dyn_release(list); + scr_dyn_release(events); + return scr_dyn_retain(self); +fail_wrapped: + if (wrapped) scr_dyn_release(wrapped); +fail_list: + scr_dyn_release(list); +fail_events: + scr_dyn_release(events); + return NULL; +} + +/* ── once wrappers ──────────────────────────────────────────────────── */ + +enum { BC_ONCE_FIRED, BC_ONCE_TARGET, BC_ONCE_TYPE, BC_ONCE_LISTENER, BC_ONCE_SELF, BC_ONCE_CAPS }; + +static ScrBox *bc_box(ScrDyn *value /* moves in */) { + ScrBox *box = scr_box_new_obj(scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v); + scr_box_set_ref(box, value); + return box; +} + +static ScrDyn *bc_cap(ScrClosure *closure, size_t index) { + return scr_box_get_ref(closure->caps[index]); /* +1 */ +} + +/* onceWrapper: the first call removes the wrapper, then calls the listener + * with the target as its receiver. The wrapper's reference to itself is + * dropped when it fires, so a fired wrapper holds no cycle. */ +static ScrDyn *bc_once_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + if (scr_box_get_f64(closure->caps[BC_ONCE_FIRED]) != 0) return scr_dyn_retain(scr_dyn_undefined()); + ScrDyn *target = bc_cap(closure, BC_ONCE_TARGET); + ScrDyn *type = bc_cap(closure, BC_ONCE_TYPE); + ScrDyn *listener = bc_cap(closure, BC_ONCE_LISTENER); + ScrDyn *self = bc_cap(closure, BC_ONCE_SELF); + ScrDyn *result = NULL; + ScrDyn *removal[2] = { type, self }; + ScrDyn *removed = bc_invoke(target, "removeListener", removal, 2); + if (removed) { + scr_dyn_release(removed); + scr_box_set_f64(closure->caps[BC_ONCE_FIRED], 1); + scr_box_set_ref(closure->caps[BC_ONCE_SELF], scr_dyn_retain(scr_dyn_undefined())); + result = bc_apply(listener, target, args, argc); + } + scr_dyn_release(self); + scr_dyn_release(listener); + scr_dyn_release(type); + scr_dyn_release(target); + return result; +} + +static ScrDyn *bc_once_wrap(ScrDyn *target, ScrDyn *type, ScrDyn *listener) { + ScrClosure *closure = scr_closure_new(NULL, BC_ONCE_CAPS); + closure->caps[BC_ONCE_FIRED] = scr_box_new(SCR_BOX_F64); + closure->caps[BC_ONCE_TARGET] = bc_box(scr_dyn_retain(target)); + closure->caps[BC_ONCE_TYPE] = bc_box(scr_dyn_retain(type)); + closure->caps[BC_ONCE_LISTENER] = bc_box(scr_dyn_retain(listener)); + closure->caps[BC_ONCE_SELF] = bc_box(scr_dyn_retain(scr_dyn_undefined())); + scr_closure_retain(closure); + ScrDyn *wrapper = scr_dyn_new_func(closure, bc_once_call, 0, "native:events", "bound onceWrapper"); + scr_box_set_ref(closure->caps[BC_ONCE_SELF], scr_dyn_retain(wrapper)); + scr_closure_release(closure); + if (!bc_set(wrapper, "listener", scr_dyn_retain(listener))) { + scr_dyn_release(wrapper); + return NULL; + } + return wrapper; +} + +/* ── EventEmitter.prototype ─────────────────────────────────────────── */ + +static ScrDyn *bc_on(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + ScrDyn *self = bc_this(); + ScrDyn *result = bc_require_object(self, "_events") + ? bc_add_listener(self, bc_arg(args, argc, 0), bc_arg(args, argc, 1), false) : NULL; + scr_dyn_release(self); + return result; +} + +static ScrDyn *bc_prepend(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + ScrDyn *self = bc_this(); + ScrDyn *result = bc_require_object(self, "_events") + ? bc_add_listener(self, bc_arg(args, argc, 0), bc_arg(args, argc, 1), true) : NULL; + scr_dyn_release(self); + return result; +} + +static ScrDyn *bc_once_common(ScrDyn *const *args, size_t argc, bool prepend) { + ScrDyn *self = bc_this(); + ScrDyn *type = bc_arg(args, argc, 0); + ScrDyn *listener = bc_arg(args, argc, 1); + ScrDyn *result = NULL; + if (bc_check_listener(listener) && bc_require_object(self, "_events")) { + ScrDyn *wrapper = bc_once_wrap(self, type, listener); + if (wrapper) { + result = bc_add_listener(self, type, wrapper, prepend); + scr_dyn_release(wrapper); + } + } + scr_dyn_release(self); + return result; +} + +static ScrDyn *bc_once(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + return bc_once_common(args, argc, false); +} + +static ScrDyn *bc_prepend_once(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + return bc_once_common(args, argc, true); +} + +static ScrDyn *bc_off(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + ScrDyn *self = bc_this(); + ScrDyn *result = bc_require_object(self, "_events") + ? bc_remove_listener(self, bc_arg(args, argc, 0), bc_arg(args, argc, 1)) : NULL; + scr_dyn_release(self); + return result; +} + +/* removeAllListeners(type?): with no 'removeListener' watcher the table is + * reset; otherwise every listener is removed through removeListener, last + * first, so the watcher sees each one (and itself last). */ +static ScrDyn *bc_remove_all(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + ScrDyn *self = bc_this(); + if (!bc_require_object(self, "_events")) { + scr_dyn_release(self); + return NULL; + } + ScrDyn *type = bc_arg(args, argc, 0); + bool all = argc == 0; + ScrDyn *events = bc_get(self, "_events"); + ScrDyn *watcher = NULL; + if (!events) goto fail; + if (bc_undefined(events)) goto done; + watcher = bc_get(events, "removeListener"); + if (!watcher) goto fail; + if (bc_undefined(watcher)) { + if (all) { + if (!bc_set(self, "_events", scr_dyn_new_obj_null_proto()) || !bc_set(self, "_eventsCount", scr_dyn_new_num(0))) + goto fail; + } else { + ScrDyn *present = bc_get_key(events, type); + if (!present) goto fail; + bool has = !bc_undefined(present); + scr_dyn_release(present); + if (has) { + ScrDyn *count = bc_get(self, "_eventsCount"); + if (!count) goto fail; + double left = bc_number(count) - 1; + scr_dyn_release(count); + if (left == 0) { + if (!bc_set(self, "_events", scr_dyn_new_obj_null_proto()) || !bc_set(self, "_eventsCount", scr_dyn_new_num(0))) + goto fail; + } else { + if (!bc_set(self, "_eventsCount", scr_dyn_new_num(left))) goto fail; + scr_dyn_key_delete_computed(events, type, false); + if (scr_exc_pending()) goto fail; + } + } + } + goto done; + } + if (all) { + ScrDyn *keys = scr_dyn_own_keys(events); + if (!keys) goto fail; + double length = scr_dyn_arr_len(keys); + for (double i = 0; i < length; i++) { + ScrDyn *key = scr_dyn_index_get(keys, i); + if (!key) { + scr_dyn_release(keys); + goto fail; + } + bool skip = key->kind == SCR_DYN_STR && key->v.str->len == 14 && memcmp(key->v.str->data, "removeListener", 14) == 0; + ScrDyn *done_key = NULL; + if (!skip) { + ScrDyn *one[1] = { key }; + done_key = bc_invoke(self, "removeAllListeners", one, 1); + } + scr_dyn_release(key); + if (!skip && !done_key) { + scr_dyn_release(keys); + goto fail; + } + if (done_key) scr_dyn_release(done_key); + } + scr_dyn_release(keys); + ScrDyn *name = bc_key("removeListener"); + ScrDyn *one[1] = { name }; + ScrDyn *cleared = bc_invoke(self, "removeAllListeners", one, 1); + scr_dyn_release(name); + if (!cleared) goto fail; + scr_dyn_release(cleared); + if (!bc_set(self, "_events", scr_dyn_new_obj_null_proto()) || !bc_set(self, "_eventsCount", scr_dyn_new_num(0))) + goto fail; + goto done; + } + { + ScrDyn *listeners = bc_get_key(events, type); + if (!listeners) goto fail; + if (scr_dyn_is_callable(listeners)) { + ScrDyn *pair[2] = { type, listeners }; + ScrDyn *removed = bc_invoke(self, "removeListener", pair, 2); + scr_dyn_release(listeners); + if (!removed) goto fail; + scr_dyn_release(removed); + } else if (listeners->kind == SCR_DYN_ARR) { + for (double i = scr_dyn_arr_len(listeners) - 1; i >= 0; i--) { + ScrDyn *item = scr_dyn_index_get(listeners, i); + if (!item) { + scr_dyn_release(listeners); + goto fail; + } + ScrDyn *pair[2] = { type, item }; + ScrDyn *removed = bc_invoke(self, "removeListener", pair, 2); + scr_dyn_release(item); + if (!removed) { + scr_dyn_release(listeners); + goto fail; + } + scr_dyn_release(removed); + } + scr_dyn_release(listeners); + } else { + scr_dyn_release(listeners); + } + } +done: + if (watcher) scr_dyn_release(watcher); + if (events) scr_dyn_release(events); + return self; +fail: + if (watcher) scr_dyn_release(watcher); + if (events) scr_dyn_release(events); + scr_dyn_release(self); + return NULL; +} + +/* emit(type, ...args): Node's unhandled 'error' rule, then each listener + * with the emitter as its receiver over a copy of the list. */ +static ScrDyn *bc_emit(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + ScrDyn *self = bc_this(); + if (!bc_require_object(self, "_events")) { + scr_dyn_release(self); + return NULL; + } + ScrDyn *type = bc_arg(args, argc, 0); + ScrDyn *const *rest = argc > 1 ? args + 1 : bc_unused_args; + size_t restc = argc > 1 ? argc - 1 : 0; + bool error = type->kind == SCR_DYN_STR && type->v.str->len == 5 && memcmp(type->v.str->data, "error", 5) == 0; + ScrDyn *events = bc_get(self, "_events"); + if (!events) goto fail_self; + if (!bc_undefined(events)) { + if (error) { + ScrDyn *handler = bc_get(events, "error"); + if (!handler) goto fail_events; + error = bc_undefined(handler); + scr_dyn_release(handler); + } + } else if (!error) { + scr_dyn_release(events); + scr_dyn_release(self); + return scr_dyn_new_bool(false); + } + if (error) { + ScrDyn *payload = restc ? rest[0] : scr_dyn_undefined(); + if (scr_dyn_err_instanceof(payload, SCR_ERR_ERROR)) { + scr_dyn_throw(scr_dyn_retain(payload)); + } else { + ScrStr *shown = scr_insp_dyn(payload, 0, 2); + ScrJsonBuf text; + scr_jb_init(&text); + scr_jb_puts(&text, "Unhandled error. ("); + scr_jb_put_str(&text, shown); + scr_jb_putc(&text, ')'); + scr_str_release(shown); + ScrStr *message = scr_jb_finish(&text); + scr_throw_error_msg_code(SCR_ERR_ERROR, message->data, message->len, "ERR_UNHANDLED_ERROR"); + scr_str_release(message); + } + goto fail_events; + } + ScrDyn *handler = bc_get_key(events, type); + if (!handler) goto fail_events; + bool fired = !bc_undefined(handler); + if (scr_dyn_is_callable(handler)) { + ScrDyn *result = bc_apply(handler, self, rest, restc); + scr_dyn_release(handler); + if (!result) goto fail_events; + scr_dyn_release(result); + } else if (handler->kind == SCR_DYN_ARR) { + /* arrayClone: listeners added or removed during the emit run next time */ + size_t length = (size_t)scr_dyn_arr_len(handler); + ScrDyn **listeners = malloc((length ? length : 1) * sizeof *listeners); + if (!listeners) scr_trap("out of memory"); + for (size_t i = 0; i < length; i++) listeners[i] = scr_dyn_index_get(handler, (double)i); + scr_dyn_release(handler); + bool ok = true; + for (size_t i = 0; i < length && ok; i++) { + if (!listeners[i]) { + ok = false; + break; + } + ScrDyn *result = bc_apply(listeners[i], self, rest, restc); + if (!result) ok = false; + else scr_dyn_release(result); + } + for (size_t i = 0; i < length; i++) + if (listeners[i]) scr_dyn_release(listeners[i]); + free(listeners); + if (!ok) goto fail_events; + } else { + scr_dyn_release(handler); + } + scr_dyn_release(events); + scr_dyn_release(self); + return scr_dyn_new_bool(fired); +fail_events: + scr_dyn_release(events); +fail_self: + scr_dyn_release(self); + return NULL; +} + +/* _listeners(target, type, unwrap): a fresh array. */ +static ScrDyn *bc_listeners_of(bool unwrap, ScrDyn *const *args, size_t argc) { + ScrDyn *self = bc_this(); + if (!bc_require_object(self, "_events")) { + scr_dyn_release(self); + return NULL; + } + ScrDyn *result = scr_dyn_new_arr(); + ScrDyn *events = bc_get(self, "_events"); + ScrDyn *list = NULL; + if (!events) goto fail; + if (bc_undefined(events)) goto done; + list = bc_get_key(events, bc_arg(args, argc, 0)); + if (!list) goto fail; + size_t length = scr_dyn_is_callable(list) ? 1 : list->kind == SCR_DYN_ARR ? (size_t)scr_dyn_arr_len(list) : 0; + for (size_t i = 0; i < length; i++) { + ScrDyn *item = scr_dyn_is_callable(list) ? scr_dyn_retain(list) : scr_dyn_index_get(list, (double)i); + if (!item) goto fail; + if (unwrap && scr_dyn_is_callable(item)) { + ScrDyn *inner = bc_get(item, "listener"); + if (!inner) { + scr_dyn_release(item); + goto fail; + } + if (!bc_undefined(inner)) { + scr_dyn_release(item); + item = inner; + } else { + scr_dyn_release(inner); + } + } + scr_dyn_arr_push(result, item); + } +done: + if (list) scr_dyn_release(list); + if (events) scr_dyn_release(events); + scr_dyn_release(self); + return result; +fail: + if (list) scr_dyn_release(list); + if (events) scr_dyn_release(events); + scr_dyn_release(result); + scr_dyn_release(self); + return NULL; +} + +static ScrDyn *bc_listeners(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + return bc_listeners_of(true, args, argc); +} + +static ScrDyn *bc_raw_listeners(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + return bc_listeners_of(false, args, argc); +} + +/* listenerCount(type, listener?) over an emitter's _events. */ +static ScrDyn *bc_count_listeners(ScrDyn *self, ScrDyn *type, ScrDyn *only) { + ScrDyn *events = bc_get(self, "_events"); + if (!events) return NULL; + double count = 0; + if (!bc_undefined(events)) { + ScrDyn *list = bc_get_key(events, type); + if (!list) { + scr_dyn_release(events); + return NULL; + } + size_t length = scr_dyn_is_callable(list) ? 1 : list->kind == SCR_DYN_ARR ? (size_t)scr_dyn_arr_len(list) : 0; + for (size_t i = 0; i < length; i++) { + if (!only) { + count++; + continue; + } + ScrDyn *item = scr_dyn_is_callable(list) ? scr_dyn_retain(list) : scr_dyn_index_get(list, (double)i); + if (!item) break; + bool same = scr_dyn_strict_eq(item, only); + if (!same && scr_dyn_is_callable(item)) { + ScrDyn *inner = bc_get(item, "listener"); + if (inner) { + same = scr_dyn_strict_eq(inner, only); + scr_dyn_release(inner); + } + } + scr_dyn_release(item); + if (same) count++; + } + scr_dyn_release(list); + } + scr_dyn_release(events); + if (scr_exc_pending()) return NULL; + return scr_dyn_new_num(count); +} + +static ScrDyn *bc_listener_count(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + ScrDyn *self = bc_this(); + ScrDyn *result = bc_require_object(self, "_events") + ? bc_count_listeners(self, bc_arg(args, argc, 0), + argc > 1 && !bc_undefined(args[1]) && args[1]->kind != SCR_DYN_NULL ? args[1] : NULL) + : NULL; + scr_dyn_release(self); + return result; +} + +/* EventEmitter.listenerCount(emitter, type): the deprecated static. */ +static ScrDyn *bc_static_listener_count(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + ScrDyn *emitter = bc_arg(args, argc, 0); + ScrDyn *type = bc_arg(args, argc, 1); + ScrDyn *method = bc_require_object(emitter, "listenerCount") ? bc_get(emitter, "listenerCount") : NULL; + if (!method) return NULL; + ScrDyn *result; + if (scr_dyn_is_callable(method)) result = bc_apply(method, emitter, &type, 1); + else result = bc_count_listeners(emitter, type, NULL); + scr_dyn_release(method); + return result; +} + +static ScrDyn *bc_event_names(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + (void)args; + (void)argc; + ScrDyn *self = bc_this(); + if (!bc_require_object(self, "_eventsCount")) { + scr_dyn_release(self); + return NULL; + } + ScrDyn *count = bc_get(self, "_eventsCount"); + ScrDyn *result = NULL; + if (count) { + if (bc_number(count) > 0) { + ScrDyn *events = bc_get(self, "_events"); + if (events) { + result = scr_dyn_own_keys(events); + scr_dyn_release(events); + } + } else { + result = scr_dyn_new_arr(); + } + scr_dyn_release(count); + } + scr_dyn_release(self); + return result; +} + +static ScrDyn *bc_set_max(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + ScrDyn *self = bc_this(); + ScrDyn *n = bc_arg(args, argc, 0); + if (n->kind != SCR_DYN_NUM) { + scr_dyn_arg_type_fail("n", "of type number", n); + scr_dyn_release(self); + return NULL; + } + if (n->v.num < 0 || n->v.num != n->v.num) { + ScrStr *shown = scr_dyn_string_coerce_js(n); + ScrJsonBuf text; + scr_jb_init(&text); + scr_jb_puts(&text, "The value of \"n\" is out of range. It must be a non-negative number. Received "); + if (shown) scr_jb_put_str(&text, shown); + if (shown) scr_str_release(shown); + ScrStr *message = scr_jb_finish(&text); + scr_throw_error_msg_code(SCR_ERR_RANGE, message->data, message->len, "ERR_OUT_OF_RANGE"); + scr_str_release(message); + scr_dyn_release(self); + return NULL; + } + if (!bc_require_object(self, "_maxListeners") || !bc_set(self, "_maxListeners", scr_dyn_retain(n))) { + scr_dyn_release(self); + return NULL; + } + return self; +} + +static ScrDyn *bc_get_max(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + (void)args; + (void)argc; + ScrDyn *self = bc_this(); + double max = bc_require_object(self, "_maxListeners") ? bc_max_listeners(self) : -1; + scr_dyn_release(self); + if (scr_exc_pending()) return NULL; + return scr_dyn_new_num(max); +} + +/* ── Stream.prototype.pipe (lib/internal/streams/legacy.js) ─────────── */ + +enum { BC_PIPE_SOURCE, BC_PIPE_DEST, BC_PIPE_ENDED, BC_PIPE_HANDLERS, BC_PIPE_CAPS }; +enum { BC_ONDATA, BC_ONDRAIN, BC_ONEND, BC_ONCLOSE, BC_ONERROR, BC_CLEANUP, BC_HANDLER_COUNT }; + +static ScrDyn *bc_pipe_handler(ScrClosure *state, int which); + +static ScrDyn *bc_pipe_get_handler(ScrClosure *state, int which) { + ScrDyn *handlers = bc_cap(state, BC_PIPE_HANDLERS); + ScrDyn *handler = scr_dyn_index_get(handlers, which); + scr_dyn_release(handlers); + return handler; +} + +/* Calls source/dest.removeListener for one pipe handler. */ +static bool bc_pipe_remove(ScrDyn *emitter, const char *event, ScrClosure *state, int which) { + ScrDyn *handler = bc_pipe_get_handler(state, which); + if (!handler) return false; + ScrDyn *name = bc_key(event); + ScrDyn *pair[2] = { name, handler }; + ScrDyn *removed = bc_invoke(emitter, "removeListener", pair, 2); + scr_dyn_release(name); + scr_dyn_release(handler); + if (!removed) return false; + scr_dyn_release(removed); + return true; +} + +static bool bc_pipe_on(ScrDyn *emitter, const char *event, ScrClosure *state, int which) { + ScrDyn *handler = bc_pipe_get_handler(state, which); + if (!handler) return false; + ScrDyn *name = bc_key(event); + ScrDyn *pair[2] = { name, handler }; + ScrDyn *added = bc_invoke(emitter, "on", pair, 2); + scr_dyn_release(name); + scr_dyn_release(handler); + if (!added) return false; + scr_dyn_release(added); + return true; +} + +static bool bc_pipe_cleanup(ScrClosure *state) { + ScrDyn *source = bc_cap(state, BC_PIPE_SOURCE); + ScrDyn *dest = bc_cap(state, BC_PIPE_DEST); + bool ok = bc_pipe_remove(source, "data", state, BC_ONDATA) && + bc_pipe_remove(dest, "drain", state, BC_ONDRAIN) && + bc_pipe_remove(source, "end", state, BC_ONEND) && + bc_pipe_remove(source, "close", state, BC_ONCLOSE) && + bc_pipe_remove(source, "error", state, BC_ONERROR) && + bc_pipe_remove(dest, "error", state, BC_ONERROR) && + bc_pipe_remove(source, "end", state, BC_CLEANUP) && + bc_pipe_remove(source, "close", state, BC_CLEANUP) && + bc_pipe_remove(dest, "close", state, BC_CLEANUP); + scr_dyn_release(dest); + scr_dyn_release(source); + /* the handlers array held the closures that hold this state */ + if (ok) scr_box_set_ref(state->caps[BC_PIPE_HANDLERS], scr_dyn_new_arr()); + return ok; +} + +static ScrDyn *bc_pipe_event(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + ScrClosure *state = (ScrClosure *)scr_box_get_ref(closure->caps[0]); + int which = (int)scr_box_get_f64(closure->caps[1]); + ScrDyn *source = bc_cap(state, BC_PIPE_SOURCE); + ScrDyn *dest = bc_cap(state, BC_PIPE_DEST); + bool ok = true; + switch (which) { + case BC_ONDATA: { + ScrDyn *writable = bc_get(dest, "writable"); + if (!writable) { + ok = false; + break; + } + bool open = scr_dyn_truthy(writable); + scr_dyn_release(writable); + if (!open) break; + ScrDyn *chunk = bc_arg(args, argc, 0); + ScrDyn *written = bc_invoke(dest, "write", &chunk, 1); + if (!written) { + ok = false; + break; + } + bool full = written->kind == SCR_DYN_BOOL && !written->v.b; + scr_dyn_release(written); + if (full) { + ScrDyn *pause = bc_get(source, "pause"); + if (!pause) { + ok = false; + break; + } + if (scr_dyn_is_callable(pause)) { + ScrDyn *paused = bc_apply(pause, source, bc_unused_args, 0); + if (paused) scr_dyn_release(paused); + else ok = false; + } + scr_dyn_release(pause); + } + break; + } + case BC_ONDRAIN: { + ScrDyn *readable = bc_get(source, "readable"); + ScrDyn *resume = readable ? bc_get(source, "resume") : NULL; + if (!readable || !resume) { + ok = false; + } else if (scr_dyn_truthy(readable) && scr_dyn_is_callable(resume)) { + ScrDyn *resumed = bc_apply(resume, source, bc_unused_args, 0); + if (resumed) scr_dyn_release(resumed); + else ok = false; + } + if (readable) scr_dyn_release(readable); + if (resume) scr_dyn_release(resume); + break; + } + case BC_ONEND: + case BC_ONCLOSE: { + if (scr_box_get_f64(state->caps[BC_PIPE_ENDED]) != 0) break; + scr_box_set_f64(state->caps[BC_PIPE_ENDED], 1); + ScrDyn *done = which == BC_ONEND ? bc_invoke(dest, "end", bc_unused_args, 0) : NULL; + if (which == BC_ONCLOSE) { + ScrDyn *destroy = bc_get(dest, "destroy"); + if (!destroy) { + ok = false; + break; + } + if (scr_dyn_is_callable(destroy)) done = bc_apply(destroy, dest, bc_unused_args, 0); + else done = scr_dyn_retain(scr_dyn_undefined()); + scr_dyn_release(destroy); + } + if (done) scr_dyn_release(done); + else ok = false; + break; + } + case BC_ONERROR: { + ok = bc_pipe_cleanup(state); + if (!ok) break; + ScrDyn *rethrow = scr_dyn_this_get(); + ScrDyn *name = bc_key("error"); + ScrDyn *count = rethrow->kind == SCR_DYN_UNDEF ? NULL : bc_invoke(rethrow, "listenerCount", &name, 1); + scr_dyn_release(name); + if (count && bc_number(count) == 0) { + scr_dyn_throw(scr_dyn_retain(bc_arg(args, argc, 0))); + ok = false; + } else if (!count && scr_exc_pending()) { + ok = false; + } + if (count) scr_dyn_release(count); + scr_dyn_release(rethrow); + break; + } + case BC_CLEANUP: + ok = bc_pipe_cleanup(state); + break; + default: + break; + } + scr_dyn_release(dest); + scr_dyn_release(source); + scr_closure_release(state); + return ok ? scr_dyn_retain(scr_dyn_undefined()) : NULL; +} + +static ScrDyn *bc_pipe_handler(ScrClosure *state, int which) { + ScrClosure *closure = scr_closure_new(NULL, 2); + ScrBox *owner = scr_box_new_obj(scr_closure_retain_v, scr_closure_release_v, scr_closure_trace_v); + scr_box_set_ref(owner, scr_closure_retain(state)); + closure->caps[0] = owner; + closure->caps[1] = scr_box_new(SCR_BOX_F64); + scr_box_set_f64(closure->caps[1], which); + return scr_dyn_new_func(closure, bc_pipe_event, 0, "native:stream", which == BC_ONDATA ? "ondata" + : which == BC_ONDRAIN ? "ondrain" : which == BC_ONEND ? "onend" : which == BC_ONCLOSE ? "onclose" + : which == BC_ONERROR ? "onerror" : "cleanup"); +} + +/* Stream.prototype.pipe(dest, options): Node's legacy pipe for streams + * that are only EventEmitters. 'end' and 'close' end or destroy the + * destination unless options.end is false or the destination is stdio. */ +static ScrDyn *bc_pipe(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + ScrDyn *source = bc_this(); + ScrDyn *dest = bc_arg(args, argc, 0); + ScrDyn *options = bc_arg(args, argc, 1); + if (!bc_require_object(source, "on") || !bc_require_object(dest, "on")) { + scr_dyn_release(source); + return NULL; + } + ScrClosure *state = scr_closure_new(NULL, BC_PIPE_CAPS); + state->caps[BC_PIPE_SOURCE] = bc_box(scr_dyn_retain(source)); + state->caps[BC_PIPE_DEST] = bc_box(scr_dyn_retain(dest)); + state->caps[BC_PIPE_ENDED] = scr_box_new(SCR_BOX_F64); + ScrDyn *handlers = scr_dyn_new_arr(); + for (int i = 0; i < BC_HANDLER_COUNT; i++) scr_dyn_arr_push(handlers, bc_pipe_handler(state, i)); + state->caps[BC_PIPE_HANDLERS] = bc_box(handlers); + bool ok = bc_pipe_on(source, "data", state, BC_ONDATA) && bc_pipe_on(dest, "drain", state, BC_ONDRAIN); + bool end_dest = true; + if (ok && options->kind != SCR_DYN_UNDEF && options->kind != SCR_DYN_NULL) { + ScrDyn *end = bc_get(options, "end"); + if (!end) ok = false; + else { + end_dest = !(end->kind == SCR_DYN_BOOL && !end->v.b); + scr_dyn_release(end); + } + } + if (ok && end_dest) { + ScrDyn *stdio = bc_get(dest, "_isStdio"); + if (!stdio) ok = false; + else { + end_dest = !scr_dyn_truthy(stdio); + scr_dyn_release(stdio); + } + } + if (ok && end_dest) + ok = bc_pipe_on(source, "end", state, BC_ONEND) && bc_pipe_on(source, "close", state, BC_ONCLOSE); + if (ok) { + /* prependListener(source, 'error', onerror) and the same on dest */ + ScrDyn *onerror = bc_pipe_get_handler(state, BC_ONERROR); + ScrDyn *name = bc_key("error"); + ScrDyn *pair[2] = { name, onerror }; + ScrDyn *a = onerror ? bc_invoke(source, "prependListener", pair, 2) : NULL; + ScrDyn *b = a ? bc_invoke(dest, "prependListener", pair, 2) : NULL; + ok = a && b; + if (a) scr_dyn_release(a); + if (b) scr_dyn_release(b); + scr_dyn_release(name); + if (onerror) scr_dyn_release(onerror); + } + if (ok) + ok = bc_pipe_on(source, "end", state, BC_CLEANUP) && bc_pipe_on(source, "close", state, BC_CLEANUP) && + bc_pipe_on(dest, "close", state, BC_CLEANUP); + ScrDyn *result = NULL; + if (ok) { + ScrDyn *name = bc_key("pipe"); + ScrDyn *pair[2] = { name, source }; + ScrDyn *emitted = bc_invoke(dest, "emit", pair, 2); + scr_dyn_release(name); + if (emitted) { + scr_dyn_release(emitted); + result = scr_dyn_retain(dest); + } + } + scr_closure_release(state); + scr_dyn_release(source); + return result; +} + +/* ── the constructors ───────────────────────────────────────────────── */ + +static ScrDyn *bc_construct(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + int kind = (int)scr_box_get_f64(closure->caps[0]); + ScrDyn *self = bc_this(); + ScrDyn *result = NULL; + if (kind == BC_EMITTER || kind == BC_STREAM) { + if (bc_emitter_init(self, bc_arg(args, argc, 0))) result = scr_dyn_retain(scr_dyn_undefined()); + } else { + char message[160]; + snprintf(message, sizeof message, + "'%s' as the base of a constructor function (util.inherits or %s.call(this)) is not supported yet", + bc_names[kind], bc_names[kind]); + scr_throw_error_msg_code(SCR_ERR_ERROR, message, strlen(message), "SC2020"); + } + scr_dyn_release(self); + return result; +} + +static void bc_method(ScrDyn *prototype, const char *name, ScrDynThunk thunk, uint32_t arity) { + ScrDyn *fn = scr_dyn_new_func(scr_closure_new(NULL, 0), thunk, arity, "native:events", name); + bc_set(prototype, name, fn); +} + +static void bc_alias(ScrDyn *prototype, const char *alias, const char *name) { + ScrDyn *fn = bc_get(prototype, name); + if (fn) bc_set(prototype, alias, fn); +} + +static void bc_teardown(void) { + for (int i = 0; i < BC_COUNT; i++) { + if (!bc_ctors[i]) continue; + ScrDyn *ctor = bc_ctors[i]; + /* prototype.constructor, EventEmitter.EventEmitter and Stream.Stream + * point back at the constructor, and Stream.Readable … at constructors + * whose own prototype is Stream: break those cycles first. */ + ScrDyn *prototype = bc_get(ctor, "prototype"); + if (prototype && prototype->kind == SCR_DYN_OBJ) bc_set(prototype, "constructor", scr_dyn_retain(scr_dyn_undefined())); + if (prototype) scr_dyn_release(prototype); + bc_set(ctor, bc_names[i], scr_dyn_retain(scr_dyn_undefined())); + if (i == BC_STREAM) + for (int other = BC_READABLE; other < BC_COUNT; other++) + bc_set(ctor, bc_names[other], scr_dyn_retain(scr_dyn_undefined())); + if (scr_exc_pending()) scr_exc_clear(); + } + for (int i = 0; i < BC_COUNT; i++) { + if (!bc_ctors[i]) continue; + ScrDyn *ctor = bc_ctors[i]; + bc_ctors[i] = NULL; + scr_dyn_release(ctor); + } + for (int i = 0; i < BC_SYMBOLS; i++) { + if (bc_symbols[i]) scr_dyn_release(bc_symbols[i]); + bc_symbols[i] = NULL; + } + if (scr_exc_pending()) scr_exc_clear(); +} + +static ScrDyn *bc_new_class(int kind, ScrDyn *parent) { + ScrClosure *closure = scr_closure_new(NULL, 1); + closure->function_kind = 4; /* an ordinary function with its own prototype */ + closure->caps[0] = scr_box_new(SCR_BOX_F64); + scr_box_set_f64(closure->caps[0], kind); + ScrDyn *ctor = scr_dyn_new_func(closure, bc_construct, 1, "native:events", bc_names[kind]); + ScrDyn *prototype = parent ? NULL : scr_dyn_new_obj(); + if (parent) { + ScrDyn *base = bc_get(parent, "prototype"); + prototype = scr_dyn_obj_create(base); + scr_dyn_release(base); + ScrDyn *linked = scr_dyn_set_prototype(ctor, parent); + if (linked) scr_dyn_release(linked); + } + bc_set(prototype, "constructor", scr_dyn_retain(ctor)); + bc_set(ctor, "prototype", scr_dyn_retain(prototype)); + if (kind == BC_EMITTER) { + bc_set(prototype, "_events", scr_dyn_retain(scr_dyn_undefined())); + bc_set(prototype, "_eventsCount", scr_dyn_new_num(0)); + bc_set(prototype, "_maxListeners", scr_dyn_retain(scr_dyn_undefined())); + bc_set_key(prototype, bc_symbol(BC_CAPTURE), scr_dyn_new_bool(false)); + bc_method(prototype, "setMaxListeners", bc_set_max, 1); + bc_method(prototype, "getMaxListeners", bc_get_max, 0); + bc_method(prototype, "emit", bc_emit, 1); + bc_method(prototype, "addListener", bc_on, 2); + bc_alias(prototype, "on", "addListener"); + bc_method(prototype, "prependListener", bc_prepend, 2); + bc_method(prototype, "once", bc_once, 2); + bc_method(prototype, "prependOnceListener", bc_prepend_once, 2); + bc_method(prototype, "removeListener", bc_off, 2); + bc_alias(prototype, "off", "removeListener"); + bc_method(prototype, "removeAllListeners", bc_remove_all, 1); + bc_method(prototype, "listeners", bc_listeners, 1); + bc_method(prototype, "rawListeners", bc_raw_listeners, 1); + bc_method(prototype, "listenerCount", bc_listener_count, 2); + bc_method(prototype, "eventNames", bc_event_names, 0); + bc_set(ctor, "EventEmitter", scr_dyn_retain(ctor)); + bc_set(ctor, "defaultMaxListeners", scr_dyn_new_num(10)); + ScrDyn *count = scr_dyn_new_func(scr_closure_new(NULL, 0), bc_static_listener_count, 2, "native:events", "listenerCount"); + bc_set(ctor, "listenerCount", count); + } else if (kind == BC_STREAM) { + ScrDyn *pipe = scr_dyn_new_func(scr_closure_new(NULL, 0), bc_pipe, 2, "native:stream", "pipe"); + bc_set(prototype, "pipe", pipe); + } + scr_dyn_release(prototype); + if (scr_exc_pending()) scr_exc_clear(); + return ctor; +} + +/* All seven constructors are created together, each after its parent, so + * Stream's static members name the same constructors every other read + * answers. */ +static ScrDyn *bc_class(int kind) { + if (!bc_ctors[kind]) { + static const int parents[BC_COUNT] = { + -1, BC_EMITTER, BC_STREAM, BC_STREAM, BC_READABLE, BC_DUPLEX, BC_TRANSFORM, + }; + for (int each = 0; each < BC_COUNT; each++) { + if (bc_ctors[each]) continue; + bc_ctors[each] = bc_new_class(each, parents[each] >= 0 ? bc_ctors[parents[each]] : NULL); + } + /* Registered after the constructors exist, so the teardown runs before + * the runtime state their properties were stored in (scr_atexit is + * last in, first out). */ + if (!bc_teardown_registered) { + bc_teardown_registered = true; + scr_atexit(bc_teardown); + } + /* require("stream") is Stream itself, carrying its classes */ + ScrDyn *stream = bc_ctors[BC_STREAM]; + bc_set(stream, "Stream", scr_dyn_retain(stream)); + for (int other = BC_READABLE; other < BC_COUNT; other++) + bc_set(stream, bc_names[other], scr_dyn_retain(bc_ctors[other])); + if (scr_exc_pending()) scr_exc_clear(); + } + return bc_ctors[kind]; +} + +/* The constructor value for kind (the BC_* order), +1. */ +ScrDyn *scr_dyn_builtin_class(double kind) { + int index = (int)kind; + if (index < 0 || index >= BC_COUNT) scr_trap("scriptc: unknown builtin class value\n"); + return scr_dyn_retain(bc_class(index)); +} + +/* OrdinaryHasInstance over the value's prototype chain. */ +static bool bc_chain_has(ScrDyn *value, ScrDyn *prototype) { + if (value->kind != SCR_DYN_OBJ && value->kind != SCR_DYN_ARR && value->kind != SCR_DYN_FUNC && + value->kind != SCR_DYN_PROXY) + return false; + ScrDyn *current = scr_dyn_get_prototype(value); + while (current && current->kind != SCR_DYN_NULL) { + if (scr_dyn_strict_eq(current, prototype)) { + scr_dyn_release(current); + return true; + } + ScrDyn *next = scr_dyn_get_prototype(current); + scr_dyn_release(current); + current = next; + } + if (current) scr_dyn_release(current); + return false; +} + +/* `value instanceof `. Node's Writable also answers true for an + * object whose _writableState is a WritableState, which no untyped object + * has, so the prototype chain answers for every kind. */ +bool scr_dyn_builtin_instanceof(ScrDyn *value, double kind) { + int index = (int)kind; + if (index < 0 || index >= BC_COUNT) scr_trap("scriptc: unknown builtin class value\n"); + ScrDyn *prototype = bc_get(bc_class(index), "prototype"); + if (!prototype) return false; + bool found = bc_chain_has(value, prototype); + scr_dyn_release(prototype); + return found; +} + +/* `value instanceof callee` for an untyped constructor function. */ +bool scr_dyn_function_instanceof(ScrDyn *value, ScrDyn *callee) { + if (!scr_dyn_is_callable(callee)) { + static const char message[] = "Right-hand side of 'instanceof' is not callable"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + return false; + } + ScrDyn *prototype = bc_get(callee, "prototype"); + if (!prototype) return false; + bool found = false; + if (prototype->kind == SCR_DYN_OBJ || prototype->kind == SCR_DYN_ARR || prototype->kind == SCR_DYN_FUNC || + prototype->kind == SCR_DYN_PROXY) { + found = bc_chain_has(value, prototype); + } else if (value->kind == SCR_DYN_OBJ || value->kind == SCR_DYN_ARR || value->kind == SCR_DYN_FUNC) { + static const char message[] = "Function has non-object prototype 'undefined' in instanceof check"; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + } + scr_dyn_release(prototype); + return found; +} diff --git a/tests/corpus/6453-constructor-function-emitter.cjs b/tests/corpus/6453-constructor-function-emitter.cjs new file mode 100644 index 000000000..d07453e14 --- /dev/null +++ b/tests/corpus/6453-constructor-function-emitter.cjs @@ -0,0 +1,114 @@ +// Constructor functions that inherit from EventEmitter the pre-class way: +// util.inherits and EventEmitter.call(this), the call-without-new guard, +// prototype methods that emit, and the listener API over the instance. +"use strict"; +const util = require("util"); +const { EventEmitter } = require("events"); + +util.inherits(Bus, EventEmitter); +function Bus(name) { + if (!(this instanceof Bus)) return new Bus(name); + EventEmitter.call(this); + this.name = name; + this.entries = []; +} +Bus.prototype.add = function (entry) { + this.entries.push(entry); + this.emit("added", entry, this.entries.length); + return this; +}; + +// A second level: Sub inherits Bus, and Bus's constructor runs through call. +function Sub(name) { + Bus.call(this, name); + this.sub = true; +} +util.inherits(Sub, Bus); +Sub.prototype.add = function (entry) { + return Bus.prototype.add.call(this, entry.toUpperCase()); +}; + +// Object.setPrototypeOf instead of util.inherits. +function Legacy() { + EventEmitter.call(this); +} +Object.setPrototypeOf(Legacy.prototype, EventEmitter.prototype); + +function main() { + const bus = Bus("main"); + console.log(bus instanceof Bus, bus instanceof EventEmitter, Bus.super_ === EventEmitter); + console.log(Object.keys(bus).join(","), bus._eventsCount, bus._maxListeners); + bus.on("added", function (entry, count) { + console.log("added", entry, count, this === bus); + }); + bus.once("added", (entry) => console.log("first only", entry)); + bus.prependListener("added", () => console.log("prepended")); + bus.prependOnceListener("added", () => console.log("prepended once")); + bus.add("a").add("b"); + console.log(bus.listenerCount("added"), bus.eventNames(), bus.getMaxListeners()); + + const handler = () => console.log("never"); + bus.on("x", handler); + bus.on("x", () => console.log("kept")); + bus.off("x", handler); + console.log(bus.emit("x"), bus.emit("nothing"), bus.listeners("x").length, bus.rawListeners("added").length); + + try { + bus.emit("error", new Error("boom")); + } catch (err) { + console.log("caught", err.message); + } + try { + bus.emit("error", "plain"); + } catch (err) { + console.log("caught", err.code, err.message); + } + bus.on("error", (err) => console.log("handled", err.message)); + bus.emit("error", new Error("quiet")); + + bus.on("newListener", (event, listener) => console.log("newListener", event, typeof listener)); + bus.on("removeListener", (event) => console.log("removeListener", event)); + bus.on("later", () => {}); + bus.removeAllListeners("added"); + console.log(bus.listenerCount("added"), bus.listenerCount("later")); + bus.removeAllListeners(); + console.log(bus.eventNames().length); + + const sub = new Sub("child"); + sub.on("added", (entry) => console.log("sub added", entry)); + sub.add("c"); + console.log(sub instanceof Sub, sub instanceof Bus, sub instanceof EventEmitter, sub.entries); + + const legacy = new Legacy(); + console.log("legacy", legacy instanceof EventEmitter, legacy.getMaxListeners()); + try { + legacy.setMaxListeners(-1); + } catch (err) { + console.log(err.name, err.code); + } + try { + legacy.on("bad", "not a function"); + } catch (err) { + console.log(err.name, err.code); + } + console.log(new Legacy()); + + // The leak warning goes to stderr with the process id: run it in a child + // and print its output with the id normalized. + const { spawnSync } = require("child_process"); + const child = spawnSync(process.execPath, [process.argv[1], "leak"], { encoding: "utf8" }); + console.log(child.stdout + child.stderr.replace(/\(node:\d+\)/g, "(node:)")); +} + +function leak() { + const legacy = new Legacy(); + legacy.setMaxListeners(1); + legacy.on("many", () => {}); + legacy.on("many", () => {}); + legacy.on("many", () => {}); + console.log("leak", legacy.listenerCount("many")); + setTimeout(() => console.log("leak done"), 1); +} + +if (process.argv[2] === "leak") leak(); +else main(); diff --git a/tests/corpus/6454-constructor-function-stream-bases.cjs b/tests/corpus/6454-constructor-function-stream-bases.cjs new file mode 100644 index 000000000..4b2bb6789 --- /dev/null +++ b/tests/corpus/6454-constructor-function-stream-bases.cjs @@ -0,0 +1,65 @@ +// The legacy Stream constructor as a constructor function's base: its +// EventEmitter state and the legacy pipe between two such streams. The +// stream classes take util.inherits, and instanceof follows the chain. +"use strict"; +const util = require("util"); +const Stream = require("stream"); +const { Readable, Writable, Duplex, Transform, PassThrough } = require("stream"); + +function Source(items) { + Stream.call(this); + this.items = items; + this.readable = true; +} +util.inherits(Source, Stream); +Source.prototype.start = function () { + for (const item of this.items) this.emit("data", item); + this.emit("end"); +}; + +function Sink() { + Stream.call(this); + this.writable = true; + this.chunks = []; +} +util.inherits(Sink, Stream); +Sink.prototype.write = function (chunk) { + this.chunks.push(chunk); + return true; +}; +Sink.prototype.end = function () { + console.log("sink end", this.chunks.join("+")); +}; + +const source = new Source(["a", "b", "c"]); +const sink = new Sink(); +sink.on("pipe", (from) => console.log("piped", from === source)); +console.log(source.pipe(sink) === sink, sink.listenerCount("pipe")); +source.start(); +console.log("after end", source.listenerCount("data"), sink.listenerCount("drain")); + +const kept = new Sink(); +const second = new Source(["x"]); +second.pipe(kept, { end: false }); +second.start(); +console.log("kept open", kept.chunks); + +function Reader() {} +util.inherits(Reader, Readable); +function Both() {} +util.inherits(Both, Duplex); +function Shift() {} +util.inherits(Shift, Transform); +function Through() {} +util.inherits(Through, PassThrough); +function Out() {} +util.inherits(Out, Writable); +console.log(Reader.super_ === Readable, Through.super_ === PassThrough, Out.super_ === Writable); +const reader = Object.create(Reader.prototype); +const through = Object.create(Through.prototype); +const out = Object.create(Out.prototype); +const both = Object.create(Both.prototype); +console.log(reader instanceof Readable, reader instanceof Writable); +console.log(through instanceof Transform, through instanceof Duplex, through instanceof Readable); +console.log(out instanceof Writable, out instanceof Readable, both instanceof Readable); +console.log(typeof Shift.prototype.constructor, Shift.prototype instanceof Duplex); diff --git a/tests/harness/self-hosting-native-toolchain.test.ts b/tests/harness/self-hosting-native-toolchain.test.ts index fa71b5a08..1de960368 100644 --- a/tests/harness/self-hosting-native-toolchain.test.ts +++ b/tests/harness/self-hosting-native-toolchain.test.ts @@ -30,6 +30,7 @@ const features: NativeLinkFeatures = { bigint: false, searchParams: false, qs: false, + dynBuiltinClass: false, parseArgs: false, stream: false, net: false, diff --git a/tests/harness/self-hosting-runtime-features.test.ts b/tests/harness/self-hosting-runtime-features.test.ts index d2c2b589b..8d65d72cd 100644 --- a/tests/harness/self-hosting-runtime-features.test.ts +++ b/tests/harness/self-hosting-runtime-features.test.ts @@ -50,6 +50,7 @@ const featureCalls: readonly [string, IrLibFn][] = [ ["bigint", "bigint.parse"], ["searchParams", "sp.new"], ["qs", "qs.parse"], + ["dynBuiltinClass", "dyn.builtinClass"], ["parseArgs", "util.parseArgs"], ["parseArgs", "util.isDeepStrictEqual"], ["parseArgs", "util.styleText"], diff --git a/tests/harness/self-hosting-runtime-pack.test.ts b/tests/harness/self-hosting-runtime-pack.test.ts index c95d6d9a0..42cf8a729 100644 --- a/tests/harness/self-hosting-runtime-pack.test.ts +++ b/tests/harness/self-hosting-runtime-pack.test.ts @@ -31,6 +31,7 @@ const base: NativeLinkFeatures = { bigint: false, searchParams: false, qs: false, + dynBuiltinClass: false, parseArgs: false, stream: false, net: false, From 08fb421628ea11b7c94855f5dd2e51399129174f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 01:27:17 -0700 Subject: [PATCH 15/31] Compile NodeJS stream interfaces as general streams when other streams flow into them Co-Authored-By: Claude Opus 5.5 --- .../ambient/scriptc-node-fallback.d.ts | 59 +++++++++--- .../src/backend/llvm/expr-stream-callbacks.ts | 42 ++++++++- .../compiler/src/backend/llvm/lib-process.ts | 22 +++++ .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../frontend/lowering/builtins/properties.ts | 38 +++++++- .../src/frontend/lowering/general-streams.ts | 94 +++++++++++++++++++ .../frontend/lowering/lower-event-emitter.ts | 14 ++- .../src/frontend/lowering/lower-modules.ts | 8 ++ .../src/frontend/lowering/lower-stdlib.ts | 11 ++- .../src/frontend/lowering/lower-stream.ts | 62 +++++++++++- .../compiler/src/frontend/lowering/lowerer.ts | 55 ++++++++++- packages/compiler/src/frontend/type-mapper.ts | 66 +++++++++---- packages/compiler/src/ir/builtin-effects.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 12 +++ packages/compiler/src/ir/runtime-features.ts | 2 + packages/compiler/src/ir/validate.ts | 14 +++ packages/runtime/src/scr_stream.c | 39 ++++++++ packages/runtime/src/scr_stream.h | 5 + .../6463-node-readable-stream-producers.ts | 75 +++++++++++++++ .../6464-node-writable-stream-producers.ts | 68 ++++++++++++++ .../6465-node-stream-js-boundary/consume.js | 12 +++ .../6465-node-stream-js-boundary/main.ts | 33 +++++++ .../node-types/node-stream-interfaces.ts | 61 ++++++++++++ tests/harness/project-config.test.ts | 18 ++++ 24 files changed, 774 insertions(+), 38 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/general-streams.ts create mode 100644 tests/corpus/6463-node-readable-stream-producers.ts create mode 100644 tests/corpus/6464-node-writable-stream-producers.ts create mode 100644 tests/corpus/6465-node-stream-js-boundary/consume.js create mode 100644 tests/corpus/6465-node-stream-js-boundary/main.ts create mode 100644 tests/fixtures/node-types/node-stream-interfaces.ts diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 436bcace3..28e481b9d 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -80,21 +80,56 @@ declare namespace NodeJS { path?: string | undefined; syscall?: string | undefined; } - /* The piped child-output stream (child.stdout / child.stderr under - * stdio ["ignore", "pipe", "pipe"]): 'data' fires one Buffer chunk per - * read (≤64KB, consumer-driven — no listener, no read), 'end' fires - * once at EOF and always BEFORE the child's 'exit'. A flowing stream - * keeps the loop alive. */ + /* The readable-stream interface every Node stream implements. The + * piped child-output stream (child.stdout / child.stderr under stdio + * ["ignore", "pipe", "pipe"]) is declared with it: 'data' fires one + * Buffer chunk per read (≤64KB, consumer-driven — no listener, no + * read), 'end' fires once at EOF and always BEFORE the child's 'exit'. A + * flowing stream keeps the loop alive. A program that also passes + * other streams (fs, zlib, node:stream) where this interface is + * expected compiles it as the node:stream Readable, with child output + * bridged into one. */ interface ReadableStream { on(event: "data", listener: (chunk: Buffer) => void): void; on(event: "data", listener: (chunk: string) => void): void; on(event: "end", listener: () => void): void; + on(event: "close", listener: () => void): void; + on(event: "error", listener: (err: Error) => void): void; once(event: "data", listener: (chunk: Buffer) => void): void; once(event: "data", listener: (chunk: string) => void): void; once(event: "end", listener: () => void): void; + once(event: "close", listener: () => void): void; + once(event: "error", listener: (err: Error) => void): void; /* setEncoding('utf8') makes subsequent data chunks strings, including * multibyte sequences split across native pipe reads. */ setEncoding(encoding: string): this; + readonly readable: boolean; + pause(): this; + resume(): this; + isPaused(): boolean; + pipe(destination: T, options?: { end?: boolean }): T; + unpipe(destination?: WritableStream): this; + } + /* The writable-stream interface every Node stream implements: + * process.stdout/process.stderr, fs.WriteStream and the node:stream + * Writable family. Written only to the process streams, it compiles as + * their descriptor; with other streams flowing into it, as the + * node:stream Writable (the process streams convert into one). */ + interface WritableStream { + readonly writable: boolean; + write(data: string | Uint8Array, callback?: (error?: Error | null) => void): boolean; + write( + data: string | Uint8Array, + encoding: BufferEncoding, + callback?: (error?: Error | null) => void, + ): boolean; + end(callback?: () => void): this; + end(data: string | Uint8Array, callback?: () => void): this; + end(data: string | Uint8Array, encoding: BufferEncoding, callback?: () => void): this; + on(event: "drain" | "finish" | "close", listener: () => void): void; + on(event: "error", listener: (err: Error) => void): void; + once(event: "drain" | "finish" | "close", listener: () => void): void; + once(event: "error", listener: (err: Error) => void): void; } } @@ -6317,7 +6352,7 @@ declare module "node:cluster" { /* node:tty — the fd probe (the same isatty(3) behind process.*.isTTY) * and the supported structural view of a process output stream. */ declare module "tty" { - export interface WriteStream { + export interface WriteStream extends NodeJS.WritableStream { write(data: string | Uint8Array, callback?: (error?: Error | null) => void): boolean; write( data: string | Uint8Array, @@ -6567,8 +6602,8 @@ declare module "stream" { resume(): this; isPaused(): boolean; setEncoding(encoding: string): this; - pipe(destination: T, options?: { end?: boolean }): T; - unpipe(destination?: Writable): this; + pipe(destination: T, options?: { end?: boolean }): T; + unpipe(destination?: NodeJS.WritableStream): this; destroy(error?: Error): this; readonly readable: boolean; readonly readableEnded: boolean; @@ -6701,12 +6736,12 @@ declare module "stream" { readonly closed: boolean; readonly destroyed: boolean; readonly errored: Error | null; - on(event: "drain" | "finish" | "close", listener: () => void): this; - on(event: "error", listener: (err: Error) => void): this; + on(event: "drain" | "finish" | "close", listener: () => void): void; + on(event: "error", listener: (err: Error) => void): void; on(event: "pipe" | "unpipe", listener: (src: Readable) => void): this; on(event: string, listener: (...args: any[]) => void): this; - once(event: "drain" | "finish" | "close", listener: () => void): this; - once(event: "error", listener: (err: Error) => void): this; + once(event: "drain" | "finish" | "close", listener: () => void): void; + once(event: "error", listener: (err: Error) => void): void; once(event: "pipe" | "unpipe", listener: (src: Readable) => void): this; once(event: string, listener: (...args: any[]) => void): this; addListener(event: "drain" | "finish" | "close", listener: () => void): this; diff --git a/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts b/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts index 3f7961163..9be757a20 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-callbacks.ts @@ -17,7 +17,7 @@ export function streamDataAdapter( const p = cbT.params[0]; if ( cbT.params.length > 1 || - (p && p.kind !== "bytes" && p.kind !== "string" && p.kind !== "dyn") + (p && p.kind !== "bytes" && p.kind !== "string" && p.kind !== "dyn" && p.kind !== "union") ) { throw new InternalCompilerError( "llvm emitter bug: stream data listener param shape (frontend must fence)", @@ -73,6 +73,46 @@ export function streamDataAdapter( ); finish("%r0"); d.push(`}`, ``); + } else if (p.kind === "union") { + // A `Buffer | string` chunk: wrap whichever slot the stream filled at + // its arm of the (boxed) union. + const def = host.unionsById.get(p.unionId); + const bytesTag = def ? def.arms.findIndex((a) => a.kind === "bytes" && a.elem === "u8") : -1; + const strTag = def ? def.arms.findIndex((a) => a.kind === "string") : -1; + if ( + !def || + host.nullableUnions.has(p.unionId) || + bytesTag < 0 || + strTag < 0 || + def.arms.length !== 2 + ) + throw new InternalCompilerError( + "llvm emitter bug: stream data listener union is not Buffer | string (frontend must fence)", + ); + host.declare(`declare ptr @scr_union_new_ref(i32, ptr, ptr, ptr, ptr)`); + host.declare(`declare ptr @scr_bytes_retain_v(ptr)`); + host.declare(`declare void @scr_bytes_release_v(ptr)`); + host.declare(`declare ptr @scr_str_retain_v(ptr)`); + host.declare(`declare void @scr_str_release_v(ptr)`); + d.push( + ` %uslot = alloca ptr`, + ` %isb = icmp ne ptr %a0, null`, + ` br i1 %isb, label %buf, label %str`, + `buf:`, + ` %rb = call ptr @scr_bytes_retain_v(ptr %a0)`, + ` %ub = call ptr @scr_union_new_ref(i32 ${bytesTag}, ptr %rb, ptr @scr_bytes_retain_v, ptr @scr_bytes_release_v, ptr null)`, + ` store ptr %ub, ptr %uslot`, + ` br label %go`, + `str:`, + ` %rs = call ptr @scr_str_retain_v(ptr %a1)`, + ` %us = call ptr @scr_union_new_ref(i32 ${strTag}, ptr %rs, ptr @scr_str_retain_v, ptr @scr_str_release_v, ptr null)`, + ` store ptr %us, ptr %uslot`, + ` br label %go`, + `go:`, + ` %uv = load ptr, ptr %uslot`, + ); + finish("%uv"); + d.push(`}`, ``); } else { // dyn: box by runtime tag — the JS lane's adapter parameter. host.declare(`declare ptr @scr_dyn_new_buffer(ptr)`); diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index eaf7f31cd..4a7be1a35 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -305,6 +305,28 @@ export function emitChildProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr B.line(`${raw} = call ptr @${get}(ptr ${recv.name}) ; +1 or NULL`); return host.wrapNullable(raw, raw, def!.arms[streamTag]!, streamTag, e.type, nullTag); } + if (e.fn === "child.stdioStream") { + // The general stream representation's stdio read: the child's bridged + // runtime stream (+1) or NULL for an unpiped slot. + if (e.type.kind !== "union") + throw new InternalCompilerError(`llvm emitter bug: ${e.fn} result is not a union`); + const def = host.unionsById.get(e.type.unionId); + const streamTag = def ? def.arms.findIndex((a) => a.kind === "object") : -1; + const nullTag = def ? def.arms.findIndex((a) => a.kind === "nullT") : -1; + const fd = e.args[1]; + if (streamTag < 0 || nullTag < 0 || fd?.kind !== "numLit") + throw new InternalCompilerError(`llvm emitter bug: ${e.fn} lacks its arms or slot`); + const recv = host.emitExpr(e.args[0]!); + const key = fd.value === 0 ? "stdin" : fd.value === 1 ? "stdout" : "stderr"; + host.declare(`declare ptr @scr_stream_child_stdio(ptr, ptr, ${host.sizeType})`); + const raw = B.tmp(); + B.line( + `${raw} = call ptr @scr_stream_child_stdio(ptr ${recv.name}, ptr ${host.cstr(key)}, ${host.sizeType} ${key.length}) ; +1 or NULL`, + ); + const out = host.wrapNullable(raw, raw, def!.arms[streamTag]!, streamTag, e.type, nullTag); + host.emitPendingCheck(); + return out; + } if (e.fn === "writer.writeString" || e.fn === "writer.writeBytes") { const writer = host.emitExpr(e.args[0]!); const data = host.emitExpr(e.args[1]!); diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 294128107..61d31435c 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -726,6 +726,7 @@ export const LIB_FN_SYMS: Record = { "child.unref": "scr_child_unref", "child.ref": "scr_child_ref", "procStream.write": "scr_proc_stream_write", + "stream.stdioWritable": "scr_stream_stdio_writable", "process.kill": "scr_process_kill_named", "process.killNum": "scr_process_kill", // fs.promises (scr_bytes_io.c / scr_lib.c): the promise forms settle diff --git a/packages/compiler/src/frontend/lowering/builtins/properties.ts b/packages/compiler/src/frontend/lowering/builtins/properties.ts index b0caa0456..3c0486f82 100644 --- a/packages/compiler/src/frontend/lowering/builtins/properties.ts +++ b/packages/compiler/src/frontend/lowering/builtins/properties.ts @@ -1,5 +1,5 @@ import { isBuiltinClassInstance } from "./receiver-types.js"; -import { strLit } from "../../../ir/build.js"; +import { numLit, strLit } from "../../../ir/build.js"; import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; import { locOf } from "../../program.js"; @@ -192,6 +192,25 @@ export function lowerBuiltinExtraProperty( // +1-or-NULL runtime pair. if (kind === "child" && (name === "stdin" || name === "stdout" || name === "stderr")) { const receiver = lowerer.lowerExpr(expr.expression); + // Under the general stream representation (general-streams.ts) the + // declared stream type maps to a runtime stream class: the read + // answers the child's bridged stream (one per stdio slot, so repeated + // reads keep identity), fed by the same pipe. + const general = lowerer.generalNodeStreams ? generalChildStdioType(lowerer, expr, name) : null; + if (general) { + const type: IrType = { + kind: "union", + unionId: lowerer.unions.intern([general, { kind: "nullT" }]), + }; + const read: IrExpr = { + kind: "libCall", + fn: "child.stdioStream", + args: [receiver, numLit(name === "stdin" ? 0 : name === "stdout" ? 1 : 2, loc)], + type, + loc, + }; + return lowerer.maybeNarrow(read, expr); + } const streamType = name === "stdin" ? CHILDWRITER_T : CHILDSTREAM_T; const type: IrType = { kind: "union", @@ -259,3 +278,20 @@ export function lowerBuiltinExtraProperty( } return null; } + +/** The runtime stream class a child stdio read produces under the general + * stream representation, or null when the declared type keeps the stdio + * handle (the fallback declarations' ChildStdin). */ +function generalChildStdioType( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, + name: "stdin" | "stdout" | "stderr", +): IrType | null { + const className = name === "stdin" ? "%Writable" : "%Readable"; + const mapped = lowerer.mapTypeOf(lowerer.typeOf(expr)); + if (!mapped) return null; + const arms = mapped.kind === "union" ? (lowerer.unions.get(mapped.unionId)?.arms ?? []) : [mapped]; + return arms.some((arm) => arm.kind === "object" && arm.className === className) + ? { kind: "object", className } + : null; +} diff --git a/packages/compiler/src/frontend/lowering/general-streams.ts b/packages/compiler/src/frontend/lowering/general-streams.ts new file mode 100644 index 000000000..6b51ce545 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/general-streams.ts @@ -0,0 +1,94 @@ +/* The general stream representation (TypeMapperCtx.generalNodeStreams). + * + * NodeJS.ReadableStream and NodeJS.WritableStream (and @types/node's + * stream.Readable / stream.Writable, which also type child stdio) name the + * interface every Node stream implements. By default they map to the + * child/process stdio handles: the specialized representation of the + * values that commonly fill them (child.stdout, process.stdout). A program + * that moves another stream — fs.createReadStream(), a zlib Transform, a + * Readable subclass — into one of those slots needs the general + * representation instead: the runtime stream classes, with the stdio + * handles converted into them where they are read or flow. + * + * The decision is per program. Reachable emit lowers without it; a flow + * that needs it marks the lowerer (needsGeneralNodeStreams) and lowerToIr + * lowers the whole program again with it. Programs without such a flow + * never take the second pass, so their IR is unchanged. */ +import type { IrExpr, IrType } from "../../ir/ir.js"; +import { everyTypeChild } from "../../ir/traverse.js"; +import type { Lowerer } from "./lowerer.js"; +import { streamSidesOf } from "./lower-stream.js"; + +const STDIO_STREAM_KINDS: ReadonlySet = new Set([ + "childStream", + "childWriter", + "procStream", +]); + +/** Whether `type` (through union arms, record fields and the structural + * type children) contains a part satisfying `test`. */ +function containsPart(lowerer: Lowerer, type: IrType, test: (part: IrType) => boolean): boolean { + const seen = new Set(); + const walk = (part: IrType): boolean => { + if (test(part)) return true; + if (part.kind === "union") { + if (seen.has(`u:${part.unionId}`)) return false; + seen.add(`u:${part.unionId}`); + return (lowerer.unions.get(part.unionId)?.arms ?? []).some(walk); + } + if (part.kind === "record") { + if (seen.has(`r:${part.shapeId}`)) return false; + seen.add(`r:${part.shapeId}`); + return (lowerer.shapes.get(part.shapeId)?.fields ?? []).some((f) => walk(f.type)); + } + let found = false; + everyTypeChild(part, (child) => { + found = walk(child); + return !found; + }); + return found; + }; + return walk(type); +} + +function isGeneralStream(lowerer: Lowerer, part: IrType): boolean { + return ( + part.kind === "object" && streamSidesOf(lowerer, lowerer.classes.get(part.className)) !== null + ); +} + +/** A mismatched flow (requireExactShape's input) that the general stream + * representation resolves: a general stream into a stdio-represented + * slot, a stdio handle into an 'unknown' slot, or an 'unknown' value + * checked into a stdio-represented slot. */ +export function needsGeneralNodeStreams( + lowerer: Lowerer, + actual: IrType, + expected: IrType, +): boolean { + const stdio = (part: IrType): boolean => STDIO_STREAM_KINDS.has(part.kind); + const pipeHandle = (part: IrType): boolean => + part.kind === "childStream" || part.kind === "childWriter"; + if (containsPart(lowerer, expected, stdio)) { + return actual.kind === "dyn" || containsPart(lowerer, actual, (p) => isGeneralStream(lowerer, p)); + } + return expected.kind === "dyn" && containsPart(lowerer, actual, pipeHandle); +} + +/** Under the general representation, process.stdout / process.stderr (the + * procStream fd scalar) flowing into a runtime Writable slot: an + * fd-backed Writable whose writes take the same synchronous path as + * process.stdout.write. Null when the flow is not that one. */ +export function stdioIntoGeneralStream( + lowerer: Lowerer, + expr: IrExpr, + expected: IrType, +): IrExpr | null { + if (!lowerer.generalNodeStreams || expr.type.kind !== "procStream") return null; + const writable: IrType = { kind: "object", className: "%Writable" }; + const accepts = + (expected.kind === "object" && expected.className === "%Writable") || + (expected.kind === "union" && lowerer.armTag(expected.unionId, writable) >= 0); + if (!accepts) return null; + return { kind: "libCall", fn: "stream.stdioWritable", args: [expr], type: writable, loc: expr.loc }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts index 06e9c4ab1..4b8b9ea14 100644 --- a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts +++ b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts @@ -1245,7 +1245,19 @@ export function lowerEmitterMethodCall( ); } const p = cbT.params[0]; - if (p !== undefined && !(p.kind === "bytes" && p.elem === "u8") && p.kind !== "string") { + // A `Buffer | string` chunk (NodeJS.ReadableStream's listener + // shape) takes whichever side the stream delivers. + const chunkUnion = + p?.kind === "union" && + (lowerer.unions.get(p.unionId)?.arms ?? []).every( + (arm) => (arm.kind === "bytes" && arm.elem === "u8") || arm.kind === "string", + ); + if ( + p !== undefined && + !(p.kind === "bytes" && p.elem === "u8") && + p.kind !== "string" && + !chunkUnion + ) { lowerer.noLowering( `a 'data' listener whose chunk parameter is '${lowerer.fmt(p)}'`, args[1]!, diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index fa4c98372..aac5883cd 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -872,6 +872,14 @@ export function moduleArtifacts( valueInstanceOfRoots.add(root.def.name); } break; + case "libCall": + // The general stream representation's child stdio read bridges + // through the runtime install that boxes both stream classes. + if (node.fn === "child.stdioStream") { + visitClass("%Readable"); + visitClass("%Writable"); + } + break; case "dynFrom": if (boxedChild(node.value.type)) { // A generic child can expose native stdio without importing stream. diff --git a/packages/compiler/src/frontend/lowering/lower-stdlib.ts b/packages/compiler/src/frontend/lowering/lower-stdlib.ts index 2f0e1c66f..4ddfbe5ba 100644 --- a/packages/compiler/src/frontend/lowering/lower-stdlib.ts +++ b/packages/compiler/src/frontend/lowering/lower-stdlib.ts @@ -4,6 +4,7 @@ import { InternalCompilerError } from "../../errors.js"; import * as ts from "../ts7/adapter.js"; import { lowerToNumberArgument } from "./lower-exprs.js"; import type { Lowerer } from "./lowerer.js"; +import { isDeclaredInAmbientNamespace } from "../type-mapper.js"; import { arrayOf, BOOL, @@ -1021,7 +1022,15 @@ export function fenceStaticAbortControllerMemberRead( function isStdlibFetchInterface(lowerer: Lowerer, node: ts.Node, owner: string): ts.Symbol | null { const type = lowerer.checker.getBaseTypeOfLiteralType(lowerer.typeOf(node)); for (const symbol of [type.getSymbol(), type.getAliasSymbol()]) { - if (symbol?.name === owner && lowerer.isStdlibSymbol(symbol)) return symbol; + // NodeJS.ReadableStream shares the WHATWG interface's name. + if ( + symbol?.name === owner && + lowerer.isStdlibSymbol(symbol) && + !symbol.declarations.some( + (d) => ts.isInterfaceDeclaration(d) && isDeclaredInAmbientNamespace(d, "NodeJS"), + ) + ) + return symbol; } return null; } diff --git a/packages/compiler/src/frontend/lowering/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts index dd0ddcc0e..66595c473 100644 --- a/packages/compiler/src/frontend/lowering/lower-stream.ts +++ b/packages/compiler/src/frontend/lowering/lower-stream.ts @@ -3296,6 +3296,62 @@ function pipeEndOption(lowerer: Lowerer, options: ts.Expression | undefined, loc return end; } +/** `readable.pipe(process.stdout)`: the process stream joins the pipe as + * an fd-backed Writable (stream.stdioWritable), and — like Node, which + * never ends process.stdout or process.stderr from a pipe — the source's + * end does not end it. pipe() answers the process stream itself. */ +function pipeIntoStdio( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, + target: IrExpr, +): IrExpr { + const loc = locOf(call); + // JS order: the receiver, then the destination, then the options. + const receiverValue = lowerer.lowerExpr(access.expression); + const source = lowerer.declareHiddenLocal("%pipeSource", receiverValue.type); + const stdio = lowerer.declareHiddenLocal("%pipeStdio", target.type); + const options = call.arguments.nodes[1]; + const stmts: IrStmt[] = [ + { kind: "varDecl", localId: source.id, init: receiverValue, loc }, + { kind: "varDecl", localId: stdio.id, init: target, loc }, + ]; + // The options literal still evaluates; its end flag has no effect here. + if (options !== undefined) { + stmts.push({ kind: "exprStmt", expr: pipeEndOption(lowerer, options, loc), loc }); + } + const receiver: IrExpr = { kind: "varRef", localId: source.id, type: receiverValue.type, loc }; + const writable: IrType = { kind: "object", className: "%Writable" }; + stmts.push({ + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "readable.pipe", + args: [ + receiver, + { + kind: "libCall", + fn: "stream.stdioWritable", + args: [{ kind: "varRef", localId: stdio.id, type: target.type, loc }], + type: writable, + loc, + }, + boolLit(false, loc), + ], + type: writable, + loc, + }, + loc, + }); + return { + kind: "seqExpr", + stmts, + result: { kind: "varRef", localId: stdio.id, type: target.type, loc }, + type: target.type, + loc, + }; +} + export function lowerStreamMethodCall( lowerer: Lowerer, call: ts.CallExpression, @@ -3545,7 +3601,9 @@ export function lowerStreamMethodCall( loc, }; } - const dst = lowerer.lowerExpr(args[0]!); + const target = lowerer.lowerExpr(args[0]!); + if (target.type.kind === "procStream") return pipeIntoStdio(lowerer, call, access, target); + const dst = target; const dstInfo = dst.type.kind === "object" ? lowerer.classes.get(dst.type.className) : undefined; const dstSides = streamSidesOf(lowerer, dstInfo); @@ -3553,7 +3611,7 @@ export function lowerStreamMethodCall( lowerer.noLowering( `pipe into a '${lowerer.fmt(dst.type)}'`, args[0]!, - "the destination must be a stream Writable/Duplex (process streams and sockets are not pipe destinations yet)", + "the destination must be a stream Writable/Duplex, process.stdout or process.stderr (sockets are not pipe destinations yet)", ); } const end = pipeEndOption(lowerer, args[1], loc); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 1361b346f..2973498e5 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1,3 +1,4 @@ +import { needsGeneralNodeStreams, stdioIntoGeneralStream } from "./general-streams.js"; import { extendAssertedRecordMembers } from "./assertion-members.js"; import { jsDynamicDeclarations } from "./js-dynamic-objects.js"; import { @@ -877,6 +878,10 @@ export interface LowererMode { /** Statically resolved child_process.fork program roots, in stable target-id order. */ forkTargets?: readonly ts.SourceFile[]; frontendServices?: FrontendServices | undefined; + /** The program's general stream representation (TypeMapperCtx. + * generalNodeStreams). lowerToIr turns it on only after a pass without + * it saw a stream flow that needs it (requiresGeneralNodeStreams). */ + generalNodeStreams?: boolean; /** LoadResult.inlineRequireCycleCalls. */ inlineRequireCycleCalls?: ReadonlySet | undefined; /** LowerOptions.libraryCallbacks (see there). */ @@ -1033,10 +1038,40 @@ export function lowerToIr( for (const d of extraModuleCycleDiags) reachableEmit.pushDiag(d); for (const d of ffiValidation.diagnostics) reachableEmit.pushDiag(d); const emitted = reachableEmit.emitReachable(options.libRoots); - const { reachable } = emitted; + let { reachable } = emitted; let result = emitted.result; let resultLowerer = reachableEmit; timing("reachable-emit", { reachable: reachable.size }); + // The general stream representation (TypeMapperCtx.generalNodeStreams) + // is a per-program decision: only a program whose reachable code moved + // a general stream into a stdio-represented stream slot lowers again + // with it. Every other program keeps the single pass above unchanged. + const generalNodeStreams = reachableEmit.requiresGeneralNodeStreams; + if (generalNodeStreams) { + const general = new Lowerer(program, entry, moduleOrder, { + frontendServices: options.frontendServices, + projectCheckJs: options.projectCheckJs ?? false, + targetPlatform, + startupCrash, + inlineRequireCycleCalls: options.inlineRequireCycleCalls, + ffiImports, + libraryCallbacks, + forkTargets, + workerTargets, + externalTypes, + externalTypeSpecifiersByFile, + tierMode, + ffiBindingSymbols: ffiValidation.symbolsByName, + generalNodeStreams: true, + }); + for (const d of extraModuleCycleDiags) general.pushDiag(d); + for (const d of ffiValidation.diagnostics) general.pushDiag(d); + const generalEmitted = general.emitReachable(options.libRoots); + reachable = generalEmitted.reachable; + result = generalEmitted.result; + resultLowerer = general; + timing("general-streams-relower", { reachable: reachable.size }); + } // A generic class-rest support decision can depend on record metadata // whose historical owner is discovered only while retained bodies lower. // If the settled answer is a fence, rerun the ordinary reachable emit so @@ -1044,10 +1079,11 @@ export function lowerToIr( // declarators stay unvisited, bindings block, cascades and stats match the // historical compiler. This is a rare compatibility fallback; programs // without such a settled fence retain checker-backed IR exactly once. - if (reachableEmit.requiresHistoricalOrderRelower) { + if (resultLowerer.requiresHistoricalOrderRelower) { const emit = new Lowerer(program, entry, moduleOrder, { frontendServices: options.frontendServices, projectCheckJs: options.projectCheckJs ?? false, + ...(generalNodeStreams ? { generalNodeStreams: true } : {}), reachable, targetPlatform, startupCrash, @@ -1107,6 +1143,7 @@ export function lowerToIr( const remainder = new Lowerer(program, entry, moduleOrder, { frontendServices: options.frontendServices, projectCheckJs: options.projectCheckJs ?? false, + ...(generalNodeStreams ? { generalNodeStreams: true } : {}), reachable, remainder: true, alreadyFlushed: resultLowerer.flushedSymbols, @@ -2496,6 +2533,12 @@ export class Lowerer { /** Settled generic class-rest metadata requires the historical emit * fallback so its fence can poison the original statement atomically. */ requiresHistoricalOrderRelower = false; + /** A general stream value (a runtime stream class instance) reached a + * slot the child/process stdio handles represent — NodeJS.ReadableStream, + * NodeJS.WritableStream, or @types/node's stream.Readable/Writable — or + * a stdio handle reached an 'unknown' slot. lowerToIr then lowers the + * program again under the general stream representation. */ + requiresGeneralNodeStreams = false; /** Helpers that snapshot shape declaration order into their bodies. * Reachability lowers inits before the declarations they discover, so * these rebuild after the worklist restores historical shape metadata. */ @@ -2943,6 +2986,7 @@ export class Lowerer { * refuse and fence exactly as before. Stage S6 demotes the units of * committed refusals in "auto" and keeps them refusals in "static-only". */ readonly tierMode: TierMode; + readonly generalNodeStreams: boolean; /** Functions finishModule placed in the dynamic tier (ir/tiers.ts). */ dynamicFunctions: DynamicFunction[] = []; /** Their IR, for coverage's dynamic statement count. */ @@ -3071,6 +3115,7 @@ export class Lowerer { this.startupCrash = mode.startupCrash ?? null; this.targetPlatform = mode.targetPlatform ?? process.platform; this.tierMode = mode.tierMode ?? "auto"; + this.generalNodeStreams = mode.generalNodeStreams ?? false; this.workerTargets = mode.workerTargets ?? []; this.workerTargets.forEach((sf, id) => { this.workerTargetIdByPath.set(tsgoPath(resolve(sf.fileName)), id); @@ -3167,6 +3212,7 @@ export class Lowerer { isExternalTypeFile: (sf) => this.externalTypeSpecifiersByFile.has(tsgoPath(resolve(sf.fileName))), typeMemo: this.typeMemo, + ...(this.generalNodeStreams ? { generalNodeStreams: true } : {}), canMemoizeType: () => this.typeParamBindings === null && this.typeParamTsBindings === null && @@ -8451,6 +8497,8 @@ export class Lowerer { * validator ICEs on them as the usual backstop. */ requireExactShape(node: ts.Node, actual: IrType, expected: IrType): void { if (typeEquals(actual, expected)) return; + if (!this.generalNodeStreams && needsGeneralNodeStreams(this, actual, expected)) + this.requiresGeneralNodeStreams = true; // dyn mismatches first. A dyn ('unknown') value flowing into a typed // slot needs a CHECKED cast — the hint points at `as `; tsc // usually rejects this before we do, so the fence is mostly defensive. @@ -8657,6 +8705,9 @@ export class Lowerer { } if (expr.type.kind === "void" && expected.kind !== "void" && this.neverValued.has(expr)) return this.divergentValue(expr, expected); + // process.stdout/stderr into a general Writable slot (general-streams.ts). + const stdioStream = stdioIntoGeneralStream(this, expr, expected); + if (stdioStream) return this.coerceToExpected(stdioStream, expected); // Iterator-typed slots: native iterators and differently typed // generators step through an adapter generator. if (expected.kind === "generator" && !typeEquals(expr.type, expected)) { diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 94506f034..afa9f4311 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -980,7 +980,7 @@ function isDeclaredInAmbientModule(d: ts.Declaration, name: string): boolean { /** True when the declaration's nearest enclosing namespace is `name` * (`declare global { namespace NodeJS { ... } }` — the NodeJS-global * interfaces @types/node declares outside any ambient module). */ -function isDeclaredInAmbientNamespace(d: ts.Declaration, name: string): boolean { +export function isDeclaredInAmbientNamespace(d: ts.Declaration, name: string): boolean { let node: ts.Node | undefined = d.parent; while (node) { if (ts.isModuleDeclaration(node) && ts.isIdentifier(node.name)) { @@ -1146,6 +1146,13 @@ export interface TypeMapperCtx { * type descriptions for project-owned values, but their imported runtime * bindings are fenced separately by the Lowerer. */ isExternalTypeFile: (sf: ts.SourceFile) => boolean; + /** The program's general stream representation (lowerToIr decides it + * per program): NodeJS.ReadableStream / NodeJS.WritableStream and + * @types/node's stream.Readable / stream.Writable map to the runtime + * stream classes instead of the child/process stdio handles, so values + * from every stream producer share their slots. Off (the default), the + * stdio handles keep those spellings. */ + generalNodeStreams?: boolean; /** Successful context-free mappings, keyed by the * TypeScript server's stable type id. A generated facade can reference the * same large record/function type thousands of times; remapping its full @@ -2998,6 +3005,20 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // global-namespace interface real code writes in its own signatures — // prefixStream's `output` param). The representation is the raw fd // scalar; the lowered surface is write(string). + // Under the program's general stream representation (generalNodeStreams) + // NodeJS.WritableStream is the runtime Writable instead: the process + // streams convert into it where they flow (process.stdioWritable). + const nodeWritableInterface = + psym?.name === "WritableStream" && + psym.declarations.some( + (d) => + ts.isInterfaceDeclaration(d) && + ctx.isStdlibFile(d.getSourceFile()) && + isDeclaredInAmbientNamespace(d, "NodeJS"), + ); + if (nodeWritableInterface && ctx.generalNodeStreams === true) { + return { kind: "object", className: "%Writable" }; + } if ( (psym?.name === "WriteStream" && psym.declarations.some( @@ -3007,13 +3028,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { (isDeclaredInAmbientModule(d, "tty") || (isDeclaredInAmbientModule(d, "process") && isDeclaredInAmbientNamespace(d, "NodeJS"))), )) || - (psym?.name === "WritableStream" && - psym.declarations.some( - (d) => - ts.isInterfaceDeclaration(d) && - ctx.isStdlibFile(d.getSourceFile()) && - isDeclaredInAmbientNamespace(d, "NodeJS"), - )) + nodeWritableInterface ) { return PROCSTREAM_T; } @@ -3030,6 +3045,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { isDeclaredInAmbientModule(d, "child_process"), )) || (psym?.name === "Writable" && + ctx.generalNodeStreams !== true && psym.declarations.some( (d) => (ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)) && @@ -3047,6 +3063,9 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // branch: the fallback's Readable must map to the runtime class, and // under the fallback child.stdout is NodeJS.ReadableStream (its own // branch below), so the two never collide. + // @types/node's Duplex/Transform/PassThrough classes have no child-stdio + // reading, so they map to the runtime classes too; its Readable and + // Writable join them under the general representation. for (const [irName, rec] of RUNTIME_STREAM_CLASSES) { if ( psym?.name === rec.lib && @@ -3054,15 +3073,17 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { (d) => ts.isClassDeclaration(d) && ctx.isStdlibFile(d.getSourceFile()) && - !isNodeTypesPath(d.getSourceFile().fileName) && + (!isNodeTypesPath(d.getSourceFile().fileName) || + ctx.generalNodeStreams === true || + (rec.lib !== "Readable" && rec.lib !== "Writable")) && isDeclaredInAmbientModule(d, "stream"), ) ) { return { kind: "object", className: irName }; } } - // node:zlib's stream classes (the shipped fallback declarations): their - // instances are runtime Transform streams. + // node:zlib's stream classes (the shipped fallback declarations and + // @types/node's): their instances are runtime Transform streams. if ( psym !== undefined && ZLIB_STREAM_MODES.has(psym.name) && @@ -3070,22 +3091,20 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { (d) => ts.isClassDeclaration(d) && ctx.isStdlibFile(d.getSourceFile()) && - !isNodeTypesPath(d.getSourceFile().fileName) && isDeclaredInAmbientModule(d, "zlib"), ) ) { return { kind: "object", className: "%Transform" }; } - // fs.ReadStream / fs.WriteStream (the shipped fallback declarations): - // their instances are runtime Readable / Writable streams - // (scr_fs_stream.c). + // fs.ReadStream / fs.WriteStream (the shipped fallback declarations and + // @types/node's): their instances are runtime Readable / Writable + // streams (scr_fs_stream.c). if ( (psym?.name === "ReadStream" || psym?.name === "WriteStream") && psym.declarations.some( (d) => ts.isClassDeclaration(d) && ctx.isStdlibFile(d.getSourceFile()) && - !isNodeTypesPath(d.getSourceFile().fileName) && isDeclaredInAmbientModule(d, "fs"), ) ) { @@ -3107,6 +3126,9 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { isDeclaredInAmbientNamespace(d, "NodeJS"), )) ) { + // The general representation: the runtime Readable, which child + // stdio reads produce through the child stream bridge. + if (ctx.generalNodeStreams === true) return { kind: "object", className: "%Readable" }; return { kind: "childStream" }; } // dgram.Socket: the same ambiguous "Socket" name disambiguated by its @@ -4125,10 +4147,18 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { const mapped = mapType(checker.getTypeOfSymbol(p), ctx); if (!mapped) return false; const arms = mapped.kind === "union" ? ctx.unions.get(mapped.unionId)?.arms : [mapped]; + // Under the general stream representation the stdio members are the + // runtime stream classes. + const isExpected = (arm: IrType): boolean => + arm.kind === expected || + (ctx.generalNodeStreams === true && + arm.kind === "object" && + ((expected === "childStream" && arm.className === "%Readable") || + (expected === "childWriter" && arm.className === "%Writable"))); return ( arms !== undefined && - arms.some((arm) => arm.kind === expected) && - arms.every((arm) => arm.kind === expected || arm.kind === unit) + arms.some(isExpected) && + arms.every((arm) => isExpected(arm) || arm.kind === unit) ); }; const childShaped = diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 6757851d9..2973a9aa2 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -75,6 +75,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "process.stdoutWriteBytes", "process.stderrWriteBytes", "procStream.write", + "child.stdioStream", "weakMap.new", "weakSet.new", "dyn.fromEntries", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 2cbb35185..2e4faedb4 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -2534,6 +2534,11 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * queue, end/destroy settle it, writable is a pure state read, and * listener callbacks move into the writer registry. */ "child.stdin": { argTypes: [CHILD_T], result: VOID }, + /** Under the general stream representation (general-streams.ts), a + * stdio read (slot 0, 1 or 2) answers the child's bridged runtime stream + * — `Writable | null` for stdin, `Readable | null` otherwise; the bridge + * throws when the program lacks the stream unit's boxing helpers. */ + "child.stdioStream": { argTypes: [CHILD_T, F64], result: VOID }, // Listener registrations: callback func shapes are program-dependent // (zero-param / Buffer / Buffer-armed union) — the slots pin arity, // the stream receiver, and the once-flag. @@ -2555,6 +2560,13 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * stderrWrite paths so buffering and ordering stay identical. Data * borrowed; the bool is Node's always-true backpressure signal. */ "procStream.write": { argTypes: [PROCSTREAM_T, STRING], result: BOOL }, + /** process.stdout/stderr flowing into a runtime Writable slot under the + * general stream representation: an fd-backed Writable (+1) whose + * writes take process.stdout.write's synchronous path. */ + "stream.stdioWritable": { + argTypes: [PROCSTREAM_T], + result: { kind: "object", className: "%Writable" }, + }, "child.killed": { argTypes: [CHILD_T], result: BOOL }, "child.kill": { argTypes: [CHILD_T, STRING], result: BOOL }, "child.killNum": { argTypes: [CHILD_T, F64], result: BOOL }, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 4d59a6241..572a93273 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -144,6 +144,7 @@ const ABORT_SIGNAL_LIB_FNS: ReadonlySet = new Set([ /** Entry points that link the stream unit (with STREAM_LIB_PREFIXES). */ const STREAM_LIB_FNS: ReadonlySet = new Set([ + "child.stdioStream", "console.bind", "crypto.hashStream", "crypto.hashStreamPiped", @@ -162,6 +163,7 @@ const STREAM_LIB_FNS: ReadonlySet = new Set([ "stream.destroyErr", "stream.errored", "stream.prop", + "stream.stdioWritable", "stream.workerStdio", ]); diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 50ba236b8..63078ae4a 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -5224,6 +5224,20 @@ function validateFunction( } return true; } + if (e.fn === "child.stdioStream") { + const def = e.type.kind === "union" ? unions.get(e.type.unionId) : undefined; + const slot = e.args[1]; + const className = + slot?.kind === "numLit" ? (slot.value === 0 ? "%Writable" : "%Readable") : null; + const ok = + def && + className !== null && + def.arms.length === 2 && + def.arms.some((a) => a.kind === "object" && a.className === className) && + def.arms.some((a) => a.kind === "nullT"); + if (!ok) err(`libCall ${e.fn} must return the stream class of its stdio slot or null`, e.loc); + return true; + } if (e.fn === "child.stdin" || e.fn === "child.stdout" || e.fn === "child.stderr") { const def = e.type.kind === "union" ? unions.get(e.type.unionId) : undefined; const handleKind = e.fn === "child.stdin" ? "childWriter" : "childStream"; diff --git a/packages/runtime/src/scr_stream.c b/packages/runtime/src/scr_stream.c index 5f0bb493d..b7d99984a 100644 --- a/packages/runtime/src/scr_stream.c +++ b/packages/runtime/src/scr_stream.c @@ -6619,3 +6619,42 @@ ScrStream *scr_crypto_hash_stream_piped(ScrCryptoHash *hash) { if (s) scr_crypto_hash_stream_hold(hash); return s; } + +/* ── the compiler's general stream representation ───────────────────── + * NodeJS.ReadableStream / NodeJS.WritableStream slots that also hold other + * streams compile as this unit's classes; the stdio handles convert into + * them where they are read or flow. Both entry points sit last in the unit + * and reference only global symbols and no new constant data, so programs + * that never convert link the same bytes as before. */ + +/* A child stdio read (the child.stdioStream libCall): the bridged stream + * the checked-dynamic ChildProcess view answers for the same key ("stdin", + * "stdout" or "stderr", passed by the compiled program), so both views + * share one stream per slot. +1, or NULL for an unpiped slot or a throw. */ +ScrStream *scr_stream_child_stdio(void *child, const char *key, size_t key_len) { + ScrDyn *handle = scr_dyn_new_handle(child, SCR_DYNH_CHILD); + ScrDyn *box = scr_dyn_handle_ops_of(handle)->get(child, key, key_len); + scr_dyn_release(handle); + if (box == NULL) return NULL; + ScrStream *stream = box->kind == SCR_DYN_TYPED_REF ? scr_dyn_typed_ref_unbox(box) : NULL; + scr_dyn_release(box); + return stream; +} + +/* process.stdout / process.stderr flowing into a Writable slot (the + * stream.stdioWritable libCall): a Writable whose _write hands each chunk + * to the descriptor through process.stdout.write's synchronous path and + * completes at once, so ordering against console output and direct writes + * is unchanged. The closure keeps the fd as given (1 or 2). */ +static void scr_stream_stdio_write(ScrClosure *cb, ScrStream *s, ScrBytes *chunk) { + int fd = (int)scr_box_get_f64(cb->caps[0]); + (void)scr_stdio_write(fd == 2 ? 2 : 1, chunk->data, chunk->len * scr_bytes_elem_size(chunk->elem)); + scr_stream_write_done(s, NULL); +} +ScrStream *scr_stream_stdio_writable(double fd) { + ScrClosure *write = scr_closure_new((void *)scr_stream_stdio_write, 1); + write->caps[0] = scr_box_new(SCR_BOX_F64); + scr_box_set_f64(write->caps[0], fd); + return scr_stream_new_writable(-1, true, true, write, scr_stream_stdio_write, + NULL, NULL, NULL, NULL); +} diff --git a/packages/runtime/src/scr_stream.h b/packages/runtime/src/scr_stream.h index 82727c1dc..f6c3c24e5 100644 --- a/packages/runtime/src/scr_stream.h +++ b/packages/runtime/src/scr_stream.h @@ -125,6 +125,11 @@ void scr_stream_init_passthrough(ScrStream *s, double rhwm, double whwm, bool au ScrStream *scr_stream_new_readable_dyn(struct ScrDyn *opts); void scr_stream_worker_stdio(void); ScrStream *scr_stream_new_writable_dyn(struct ScrDyn *opts); +/* The general stream representation's conversions (+1): a child's bridged + * stdio stream by key (NULL when unpiped), and process.stdout/stderr as a + * Writable (fd 1 or 2). */ +ScrStream *scr_stream_child_stdio(void *child, const char *key, size_t key_len); +ScrStream *scr_stream_stdio_writable(double fd); ScrStream *scr_stream_new_duplex_dyn(struct ScrDyn *opts); ScrStream *scr_stream_new_transform_dyn(struct ScrDyn *opts); ScrStream *scr_stream_new_passthrough_dyn(struct ScrDyn *opts); diff --git a/tests/corpus/6463-node-readable-stream-producers.ts b/tests/corpus/6463-node-readable-stream-producers.ts new file mode 100644 index 000000000..4d06fac94 --- /dev/null +++ b/tests/corpus/6463-node-readable-stream-producers.ts @@ -0,0 +1,75 @@ +// NodeJS.ReadableStream is the interface every Node readable implements. +// One helper typed with it consumes a file stream, a Readable subclass, +// Readable.from, a gunzip Transform fed through pipe(), and a child's +// stdout. A program that mixes those producers compiles the interface as +// the node:stream Readable, with the child's pipe bridged into one. +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Readable } from "node:stream"; +import * as zlib from "node:zlib"; + +function drain(label: string, src: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + let text = ""; + let chunks = 0; + src.on("data", (chunk: Buffer | string) => { + chunks++; + text += typeof chunk === "string" ? `[s]${chunk}` : chunk.toString("utf8"); + }); + src.on("error", (err) => reject(err)); + src.on("end", () => { + console.log(label, "end", chunks > 0, src.readable); + resolve(text); + }); + }); +} + +class Countdown extends Readable { + private left = 3; + _read(): void { + this.push(this.left > 0 ? `t-${this.left--};` : null); + } +} + +function openText(file: string): NodeJS.ReadableStream { + return fs.createReadStream(file); +} + +function openDecoded(file: string): NodeJS.ReadableStream { + const stream = fs.createReadStream(file); + stream.setEncoding("utf8"); + return stream; +} + +async function main(): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "scriptc-node-readable-")); + const plain = path.join(dir, "plain.txt"); + const packed = path.join(dir, "packed.gz"); + fs.writeFileSync(plain, "hello from a file\n"); + fs.writeFileSync(packed, zlib.gzipSync(Buffer.from("inflated text\n"))); + + console.log(JSON.stringify(await drain("file", openText(plain)))); + console.log(JSON.stringify(await drain("decoded", openDecoded(plain)))); + console.log(JSON.stringify(await drain("subclass", new Countdown()))); + console.log(JSON.stringify(await drain("from", Readable.from(["a", "b", "c"])))); + const gunzip = fs.createReadStream(packed).pipe(zlib.createGunzip()); + console.log(JSON.stringify(await drain("gunzip", gunzip))); + + const child = spawn("sh", ["-c", "printf 'child line\\n'"], { + stdio: ["ignore", "pipe", "inherit"], + }); + const out = child.stdout; + if (out !== null) console.log(JSON.stringify(await drain("child", out))); + console.log("same stdout", child.stdout === out); + + try { + await drain("missing", openText(path.join(dir, "missing.txt"))); + } catch (err) { + console.log("missing", (err as NodeJS.ErrnoException).code); + } + fs.rmSync(dir, { recursive: true }); +} + +void main(); diff --git a/tests/corpus/6464-node-writable-stream-producers.ts b/tests/corpus/6464-node-writable-stream-producers.ts new file mode 100644 index 000000000..bc9986b9b --- /dev/null +++ b/tests/corpus/6464-node-writable-stream-producers.ts @@ -0,0 +1,68 @@ +// NodeJS.WritableStream is the interface every Node writable implements: +// process.stdout/stderr, fs.WriteStream and the node:stream Writable +// family. Helpers typed with it accept all of them, a copy helper pipes a +// file stream into one, and readable.pipe(process.stdout) leaves the +// process stream open afterwards, as in Node. +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Readable, Writable } from "node:stream"; + +function emit(dst: NodeJS.WritableStream, line: string): boolean { + return dst.write(`${line}\n`); +} + +function copy(src: NodeJS.ReadableStream, dst: NodeJS.WritableStream): Promise { + return new Promise((resolve, reject) => { + src.on("error", reject); + dst.on("error", reject); + dst.on("finish", () => resolve()); + src.pipe(dst); + }); +} + +class Collector extends Writable { + readonly parts: string[] = []; + _write(chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void { + this.parts.push(chunk.toString("utf8")); + callback(); + } +} + +function finished(stream: NodeJS.WritableStream): Promise { + return new Promise((resolve) => { + stream.end(() => resolve()); + }); +} + +async function main(): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "scriptc-node-writable-")); + const target = path.join(dir, "out.txt"); + const file = fs.createWriteStream(target); + console.log("file write", emit(file, "to a file")); + console.log("stdout write", emit(process.stdout, "to stdout")); + emit(process.stderr, "to stderr"); + const collector = new Collector(); + emit(collector, "to a collector"); + await finished(file); + await finished(collector); + console.log(JSON.stringify(fs.readFileSync(target, "utf8"))); + console.log(JSON.stringify(collector.parts)); + + const source = path.join(dir, "source.txt"); + const copied = path.join(dir, "copied.txt"); + fs.writeFileSync(source, "copied through pipe\n"); + await copy(fs.createReadStream(source), fs.createWriteStream(copied)); + console.log(JSON.stringify(fs.readFileSync(copied, "utf8"))); + + await new Promise((resolve) => { + const piped = Readable.from(["piped ", "to ", "stdout\n"]); + piped.on("end", () => resolve()); + console.log("pipe answers stdout", piped.pipe(process.stdout) === process.stdout); + }); + // The pipe did not end process.stdout. + emit(process.stdout, "still writable"); + fs.rmSync(dir, { recursive: true }); +} + +void main(); diff --git a/tests/corpus/6465-node-stream-js-boundary/consume.js b/tests/corpus/6465-node-stream-js-boundary/consume.js new file mode 100644 index 000000000..ceb8f3abf --- /dev/null +++ b/tests/corpus/6465-node-stream-js-boundary/consume.js @@ -0,0 +1,12 @@ +export function streamToText(stream) { + return new Promise((resolve, reject) => { + const chunks = []; + stream.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + stream.on("error", reject); + stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + }); +} + +export function describeStream(stream) { + console.log(typeof stream.on, typeof stream.pipe, stream.readable); +} diff --git a/tests/corpus/6465-node-stream-js-boundary/main.ts b/tests/corpus/6465-node-stream-js-boundary/main.ts new file mode 100644 index 000000000..0079c0733 --- /dev/null +++ b/tests/corpus/6465-node-stream-js-boundary/main.ts @@ -0,0 +1,33 @@ +// NodeJS.ReadableStream values crossing into untyped JavaScript and into +// 'unknown': a file stream and a child's stdout share one helper with a +// JavaScript stream consumer, which sees ordinary Node streams. +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describeStream, streamToText } from "./consume.js"; + +function open(file: string): NodeJS.ReadableStream { + return fs.createReadStream(file); +} + +function textOf(stream: NodeJS.ReadableStream): Promise { + return streamToText(stream); +} + +async function main(): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "scriptc-node-stream-js-")); + const file = path.join(dir, "input.txt"); + fs.writeFileSync(file, "through javascript\n"); + const held: unknown = open(file); + console.log(typeof held); + describeStream(held); + console.log(JSON.stringify(await textOf(open(file)))); + const child = spawn("sh", ["-c", "printf 'child output\\n'"], { + stdio: ["ignore", "pipe", "inherit"], + }); + if (child.stdout !== null) console.log(JSON.stringify(await textOf(child.stdout))); + fs.rmSync(dir, { recursive: true }); +} + +void main(); diff --git a/tests/fixtures/node-types/node-stream-interfaces.ts b/tests/fixtures/node-types/node-stream-interfaces.ts new file mode 100644 index 000000000..2809dc315 --- /dev/null +++ b/tests/fixtures/node-types/node-stream-interfaces.ts @@ -0,0 +1,61 @@ +// @types/node's stream interfaces fed by every producer: NodeJS.ReadableStream +// and stream.Readable slots take file streams, a gunzip Transform and a +// child's stdout; NodeJS.WritableStream and stream.Writable slots take file +// streams and process.stdout. +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Readable, Writable } from "node:stream"; +import * as zlib from "node:zlib"; + +function drain(label: string, src: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + let text = ""; + src.on("data", (chunk: Buffer | string) => { + text += chunk.toString(); + }); + src.on("error", reject); + src.on("end", () => { + console.log(label, "end"); + resolve(text); + }); + }); +} + +function emit(dst: NodeJS.WritableStream, line: string): void { + dst.write(`${line}\n`); +} + +function finish(dst: Writable): Promise { + return new Promise((resolve) => dst.end(() => resolve())); +} + +async function main(): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "scriptc-node-types-streams-")); + const plain = path.join(dir, "plain.txt"); + const packed = path.join(dir, "packed.gz"); + fs.writeFileSync(plain, "from a file\n"); + fs.writeFileSync(packed, zlib.gzipSync(Buffer.from("from gunzip\n"))); + const file: Readable = fs.createReadStream(plain); + console.log(JSON.stringify(await drain("file", file))); + console.log(JSON.stringify(await drain("gunzip", fs.createReadStream(packed).pipe(zlib.createGunzip())))); + const child = spawn("sh", ["-c", "printf 'from a child\\n'"], { stdio: ["ignore", "pipe", "inherit"] }); + if (child.stdout) console.log(JSON.stringify(await drain("child", child.stdout))); + + const target = path.join(dir, "out.txt"); + const out: Writable = fs.createWriteStream(target); + emit(out, "to a file"); + emit(process.stdout, "to stdout"); + await finish(out); + console.log(JSON.stringify(fs.readFileSync(target, "utf8"))); + await new Promise((resolve) => { + const piped = fs.createReadStream(plain); + piped.on("end", () => resolve()); + piped.pipe(process.stdout); + }); + emit(process.stdout, "stdout still open"); + fs.rmSync(dir, { recursive: true }); +} + +void main(); diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index 14e4c27af..34063aa9c 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -461,6 +461,24 @@ test("node-types: stream subclasses extend the @types/node stream classes", asyn expect(comparableStderr(native.stderr)).toBe(node.stderr); }); +test("node-types: NodeJS stream interfaces and stream.Readable/Writable take every stream producer", async () => { + const outDir = outDirFor("node-stream-interfaces"); + const entry = join(nodeTypesDir, "node-stream-interfaces.ts"); + const result = await compile(entry, { + outPath: join(outDir, "node-stream-interfaces"), + outDir, + sanitize, + }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const [native, node] = await Promise.all([ + execFileAsync(result.binaryPath), + execFileAsync(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", entry]), + ]); + expect(native.stdout).toBe(node.stdout); + expect(comparableStderr(native.stderr)).toBe(node.stderr); +}); + test("node-types: promisified setTimeout and setImmediate lower as timers/promises", async () => { const outDir = outDirFor("promisify-timers"); const entry = join(nodeTypesDir, "promisify-timers.ts"); From 915c0f63c5e30bbee77662e894ae409348073618 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 01:40:28 -0700 Subject: [PATCH 16/31] Request the general stream representation where a stdio handle meets the stream surface child.stdout.pipe(), stream events and writes beyond write(string) on a NodeJS stream interface slot, and pipes into a child's stdin now lower the program with the general representation instead of fencing. The fallback ChildStdin is a NodeJS.WritableStream, and the coverage remainder relowers its unreached code the same way. Co-Authored-By: Claude Opus 5.5 --- .../builtin-apis/streams-and-readline.mdx | 2 + .../console-and-process-streams.mdx | 3 +- .../ambient/scriptc-node-fallback.d.ts | 10 ++-- .../builtins/child-process-methods.ts | 7 +++ .../src/frontend/lowering/builtins/process.ts | 3 ++ .../frontend/lowering/builtins/properties.ts | 3 +- .../src/frontend/lowering/general-streams.ts | 30 +++++++++--- .../src/frontend/lowering/lower-stream.ts | 2 + .../compiler/src/frontend/lowering/lowerer.ts | 47 +++++++++++-------- packages/compiler/src/frontend/type-mapper.ts | 40 ++++++++++++++++ .../corpus/6466-node-stdio-stream-surface.ts | 45 ++++++++++++++++++ 11 files changed, 160 insertions(+), 32 deletions(-) create mode 100644 tests/corpus/6466-node-stdio-stream-surface.ts diff --git a/docs/content/limitations/builtin-apis/streams-and-readline.mdx b/docs/content/limitations/builtin-apis/streams-and-readline.mdx index 0ac0ca3a6..860daa5d8 100644 --- a/docs/content/limitations/builtin-apis/streams-and-readline.mdx +++ b/docs/content/limitations/builtin-apis/streams-and-readline.mdx @@ -1,4 +1,6 @@ - **Streams and readline:** Stream chunks are Buffers or strings. + `NodeJS.ReadableStream` and `NodeJS.WritableStream`, and `stream.Readable` and `stream.Writable` under `@types/node`, accept every stream: when a program passes file, zlib, or `node:stream` streams through them, child-process pipes and `process.stdout` and `process.stderr` join those streams as `Readable` and `Writable` instances. + `readable.pipe(process.stdout)` and `readable.pipe(process.stderr)` compile and, as in Node, do not end the process stream. `Readable.from` accepts arrays, Sets, Map and Set iterators, generators, async generators, and class iterables and async iterables whose values are strings or Buffers, and in JavaScript any iterable or async iterable of them; a stream that mixes strings and Buffers is unsupported. In JavaScript, a sync iterator value with a `then` method that is not a promise, and an object whose `[Symbol.asyncIterator]` or `[Symbol.iterator]` method is a generator method, are unsupported. Object-mode streams of other values (including `Readable.from` over a `Map`, whose values are entry arrays) are unsupported. diff --git a/docs/content/limitations/console-and-process-streams.mdx b/docs/content/limitations/console-and-process-streams.mdx index 0d7cd8f30..c62b82f0c 100644 --- a/docs/content/limitations/console-and-process-streams.mdx +++ b/docs/content/limitations/console-and-process-streams.mdx @@ -9,4 +9,5 @@ JavaScript can store `process.stdin`, `stdout`, and `stderr`, replace output `wr Input supports data/end/error listeners, pause/resume, buffered reads, and TTY raw mode. Output writes are synchronous, with successful callbacks on the next-tick queue; queued backpressure is not modeled. Stored streams do not support piping, encoding changes, arbitrary properties, or other events. -TypeScript output parameters support `write(string)`. +A TypeScript `NodeJS.WritableStream` parameter that only receives `process.stdout` or `process.stderr` supports `write(string)`. +When other streams flow into the same parameter, the process streams join them as `Writable` instances with the full writable surface. diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 28e481b9d..0df5aceeb 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -123,9 +123,9 @@ declare namespace NodeJS { encoding: BufferEncoding, callback?: (error?: Error | null) => void, ): boolean; - end(callback?: () => void): this; - end(data: string | Uint8Array, callback?: () => void): this; - end(data: string | Uint8Array, encoding: BufferEncoding, callback?: () => void): this; + end(callback?: () => void): void; + end(data: string | Uint8Array, callback?: () => void): void; + end(data: string | Uint8Array, encoding: BufferEncoding, callback?: () => void): void; on(event: "drain" | "finish" | "close", listener: () => void): void; on(event: "error", listener: (err: Error) => void): void; once(event: "drain" | "finish" | "close", listener: () => void): void; @@ -3092,9 +3092,9 @@ declare module "child_process" { write(chunk: string | Uint8Array): boolean; end(): void; destroy(): void; - on(event: "drain" | "finish", listener: () => void): void; + on(event: "drain" | "finish" | "close", listener: () => void): void; on(event: "error", listener: (err: Error) => void): void; - once(event: "drain" | "finish", listener: () => void): void; + once(event: "drain" | "finish" | "close", listener: () => void): void; once(event: "error", listener: (err: Error) => void): void; } diff --git a/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts b/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts index a435bf740..7f0c2cde0 100644 --- a/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts +++ b/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts @@ -1,5 +1,6 @@ import { boolLit, strLit } from "../../../ir/build.js"; import * as ts from "../../ts7/adapter.js"; +import { requestGeneralStreamSurface } from "../general-streams.js"; import { type Lowerer } from "../lowerer.js"; import { isJsSourceFile, locOf } from "../../program.js"; import { isChildSurfaceMember } from "../surfaces.js"; @@ -335,6 +336,7 @@ export function lowerChildStreamMethodCall( const evT = lowerer.typeOf(call.arguments.nodes[0]!); const event = evT.isStringLiteralType() ? evT.value : null; if (event !== "data" && event !== "end") { + requestGeneralStreamSurface(lowerer, access.expression); lowerer.noLowering( `stream.${name}(${event === null ? "non-literal event" : `"${event}"`}, ...)`, call.arguments.nodes[0]!, @@ -342,6 +344,7 @@ export function lowerChildStreamMethodCall( ); } if (!ts.isExpressionStatement(call.parent)) { + requestGeneralStreamSurface(lowerer, access.expression); lowerer.unsupported( "SC1090", call, @@ -393,6 +396,7 @@ export function lowerChildStreamMethodCall( loc, }; } + requestGeneralStreamSurface(lowerer, access.expression); lowerer.noLowering( `ReadableStream.${name}`, call, @@ -418,6 +422,7 @@ export function lowerChildWriterMethodCall( if (name === "write") { if (call.arguments.nodes.length !== 1 || call.arguments.nodes.some(ts.isSpreadElement)) { + requestGeneralStreamSurface(lowerer, access.expression); lowerer.noLowering( `child stdin write with ${call.arguments.nodes.length} arguments`, call, @@ -461,6 +466,7 @@ export function lowerChildWriterMethodCall( const eventType = lowerer.typeOf(call.arguments.nodes[0]!); const event = eventType.isStringLiteralType() ? eventType.value : null; if (event !== "drain" && event !== "finish" && event !== "error") { + requestGeneralStreamSurface(lowerer, access.expression); lowerer.noLowering( `child stdin ${name}(${event === null ? "non-literal event" : `"${event}"`}, ...)`, call.arguments.nodes[0]!, @@ -507,6 +513,7 @@ export function lowerChildWriterMethodCall( return { kind: "libCall", fn, args: [receiver, cb, once], type: VOID, loc }; } + requestGeneralStreamSurface(lowerer, access.expression); lowerer.noLowering( `Writable.${name}`, call, diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index dc2f5ee89..5dd747065 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -3,6 +3,7 @@ import { envSnapshotHelper, lowerProcessLoadEnvFile } from "./environment.js"; import { dynUndefinedExpr, boolLit, numLit, strLit, varRef } from "../../../ir/build.js"; import { timerStyleCallback } from "../lower-timers.js"; import * as ts from "../../ts7/adapter.js"; +import { requestGeneralStreamSurface } from "../general-streams.js"; import { type Lowerer, own } from "../lowerer.js"; import { isJsSourceFile, locOf } from "../../program.js"; import { isSafeToDiscard } from "../expressions/evaluation-safety.js"; @@ -224,6 +225,7 @@ export function lowerProcStreamMethodCall( const receiver = lowerer.lowerExpr(access.expression); const data = lowerer.lowerExpr(call.arguments.nodes[0]!); if (data.type.kind !== "string") { + requestGeneralStreamSurface(lowerer, access.expression); lowerer.noLowering( `write of '${lowerer.fmt(data.type)}' data on a stream value`, call.arguments.nodes[0]!, @@ -232,6 +234,7 @@ export function lowerProcStreamMethodCall( } return { kind: "libCall", fn: "procStream.write", args: [receiver, data], type: BOOL, loc }; } + requestGeneralStreamSurface(lowerer, access.expression); lowerer.noLowering( `WritableStream.${name}`, call, diff --git a/packages/compiler/src/frontend/lowering/builtins/properties.ts b/packages/compiler/src/frontend/lowering/builtins/properties.ts index 3c0486f82..e60a14c0c 100644 --- a/packages/compiler/src/frontend/lowering/builtins/properties.ts +++ b/packages/compiler/src/frontend/lowering/builtins/properties.ts @@ -290,7 +290,8 @@ function generalChildStdioType( const className = name === "stdin" ? "%Writable" : "%Readable"; const mapped = lowerer.mapTypeOf(lowerer.typeOf(expr)); if (!mapped) return null; - const arms = mapped.kind === "union" ? (lowerer.unions.get(mapped.unionId)?.arms ?? []) : [mapped]; + const arms = + mapped.kind === "union" ? (lowerer.unions.get(mapped.unionId)?.arms ?? []) : [mapped]; return arms.some((arm) => arm.kind === "object" && arm.className === className) ? { kind: "object", className } : null; diff --git a/packages/compiler/src/frontend/lowering/general-streams.ts b/packages/compiler/src/frontend/lowering/general-streams.ts index 6b51ce545..98993798c 100644 --- a/packages/compiler/src/frontend/lowering/general-streams.ts +++ b/packages/compiler/src/frontend/lowering/general-streams.ts @@ -16,6 +16,8 @@ * never take the second pass, so their IR is unchanged. */ import type { IrExpr, IrType } from "../../ir/ir.js"; import { everyTypeChild } from "../../ir/traverse.js"; +import * as ts from "../ts7/adapter.js"; +import { isGeneralNodeStreamInterface } from "../type-mapper.js"; import type { Lowerer } from "./lowerer.js"; import { streamSidesOf } from "./lower-stream.js"; @@ -59,8 +61,7 @@ function isGeneralStream(lowerer: Lowerer, part: IrType): boolean { /** A mismatched flow (requireExactShape's input) that the general stream * representation resolves: a general stream into a stdio-represented - * slot, a stdio handle into an 'unknown' slot, or an 'unknown' value - * checked into a stdio-represented slot. */ + * slot, or a child pipe handle into an 'unknown' slot. */ export function needsGeneralNodeStreams( lowerer: Lowerer, actual: IrType, @@ -69,9 +70,8 @@ export function needsGeneralNodeStreams( const stdio = (part: IrType): boolean => STDIO_STREAM_KINDS.has(part.kind); const pipeHandle = (part: IrType): boolean => part.kind === "childStream" || part.kind === "childWriter"; - if (containsPart(lowerer, expected, stdio)) { - return actual.kind === "dyn" || containsPart(lowerer, actual, (p) => isGeneralStream(lowerer, p)); - } + if (containsPart(lowerer, expected, stdio)) + return containsPart(lowerer, actual, (p) => isGeneralStream(lowerer, p)); return expected.kind === "dyn" && containsPart(lowerer, actual, pipeHandle); } @@ -90,5 +90,23 @@ export function stdioIntoGeneralStream( (expected.kind === "object" && expected.className === "%Writable") || (expected.kind === "union" && lowerer.armTag(expected.unionId, writable) >= 0); if (!accepts) return null; - return { kind: "libCall", fn: "stream.stdioWritable", args: [expr], type: writable, loc: expr.loc }; + return { + kind: "libCall", + fn: "stream.stdioWritable", + args: [expr], + type: writable, + loc: expr.loc, + }; +} + +/** A stdio-handle receiver whose member has no lowering on the handle + * (`out.on("error", ...)`, `child.stdout.pipe(...)`, `output.end()`): when + * its declared type is one the general representation maps to a runtime + * stream class, the program lowers again with it, where the full stream + * surface applies. The caller still reports its fence for this pass. */ +export function requestGeneralStreamSurface(lowerer: Lowerer, receiver: ts.Expression): void { + if (lowerer.generalNodeStreams) return; + const type = lowerer.checker.getNonNullableType(lowerer.typeOf(receiver)); + if (isGeneralNodeStreamInterface(type, lowerer.typeCtx)) + lowerer.requiresGeneralNodeStreams = true; } diff --git a/packages/compiler/src/frontend/lowering/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts index 66595c473..c73945d2b 100644 --- a/packages/compiler/src/frontend/lowering/lower-stream.ts +++ b/packages/compiler/src/frontend/lowering/lower-stream.ts @@ -49,6 +49,7 @@ import { sideEffectFreeOptionValue } from "./surfaces.js"; import { lowerDynObjectLiteral } from "./expressions/object-literals.js"; import { dynUndefinedExpr, varRef } from "../../ir/build.js"; import { genResultRecord } from "../type-mapper.js"; +import { requestGeneralStreamSurface } from "./general-streams.js"; import { extractIteratorValue, genStep, genValue, stepDrivable } from "./lower-generators.js"; import { BOOL, @@ -3608,6 +3609,7 @@ export function lowerStreamMethodCall( dst.type.kind === "object" ? lowerer.classes.get(dst.type.className) : undefined; const dstSides = streamSidesOf(lowerer, dstInfo); if (dstSides !== "w" && dstSides !== "rw") { + requestGeneralStreamSurface(lowerer, args[0]!); lowerer.noLowering( `pipe into a '${lowerer.fmt(dst.type)}'`, args[0]!, diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 2973498e5..c9a0c6435 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1140,25 +1140,34 @@ export function lowerToIr( }; else if (contracts.count > 0) result = { ...result, contracts }; if (options.coverage !== true) return result; - const remainder = new Lowerer(program, entry, moduleOrder, { - frontendServices: options.frontendServices, - projectCheckJs: options.projectCheckJs ?? false, - ...(generalNodeStreams ? { generalNodeStreams: true } : {}), - reachable, - remainder: true, - alreadyFlushed: resultLowerer.flushedSymbols, - inlineRequireCycleCalls: options.inlineRequireCycleCalls, - targetPlatform, - ffiImports, - libraryCallbacks, - forkTargets, - workerTargets, - ffiBindingSymbols: ffiValidation.symbolsByName, - externalTypes, - externalTypeSpecifiersByFile, - tierMode, - }); - const rem = remainder.run(); + const remainderPass = (general: boolean): Lowerer => + new Lowerer(program, entry, moduleOrder, { + frontendServices: options.frontendServices, + projectCheckJs: options.projectCheckJs ?? false, + ...(general ? { generalNodeStreams: true } : {}), + reachable, + remainder: true, + alreadyFlushed: resultLowerer.flushedSymbols, + inlineRequireCycleCalls: options.inlineRequireCycleCalls, + targetPlatform, + ffiImports, + libraryCallbacks, + forkTargets, + workerTargets, + ffiBindingSymbols: ffiValidation.symbolsByName, + externalTypes, + externalTypeSpecifiersByFile, + tierMode, + }); + let remainder = remainderPass(generalNodeStreams); + let rem = remainder.run(); + // Unreached code reports what it would compile to once reached: a + // stream flow there that needs the general representation lowers the + // remainder again with it, as reachable emit would. + if (!generalNodeStreams && remainder.requiresGeneralNodeStreams) { + remainder = remainderPass(true); + rem = remainder.run(); + } timing("coverage-remainder"); // Tiers (tier-analysis.ts): reached statements in dynamic-tier functions, // and with tierReport the report-only demotion analysis. diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index afa9f4311..08e603211 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -991,6 +991,31 @@ export function isDeclaredInAmbientNamespace(d: ts.Declaration, name: string): b return false; } +/** Whether `type` is one of the stream spellings the general stream + * representation (TypeMapperCtx.generalNodeStreams) maps differently: + * NodeJS.ReadableStream / NodeJS.WritableStream, @types/node's + * stream.Readable / stream.Writable, or the fallback declarations' + * ChildStdin. */ +export function isGeneralNodeStreamInterface( + type: ts.Type, + ctx: Pick, +): boolean { + const psym = type.getSymbol() ?? type.getAliasSymbol(); + if (psym === undefined) return false; + return psym.declarations.some((d) => { + if (!(ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d))) return false; + if (!ctx.isStdlibFile(d.getSourceFile())) return false; + if (psym.name === "ReadableStream" || psym.name === "WritableStream") + return ts.isInterfaceDeclaration(d) && isDeclaredInAmbientNamespace(d, "NodeJS"); + if (psym.name === "ChildStdin") return isDeclaredInAmbientModule(d, "child_process"); + return ( + (psym.name === "Readable" || psym.name === "Writable") && + isNodeTypesPath(d.getSourceFile().fileName) && + isDeclaredInAmbientModule(d, "stream") + ); + }); +} + /** A type declared in the shipped WebAssembly namespace (scriptc.d.ts): * its modules, instances, memories, tables, globals, error classes and the * Exports/Imports records. */ @@ -3038,6 +3063,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // of the broad @types class fence where they are constructed. if ( (psym?.name === "ChildStdin" && + ctx.generalNodeStreams !== true && psym.declarations.some( (d) => ts.isInterfaceDeclaration(d) && @@ -3056,6 +3082,20 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ) { return { kind: "childWriter" }; } + // Under the general stream representation the fallback declarations' + // ChildStdin is the runtime Writable too (child.stdioStream bridges it). + if ( + ctx.generalNodeStreams === true && + psym?.name === "ChildStdin" && + psym.declarations.some( + (d) => + ts.isInterfaceDeclaration(d) && + ctx.isStdlibFile(d.getSourceFile()) && + isDeclaredInAmbientModule(d, "child_process"), + ) + ) { + return { kind: "object", className: "%Writable" }; + } // The static node:stream classes (the shipped fallback declarations — // NOT @types/node, whose stream.Readable also types child stdio; under // @types/node the childStream mapping below keeps priority and the diff --git a/tests/corpus/6466-node-stdio-stream-surface.ts b/tests/corpus/6466-node-stdio-stream-surface.ts new file mode 100644 index 000000000..d3726360f --- /dev/null +++ b/tests/corpus/6466-node-stdio-stream-surface.ts @@ -0,0 +1,45 @@ +// The stdio handles reach the full stream surface when a program uses it: +// child.stdout piped to process.stdout, a file stream piped into a +// child's stdin, and an 'error' listener on a NodeJS.WritableStream +// parameter that only receives the process streams. Each of these +// compiles the NodeJS stream interfaces as the node:stream classes. +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +function report(out: NodeJS.WritableStream, text: string): void { + out.on("error", (err) => console.log("error", err.message)); + out.write(`${text}\n`); +} + +function echo(text: string): Promise { + return new Promise((resolve) => { + const child = spawn("sh", ["-c", `echo ${text}`], { stdio: ["ignore", "pipe", "inherit"] }); + child.stdout?.pipe(process.stdout); + child.on("close", (code) => resolve(code)); + }); +} + +function cat(input: string): Promise { + return new Promise((resolve) => { + const child = spawn("cat", [], { stdio: ["pipe", "pipe", "inherit"] }); + child.stdout?.pipe(process.stdout); + child.on("close", (code) => resolve(code)); + if (child.stdin) fs.createReadStream(input).pipe(child.stdin); + }); +} + +async function main(): Promise { + report(process.stdout, "reported"); + report(process.stderr, "reported to stderr"); + console.log("echo exit", await echo("piped from a child")); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "scriptc-node-stdio-")); + const input = path.join(dir, "input.txt"); + fs.writeFileSync(input, "file contents through cat\n"); + console.log("cat exit", await cat(input)); + report(process.stdout, "stdout stays open"); + fs.rmSync(dir, { recursive: true }); +} + +void main(); From 7cc8179a22a93218fe42bb21173e9717c8842ff3 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 02:31:27 -0700 Subject: [PATCH 17/31] Close a bridged child stdin on destroy and keep stream listener typing untyped The fallback NodeJS stream interfaces declare Node's EventEmitter listener signature for events beyond data/end, so checked JavaScript keeps its contextual typing for process stream listeners. The child stdin fence fixture becomes a differential test of the forms that now compile. Co-Authored-By: Claude Opus 5.5 --- .../ambient/scriptc-node-fallback.d.ts | 19 +++++---- ...463-node-readable-stream-producers.ts.json | 6 +++ ...464-node-writable-stream-producers.ts.json | 6 +++ .../6465-node-stream-js-boundary/main.ts.json | 7 ++++ .../6466-node-stdio-stream-surface.ts.json | 6 +++ .../node-types/child-stdin-fences.ts.json | 6 --- .../node-types/child-stdin-streams.ts.json | 6 +++ .../node-types/node-stream-interfaces.ts.json | 6 +++ packages/runtime/src/scr_stream.c | 22 +++++++++- .../fixtures/node-types/child-stdin-fences.ts | 6 --- .../node-types/child-stdin-streams.ts | 40 +++++++++++++++++++ tests/harness/project-config.test.ts | 26 +++++------- 12 files changed, 118 insertions(+), 38 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6463-node-readable-stream-producers.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6464-node-writable-stream-producers.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6465-node-stream-js-boundary/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6466-node-stdio-stream-surface.ts.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/child-stdin-fences.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/child-stdin-streams.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/node-stream-interfaces.ts.json delete mode 100644 tests/fixtures/node-types/child-stdin-fences.ts create mode 100644 tests/fixtures/node-types/child-stdin-streams.ts diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 0df5aceeb..037801338 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -93,13 +93,11 @@ declare namespace NodeJS { on(event: "data", listener: (chunk: Buffer) => void): void; on(event: "data", listener: (chunk: string) => void): void; on(event: "end", listener: () => void): void; - on(event: "close", listener: () => void): void; - on(event: "error", listener: (err: Error) => void): void; + on(event: string, listener: (...args: any[]) => void): void; once(event: "data", listener: (chunk: Buffer) => void): void; once(event: "data", listener: (chunk: string) => void): void; once(event: "end", listener: () => void): void; - once(event: "close", listener: () => void): void; - once(event: "error", listener: (err: Error) => void): void; + once(event: string, listener: (...args: any[]) => void): void; /* setEncoding('utf8') makes subsequent data chunks strings, including * multibyte sequences split across native pipe reads. */ setEncoding(encoding: string): this; @@ -126,10 +124,9 @@ declare namespace NodeJS { end(callback?: () => void): void; end(data: string | Uint8Array, callback?: () => void): void; end(data: string | Uint8Array, encoding: BufferEncoding, callback?: () => void): void; - on(event: "drain" | "finish" | "close", listener: () => void): void; - on(event: "error", listener: (err: Error) => void): void; - once(event: "drain" | "finish" | "close", listener: () => void): void; - once(event: "error", listener: (err: Error) => void): void; + /* Node's EventEmitter signature: listeners take untyped arguments. */ + on(event: string, listener: (...args: any[]) => void): void; + once(event: string, listener: (...args: any[]) => void): void; } } @@ -3092,10 +3089,12 @@ declare module "child_process" { write(chunk: string | Uint8Array): boolean; end(): void; destroy(): void; - on(event: "drain" | "finish" | "close", listener: () => void): void; + on(event: "drain" | "finish", listener: () => void): void; on(event: "error", listener: (err: Error) => void): void; - once(event: "drain" | "finish" | "close", listener: () => void): void; + on(event: string, listener: (...args: any[]) => void): void; + once(event: "drain" | "finish", listener: () => void): void; once(event: "error", listener: (err: Error) => void): void; + once(event: string, listener: (...args: any[]) => void): void; } export interface ChildProcess extends Disposable { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6463-node-readable-stream-producers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6463-node-readable-stream-producers.ts.json new file mode 100644 index 000000000..e413951ae --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6463-node-readable-stream-producers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6463-node-readable-stream-producers.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6464-node-writable-stream-producers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6464-node-writable-stream-producers.ts.json new file mode 100644 index 000000000..fd805a2af --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6464-node-writable-stream-producers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6464-node-writable-stream-producers.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6465-node-stream-js-boundary/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6465-node-stream-js-boundary/main.ts.json new file mode 100644 index 000000000..95278eab8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6465-node-stream-js-boundary/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6465-node-stream-js-boundary/consume.js", + "/tests/corpus/6465-node-stream-js-boundary/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6466-node-stdio-stream-surface.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6466-node-stdio-stream-surface.ts.json new file mode 100644 index 000000000..8ebcddd22 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6466-node-stdio-stream-surface.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6466-node-stdio-stream-surface.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/child-stdin-fences.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/child-stdin-fences.ts.json deleted file mode 100644 index cfa602a2e..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/child-stdin-fences.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/fixtures/node-types/child-stdin-fences.ts" - ], - "diags": [] -} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/child-stdin-streams.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/child-stdin-streams.ts.json new file mode 100644 index 000000000..2bb443d2a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/child-stdin-streams.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/child-stdin-streams.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/node-stream-interfaces.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/node-stream-interfaces.ts.json new file mode 100644 index 000000000..cfb5c1a10 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/node-stream-interfaces.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/node-stream-interfaces.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_stream.c b/packages/runtime/src/scr_stream.c index b7d99984a..3b2f7c8f6 100644 --- a/packages/runtime/src/scr_stream.c +++ b/packages/runtime/src/scr_stream.c @@ -6627,10 +6627,20 @@ ScrStream *scr_crypto_hash_stream_piped(ScrCryptoHash *hash) { * and reference only global symbols and no new constant data, so programs * that never convert link the same bytes as before. */ +/* destroy() on a bridged child stdin closes the pipe, as Node's does. */ +static void scr_stream_child_stdin_destroy(ScrClosure *cb, ScrStream *s, ScrError *err) { + ScrChildWriter *writer = scr_box_get_ref(cb->caps[0]); + scr_child_writer_destroy(writer); + scr_child_writer_release(writer); + scr_stream_destroy_done(s, err ? scr_error_retain(err) : NULL); +} + /* A child stdio read (the child.stdioStream libCall): the bridged stream * the checked-dynamic ChildProcess view answers for the same key ("stdin", * "stdout" or "stderr", passed by the compiled program), so both views - * share one stream per slot. +1, or NULL for an unpiped slot or a throw. */ + * share one stream per slot. The stdin stream (the only five-byte key) + * gains the destroy hook that closes the pipe. +1, or NULL for an unpiped + * slot or a throw. */ ScrStream *scr_stream_child_stdio(void *child, const char *key, size_t key_len) { ScrDyn *handle = scr_dyn_new_handle(child, SCR_DYNH_CHILD); ScrDyn *box = scr_dyn_handle_ops_of(handle)->get(child, key, key_len); @@ -6638,6 +6648,16 @@ ScrStream *scr_stream_child_stdio(void *child, const char *key, size_t key_len) if (box == NULL) return NULL; ScrStream *stream = box->kind == SCR_DYN_TYPED_REF ? scr_dyn_typed_ref_unbox(box) : NULL; scr_dyn_release(box); + if (stream != NULL && key_len == 5 && stream->st->destroy_cb == NULL) { + ScrChildWriter *writer = scr_child_stdin(child); + if (writer != NULL) { + ScrClosure *destroy = scr_closure_new((void *)scr_stream_child_stdin_destroy, 1); + destroy->caps[0] = scr_box_new_obj(scr_child_writer_retain_v, scr_child_writer_release_v, NULL); + scr_box_set_ref(destroy->caps[0], writer); + stream->st->destroy_cb = destroy; + stream->st->destroy_inv = scr_stream_child_stdin_destroy; + } + } return stream; } diff --git a/tests/fixtures/node-types/child-stdin-fences.ts b/tests/fixtures/node-types/child-stdin-fences.ts deleted file mode 100644 index 0859d8513..000000000 --- a/tests/fixtures/node-types/child-stdin-fences.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { spawn } from "node:child_process"; - -const child = spawn("node", []); -child.stdin.write("x", "utf8"); -child.stdin.end("x"); -child.stdin.destroy(new Error("stop")); diff --git a/tests/fixtures/node-types/child-stdin-streams.ts b/tests/fixtures/node-types/child-stdin-streams.ts new file mode 100644 index 000000000..c433d7bfb --- /dev/null +++ b/tests/fixtures/node-types/child-stdin-streams.ts @@ -0,0 +1,40 @@ +// child.stdin forms beyond write(string): a write with an encoding, end +// with a final chunk, and destroy with an error observed by an 'error' +// listener. They compile through the general stream representation, where +// child.stdin is the child's bridged Writable. +import { spawn } from "node:child_process"; + +function feed(): Promise { + return new Promise((resolve) => { + const child = spawn("cat", [], { stdio: ["pipe", "pipe", "inherit"] }); + let out = ""; + child.stdout.on("data", (chunk: Buffer) => { + out += chunk.toString(); + }); + child.on("close", (code) => { + console.log("fed", JSON.stringify(out), code); + resolve(); + }); + child.stdin.write("first ", "utf8"); + child.stdin.end("second\n"); + }); +} + +function abort(): Promise { + return new Promise((resolve) => { + const child = spawn("cat", [], { stdio: ["pipe", "ignore", "inherit"] }); + child.stdin.on("error", (err) => console.log("stdin error", err.message)); + child.on("close", (code) => { + console.log("aborted", code); + resolve(); + }); + child.stdin.destroy(new Error("stop")); + }); +} + +async function main(): Promise { + await feed(); + await abort(); +} + +void main(); diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index 34063aa9c..3635c88ed 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -551,26 +551,22 @@ test("node-types: promisified dns lookups settle with the lookup objects", async expect(comparableStderr(native.stderr)).toBe(node.stderr); }); -test("node-types: unsupported child stdin forms remain named fences", async () => { - const dir = outDirFor("node-child-stdin-fences"); - const entry = join(nodeTypesDir, "child-stdin-fences.ts"); +test("node-types: child stdin forms beyond write(string) compile through the general stream representation", async () => { + const dir = outDirFor("node-child-stdin-streams"); + const entry = join(nodeTypesDir, "child-stdin-streams.ts"); const result = await compile(entry, { - outPath: join(dir, "child-stdin-fences"), + outPath: join(dir, "child-stdin-streams"), outDir: dir, sanitize, }); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ - "SC2020", - "SC2020", - "SC2020", - ]); - expect(result.diagnostics.map((diagnostic) => diagnostic.message)).toEqual([ - expect.stringContaining("child stdin write with 2 arguments"), - expect.stringContaining("Writable.end"), - expect.stringContaining("Writable.destroy"), + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const [native, node] = await Promise.all([ + execFileAsync(result.binaryPath), + execFileAsync(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", entry]), ]); + expect(native.stdout).toBe(node.stdout); + expect(comparableStderr(native.stderr)).toBe(node.stderr); }); test("node-types: callback exec uses the typed ExecException overloads", async () => { From 1e96179f3a4e6b81d881688bc4250168fffd662c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 03:13:15 -0700 Subject: [PATCH 18/31] Carry untyped streams, casts, for await and pipeline stages into the general stream representation An 'unknown' value flowing or cast into a NodeJS.ReadableStream slot and 'for await' over one now select the general representation, where the checked cast to Readable applies. pipeline() stages that arrive untyped unwrap boxed native streams at run time. @types/node's zlib interface declarations map to the runtime Transform. Co-Authored-By: Claude Opus 5.5 --- .../ambient/scriptc-node-fallback.d.ts | 1 + .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../lowering/expressions/narrowing.ts | 4 ++ .../src/frontend/lowering/general-streams.ts | 4 +- .../src/frontend/lowering/lower-stmts.ts | 3 ++ .../src/frontend/lowering/lower-stream.ts | 18 +++++++ packages/compiler/src/frontend/type-mapper.ts | 3 +- packages/compiler/src/ir/builtin-effects.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 8 +++ packages/compiler/src/ir/runtime-features.ts | 1 + packages/runtime/src/scr_stream.c | 17 +++++++ packages/runtime/src/scr_stream.h | 3 ++ .../6467-node-stream-untyped-sources/main.ts | 51 +++++++++++++++++++ .../sources.js | 20 ++++++++ 14 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 tests/corpus/6467-node-stream-untyped-sources/main.ts create mode 100644 tests/corpus/6467-node-stream-untyped-sources/sources.js diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 037801338..3885e0fef 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -107,6 +107,7 @@ declare namespace NodeJS { isPaused(): boolean; pipe(destination: T, options?: { end?: boolean }): T; unpipe(destination?: WritableStream): this; + [Symbol.asyncIterator](): AsyncIterableIterator; } /* The writable-stream interface every Node stream implements: * process.stdout/process.stderr, fs.WriteStream and the node:stream diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 61d31435c..4602a2bdd 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -727,6 +727,7 @@ export const LIB_FN_SYMS: Record = { "child.ref": "scr_child_ref", "procStream.write": "scr_proc_stream_write", "stream.stdioWritable": "scr_stream_stdio_writable", + "stream.unboxDyn": "scr_stream_unbox_dyn", "process.kill": "scr_process_kill_named", "process.killNum": "scr_process_kill", // fs.promises (scr_bytes_io.c / scr_lib.c): the promise forms settle diff --git a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts index b54da8180..6b75755b1 100644 --- a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts +++ b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts @@ -26,6 +26,7 @@ import { jsBindingHasOpenWrites } from "../lower-stmts.js"; import { narrowClassUnion, narrowStoredClassValue, structuralNarrowing } from "../class-unions.js"; import { checkedClassAssertion, checkedClassUnionAssertion } from "../class-assertions.js"; import { isOptionalProcessStreamProperty } from "../builtins/process.js"; +import { requestGeneralStreamSurface } from "../general-streams.js"; import { literalValues } from "../../literal-values.js"; import { literalUnionArm } from "../../union-discriminants.js"; import { checkedArmExtraction } from "../coercions/unions.js"; @@ -1450,6 +1451,9 @@ export function lowerAsExpression( if (DYN_HANDLE_KINDS.has(target.kind)) { return { kind: "dynCheck", value: inner, type: target, loc: locOf(expr) }; } + // `value as NodeJS.ReadableStream`: the general stream classes take + // the checked cast (general-streams.ts). + requestGeneralStreamSurface(lowerer, expr); lowerer.unsupported( "SC1090", expr, diff --git a/packages/compiler/src/frontend/lowering/general-streams.ts b/packages/compiler/src/frontend/lowering/general-streams.ts index 98993798c..0113a9958 100644 --- a/packages/compiler/src/frontend/lowering/general-streams.ts +++ b/packages/compiler/src/frontend/lowering/general-streams.ts @@ -61,7 +61,8 @@ function isGeneralStream(lowerer: Lowerer, part: IrType): boolean { /** A mismatched flow (requireExactShape's input) that the general stream * representation resolves: a general stream into a stdio-represented - * slot, or a child pipe handle into an 'unknown' slot. */ + * slot, a child pipe handle into an 'unknown' slot, or an 'unknown' value + * into a child-pipe slot (the general classes take a checked cast). */ export function needsGeneralNodeStreams( lowerer: Lowerer, actual: IrType, @@ -70,6 +71,7 @@ export function needsGeneralNodeStreams( const stdio = (part: IrType): boolean => STDIO_STREAM_KINDS.has(part.kind); const pipeHandle = (part: IrType): boolean => part.kind === "childStream" || part.kind === "childWriter"; + if (actual.kind === "dyn") return containsPart(lowerer, expected, pipeHandle); if (containsPart(lowerer, expected, stdio)) return containsPart(lowerer, actual, (p) => isGeneralStream(lowerer, p)); return expected.kind === "dyn" && containsPart(lowerer, actual, pipeHandle); diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 064cd7a7b..afc329f40 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -1,4 +1,5 @@ import { hostsAll } from "./expressions/hosted-narrowing.js"; +import { requestGeneralStreamSurface } from "./general-streams.js"; import { matchAllRowsReadByElements, storedMatchAllRowsReadByElements, @@ -8916,6 +8917,8 @@ export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt { } const builtin = lowerForAwaitBuiltin(lowerer, stmt); if (builtin) return builtin; + // A NodeJS.ReadableStream iterates as the general Readable. + requestGeneralStreamSurface(lowerer, stmt.expression); lowerer.unsupported( "SC1070", stmt, diff --git a/packages/compiler/src/frontend/lowering/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts index c73945d2b..400fd79ac 100644 --- a/packages/compiler/src/frontend/lowering/lower-stream.ts +++ b/packages/compiler/src/frontend/lowering/lower-stream.ts @@ -2045,6 +2045,24 @@ function lowerStreamArg( const v = isHashValueType(lowered.type) ? hashStreamOf(lowered, what === "pipeline" || what === "compose") : lowered; + // A checked-dynamic pipeline stage (an untyped source or destination): + // a boxed native stream unwraps at run time; other values (iterables, + // functions) throw the not-supported Error there. + if (v.type.kind === "dyn" && what === "pipeline") { + return { + kind: "libCall", + fn: "stream.unboxDyn", + args: [ + v, + strLit( + "pipeline stages other than native streams (iterables, generators, plain functions, and { readable, writable } pairs) are not supported yet", + locOf(node), + ), + ], + type: { kind: "object", className: "%Duplex" }, + loc: locOf(node), + }; + } const info = v.type.kind === "object" ? lowerer.classes.get(v.type.className) : undefined; if (!streamSidesOf(lowerer, info)) { // A provably-non-stream value in a JS source (the invalid-input diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 08e603211..7acb539d1 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -3129,7 +3129,8 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ZLIB_STREAM_MODES.has(psym.name) && psym.declarations.some( (d) => - ts.isClassDeclaration(d) && + (ts.isClassDeclaration(d) || + (ts.isInterfaceDeclaration(d) && isNodeTypesPath(d.getSourceFile().fileName))) && ctx.isStdlibFile(d.getSourceFile()) && isDeclaredInAmbientModule(d, "zlib"), ) diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 2973a9aa2..1c390bbe4 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -76,6 +76,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "process.stderrWriteBytes", "procStream.write", "child.stdioStream", + "stream.unboxDyn", "weakMap.new", "weakSet.new", "dyn.fromEntries", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 2e4faedb4..597076cb1 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -2563,6 +2563,14 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ /** process.stdout/stderr flowing into a runtime Writable slot under the * general stream representation: an fd-backed Writable (+1) whose * writes take process.stdout.write's synchronous path. */ + /** A pipeline() stage that is a checked-dynamic value: a boxed native + * stream unwraps by identity (+1); anything else throws the given + * not-supported message. Typed as a Duplex so every stage position + * accepts it; the runtime pipeline checks the stream's real sides. */ + "stream.unboxDyn": { + argTypes: [DYN, STRING], + result: { kind: "object", className: "%Duplex" }, + }, "stream.stdioWritable": { argTypes: [PROCSTREAM_T], result: { kind: "object", className: "%Writable" }, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 572a93273..99ba76f79 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -164,6 +164,7 @@ const STREAM_LIB_FNS: ReadonlySet = new Set([ "stream.errored", "stream.prop", "stream.stdioWritable", + "stream.unboxDyn", "stream.workerStdio", ]); diff --git a/packages/runtime/src/scr_stream.c b/packages/runtime/src/scr_stream.c index 3b2f7c8f6..1c5e339a0 100644 --- a/packages/runtime/src/scr_stream.c +++ b/packages/runtime/src/scr_stream.c @@ -6678,3 +6678,20 @@ ScrStream *scr_stream_stdio_writable(double fd) { return scr_stream_new_writable(-1, true, true, write, scr_stream_stdio_write, NULL, NULL, NULL, NULL); } + +/* A pipeline() stage that arrives as a checked-dynamic value (the + * stream.unboxDyn libCall): a boxed native stream unwraps by identity + * (+1); any other value throws the compiled program's not-supported + * message (Node also takes iterables and functions there). */ +ScrStream *scr_stream_unbox_dyn(const ScrDyn *value, const ScrStr *message) { + if (value->kind == SCR_DYN_TYPED_REF && value->v.typed_ref.type_key_len > 7 && + !memcmp(value->v.typed_ref.type_key, "object:", 7)) { + ScrStream *stream = scr_dyn_typed_ref_unbox(value); + const ScrVt *vt = stream->vt; + if ((vt->pre >= scr_readable_vt.pre && vt->pre <= scr_readable_vt.post) || + (vt->pre >= scr_writable_vt.pre && vt->pre <= scr_writable_vt.post)) return stream; + value->v.typed_ref.release(stream); + } + scr_throw_error_msg_code(SCR_ERR_ERROR, message->data, message->len, "SC2020"); + return NULL; +} diff --git a/packages/runtime/src/scr_stream.h b/packages/runtime/src/scr_stream.h index f6c3c24e5..63ed0c28a 100644 --- a/packages/runtime/src/scr_stream.h +++ b/packages/runtime/src/scr_stream.h @@ -130,6 +130,9 @@ ScrStream *scr_stream_new_writable_dyn(struct ScrDyn *opts); * Writable (fd 1 or 2). */ ScrStream *scr_stream_child_stdio(void *child, const char *key, size_t key_len); ScrStream *scr_stream_stdio_writable(double fd); +/* A boxed native stream out of a checked-dynamic pipeline stage (+1), or + * NULL with `message` thrown as the not-supported Error. */ +ScrStream *scr_stream_unbox_dyn(const struct ScrDyn *value, const ScrStr *message); ScrStream *scr_stream_new_duplex_dyn(struct ScrDyn *opts); ScrStream *scr_stream_new_transform_dyn(struct ScrDyn *opts); ScrStream *scr_stream_new_passthrough_dyn(struct ScrDyn *opts); diff --git a/tests/corpus/6467-node-stream-untyped-sources/main.ts b/tests/corpus/6467-node-stream-untyped-sources/main.ts new file mode 100644 index 000000000..bab7e8cc7 --- /dev/null +++ b/tests/corpus/6467-node-stream-untyped-sources/main.ts @@ -0,0 +1,51 @@ +// Streams that arrive untyped meet NodeJS stream interface slots: an +// untyped JavaScript function's stream returned as NodeJS.ReadableStream, +// a checked cast from 'unknown', pipeline() over untyped source and +// destination stages, and 'for await' over a NodeJS.ReadableStream +// parameter fed by a file stream and a child's stdout. +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { pipeline } from "node:stream/promises"; +import * as zlib from "node:zlib"; +import { collector, joined, openUntyped } from "./sources.js"; + +function open(file: string): NodeJS.ReadableStream { + return openUntyped(file); +} + +function held(file: string): unknown { + return fs.createReadStream(file); +} + +async function collect(label: string, src: NodeJS.ReadableStream): Promise { + let text = ""; + for await (const chunk of src) text += chunk.toString(); + console.log(label, JSON.stringify(text)); +} + +async function main(): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "scriptc-node-stream-untyped-")); + const plain = path.join(dir, "plain.txt"); + const packed = path.join(dir, "packed.gz"); + fs.writeFileSync(plain, "plain text\n"); + fs.writeFileSync(packed, zlib.gzipSync(Buffer.from("packed text\n"))); + + await collect("returned", open(plain)); + await collect("cast", held(plain) as NodeJS.ReadableStream); + const child = spawn("sh", ["-c", "printf 'child text\\n'"], { stdio: ["ignore", "pipe", "inherit"] }); + if (child.stdout) await collect("child", child.stdout); + + const sink = collector(); + await pipeline(openUntyped(packed), zlib.createGunzip(), sink); + console.log("pipeline", JSON.stringify(joined())); + try { + await pipeline(openUntyped(path.join(dir, "missing.gz")), zlib.createGunzip(), collector()); + } catch (err) { + console.log("pipeline error", (err as NodeJS.ErrnoException).code); + } + fs.rmSync(dir, { recursive: true }); +} + +void main(); diff --git a/tests/corpus/6467-node-stream-untyped-sources/sources.js b/tests/corpus/6467-node-stream-untyped-sources/sources.js new file mode 100644 index 000000000..ce4b5dcb7 --- /dev/null +++ b/tests/corpus/6467-node-stream-untyped-sources/sources.js @@ -0,0 +1,20 @@ +import * as fs from "node:fs"; +import { PassThrough } from "node:stream"; + +const parts = []; + +/** @returns {any} */ +export function openUntyped(file) { + return fs.createReadStream(file); +} + +/** @returns {any} */ +export function collector() { + const sink = new PassThrough(); + sink.on("data", (chunk) => parts.push(chunk.toString("utf8"))); + return sink; +} + +export function joined() { + return parts.splice(0).join(""); +} From 95b2f81716dc644ec5a58309cf6d08ff71c81ca9 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 03:24:08 -0700 Subject: [PATCH 19/31] Pipe into untyped destinations by unwrapping boxed native streams Co-Authored-By: Claude Opus 5.5 --- .../builtin-apis/streams-and-readline.mdx | 1 + .../src/frontend/lowering/lower-stream.ts | 53 +++++++++++++++++++ .../6467-node-stream-untyped-sources/main.ts | 11 +++- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/docs/content/limitations/builtin-apis/streams-and-readline.mdx b/docs/content/limitations/builtin-apis/streams-and-readline.mdx index 860daa5d8..042fd1e50 100644 --- a/docs/content/limitations/builtin-apis/streams-and-readline.mdx +++ b/docs/content/limitations/builtin-apis/streams-and-readline.mdx @@ -1,6 +1,7 @@ - **Streams and readline:** Stream chunks are Buffers or strings. `NodeJS.ReadableStream` and `NodeJS.WritableStream`, and `stream.Readable` and `stream.Writable` under `@types/node`, accept every stream: when a program passes file, zlib, or `node:stream` streams through them, child-process pipes and `process.stdout` and `process.stderr` join those streams as `Readable` and `Writable` instances. `readable.pipe(process.stdout)` and `readable.pipe(process.stderr)` compile and, as in Node, do not end the process stream. + A `pipeline` stage that arrives as an untyped value must be a native stream at run time; other values throw a not-supported error. `Readable.from` accepts arrays, Sets, Map and Set iterators, generators, async generators, and class iterables and async iterables whose values are strings or Buffers, and in JavaScript any iterable or async iterable of them; a stream that mixes strings and Buffers is unsupported. In JavaScript, a sync iterator value with a `then` method that is not a promise, and an object whose `[Symbol.asyncIterator]` or `[Symbol.iterator]` method is a generator method, are unsupported. Object-mode streams of other values (including `Readable.from` over a `Map`, whose values are entry arrays) are unsupported. diff --git a/packages/compiler/src/frontend/lowering/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts index 400fd79ac..51c86fe39 100644 --- a/packages/compiler/src/frontend/lowering/lower-stream.ts +++ b/packages/compiler/src/frontend/lowering/lower-stream.ts @@ -3371,6 +3371,58 @@ function pipeIntoStdio( }; } +/** `readable.pipe(untyped)`: a checked-dynamic destination unwraps a boxed + * native stream at run time (stream.unboxDyn; other values throw the + * not-supported Error), and pipe() answers the destination value itself. */ +function pipeIntoDyn( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, + target: IrExpr, +): IrExpr { + const loc = locOf(call); + const receiverValue = lowerer.lowerExpr(access.expression); + const source = lowerer.declareHiddenLocal("%pipeSource", receiverValue.type); + const destination = lowerer.declareHiddenLocal("%pipeDestination", target.type); + const stmts: IrStmt[] = [ + { kind: "varDecl", localId: source.id, init: receiverValue, loc }, + { kind: "varDecl", localId: destination.id, init: target, loc }, + ]; + const end = pipeEndOption(lowerer, call.arguments.nodes[1], loc); + const duplex: IrType = { kind: "object", className: "%Duplex" }; + stmts.push({ + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "readable.pipe", + args: [ + { kind: "varRef", localId: source.id, type: receiverValue.type, loc }, + { + kind: "libCall", + fn: "stream.unboxDyn", + args: [ + { kind: "varRef", localId: destination.id, type: target.type, loc }, + strLit("pipe destinations other than native streams are not supported yet", loc), + ], + type: duplex, + loc, + }, + end, + ], + type: duplex, + loc, + }, + loc, + }); + return { + kind: "seqExpr", + stmts, + result: { kind: "varRef", localId: destination.id, type: target.type, loc }, + type: target.type, + loc, + }; +} + export function lowerStreamMethodCall( lowerer: Lowerer, call: ts.CallExpression, @@ -3622,6 +3674,7 @@ export function lowerStreamMethodCall( } const target = lowerer.lowerExpr(args[0]!); if (target.type.kind === "procStream") return pipeIntoStdio(lowerer, call, access, target); + if (target.type.kind === "dyn") return pipeIntoDyn(lowerer, call, access, target); const dst = target; const dstInfo = dst.type.kind === "object" ? lowerer.classes.get(dst.type.className) : undefined; diff --git a/tests/corpus/6467-node-stream-untyped-sources/main.ts b/tests/corpus/6467-node-stream-untyped-sources/main.ts index bab7e8cc7..5cb3a628d 100644 --- a/tests/corpus/6467-node-stream-untyped-sources/main.ts +++ b/tests/corpus/6467-node-stream-untyped-sources/main.ts @@ -1,7 +1,7 @@ // Streams that arrive untyped meet NodeJS stream interface slots: an // untyped JavaScript function's stream returned as NodeJS.ReadableStream, -// a checked cast from 'unknown', pipeline() over untyped source and -// destination stages, and 'for await' over a NodeJS.ReadableStream +// a checked cast from 'unknown', pipe() and pipeline() into untyped +// destinations, and 'for await' over a NodeJS.ReadableStream // parameter fed by a file stream and a child's stdout. import { spawn } from "node:child_process"; import * as fs from "node:fs"; @@ -37,6 +37,13 @@ async function main(): Promise { const child = spawn("sh", ["-c", "printf 'child text\\n'"], { stdio: ["ignore", "pipe", "inherit"] }); if (child.stdout) await collect("child", child.stdout); + await new Promise((resolve) => { + const piped = fs.createReadStream(plain); + piped.on("end", () => resolve()); + piped.pipe(collector()); + }); + console.log("pipe", JSON.stringify(joined())); + const sink = collector(); await pipeline(openUntyped(packed), zlib.createGunzip(), sink); console.log("pipeline", JSON.stringify(joined())); From 1f58ff62568fd3cb1656dd0db1524ba5660d7467 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 03:41:24 -0700 Subject: [PATCH 20/31] Record the order-parity baseline for the untyped stream corpus program Co-Authored-By: Claude Opus 5.5 --- .../corpus/6467-node-stream-untyped-sources/main.ts.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6467-node-stream-untyped-sources/main.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6467-node-stream-untyped-sources/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6467-node-stream-untyped-sources/main.ts.json new file mode 100644 index 000000000..dc843c047 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6467-node-stream-untyped-sources/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6467-node-stream-untyped-sources/sources.js", + "/tests/corpus/6467-node-stream-untyped-sources/main.ts" + ], + "diags": [] +} From 447e9bf000a87d14757d9b9def438eb2afe8bdf5 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 04:14:52 -0700 Subject: [PATCH 21/31] Compile Transform option callbacks and loosely typed child-process listeners @types/node types the transform/flush completion callback's data as any and a child stream 'data' chunk as any, and ChildProcess accepts (code: number, signal: string) exit and close listeners through its catch-all overload. None of these compiled: the any-typed completion callback crashed the LLVM emitter, and the child listeners were refused. The completion callback's data parameter is typed Buffer | string | undefined while the option callback lowers. Child stream 'data' listeners with an unknown or any chunk box it as a checked-dynamic value, taking strings when a setEncoding statement on the same stream precedes the listener and Buffers when the function never calls setEncoding on it; other placements are refused. Exit and close listener parameters declared number or string bind as checked-dynamic values, so the body sees the null Node passes. Co-Authored-By: Claude Opus 5.5 --- .../builtin-apis/streams-and-readline.mdx | 1 + .../limitations/child-process-workers.mdx | 4 + packages/compiler/src/backend/llvm/emitter.ts | 5 + .../src/backend/llvm/expr-callbacks.ts | 25 +++ .../compiler/src/backend/llvm/lib-stream.ts | 11 +- .../builtins/child-process-methods.ts | 156 +++++++++++++++++- .../src/frontend/lowering/lower-stream.ts | 64 +++++-- .../compiler/src/frontend/lowering/lowerer.ts | 4 +- .../compiler/src/ir/builtin-signatures.ts | 2 +- ...stream-transform-any-data-callback.ts.json | 6 + .../6471-stream-data-buffer-or-string.ts.json | 6 + .../6472-child-data-unknown-chunk.ts.json | 6 + .../node-types/stream-construction.ts.json | 6 + ...6470-stream-transform-any-data-callback.ts | 60 +++++++ .../6471-stream-data-buffer-or-string.ts | 44 +++++ tests/corpus/6472-child-data-unknown-chunk.ts | 65 ++++++++ tests/diagnostics/child-process.ts | 5 + .../node-types/stream-construction.ts | 100 +++++++++++ .../__snapshots__/child-process.ts.txt | 9 +- tests/harness/project-config.test.ts | 18 ++ 20 files changed, 568 insertions(+), 29 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6470-stream-transform-any-data-callback.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6471-stream-data-buffer-or-string.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6472-child-data-unknown-chunk.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/stream-construction.ts.json create mode 100644 tests/corpus/6470-stream-transform-any-data-callback.ts create mode 100644 tests/corpus/6471-stream-data-buffer-or-string.ts create mode 100644 tests/corpus/6472-child-data-unknown-chunk.ts create mode 100644 tests/fixtures/node-types/stream-construction.ts diff --git a/docs/content/limitations/builtin-apis/streams-and-readline.mdx b/docs/content/limitations/builtin-apis/streams-and-readline.mdx index 042fd1e50..3de3e5628 100644 --- a/docs/content/limitations/builtin-apis/streams-and-readline.mdx +++ b/docs/content/limitations/builtin-apis/streams-and-readline.mdx @@ -14,6 +14,7 @@ `stream.compose` accepts two or more streams, or one `Duplex`; functions, iterables, and web streams as stages are unsupported, and the `ERR_INVALID_ARG_VALUE` message for a stream that is no longer readable or writable names the stream's class instead of printing the stream. `pipeline` from `node:stream/promises` accepts a trailing `{ signal, end }` object, and `finished` accepts `readable`, `writable`, `error`, `cleanup`, and `signal` options, with boolean literals for the flags. Stream constructors accept a `signal` option in an options object literal, and `stream.addAbortSignal` accepts any stream. + A `transform` or `flush` option callback whose completion callback types `data` as `any`, as @types/node's `TransformCallback` does, may pass a Buffer, a string, or nothing; any other value throws a TypeError where the callback is called, instead of Node's `ERR_INVALID_ARG_TYPE` stream error. `node:readline` and `node:readline/promises` (also reached as `readline.promises`) interfaces, from `createInterface(options)` or `createInterface(input, output, completer, terminal)`, read `process.stdin` or a readable stream: `question`, `prompt`, `write` (with a key object literal of `name`, `ctrl`, `meta`, and `shift`), `pause`, `resume`, `'line'`/`'close'`/`'pause'`/`'resume'`/`'error'`/`'history'`/`'SIGINT'`/`'SIGTSTP'`/`'SIGCONT'` listeners, and `for await` compile. The `history`, `historySize`, `removeHistoryDuplicates`, `completer`, `tabSize`, and `escapeCodeTimeout` options are validated as in Node. With `terminal: true`, or with no `terminal` option and `process.stdout` as the output when it is a TTY, the interface runs Node's terminal mode: the editing keys, the kill ring, undo and redo, history recording and recall, Tab completion through the completer, Ctrl+C, Ctrl+D, and Ctrl+Z, `TERM=dumb`, and the same escape sequences written to the output. diff --git a/docs/content/limitations/child-process-workers.mdx b/docs/content/limitations/child-process-workers.mdx index 5b41c5e53..9d6602509 100644 --- a/docs/content/limitations/child-process-workers.mdx +++ b/docs/content/limitations/child-process-workers.mdx @@ -12,3 +12,7 @@ Message listeners may return `void` or `Promise`. Messages use newline-delimited JSON, so their static types must be JSON-stringifiable or `unknown` backed by a checked JSON tree. `send()` returns the channel's backpressure state and invokes its callback later with `null` or an `Error`. `disconnect()` changes `connected` synchronously, before the later disconnect events. + +A `'data'` listener on a child's `stdout` or `stderr` whose chunk is `unknown` or `any` receives strings when a `setEncoding()` statement on the same stream precedes it in its block or an enclosing block, and Buffers when the function never calls `setEncoding()` on that stream. +Any other placement of `setEncoding()` is refused at compile time, and a `setEncoding()` call that runs elsewhere afterwards makes the listener throw a TypeError instead of receiving strings. +An inline `'exit'` or `'close'` listener may declare `(code: number, signal: string)`, which TypeScript accepts through the catch-all `on` overload; it receives the `null` values Node passes, and a typed use of a `null` value throws a TypeError. diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index f7b9f2517..427c15aac 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -181,6 +181,7 @@ import { genResultThunkFor, childExitThunkFor, childExitSignalThunkFor, + childDataDynThunkFor, childDataThunkFor, execFileThunkFor, ipcMessageThunkFor, @@ -5564,6 +5565,10 @@ export class LlEmitter { return childDataThunkFor(this, param); } + childDataDynThunkFor(chunk: "bytes" | "str"): string { + return childDataDynThunkFor(this, chunk); + } + emitterFixedAdapter(cbT: IrType & { kind: "func" }): { fn: string; shim: string } { return emitterFixedAdapter(this, cbT); } diff --git a/packages/compiler/src/backend/llvm/expr-callbacks.ts b/packages/compiler/src/backend/llvm/expr-callbacks.ts index db1dca1af..6b6aae055 100644 --- a/packages/compiler/src/backend/llvm/expr-callbacks.ts +++ b/packages/compiler/src/backend/llvm/expr-callbacks.ts @@ -754,6 +754,31 @@ export function childDataThunkFor(host: LlvmEmitterContext, param: IrType): stri return sym; } +/** The child-stream 'data' thunk for an unknown/any chunk parameter: the + * runtime's Buffer (or, after setEncoding, string) chunk boxes into a + * checked-dynamic value the listener owns. */ +export function childDataDynThunkFor(host: LlvmEmitterContext, chunk: "bytes" | "str"): string { + const key = `cdd:${chunk}`; + let sym = host.resolveThunks.get(key); + if (sym) return sym; + sym = `sc_cdd_${host.resolveThunks.size}`; + host.resolveThunks.set(key, sym); + const box = chunk === "bytes" ? "scr_dyn_new_buffer" : "scr_dyn_new_str"; + host.declare(`declare ptr @${box}(ptr)`); + host.resolveThunkDefs.push( + `define internal void @${sym}(ptr %cb, ptr %chunk) ${FN_ATTRS} { ; child 'data' → dyn (${chunk})`, + `entry:`, + ` %d = call ptr @${box}(ptr %chunk)`, + ` %fnp = getelementptr inbounds %ScrClosure, ptr %cb, i64 0, i32 1`, + ` %fn = load ptr, ptr %fnp`, + ` call void %fn(ptr %cb, ptr %d)`, + ` ret void`, + `}`, + ``, + ); + return sym; +} + export function emitterFixedAdapter( host: LlvmEmitterContext, cbT: IrType & { kind: "func" }, diff --git a/packages/compiler/src/backend/llvm/lib-stream.ts b/packages/compiler/src/backend/llvm/lib-stream.ts index b62b237df..0ded59a1a 100644 --- a/packages/compiler/src/backend/llvm/lib-stream.ts +++ b/packages/compiler/src/backend/llvm/lib-stream.ts @@ -56,6 +56,8 @@ export function emitStreamLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlV host.declare(`declare void @scr_child_stream_thunk0(ptr, ptr)`); } else if (param.kind === "union") { adapter = host.childDataThunkFor(param); + } else if (param.kind === "dyn") { + adapter = host.childDataDynThunkFor("bytes"); } else { adapter = "scr_child_stream_thunk_bytes"; host.declare(`declare void @scr_child_stream_thunk_bytes(ptr, ptr)`); @@ -78,7 +80,8 @@ export function emitStreamLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlV if (e.fn === "stream.onDataStr") { host.usesTimers = true; const cbT = e.args[1]!.type; - if (cbT.kind !== "func" || cbT.params[0]?.kind !== "string") { + const strParam = cbT.kind === "func" ? cbT.params[0]?.kind : undefined; + if (strParam !== "string" && strParam !== "dyn") { throw new InternalCompilerError( "llvm emitter bug: stream.onDataStr callback not a string func", ); @@ -87,10 +90,12 @@ export function emitStreamLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlV const cb = host.emitExpr(e.args[1]!); const once = host.emitExpr(e.args[2]!); host.toHeap(cb); - host.declare(`declare void @scr_child_stream_thunk_str(ptr, ptr)`); + let adapter = "scr_child_stream_thunk_str"; + if (strParam === "dyn") adapter = host.childDataDynThunkFor("str"); + else host.declare(`declare void @scr_child_stream_thunk_str(ptr, ptr)`); host.declare(`declare void @scr_child_stream_on_data_str(ptr, ptr, ptr, i1 zeroext)`); B.line( - `call void @scr_child_stream_on_data_str(ptr ${s0.name}, ptr ${cb.name}, ptr @scr_child_stream_thunk_str, i1 ${once.name})`, + `call void @scr_child_stream_on_data_str(ptr ${s0.name}, ptr ${cb.name}, ptr @${adapter}, i1 ${once.name})`, ); return { name: "", type: e.type }; } diff --git a/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts b/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts index 7f0c2cde0..a2b0e1f57 100644 --- a/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts +++ b/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts @@ -132,8 +132,16 @@ export function lowerChildMethodCall( ) { for (const param of listenerNode.parameters.nodes) lowerer.checkedCallbackParams.add(param); } - const cb = lowerer.lowerExpr(call.arguments.nodes[1]!); + const narrowed = + event === "exit" || event === "close" + ? narrowedExitListenerParams(lowerer, listenerNode) + : []; + const cb = + narrowed.length > 0 + ? lowerWithCheckedParams(lowerer, listenerNode, narrowed) + : lowerer.lowerExpr(call.arguments.nodes[1]!); if ( + narrowed.length > 0 || (isJsSourceFile(listenerNode.getSourceFile()) && cb.type.kind === "dyn") || (cb.type.kind === "func" && (cb.type.rest || @@ -302,10 +310,12 @@ export function lowerChildMethodCall( /** Method calls on piped child-output stream receivers (child.stdout / * child.stderr — the childStream kind): on/once("data" | "end"). - * 'data' listeners take zero parameters, `(chunk: Buffer)`, or a - * `Buffer | string`-union chunk (the ngrok appendOutput shape — the - * runtime only ever fires Buffers; the compiler-emitted adapter wraps - * the chunk at the union's Buffer arm); 'end' listeners take none. + * 'data' listeners take zero parameters, `(chunk: Buffer)`, `(chunk: + * string)`, an unknown/any chunk (boxed as a checked-dynamic value; see + * childStreamEncodedAt), or a `Buffer | string`-union chunk (the ngrok + * appendOutput shape — the runtime only ever fires Buffers; the + * compiler-emitted adapter wraps the chunk at the union's Buffer arm); + * 'end' listeners take none. * Statement position only (Node returns the stream for chaining; here * the result is void). Chained receivers (`child.stdout?.on(...)`) * ride the optional-chain re-dispatch (chainBlocked). Everything else @@ -379,18 +389,26 @@ export function lowerChildStreamMethodCall( if ( param !== undefined && param.kind !== "string" && + param.kind !== "dyn" && !(param.kind === "bytes" && param.elem === "u8") && !unionOk(param) ) { lowerer.unsupported( "SC1090", call.arguments.nodes[1]!, - `data listeners whose parameter is not 'Buffer', 'string', or a Buffer-armed union (got '${lowerer.fmt(param)}')`, + `data listeners whose parameter is not 'Buffer', 'string', 'unknown', or a Buffer-armed union (got '${lowerer.fmt(param)}')`, ); } + // An unknown/any chunk is a Buffer, or a string once setEncoding ran. + // The registration takes whichever the enclosing code establishes; a + // later setEncoding that contradicts it throws the runtime's + // listener-type TypeError instead of delivering the other kind. + const strings = + param?.kind === "string" || + (param?.kind === "dyn" && childStreamEncodedAt(lowerer, access.expression, call)); return { kind: "libCall", - fn: param?.kind === "string" ? "stream.onDataStr" : "stream.onData", + fn: strings ? "stream.onDataStr" : "stream.onData", args: [receiver, cb, once], type: VOID, loc, @@ -521,3 +539,127 @@ export function lowerChildWriterMethodCall( lowerer.checker.getSymbolAtLocation(access.name), ); } + +/** A receiver spelled as a plain access path (`child.stdout`, `out`): its + * root symbol and member names, or null for other forms. */ +function accessPathOf( + lowerer: Lowerer, + expr: ts.Expression, +): { root: ts.Symbol; path: string } | null { + if (ts.isParenthesizedExpression(expr) || ts.isNonNullExpression(expr)) { + return accessPathOf(lowerer, expr.expression); + } + if (ts.isIdentifier(expr)) { + const root = lowerer.checker.getSymbolAtLocation(expr); + return root ? { root, path: "" } : null; + } + if (ts.isPropertyAccessExpression(expr) && !expr.questionDotToken) { + const base = accessPathOf(lowerer, expr.expression); + return base === null ? null : { root: base.root, path: `${base.path}.${expr.name.text}` }; + } + return null; +} + +/** Whether an unknown-typed 'data' listener registered by `call` receives + * strings: a `setEncoding` statement on the same receiver path precedes + * the registration in its own block or an enclosing block of the same + * function. With no setEncoding on that path in the function, chunks are + * Buffers. Any other placement leaves the encoding state unclear, which + * is reported. */ +function childStreamEncodedAt( + lowerer: Lowerer, + receiver: ts.Expression, + call: ts.CallExpression, +): boolean { + const key = accessPathOf(lowerer, receiver); + const statement: ts.Node = call.parent!; + let scope: ts.Node | undefined = statement; + while (scope && !ts.isFunctionLike(scope) && !ts.isSourceFile(scope)) scope = scope.parent; + const unclear = (): never => + lowerer.unsupported( + "SC1090", + call.arguments.nodes[1]!, + "untyped child-stream 'data' listeners whose setEncoding state is unclear at their registration sites (declare the chunk as 'Buffer' or 'string')", + ); + if (key === null || scope === undefined) return unclear(); + const preceding = new Set(); + for (let node: ts.Node = statement; node !== scope;) { + const parent: ts.Node | undefined = node.parent; + if (parent === undefined) break; + if (ts.isBlock(parent) || ts.isSourceFile(parent)) { + for (const sibling of parent.statements.nodes) { + if (sibling === node) break; + preceding.add(sibling); + } + } + node = parent; + } + const samePath = ( + a: { root: ts.Symbol; path: string } | null, + b: { root: ts.Symbol; path: string }, + ): boolean => a !== null && a.root === b.root && a.path === b.path; + let before = false; + let elsewhere = false; + const visit = (node: ts.Node): void => { + if (node !== scope && ts.isFunctionLike(node)) return; + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === "setEncoding" && + samePath(accessPathOf(lowerer, node.expression.expression), key) + ) { + if (ts.isExpressionStatement(node.parent) && preceding.has(node.parent)) before = true; + else elsewhere = true; + } + ts.forEachChild(node, visit); + }; + visit(scope); + if (elsewhere) return unclear(); + return before; +} + +/** An inline 'exit'/'close' listener may declare `(code: number, signal: + * string)`: TypeScript accepts it through ChildProcess's catch-all `on` + * overload, while Node passes `number | null` and `Signals | null`. Those + * parameters bind as checked-dynamic values (the socket 'data' listener + * precedent), so the body observes the null Node passes; a typed use of a + * null value throws the checked conversion's TypeError. Returns the + * narrowed parameter symbols, empty when the declared types already + * admit null. */ +function narrowedExitListenerParams(lowerer: Lowerer, listener: ts.Expression): ts.Symbol[] { + if (!ts.isArrowFunction(listener) && !ts.isFunctionExpression(listener)) return []; + if (isJsSourceFile(listener.getSourceFile())) return []; + const symbols: ts.Symbol[] = []; + const wanted: readonly string[] = ["f64", "string"]; + listener.parameters.nodes.slice(0, 2).forEach((param, i) => { + if (!ts.isIdentifier(param.name) || param.dotDotDotToken || param.initializer) return; + if (param.questionToken) return; + const symbol = lowerer.checker.getSymbolAtLocation(param.name); + if (!symbol || lowerer.runtimeOptionalBindingTypes.has(symbol)) return; + if (lowerer.paramShape(param).type.kind !== wanted[i]) return; + symbols.push(symbol); + }); + return symbols; +} + +/** Lowers an inline listener with the given parameters bound as + * checked-dynamic values typed `unknown` for the body. */ +function lowerWithCheckedParams( + lowerer: Lowerer, + listener: ts.Expression, + symbols: readonly ts.Symbol[], +): IrExpr { + const previousImplicit = lowerer.implicitParamTypes; + const implicit = new Map(previousImplicit ?? []); + for (const symbol of symbols) { + implicit.set(symbol, lowerer.checker.getUnknownType()); + lowerer.runtimeOptionalBindingTypes.set(symbol, DYN); + } + lowerer.implicitParamTypes = implicit; + try { + return lowerer.lowerExpr(listener); + } finally { + for (const symbol of symbols) lowerer.runtimeOptionalBindingTypes.delete(symbol); + lowerer.implicitParamTypes = previousImplicit; + } +} diff --git a/packages/compiler/src/frontend/lowering/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts index 51c86fe39..fc89858b5 100644 --- a/packages/compiler/src/frontend/lowering/lower-stream.ts +++ b/packages/compiler/src/frontend/lowering/lower-stream.ts @@ -587,6 +587,33 @@ function dynOptionsValue(lowerer: Lowerer, arg: ts.Expression): IrExpr | null { return v.type.kind === "dyn" ? v : null; } +/** @types/node declares the transform/flush completion callback as + * `(error?: Error | null, data?: any) => void`. Outside objectMode Node + * accepts only a Buffer, a string or nothing as `data`, so the callback + * parameter is typed with that union while the option callback lowers: a + * checked-dynamic value passed as `data` converts at the call. Returns the + * pinned parameter symbol (the caller unpins it), or null when the + * declared type needs no pin. */ +function pinAnyDataCompletion( + lowerer: Lowerer, + which: string, + fnNode: ts.MethodDeclaration | ts.FunctionExpression | ts.ArrowFunction, +): ts.Symbol | null { + const index = which === "transform" ? 2 : which === "flush" ? 0 : -1; + const param = index < 0 ? undefined : fnNode.parameters.nodes[index]; + if (param === undefined || !ts.isIdentifier(param.name) || param.dotDotDotToken) return null; + const symbol = lowerer.checker.getSymbolAtLocation(param.name); + if (!symbol || lowerer.stageSourceTypes.has(symbol)) return null; + const declared = lowerer.paramShape(param).type; + if (declared.kind !== "func" || declared.rest || declared.params[1]?.kind !== "dyn") return null; + const data = unionOf(lowerer, [BYTES, STRING, { kind: "undefinedT" }]); + lowerer.stageSourceTypes.set( + symbol, + funcOf([declared.params[0]!, data, ...declared.params.slice(2)], declared.ret), + ); + return symbol; +} + /** Parses the options object literal of a stream constructor. `which` * names the callbacks this class accepts, in canonical flag order. */ function parseStreamOptions( @@ -672,22 +699,27 @@ function parseStreamOptions( continue; } const full = tuples[name]!.map((t) => (t === "fn" ? null : t)); - // The completion-callback tail: typed by the checker contextually; - // accept the user's declared func type as the tuple's entry so the - // prefix check compares like with like. - const { funcType } = lowerer.lambdaSignature(fnNode); - const tuple: IrType[] = full.map((t, i) => { - if (t !== null) return t; - const declared = funcType.params[i]; - if (declared !== undefined && declared.kind !== "func" && declared.kind !== "dyn") { - lowerer.noLowering( - `a '${name}' callback whose parameter ${i} is '${lowerer.fmt(declared)}' where Node passes the completion callback`, - fnNode!, - ); - } - return declared ?? funcOf([], VOID); - }); - present.set(name, lowerStreamCallback(lowerer, name, fnNode, thisType, tuple)); + const pinned = pinAnyDataCompletion(lowerer, name, fnNode); + try { + // The completion-callback tail: typed by the checker contextually; + // accept the user's declared func type as the tuple's entry so the + // prefix check compares like with like. + const { funcType } = lowerer.lambdaSignature(fnNode); + const tuple: IrType[] = full.map((t, i) => { + if (t !== null) return t; + const declared = funcType.params[i]; + if (declared !== undefined && declared.kind !== "func" && declared.kind !== "dyn") { + lowerer.noLowering( + `a '${name}' callback whose parameter ${i} is '${lowerer.fmt(declared)}' where Node passes the completion callback`, + fnNode!, + ); + } + return declared ?? funcOf([], VOID); + }); + present.set(name, lowerStreamCallback(lowerer, name, fnNode, thisType, tuple)); + } finally { + if (pinned) lowerer.stageSourceTypes.delete(pinned); + } continue; } // `{ signal }` shorthand: the binding is the value. diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index c9a0c6435..393db65d9 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2064,7 +2064,9 @@ export class Lowerer { /** A pipeline() function stage's `source` parameter: the IR type of what * the pipeline passes it (the previous stage as an async iterable), set * while the stage lowers. Parameter typing and `for await` heads read - * it before the checker's `AsyncIterable`. */ + * it before the checker's `AsyncIterable`. A stream option callback's + * completion parameter declared with an any-typed `data` is pinned here + * too while the callback lowers (pinAnyDataCompletion). */ readonly stageSourceTypes = new Map(); /** Set when a match result lowers to execGroups/matchGroups, the inputs * of regex span folding. */ diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 597076cb1..57c5af04c 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -2540,7 +2540,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * throws when the program lacks the stream unit's boxing helpers. */ "child.stdioStream": { argTypes: [CHILD_T, F64], result: VOID }, // Listener registrations: callback func shapes are program-dependent - // (zero-param / Buffer / Buffer-armed union) — the slots pin arity, + // (zero-param / Buffer / string / unknown / Buffer-armed union) — the slots pin arity, // the stream receiver, and the once-flag. "stream.onData": { argTypes: [CHILDSTREAM_T, null, BOOL], result: VOID }, "stream.onDataStr": { argTypes: [CHILDSTREAM_T, null, BOOL], result: VOID }, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6470-stream-transform-any-data-callback.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6470-stream-transform-any-data-callback.ts.json new file mode 100644 index 000000000..3d4a39486 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6470-stream-transform-any-data-callback.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6470-stream-transform-any-data-callback.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6471-stream-data-buffer-or-string.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6471-stream-data-buffer-or-string.ts.json new file mode 100644 index 000000000..e8ff1e084 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6471-stream-data-buffer-or-string.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6471-stream-data-buffer-or-string.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6472-child-data-unknown-chunk.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6472-child-data-unknown-chunk.ts.json new file mode 100644 index 000000000..3c7183bb6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6472-child-data-unknown-chunk.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6472-child-data-unknown-chunk.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/stream-construction.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/stream-construction.ts.json new file mode 100644 index 000000000..35dc0be0d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/stream-construction.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/stream-construction.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6470-stream-transform-any-data-callback.ts b/tests/corpus/6470-stream-transform-any-data-callback.ts new file mode 100644 index 000000000..cc4935a7e --- /dev/null +++ b/tests/corpus/6470-stream-transform-any-data-callback.ts @@ -0,0 +1,60 @@ +// Transform option callbacks whose completion callback declares an +// any-typed `data` (the @types/node TransformCallback spelling): the data +// passed back may be a Buffer, a string, or omitted. +import { PassThrough, Transform } from "node:stream"; +import { createGunzip, gzipSync } from "node:zlib"; + +type Done = (error?: Error | null, data?: any) => void; + +interface Progress { + bytes: number; + chunks: number; +} + +function counting(progress: Progress): Transform { + return new Transform({ + transform(chunk: Buffer, _encoding: string, callback: Done) { + progress.bytes += chunk.length; + progress.chunks++; + callback(null, chunk); + }, + }); +} + +const upper = new Transform({ + transform(chunk: Buffer, _encoding: string, callback: Done) { + const text = chunk.toString(); + if (text === "skip") { + callback(); + return; + } + callback(null, text.toUpperCase()); + }, + flush(callback: Done) { + callback(null, Buffer.from("!")); + }, +}); +upper.on("data", (d: Buffer) => console.log("upper", JSON.stringify(d.toString()))); +upper.on("end", () => console.log("upper end")); +upper.write("hello"); +upper.write("skip"); +upper.end("world"); + +const failing = new Transform({ + transform(chunk: Buffer, _encoding: string, callback: Done) { + callback(new Error(`refused ${chunk.length} bytes`)); + }, +}); +failing.on("error", (e: Error) => console.log("error", e.message)); +failing.write("abc"); + +const progress: Progress = { bytes: 0, chunks: 0 }; +const counter = counting(progress); +const out = new PassThrough(); +counter.pipe(createGunzip()).pipe(out); +const parts: string[] = []; +out.on("data", (d: Buffer) => parts.push(d.toString())); +out.on("end", () => { + console.log("gunzip", JSON.stringify(parts.join("")), progress.bytes > 0, progress.chunks); +}); +counter.end(gzipSync(Buffer.from("compressed payload"))); diff --git a/tests/corpus/6471-stream-data-buffer-or-string.ts b/tests/corpus/6471-stream-data-buffer-or-string.ts new file mode 100644 index 000000000..69289ced5 --- /dev/null +++ b/tests/corpus/6471-stream-data-buffer-or-string.ts @@ -0,0 +1,44 @@ +// 'data' listeners on runtime streams may declare the chunk as +// `Buffer | string` (the @types/node ReadStream listener type): the +// listener receives a Buffer, or a string once an encoding applies. +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { PassThrough, Readable } from "node:stream"; + +function describe(label: string, chunk: Buffer | string): void { + if (typeof chunk === "string") console.log(label, "string", JSON.stringify(chunk)); + else console.log(label, "buffer", chunk.length, JSON.stringify(chunk.toString())); +} + +const raw = new PassThrough(); +raw.on("data", (chunk: Buffer | string) => describe("raw", chunk)); +raw.write("abc"); +raw.end(Buffer.from("de")); + +const text = new PassThrough(); +text.setEncoding("utf8"); +text.on("data", (chunk: Buffer | string) => describe("text", chunk)); +text.end("héllo"); + +const r = new Readable({ read() {} }); +r.on("data", (chunk: Buffer | string) => describe("readable", chunk)); +r.on("end", () => console.log("readable end")); +r.push("one"); +r.push(null); + +const file = path.join(os.tmpdir(), `scriptc-6471-${process.pid}.txt`); +fs.writeFileSync(file, "file contents\n"); +const stream = fs.createReadStream(file); +const release = (): void => console.log("released"); +stream.on("data", (chunk: Buffer | string) => describe("file", chunk)); +stream.on("close", release); +stream.on("error", release); +stream.on("end", () => { + const encoded = fs.createReadStream(file, { encoding: "utf8" }); + encoded.on("data", (chunk: Buffer | string) => describe("file utf8", chunk)); + encoded.on("close", () => { + fs.unlinkSync(file); + console.log("done"); + }); +}); diff --git a/tests/corpus/6472-child-data-unknown-chunk.ts b/tests/corpus/6472-child-data-unknown-chunk.ts new file mode 100644 index 000000000..4a0e97d34 --- /dev/null +++ b/tests/corpus/6472-child-data-unknown-chunk.ts @@ -0,0 +1,65 @@ +// A child stream 'data' listener whose chunk is unknown or any receives a +// Buffer, or a string when setEncoding ran before the listener registered. +import { spawn } from "node:child_process"; + +function report(code: number | null, kinds: string[], stdout: string, stderr: string): void { + console.log("code", code, "kinds", [...new Set(kinds)].join(",")); + console.log("stdout", JSON.stringify(stdout)); + console.log("stderr", JSON.stringify(stderr)); +} + +function runEncoded(script: string): Promise { + return new Promise((resolve) => { + const child = spawn("/bin/sh", ["-c", script], { stdio: "pipe" }); + let stdout = ""; + let stderr = ""; + const kinds: string[] = []; + if (child.stdout) { + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (c: any) => { + kinds.push(typeof c); + stdout += c; + }); + } + const err = child.stderr; + if (err !== null) { + err.setEncoding("utf8"); + err.on("data", (c: unknown) => { + kinds.push(typeof c); + stderr += String(c); + }); + } + child.on("close", (code) => { + report(code, kinds, stdout, stderr); + resolve(); + }); + }); +} + +function runRaw(script: string): Promise { + return new Promise((resolve) => { + const child = spawn("/bin/sh", ["-c", script], { stdio: "pipe" }); + let stdout = ""; + let stderr = ""; + const kinds: string[] = []; + child.stdout?.on("data", (c: unknown) => { + kinds.push(Buffer.isBuffer(c) ? "buffer" : typeof c); + stdout += String(c); + }); + if (child.stderr) { + child.stderr.on("data", (c: any) => { + const chunk = String(c); + kinds.push(Buffer.isBuffer(c) ? "buffer" : typeof c); + stderr += chunk; + }); + } + child.on("close", (code) => { + report(code, kinds, stdout, stderr); + resolve(); + }); + }); +} + +runEncoded("printf 'h\\303\\251llo'; echo warn >&2") + .then(() => runRaw("printf raw; echo oops >&2; exit 2")) + .then(() => console.log("done")); diff --git a/tests/diagnostics/child-process.ts b/tests/diagnostics/child-process.ts index f96bce418..6c5683efc 100644 --- a/tests/diagnostics/child-process.ts +++ b/tests/diagnostics/child-process.ts @@ -42,3 +42,8 @@ const staticWorker = new URL("./child-process.ts", import.meta.url); fork(staticWorker, [], { serialization: "advanced" }); fork(staticWorker, [], { stdio: ["ignore", "ignore", "ignore", "ignore"] }); // Keep each fence on its own statement so diagnostics remain site-specific. +// An unknown 'data' chunk takes the setEncoding state established before +// the listener registers; a setEncoding placed elsewhere leaves it unclear. +const pipedOut = piped.stdout!; +pipedOut.on("data", (chunk: unknown) => console.log(String(chunk))); +pipedOut.setEncoding("utf8"); diff --git a/tests/fixtures/node-types/stream-construction.ts b/tests/fixtures/node-types/stream-construction.ts new file mode 100644 index 000000000..37a1b852e --- /dev/null +++ b/tests/fixtures/node-types/stream-construction.ts @@ -0,0 +1,100 @@ +// Streams constructed under @types/node: option-bag Transform and +// PassThrough streams with @types/node's TransformCallback, a gunzip stage, +// fs.ReadStream listeners, and child-process listeners declared with +// unknown chunks and non-null exit types. +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { PassThrough, Transform } from "node:stream"; +import { createGunzip, gzipSync } from "node:zlib"; + +interface Progress { + bytes: number; + chunks: number; +} + +function createCounter(progress: Progress) { + return new Transform({ + transform(chunk, _encoding, callback) { + const length = typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length; + progress.bytes += length; + progress.chunks++; + callback(null, chunk); + }, + flush(callback) { + callback(); + }, + }); +} + +function gunzipThrough(payload: Buffer): Promise { + return new Promise((resolve) => { + const progress: Progress = { bytes: 0, chunks: 0 }; + const counter = createCounter(progress); + const gunzip = createGunzip(); + const out = new PassThrough(); + const parts: string[] = []; + counter.pipe(gunzip).pipe(out); + out.on("data", (d: Buffer) => parts.push(d.toString())); + out.on("end", () => resolve(`${parts.join("")} ${progress.bytes > 0} ${progress.chunks}`)); + counter.end(payload); + }); +} + +function readFileStream(file: string): Promise { + return new Promise((resolve) => { + const stream = fs.createReadStream(file); + let text = ""; + const release = () => console.log("released"); + stream.on("close", release); + stream.on("error", release); + stream.on("data", (chunk) => { + text += chunk.toString(); + }); + stream.on("end", () => resolve(text)); + }); +} + +function run(script: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn("/bin/sh", ["-c", script], { stdio: "pipe" }); + let stdout = ""; + let stderr = ""; + if (child.stdout) { + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (c) => { + stdout += c; + }); + } + if (child.stderr) { + child.stderr.on("data", (c) => { + const chunk = String(c); + stderr += chunk; + }); + } + child.on("error", reject); + child.on("close", (code: number, signal: string) => { + if (code === 0) return resolve(stdout); + reject(new Error(`Exited with ${code || signal}: ${stderr.trim()}`)); + }); + }); +} + +async function main(): Promise { + console.log("gunzip", await gunzipThrough(gzipSync(Buffer.from("payload")))); + const file = path.join(os.tmpdir(), `scriptc-stream-construction-${process.pid}.txt`); + fs.writeFileSync(file, "file text\n"); + console.log("file", JSON.stringify(await readFileStream(file))); + fs.unlinkSync(file); + console.log("run", JSON.stringify(await run("echo hi; echo warn >&2"))); + for (const script of ["echo bad >&2; exit 3", "kill -TERM $$"]) { + try { + await run(script); + } catch (e) { + console.log("error", (e as Error).message); + } + } +} + +void main(); diff --git a/tests/harness/__snapshots__/child-process.ts.txt b/tests/harness/__snapshots__/child-process.ts.txt index 2d83132f5..6fee8a2c2 100644 --- a/tests/harness/__snapshots__/child-process.ts.txt +++ b/tests/harness/__snapshots__/child-process.ts.txt @@ -91,4 +91,11 @@ child-process.ts:43:64 - error SC2020: 'fork without a fourth-slot IPC channel' | ^~~~~~~~ 44 | // Keep each fence on its own statement so diagnostics remain site-specific. - hint: the fourth stdio entry must be "ipc" \ No newline at end of file + hint: the fourth stdio entry must be "ipc" + +child-process.ts:48:21 - error SC1090: untyped child-stream 'data' listeners whose setEncoding state is unclear at their registration sites (declare the chunk as 'Buffer' or 'string') are not supported yet + + 47 | const pipedOut = piped.stdout!; + 48 | pipedOut.on("data", (chunk: unknown) => console.log(String(chunk))); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 49 | pipedOut.setEncoding("utf8"); \ No newline at end of file diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index 3635c88ed..31e84fdbb 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -479,6 +479,24 @@ test("node-types: NodeJS stream interfaces and stream.Readable/Writable take eve expect(comparableStderr(native.stderr)).toBe(node.stderr); }); +test("node-types: streams construct and child listeners take @types/node's parameter types", async () => { + const outDir = outDirFor("stream-construction"); + const entry = join(nodeTypesDir, "stream-construction.ts"); + const result = await compile(entry, { + outPath: join(outDir, "stream-construction"), + outDir, + sanitize, + }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const [native, node] = await Promise.all([ + execFileAsync(result.binaryPath), + execFileAsync(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", entry]), + ]); + expect(native.stdout).toBe(node.stdout); + expect(comparableStderr(native.stderr)).toBe(node.stderr); +}); + test("node-types: promisified setTimeout and setImmediate lower as timers/promises", async () => { const outDir = outDirFor("promisify-timers"); const entry = join(nodeTypesDir, "promisify-timers.ts"); From 8e87f6f9ef5a5b90444f7cd624902bf87f163238 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 08:49:48 -0500 Subject: [PATCH 22/31] Resolve const aliases when deciding a child stream's setEncoding state An untyped child-stream 'data' listener takes strings when a setEncoding call on the same stream precedes it. A const alias of an access path (`const out = child.stdout`) now resolves to that path, so a setEncoding through one alias and a listener through another, or through the stream itself, agree. A setEncoding on another receiver that can hold the same stream (a let copy, a parameter, a call result) leaves the state unclear and reports the existing SC1090. Receivers that name different members of one root, or two const roots each initialized by a call or new, are treated as different streams. --- .../builtins/child-process-methods.ts | 70 +++++++++++++++---- tests/corpus/6472-child-data-unknown-chunk.ts | 22 ++++++ tests/diagnostics/child-process.ts | 5 ++ .../__snapshots__/child-process.ts.txt | 9 ++- 4 files changed, 93 insertions(+), 13 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts b/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts index a2b0e1f57..aae51a8be 100644 --- a/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts +++ b/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts @@ -541,25 +541,64 @@ export function lowerChildWriterMethodCall( } /** A receiver spelled as a plain access path (`child.stdout`, `out`): its - * root symbol and member names, or null for other forms. */ + * root symbol and member names, or null for other forms. A `const` alias + * of an access path (`const out = child.stdout`) resolves to that path, + * so every spelling of one stream gets the same key. */ function accessPathOf( lowerer: Lowerer, expr: ts.Expression, + depth = 0, ): { root: ts.Symbol; path: string } | null { + if (depth > 16) return null; if (ts.isParenthesizedExpression(expr) || ts.isNonNullExpression(expr)) { - return accessPathOf(lowerer, expr.expression); + return accessPathOf(lowerer, expr.expression, depth + 1); } if (ts.isIdentifier(expr)) { const root = lowerer.checker.getSymbolAtLocation(expr); - return root ? { root, path: "" } : null; + if (!root) return null; + const init = constInitializerOf(root); + const aliased = init === null ? null : accessPathOf(lowerer, init, depth + 1); + return aliased ?? { root, path: "" }; } if (ts.isPropertyAccessExpression(expr) && !expr.questionDotToken) { - const base = accessPathOf(lowerer, expr.expression); + const base = accessPathOf(lowerer, expr.expression, depth + 1); return base === null ? null : { root: base.root, path: `${base.path}.${expr.name.text}` }; } return null; } +/** The initializer of a symbol declared once as a `const` variable, else null. */ +function constInitializerOf(symbol: ts.Symbol): ts.Expression | null { + const decls = symbol.declarations; + if (decls.length !== 1) return null; + const decl = decls[0]!; + if ( + !ts.isVariableDeclaration(decl) || + !ts.isIdentifier(decl.name) || + decl.initializer === undefined || + (ts.getCombinedNodeFlags(decl) & ts.NodeFlags.Const) === 0 + ) + return null; + return decl.initializer; +} + +/** Whether two resolved access paths name different objects: the same root + * with different members, or two `const` roots each initialized by a call + * or `new` (such as two `spawn()` results). */ +function distinctPaths( + a: { root: ts.Symbol; path: string }, + b: { root: ts.Symbol; path: string }, +): boolean { + if (a.root === b.root) return a.path !== b.path; + const fresh = (root: ts.Symbol): boolean => { + let init = constInitializerOf(root); + while (init !== null && (ts.isParenthesizedExpression(init) || ts.isAwaitExpression(init))) + init = init.expression; + return init !== null && (ts.isCallExpression(init) || ts.isNewExpression(init)); + }; + return fresh(a.root) && fresh(b.root); +} + /** Whether an unknown-typed 'data' listener registered by `call` receives * strings: a `setEncoding` statement on the same receiver path precedes * the registration in its own block or an enclosing block of the same @@ -594,10 +633,7 @@ function childStreamEncodedAt( } node = parent; } - const samePath = ( - a: { root: ts.Symbol; path: string } | null, - b: { root: ts.Symbol; path: string }, - ): boolean => a !== null && a.root === b.root && a.path === b.path; + const receiverType = lowerer.checker.getNonNullableType(lowerer.typeOf(receiver)); let before = false; let elsewhere = false; const visit = (node: ts.Node): void => { @@ -605,11 +641,21 @@ function childStreamEncodedAt( if ( ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && - node.expression.name.text === "setEncoding" && - samePath(accessPathOf(lowerer, node.expression.expression), key) + node.expression.name.text === "setEncoding" ) { - if (ts.isExpressionStatement(node.parent) && preceding.has(node.parent)) before = true; - else elsewhere = true; + const target = node.expression.expression; + const path = accessPathOf(lowerer, target); + if (path !== null && path.root === key.root && path.path === key.path) { + if (ts.isExpressionStatement(node.parent) && preceding.has(node.parent)) before = true; + else elsewhere = true; + } else if ( + (path === null || !distinctPaths(path, key)) && + lowerer.checker.isTypeAssignableTo(receiverType, lowerer.typeOf(target)) + ) { + // Another spelling that can hold this stream (a parameter, a `let` + // copy, a call result): its setEncoding may be this stream's. + elsewhere = true; + } } ts.forEachChild(node, visit); }; diff --git a/tests/corpus/6472-child-data-unknown-chunk.ts b/tests/corpus/6472-child-data-unknown-chunk.ts index 4a0e97d34..8308aa8cf 100644 --- a/tests/corpus/6472-child-data-unknown-chunk.ts +++ b/tests/corpus/6472-child-data-unknown-chunk.ts @@ -60,6 +60,28 @@ function runRaw(script: string): Promise { }); } +// setEncoding through one const alias of the stream and the listener +// through another: both name child.stdout, so chunks are strings. +function runAliased(script: string): Promise { + return new Promise((resolve) => { + const child = spawn("/bin/sh", ["-c", script], { stdio: "pipe" }); + const encoded = child.stdout!; + const listened = child.stdout!; + let stdout = ""; + const kinds: string[] = []; + encoded.setEncoding("utf8"); + listened.on("data", (c: unknown) => { + kinds.push(typeof c); + stdout += String(c); + }); + child.on("close", (code) => { + report(code, kinds, stdout, ""); + resolve(); + }); + }); +} + runEncoded("printf 'h\\303\\251llo'; echo warn >&2") .then(() => runRaw("printf raw; echo oops >&2; exit 2")) + .then(() => runAliased("printf alias")) .then(() => console.log("done")); diff --git a/tests/diagnostics/child-process.ts b/tests/diagnostics/child-process.ts index 6c5683efc..02b4349e8 100644 --- a/tests/diagnostics/child-process.ts +++ b/tests/diagnostics/child-process.ts @@ -47,3 +47,8 @@ fork(staticWorker, [], { stdio: ["ignore", "ignore", "ignore", "ignore"] }); const pipedOut = piped.stdout!; pipedOut.on("data", (chunk: unknown) => console.log(String(chunk))); pipedOut.setEncoding("utf8"); +// A setEncoding through a copy that can hold the same stream (a `let` +// binding here) leaves the state unclear as well. +let pipedErr = piped.stderr!; +pipedErr.setEncoding("utf8"); +piped.stderr!.on("data", (chunk: unknown) => console.log(String(chunk))); diff --git a/tests/harness/__snapshots__/child-process.ts.txt b/tests/harness/__snapshots__/child-process.ts.txt index 6fee8a2c2..f5f9f8f86 100644 --- a/tests/harness/__snapshots__/child-process.ts.txt +++ b/tests/harness/__snapshots__/child-process.ts.txt @@ -98,4 +98,11 @@ child-process.ts:48:21 - error SC1090: untyped child-stream 'data' listeners who 47 | const pipedOut = piped.stdout!; 48 | pipedOut.on("data", (chunk: unknown) => console.log(String(chunk))); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 49 | pipedOut.setEncoding("utf8"); \ No newline at end of file + 49 | pipedOut.setEncoding("utf8"); + +child-process.ts:54:26 - error SC1090: untyped child-stream 'data' listeners whose setEncoding state is unclear at their registration sites (declare the chunk as 'Buffer' or 'string') are not supported yet + + 53 | pipedErr.setEncoding("utf8"); + 54 | piped.stderr!.on("data", (chunk: unknown) => console.log(String(chunk))); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 55 | \ No newline at end of file From 02fc8ab13c512df7224be4a1ce6f73f9f604dab2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 08:54:18 -0500 Subject: [PATCH 23/31] Keep one process-stream Writable per descriptor and never end it from a pipe Under the general stream representation, every conversion of process.stdout or process.stderr into a Writable slot made a new stream, so two NodeJS.WritableStream slots holding process.stdout compared unequal and kept separate listeners and writable state. A pipe into such a slot also ended the converted Writable at the source's end, so later writes through the slot failed; Node never ends process.stdout or process.stderr from a pipe. scr_stream_stdio_writable now keeps one Writable per descriptor and thread, released at exit. Under the general representation, a pipe into a Writable slot lowers to readable.pipeGeneral (scr_stream_pipe_general, last in the stream unit), which keeps end off when the destination is one of those Writables; programs without the general representation lower pipes as before. The fallback stream.Writable's on() and once() overloads for drain, finish, close and error return the stream again, so listener chains such as out.on("finish", listener).end(data) type-check. Adds corpus 6468. --- .../ambient/scriptc-node-fallback.d.ts | 8 +-- .../compiler/src/backend/llvm/lib-shared.ts | 2 + .../src/frontend/lowering/lower-stream.ts | 14 +++++- packages/compiler/src/ir/builtin-effects.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 5 +- packages/compiler/src/ir/validate.ts | 4 +- .../6468-node-stdio-writable-slot.ts.json | 6 +++ packages/runtime/src/scr_stream.c | 36 +++++++++++--- packages/runtime/src/scr_stream.h | 3 ++ tests/corpus/6468-node-stdio-writable-slot.ts | 49 +++++++++++++++++++ .../6468-node-stdio-writable-slot.ts.json | 1 + 11 files changed, 114 insertions(+), 15 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6468-node-stdio-writable-slot.ts.json create mode 100644 tests/corpus/6468-node-stdio-writable-slot.ts create mode 100644 tests/harness/corpus-costs/6468-node-stdio-writable-slot.ts.json diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 3885e0fef..4e39d9e42 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -6736,12 +6736,12 @@ declare module "stream" { readonly closed: boolean; readonly destroyed: boolean; readonly errored: Error | null; - on(event: "drain" | "finish" | "close", listener: () => void): void; - on(event: "error", listener: (err: Error) => void): void; + on(event: "drain" | "finish" | "close", listener: () => void): this; + on(event: "error", listener: (err: Error) => void): this; on(event: "pipe" | "unpipe", listener: (src: Readable) => void): this; on(event: string, listener: (...args: any[]) => void): this; - once(event: "drain" | "finish" | "close", listener: () => void): void; - once(event: "error", listener: (err: Error) => void): void; + once(event: "drain" | "finish" | "close", listener: () => void): this; + once(event: "error", listener: (err: Error) => void): this; once(event: "pipe" | "unpipe", listener: (src: Readable) => void): this; once(event: string, listener: (...args: any[]) => void): this; addListener(event: "drain" | "finish" | "close", listener: () => void): this; diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 4602a2bdd..d38379bf0 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1106,6 +1106,7 @@ export const LIB_FN_SYMS: Record = { "readable.opFind": "scr_stream_op_find", "readable.opReduce": "scr_stream_op_reduce", "readable.pipe": "scr_stream_pipe", + "readable.pipeGeneral": "scr_stream_pipe_general", "writable.cork": "scr_stream_cork", "writable.uncork": "scr_stream_uncork", "stream.destroyErr": "scr_stream_destroy", @@ -1588,6 +1589,7 @@ export const USES_TIMERS_LIB_FNS = new Set([ "readable.opReduce", "readable.resume", "readable.pipe", + "readable.pipeGeneral", "readable.unpipe", "writable.write", "writable.writeStr", diff --git a/packages/compiler/src/frontend/lowering/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts index 51c86fe39..88ba1f5b8 100644 --- a/packages/compiler/src/frontend/lowering/lower-stream.ts +++ b/packages/compiler/src/frontend/lowering/lower-stream.ts @@ -3371,6 +3371,16 @@ function pipeIntoStdio( }; } +/** The pipe() libCall for a destination of type `dst`. Under the general + * stream representation a Writable slot can hold the converted + * process.stdout / process.stderr, which a pipe never ends (as in Node), so + * the pipe takes readable.pipeGeneral, which checks for them. */ +function generalPipe(lowerer: Lowerer, dst: IrType): "readable.pipe" | "readable.pipeGeneral" { + return lowerer.generalNodeStreams && dst.kind === "object" && dst.className === "%Writable" + ? "readable.pipeGeneral" + : "readable.pipe"; +} + /** `readable.pipe(untyped)`: a checked-dynamic destination unwraps a boxed * native stream at run time (stream.unboxDyn; other values throw the * not-supported Error), and pipe() answers the destination value itself. */ @@ -3394,7 +3404,7 @@ function pipeIntoDyn( kind: "exprStmt", expr: { kind: "libCall", - fn: "readable.pipe", + fn: lowerer.generalNodeStreams ? "readable.pipeGeneral" : "readable.pipe", args: [ { kind: "varRef", localId: source.id, type: receiverValue.type, loc }, { @@ -3691,7 +3701,7 @@ export function lowerStreamMethodCall( const receiver = lowerer.lowerExpr(access.expression); return { kind: "libCall", - fn: "readable.pipe", + fn: generalPipe(lowerer, dst.type), args: [receiver, dst, end], type: dst.type, loc, diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 1c390bbe4..11a292a2e 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -326,6 +326,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "stream.onDyn", "readable.resume", "readable.pipe", + "readable.pipeGeneral", "readable.unpipe", "writable.write", "writable.writeStr", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 597076cb1..82ecf461d 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3612,7 +3612,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * returns the below-hwm answer. readable.read: (recv, size — -1 for * absent) → Buffer|null union. pause/resume return recv +1 (`this`); * isPaused answers the flag. readable.pipe: (recv, dst, end) → dst +1, - * fires 'pipe' on dst; readable.unpipe (recv[, dst]) → recv +1. + * fires 'pipe' on dst; readable.pipeGeneral: the same, with `end` off + * when dst is the converted process.stdout / process.stderr; + * readable.unpipe (recv[, dst]) → recv +1. * writable.write/writeStr: (recv, chunk[, cb]) → below-hwm bool (cb * MOVES when present, called after the user write completes). * writable.end: (recv, flags[, chunk][, cb]) → recv +1. cork/uncork are @@ -3810,6 +3812,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "readable.resume": { argTypes: [null], result: VOID }, "readable.isPaused": { argTypes: [null], result: BOOL }, "readable.pipe": { argTypes: [null, null, BOOL], result: VOID }, + "readable.pipeGeneral": { argTypes: [null, null, BOOL], result: VOID }, "readable.unpipe": { argTypes: [null], result: VOID }, "readable.flowing": { argTypes: [null], result: VOID }, "writable.write": { argTypes: [null, BYTES_U8], result: BOOL }, diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 63078ae4a..742787d98 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -5729,9 +5729,9 @@ function validateFunction( } return true; } - if (e.fn === "readable.pipe") { + if (e.fn === "readable.pipe" || e.fn === "readable.pipeGeneral") { if (!isStreamObject(e.args[1]?.type) || !typeEquals(e.type, e.args[1]!.type)) { - err(`libCall readable.pipe must return its destination's type`, e.loc); + err(`libCall ${e.fn} must return its destination's type`, e.loc); } return true; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6468-node-stdio-writable-slot.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6468-node-stdio-writable-slot.ts.json new file mode 100644 index 000000000..baf428dee --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6468-node-stdio-writable-slot.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6468-node-stdio-writable-slot.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_stream.c b/packages/runtime/src/scr_stream.c index 1c5e339a0..7c7b2279c 100644 --- a/packages/runtime/src/scr_stream.c +++ b/packages/runtime/src/scr_stream.c @@ -6665,18 +6665,42 @@ ScrStream *scr_stream_child_stdio(void *child, const char *key, size_t key_len) * stream.stdioWritable libCall): a Writable whose _write hands each chunk * to the descriptor through process.stdout.write's synchronous path and * completes at once, so ordering against console output and direct writes - * is unchanged. The closure keeps the fd as given (1 or 2). */ + * is unchanged. The closure keeps the fd as given (1 or 2). Each thread + * keeps one per descriptor, so every conversion of process.stdout answers + * the same stream, as Node's process.stdout is one object: listeners, + * writable state and identity are shared. The cache is released at exit. */ +static SCR_TL ScrStream *scr_stream_stdio_writables[2]; static void scr_stream_stdio_write(ScrClosure *cb, ScrStream *s, ScrBytes *chunk) { int fd = (int)scr_box_get_f64(cb->caps[0]); (void)scr_stdio_write(fd == 2 ? 2 : 1, chunk->data, chunk->len * scr_bytes_elem_size(chunk->elem)); scr_stream_write_done(s, NULL); } +static void scr_stream_stdio_writables_free(void) { + for (int i = 0; i < 2; i++) { + ScrStream *s = scr_stream_stdio_writables[i]; + scr_stream_stdio_writables[i] = NULL; + if (s) scr_stream_release(s); + } +} ScrStream *scr_stream_stdio_writable(double fd) { - ScrClosure *write = scr_closure_new((void *)scr_stream_stdio_write, 1); - write->caps[0] = scr_box_new(SCR_BOX_F64); - scr_box_set_f64(write->caps[0], fd); - return scr_stream_new_writable(-1, true, true, write, scr_stream_stdio_write, - NULL, NULL, NULL, NULL); + int i = fd == 2 ? 1 : 0; + if (scr_stream_stdio_writables[i] == NULL) { + if (scr_stream_stdio_writables[1 - i] == NULL) scr_atexit(scr_stream_stdio_writables_free); + ScrClosure *write = scr_closure_new((void *)scr_stream_stdio_write, 1); + write->caps[0] = scr_box_new(SCR_BOX_F64); + scr_box_set_f64(write->caps[0], i ? 2 : 1); + scr_stream_stdio_writables[i] = scr_stream_new_writable(-1, true, true, write, + scr_stream_stdio_write, NULL, NULL, NULL, NULL); + } + return scr_stream_retain(scr_stream_stdio_writables[i]); +} + +/* readable.pipe() into a Writable slot under the general representation + * (the readable.pipeGeneral libCall): Node never ends process.stdout or + * process.stderr from a pipe, so their converted Writable keeps `end` off. */ +ScrStream *scr_stream_pipe_general(ScrStream *src, ScrStream *dst, bool end) { + if (dst == scr_stream_stdio_writables[0] || dst == scr_stream_stdio_writables[1]) end = false; + return scr_stream_pipe(src, dst, end); } /* A pipeline() stage that arrives as a checked-dynamic value (the diff --git a/packages/runtime/src/scr_stream.h b/packages/runtime/src/scr_stream.h index 63ed0c28a..69da3dc0e 100644 --- a/packages/runtime/src/scr_stream.h +++ b/packages/runtime/src/scr_stream.h @@ -130,6 +130,9 @@ ScrStream *scr_stream_new_writable_dyn(struct ScrDyn *opts); * Writable (fd 1 or 2). */ ScrStream *scr_stream_child_stdio(void *child, const char *key, size_t key_len); ScrStream *scr_stream_stdio_writable(double fd); +/* readable.pipe() under the general representation: a converted + * process.stdout / process.stderr destination is never ended (dst +1). */ +ScrStream *scr_stream_pipe_general(ScrStream *src, ScrStream *dst, bool end); /* A boxed native stream out of a checked-dynamic pipeline stage (+1), or * NULL with `message` thrown as the not-supported Error. */ ScrStream *scr_stream_unbox_dyn(const struct ScrDyn *value, const ScrStr *message); diff --git a/tests/corpus/6468-node-stdio-writable-slot.ts b/tests/corpus/6468-node-stdio-writable-slot.ts new file mode 100644 index 000000000..a326344d1 --- /dev/null +++ b/tests/corpus/6468-node-stdio-writable-slot.ts @@ -0,0 +1,49 @@ +// process.stdout and process.stderr in NodeJS.WritableStream slots of a +// program that also passes other streams there: every slot holds the same +// stream object, a pipe through such a slot leaves the process stream +// open, and Writable's listener methods return the stream for chaining. +import { Readable, Writable } from "node:stream"; + +class Sink extends Writable { + _write(_chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void { + callback(); + } +} + +function same(a: NodeJS.WritableStream, b: NodeJS.WritableStream): boolean { + return a === b; +} + +function pipeInto(dst: NodeJS.WritableStream, parts: string[]): Promise { + return new Promise((resolve) => { + const src = Readable.from(parts); + src.on("end", () => resolve()); + src.pipe(dst); + }); +} + +function write(dst: NodeJS.WritableStream, text: string): boolean { + return dst.write(text); +} + +async function main(): Promise { + console.log("stdout same", same(process.stdout, process.stdout)); + console.log("stderr same", same(process.stderr, process.stderr)); + console.log("stdout and stderr", same(process.stdout, process.stderr)); + console.log("sink and stdout", same(new Sink(), process.stdout)); + + await pipeInto(process.stdout, ["piped ", "through ", "a slot\n"]); + // The pipe did not end process.stdout: writes through a slot still work. + console.log("write after pipe", write(process.stdout, "still open\n")); + await pipeInto(process.stderr, ["piped to stderr\n"]); + write(process.stderr, "stderr still open\n"); + + // Writable's listener methods return the stream. + const sink = new Sink(); + await new Promise((resolve) => { + sink.on("finish", () => resolve()).end("done"); + }); + console.log("sink finished", sink.writableFinished); +} + +void main(); diff --git a/tests/harness/corpus-costs/6468-node-stdio-writable-slot.ts.json b/tests/harness/corpus-costs/6468-node-stdio-writable-slot.ts.json new file mode 100644 index 000000000..3fafdfc4b --- /dev/null +++ b/tests/harness/corpus-costs/6468-node-stdio-writable-slot.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 3500 } From e5b9102e0d5b3f002477900f6cf0a8f760dce50d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 08:32:19 -0700 Subject: [PATCH 24/31] Compile compound assignment, ++/-- and ??= through literal-union and index-signature record keys `totals[side] += n`, where `side` is a union of string literals naming declared fields of one type, now dispatches over those fields: the receiver and key evaluate once, the old value is read from the field the key names, then the right side, then the write to that same field. Every compound operator, prefix and postfix ++/--, and ??=/||=/&&= share the path. A single literal key (spelled at the site or carried by a const) reads and writes the field directly. A key smuggled outside its literal type throws the fixed-shape TypeError of a keyed write miss. A string key into a pure index-signature record (`counts[name] += 1`, `counts[name]++`) reads the entry, NaN for a missing one under numeric operators, and inserts or replaces it. Keys naming fields of different types, class instances and JavaScript sources keep their refusals. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/element-access.ts | 7 + .../expressions/keyed-field-targets.ts | 271 ++++++++++++++++++ .../expressions/nullish-assignment.ts | 17 ++ .../lowering/expressions/property-access.ts | 2 + .../frontend/lowering/expressions/updates.ts | 8 +- .../src/frontend/lowering/lower-exprs.ts | 15 + .../6490-literal-key-field-updates.ts.json | 6 + .../corpus/6491-index-record-updates.ts.json | 6 + .../corpus/6490-literal-key-field-updates.ts | 132 +++++++++ tests/corpus/6491-index-record-updates.ts | 44 +++ 10 files changed, 506 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/expressions/keyed-field-targets.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6490-literal-key-field-updates.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6491-index-record-updates.ts.json create mode 100644 tests/corpus/6490-literal-key-field-updates.ts create mode 100644 tests/corpus/6491-index-record-updates.ts diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 144742257..f39b7c428 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -63,6 +63,8 @@ import { npmStaticPackageOfPath } from "../../npm-static.js"; import { isSafeToRepeat } from "./evaluation-safety.js"; import { lowerClassPrototypeComputedAssignment } from "../class-prototypes.js"; import { isClassCallback } from "../class-callbacks.js"; +import { isKeyedFieldAccess } from "./keyed-field-targets.js"; +import { lowerFieldCompoundValue } from "./updates.js"; /** `a[i]` reads. Only f64 indices into array receivers are modeled; JS * string-key element access (`a["length"]`) and string indexing (`s[0]` @@ -1417,6 +1419,11 @@ export function lowerElementCompoundParts( const target = expr.left as ts.ElementAccessExpression; fenceNodeModuleMutation(lowerer, target, "assignment"); const loc = locOf(expr); + // A record through a computed key (`totals[side] += n` with a literal + // union key, `counts[name] += 1` on an index-signature record): the + // member compound's receiver, key, old value, rhs, write sequence. + if (isKeyedFieldAccess(lowerer, target)) + return { expr: lowerFieldCompoundValue(lowerer, target, op, expr.right, loc), stmts: null }; const checked = lowerer.lowerExpr(target.expression); if (checked?.type.kind === "dyn" && isJsSourceFile(expr.getSourceFile())) { const stmts: IrStmt[] = []; diff --git a/packages/compiler/src/frontend/lowering/expressions/keyed-field-targets.ts b/packages/compiler/src/frontend/lowering/expressions/keyed-field-targets.ts new file mode 100644 index 000000000..7a4f9c557 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/expressions/keyed-field-targets.ts @@ -0,0 +1,271 @@ +/* Read-modify-write targets for computed record keys: `r[k] op= v`, `r[k]++` and `r[k] ??= v`. */ +import * as ts from "../../ts7/adapter.js"; +import { + BOOL, + F64, + STRING, + typeEquals, + type IrExpr, + type IrStmt, + type SrcLoc, +} from "../../../ir/ir.js"; +import { isJsSourceFile } from "../../program.js"; +import { constituentTypes } from "../../ts7/checker.js"; +import type { FieldTarget } from "../lower-exprs.js"; +import type { Lowerer } from "../lowerer.js"; + +export type KeyedFieldTarget = FieldTarget & { container: "recordKeyed" }; + +/** The property names a computed key's type allows: one or more string + * literals (a literal union such as `"sent" | "received"`, enum string + * members, or a type parameter bound to one inside a generic instance). + * Null for any other key type. */ +export function literalFieldKeys(lowerer: Lowerer, keyNode: ts.Expression): string[] | null { + let t: ts.Type = lowerer.typeOf(keyNode); + if (t.flags & ts.TypeFlags.TypeParameter) { + const bound = lowerer.typeParamTsResolver(t); + if (!bound) return null; + t = bound; + } + const parts = t.flags & ts.TypeFlags.Union ? constituentTypes(t) : [t]; + const keys: string[] = []; + for (const part of parts) { + if (!part.isStringLiteralType()) return null; + if (!keys.includes(part.value)) keys.push(part.value); + } + return keys.length > 0 ? keys : null; +} + +/** Does `r[k]` read and write through declared record storage by key? A + * checker-only test (nothing lowers), so a caller can route before it + * evaluates the receiver: TypeScript sources only, a record whose declared + * fields include every name the key's literal type allows, or a pure + * index-signature record with a string key. */ +export function isKeyedFieldAccess(lowerer: Lowerer, access: ts.ElementAccessExpression): boolean { + if ( + access.questionDotToken || + isJsSourceFile(access.getSourceFile()) || + lowerer.isProcessEnv(access.expression) + ) + return false; + const keys = literalFieldKeys(lowerer, access.argumentExpression); + if (!keys && !(lowerer.typeOf(access.argumentExpression).flags & ts.TypeFlags.StringLike)) + return false; + const receiver = lowerer.mapTypeOf(lowerer.typeOf(access.expression)); + if (receiver?.kind !== "record") return false; + const shape = lowerer.shapes.get(receiver.shapeId); + if (!shape || shape.tuple) return false; + if (shape.fields.length === 0) return shape.indexValue !== undefined; + return keys !== null && keys.every((k) => shape.fields.some((f) => f.name === k)); +} + +/** The target of a read-modify-write through `r[k]`, or null when + * isKeyedFieldAccess declines. A literal-union key dispatches over the + * declared fields it names, which must share one type; a string key on a + * pure index-signature record reads and writes its entries. The receiver + * and key may arrive already lowered (and saved) by the caller; otherwise + * both lower here, unsaved. A caller that evaluates the target more than + * once saves `obj` and then `key`, the JavaScript evaluation order. With + * `numeric`, a missing index-signature entry reads as NaN (every numeric + * update applies ToNumber to it) instead of a value armed with undefined. */ +export function keyedFieldTarget( + lowerer: Lowerer, + access: ts.ElementAccessExpression, + numeric: boolean, + lowered?: { obj: IrExpr; key: IrExpr }, +): KeyedFieldTarget | null { + if (!isKeyedFieldAccess(lowerer, access)) return null; + const keyNode = access.argumentExpression; + const keys = literalFieldKeys(lowerer, keyNode); + const obj = lowered?.obj ?? lowerer.lowerExpr(access.expression); + if (obj.type.kind !== "record") { + lowerer.unsupported( + "SC1090", + access.expression, + `updating a computed key of a '${lowerer.fmt(obj.type)}' value (narrow the receiver to its record type first)`, + ); + } + const shapeId = obj.type.shapeId; + const shape = lowerer.shapes.get(shapeId)!; + // A single literal spelled at the site (or an identifier whose type is + // that one literal) names the field statically: identifier evaluation + // is pure, so skipping it matches JavaScript. + const indexed = shape.fields.length === 0; + const staticKey = + !indexed && keys?.length === 1 && (ts.isStringLiteralLike(keyNode) || ts.isIdentifier(keyNode)); + const key = staticKey ? null : (lowered?.key ?? lowerer.lowerExpr(keyNode)); + if (key && key.type.kind !== "string") { + lowerer.unsupported( + "SC1090", + keyNode, + `updating a record through a '${lowerer.fmt(key.type)}' key (convert the key to a string first)`, + ); + } + if (keys && !indexed) { + const fields = keys.map((k) => shape.fields.find((f) => f.name === k)); + const first = fields[0]; + if (!first || fields.some((f) => !f || !typeEquals(f.type, first.type))) { + lowerer.unsupported( + "SC1090", + access, + `updating '${lowerer.fmt(obj.type)}' through a key whose fields are not all stored with one type (${keys.map((k) => `'${k}'`).join(", ")}; switch over the key and update each field)`, + ); + } + return { + container: "recordKeyed", + obj, + shapeId, + field: keys[0]!, + keys, + key, + fieldType: first.type, + }; + } + if (!indexed || !shape.indexValue) { + lowerer.unsupported( + "SC1090", + access, + `updating '${lowerer.fmt(obj.type)}' through a runtime key (the stored record declares fields)`, + ); + } + return { + container: "recordKeyed", + obj, + shapeId, + field: "", + keys: null, + key, + fieldType: shape.indexValue, + ...(numeric && shape.indexValue.kind === "f64" ? { missNaN: true as const } : {}), + }; +} + +/** Does `key` name `field`? */ +function keyIs(key: IrExpr, field: string, loc: SrcLoc): IrExpr { + return { + kind: "strEq", + negated: false, + left: key, + right: { kind: "strLit", value: field, type: STRING, loc }, + type: BOOL, + loc, + }; +} + +/** A key outside its literal type (smuggled through a cast) names no + * declared field: a fixed-shape record cannot add the property, so the + * update throws the TypeError of a keyed write miss. */ +function keyMiss(key: IrExpr, target: KeyedFieldTarget, loc: SrcLoc): IrExpr { + const message: IrExpr = { + kind: "strConcat", + left: { + kind: "strConcat", + left: { kind: "strLit", value: "Cannot add property '", type: STRING, loc }, + right: key, + type: STRING, + loc, + }, + right: { kind: "strLit", value: "' to a fixed-shape object", type: STRING, loc }, + type: STRING, + loc, + }; + return { + kind: "libCall", + fn: "error.nodeThrow", + args: [ + { kind: "numLit", value: 1, type: F64, loc }, + { kind: "strLit", value: "", type: STRING, loc }, + message, + ], + type: target.fieldType, + loc, + }; +} + +/** The current value of a keyed target. */ +export function keyedFieldRead(lowerer: Lowerer, target: KeyedFieldTarget, loc: SrcLoc): IrExpr { + const { obj, shapeId, key, fieldType } = target; + if (!target.keys) { + const type = target.missNaN + ? fieldType + : fieldType.kind === "dyn" + ? fieldType + : lowerer.withUndefinedArmOf(fieldType); + if (!type || !key) throw new Error("keyed index-signature target without a key or read type"); + return { + kind: "recordKeyGet", + obj, + shapeId, + key, + ...(target.missNaN ? { missNaN: true as const } : {}), + type, + loc, + }; + } + const get = (field: string): IrExpr => ({ + kind: "recordGet", + obj, + shapeId, + field, + type: fieldType, + loc, + }); + if (!key) return get(target.keys[0]!); + return target.keys.reduceRight( + (otherwise, field) => ({ + kind: "ternary", + cond: keyIs(key, field, loc), + then: get(field), + else_: otherwise, + type: fieldType, + loc, + }), + keyMiss(key, target, loc), + ); +} + +/** Store `value` (already of the target's field type) through a keyed + * target. A multi-key dispatch references the value once per field, so a + * value that is not a plain local is saved first. */ +export function keyedFieldWrite( + lowerer: Lowerer, + target: KeyedFieldTarget, + value: IrExpr, + loc: SrcLoc, +): IrStmt { + const { obj, shapeId, key } = target; + if (!target.keys) { + if (!key) throw new Error("keyed index-signature target without a key"); + return { kind: "recordKeySet", obj, shapeId, key, value, loc }; + } + if (!key) return { kind: "recordSet", obj, shapeId, field: target.keys[0]!, value, loc }; + const prefix: IrStmt[] = []; + let stored = value; + if (value.kind !== "varRef") { + const local = lowerer.declareHiddenLocal("%keyedValue", value.type); + prefix.push({ kind: "varDecl", localId: local.id, init: value, loc }); + stored = { kind: "varRef", localId: local.id, type: value.type, loc }; + } + const chain = target.keys.reduceRight( + (otherwise, field) => [ + { + kind: "if", + cond: keyIs(key, field, loc), + then: [{ kind: "recordSet", obj, shapeId, field, value: stored, loc }], + else_: otherwise, + loc, + }, + ], + [{ kind: "exprStmt", expr: keyMiss(key, target, loc), loc }], + ); + return { kind: "block", body: [...prefix, ...chain], loc }; +} + +/** Save a keyed target's key after its receiver, so a multi-key dispatch + * reads and writes the field the key named when it was evaluated. */ +export function saveKeyedFieldKey( + target: FieldTarget, + save: (value: IrExpr, name: string) => IrExpr, +): void { + if (target.container === "recordKeyed" && target.key) target.key = save(target.key, "%keyedKey"); +} diff --git a/packages/compiler/src/frontend/lowering/expressions/nullish-assignment.ts b/packages/compiler/src/frontend/lowering/expressions/nullish-assignment.ts index 4366b8d6b..81b3b9d69 100644 --- a/packages/compiler/src/frontend/lowering/expressions/nullish-assignment.ts +++ b/packages/compiler/src/frontend/lowering/expressions/nullish-assignment.ts @@ -18,6 +18,11 @@ import { arrayNumberRead, arrayValueRead, arrayValueStore } from "../array-value import { fenceNodeModuleMutation } from "../lower-node-module.js"; import { globalSymbolKey } from "./global-symbols.js"; import { tryLowerExpression } from "./try-lower-expression.js"; +import { + type KeyedFieldTarget, + keyedFieldTarget, + saveKeyedFieldKey, +} from "./keyed-field-targets.js"; import { eventAccessorRead, eventAccessorWrite, @@ -147,6 +152,7 @@ export function lowerShortCircuitAssignment(lowerer: Lowerer, expr: ts.BinaryExp } else { const receiver = save(lowerer.lowerExpr(target.expression), "%shortCircuitReceiver"); let key = lowerer.lowerExpr(target.argumentExpression); + let keyed: KeyedFieldTarget | null = null; if (receiver.type.kind === "dyn") { key = save( { @@ -245,6 +251,17 @@ export function lowerShortCircuitAssignment(lowerer: Lowerer, expr: ts.BinaryExp value: lowerer.coerceToExpected(value, indexValue), loc, }); + } else if ( + receiver.type.kind === "record" && + (keyed = keyedFieldTarget(lowerer, target, false, { obj: receiver, key })) + ) { + // `totals[side] ??= v` through a literal-union key: the read and + // the write dispatch over the declared fields the key can name. + const field = keyed; + saveKeyedFieldKey(field, save); + storage = field.fieldType; + read = lowerer.fieldGetExpr(field, locOf(target), target); + write = (value) => lowerer.fieldSetStmt(field, value, loc, target); } else { lowerer.unsupported( "SC1090", diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 6b37b7a19..1de1ec83d 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -154,6 +154,7 @@ import { errorPropertyRead } from "../error-methods.js"; import { isSafeToRepeat } from "./evaluation-safety.js"; import { isZlibStreamMember, lowerZlibStreamBytesWritten } from "../builtins/compression.js"; import { isFsStreamMember, lowerFsStreamProperty } from "../builtins/fs-streams.js"; +import { keyedFieldRead } from "./keyed-field-targets.js"; /** True when a property access names an ABSTRACT property declaration * (`abstract p: number` — a PropertyDeclaration, not an accessor). @@ -3505,6 +3506,7 @@ export function fieldGetExpr( ); if (checked) target = { ...target, obj: checked }; } + if (target.container === "recordKeyed") return keyedFieldRead(lowerer, target, loc); if (target.container === "dynamic") return lowerer.coerceInto( blame, diff --git a/packages/compiler/src/frontend/lowering/expressions/updates.ts b/packages/compiler/src/frontend/lowering/expressions/updates.ts index 2270f6043..41d8f6332 100644 --- a/packages/compiler/src/frontend/lowering/expressions/updates.ts +++ b/packages/compiler/src/frontend/lowering/expressions/updates.ts @@ -42,6 +42,7 @@ import type { CompoundOp } from "../surfaces.js"; import { tryLowerExpression } from "./try-lower-expression.js"; import { hasRuntimeStatics } from "../class-runtime-statics.js"; import { declareContextLocal } from "../function-context.js"; +import { keyedFieldTarget, keyedFieldWrite, saveKeyedFieldKey } from "./keyed-field-targets.js"; /** Read-modify-write updates read the member first: an unassigned * deferred-init receiver throws Node's member-read TypeError. */ @@ -337,10 +338,11 @@ export function lowerIncDec( } const target = ts.isPropertyAccessExpression(access) ? lowerer.fieldTarget(access) - : symbolFieldTarget(lowerer, access); + : (symbolFieldTarget(lowerer, access) ?? keyedFieldTarget(lowerer, access, true)); if (target && lowerer.dynConvertible(target.fieldType)) { checkDeferredMemberReceiver(lowerer, target, access); target.obj = save(target.obj, "%incrementReceiver"); + saveKeyedFieldKey(target, save); return finish(lowerer.fieldGetExpr(target, loc, access), (value) => lowerer.fieldSetStmt( target, @@ -617,6 +619,7 @@ export function fieldSetStmt( }; } } + if (target.container === "recordKeyed") return keyedFieldWrite(lowerer, target, value, loc); if (target.container === "dynamic") return { kind: "exprStmt", @@ -872,12 +875,13 @@ export function lowerFieldCompoundValue( const targetOf = (): FieldTarget | null => ts.isPropertyAccessExpression(access) ? lowerer.fieldTarget(access) - : symbolFieldTarget(lowerer, access); + : (symbolFieldTarget(lowerer, access) ?? keyedFieldTarget(lowerer, access, true)); const target = targetOf(); if (!target) lowerer.unsupported("SC1090", access, "compound assignment to unsupported field targets"); checkDeferredMemberReceiver(lowerer, target, access); target.obj = save(target.obj, "%compoundReceiver"); + saveKeyedFieldKey(target, save); // Accessors observe getter, RHS side effects, then setter, all through // the saved receiver. const fieldRead = lowerer.fieldGetExpr(target, locOf(access), access); diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 5cbf103d5..b33700b7e 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -151,6 +151,21 @@ export type FieldTarget = // (the write-slot type; reads arm it with undefined under // noUncheckedIndexedAccess in fieldGetExpr). | { container: "recordOvf"; obj: IrExpr; shapeId: string; field: string; fieldType: IrType } + // A computed key of a record in a read-modify-write (`r[k] += v`, + // `r[k]++`, `r[k] ??= v`; keyed-field-targets.ts): `keys` lists the + // declared fields a literal-union key can name, which share fieldType; + // null keys mean a string key into a pure index-signature record, whose + // entries fieldType types. `key` is null when the one literal is static. + | { + container: "recordKeyed"; + obj: IrExpr; + shapeId: string; + field: string; + keys: readonly string[] | null; + key: IrExpr | null; + fieldType: IrType; + missNaN?: true; + } | { container: "accessor"; obj: IrExpr; className: string; field: string; fieldType: IrType } // A RECORD accessor property (an object-literal `get x()`/`set x(v)` — // the shape carries %get:/%set: closure slots): reads call the getter diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6490-literal-key-field-updates.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6490-literal-key-field-updates.ts.json new file mode 100644 index 000000000..1e4e1e939 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6490-literal-key-field-updates.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6490-literal-key-field-updates.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6491-index-record-updates.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6491-index-record-updates.ts.json new file mode 100644 index 000000000..babda8c3c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6491-index-record-updates.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6491-index-record-updates.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6490-literal-key-field-updates.ts b/tests/corpus/6490-literal-key-field-updates.ts new file mode 100644 index 000000000..f90739c31 --- /dev/null +++ b/tests/corpus/6490-literal-key-field-updates.ts @@ -0,0 +1,132 @@ +// Read-modify-write through a key whose type is a union of literal field +// names (`totals[side] += n`): every compound operator, ++/-- and logical +// assignment dispatch over the declared fields the key can name. The +// receiver and key evaluate once, before the right side. +type Side = "sent" | "received"; +interface Traffic { + sent: number; + received: number; + label: string; + updatedAt: number; +} + +const traffic: Traffic = { sent: 0, received: 0, label: "link", updatedAt: 0 }; + +function record(side: Side, bytes: number): void { + traffic[side] += bytes; + traffic.updatedAt++; +} +record("sent", 120); +record("received", 64); +record("sent", 8); +console.log(JSON.stringify(traffic)); + +function adjust(side: Side): string { + const parts: number[] = []; + parts.push((traffic[side] -= 3)); + parts.push((traffic[side] *= 2)); + parts.push((traffic[side] /= 4)); + parts.push((traffic[side] %= 7)); + parts.push((traffic[side] **= 2)); + parts.push((traffic[side] |= 1)); + parts.push((traffic[side] &= 0xff)); + parts.push((traffic[side] ^= 0x0f)); + parts.push((traffic[side] <<= 2)); + parts.push((traffic[side] >>= 1)); + parts.push((traffic[side] >>>= 1)); + return parts.join(","); +} +console.log(adjust("sent"), adjust("received")); + +// ++ and -- in statement and value positions, prefix and postfix. +const sides: Side[] = ["sent", "received", "sent"]; +for (const s of sides) traffic[s]++; +for (const s of sides) --traffic[s]; +const post = traffic[sides[1]!]--; +const pre = ++traffic[sides[0]!]; +console.log(post, pre, traffic.sent, traffic.received); + +// One literal key, spelled at the site or carried by a const. +traffic["sent"] += 1000; +const fixed = "received"; +traffic[fixed] -= 1; +traffic["received"]++; +console.log(traffic.sent, traffic.received); + +// Evaluation order: receiver, key, old value, right side, write. +let trace = ""; +function pick(s: Side): Side { + trace += `key:${s};`; + return s; +} +function target(): Traffic { + trace += "receiver;"; + return traffic; +} +function amount(n: number): number { + trace += `rhs:${n};`; + return n; +} +console.log((target()[pick("received")] += amount(5)), trace); +trace = ""; +console.log(target()[pick("sent")]++, trace); +trace = ""; +let current = traffic; +function swap(): number { + current = { sent: -1, received: -1, label: "other", updatedAt: 0 }; + return 1; +} +current[pick("sent")] += swap(); +console.log(traffic.sent, current.sent, trace); + +// The right side throws after the old value is read: nothing is written. +function fail(): number { + throw new Error("rhs failed"); +} +try { + traffic[pick("received")] *= fail(); +} catch (e) { + if (e instanceof Error) console.log(e.message, traffic.received); +} + +// String fields append. +type Part = "head" | "tail"; +const text: { head: string; tail: string; size: number } = { head: "a", tail: "z", size: 0 }; +function append(p: Part, s: string): string { + return (text[p] += s); +} +console.log(append("head", "bc"), append("tail", "yx"), append("head", "!")); +console.log(JSON.stringify(text)); + +// Optional fields with ??=: only a missing value takes the right side. +type Phase = "dns" | "connect" | "tls"; +const timings: { dns?: number; connect?: number; tls?: number; total: number } = { total: 0 }; +function mark(phase: Phase, at: number): number { + return (timings[phase] ??= at); +} +console.log(mark("dns", 4), mark("connect", 9), mark("dns", 12), mark("tls", 0), mark("tls", 7)); +console.log(timings.dns, timings.connect, timings.tls, timings.total); + +// ||= and &&= test truthiness and write only in the selected branch. +const flags = { quiet: false, verbose: true, color: false }; +type Flag = keyof typeof flags; +function settle(f: Flag, value: boolean): string { + const a = (flags[f] ||= value); + const b = (flags[f] &&= f !== "color"); + return `${f}:${a}:${b}`; +} +console.log(settle("quiet", true), settle("verbose", false), settle("color", true)); +console.log(JSON.stringify(flags)); + +// Nested receivers and keys from a typed lookup. +const stats = { day: { hits: 1, misses: 2 }, week: { hits: 10, misses: 20 } }; +type Window = keyof typeof stats; +type Outcome = "hits" | "misses"; +const events: [Window, Outcome][] = [ + ["day", "hits"], + ["week", "misses"], + ["day", "misses"], + ["day", "hits"], +]; +for (const [w, o] of events) stats[w][o] += 1; +console.log(JSON.stringify(stats)); diff --git a/tests/corpus/6491-index-record-updates.ts b/tests/corpus/6491-index-record-updates.ts new file mode 100644 index 000000000..108d34012 --- /dev/null +++ b/tests/corpus/6491-index-record-updates.ts @@ -0,0 +1,44 @@ +// Compound assignment and ++/-- through a string key of an +// index-signature record: a missing entry reads as undefined, which every +// numeric operator turns into NaN, and the write inserts or replaces it. +const counts: Record = {}; +const words = "the cat and the hat and the bat".split(" "); +for (const w of words) { + counts[w] ??= 0; + counts[w]++; +} +console.log(JSON.stringify(counts)); + +const sums: { [key: string]: number } = { a: 1, b: 2 }; +sums["a"] += 10; +sums.b *= 3; +const key = "a"; +sums[key] -= 1; +console.log(JSON.stringify(sums)); + +// A missing entry: undefined op number is NaN, and the value form yields it. +console.log((sums["missing"] += 1), sums["missing"], Number.isNaN(sums["missing"])); +const later = sums["gone"]++; +console.log(later, sums["gone"], Object.keys(sums).join(",")); + +// Totals keyed by a runtime string, with the key and right side evaluated once. +let trace = ""; +function name(n: string): string { + trace += `key:${n};`; + return n; +} +function size(n: number): number { + trace += `rhs:${n};`; + return n; +} +const totals: Record = { disk: 0, net: 0 }; +const samples: [string, number][] = [ + ["disk", 4], + ["net", 7], + ["disk", 2], +]; +for (const [k, v] of samples) totals[name(k)] += size(v); +console.log(JSON.stringify(totals), trace); +const before = totals["net"]--; +const after = --totals["net"]; +console.log(before, after, totals["net"]); From 68dac76fdcd35fe3ad82d377b32d2c54c7562bae Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 11:11:59 -0500 Subject: [PATCH 25/31] Treat setEncoding's return value as the stream it was called on A method call declared to return `this`, such as setEncoding, resolves to its receiver's access path, so a 'data' listener on the stream that `const out = child.stdout.setEncoding("utf8")` returns receives strings. A setEncoding call that a preceding statement always evaluates (its expression, a variable initializer, or the receiver of a method chain in one of those) counts as preceding the listener. Only `new` and the child_process factories (spawn, fork, exec, execFile) count as creating a separate stream; any other call result may be a stream another binding already holds. --- .../builtins/child-process-methods.ts | 98 +++++++++++++++++-- .../node-types/stream-construction.ts | 21 ++++ 2 files changed, 111 insertions(+), 8 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts b/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts index aae51a8be..e1a25d169 100644 --- a/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts +++ b/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts @@ -543,7 +543,9 @@ export function lowerChildWriterMethodCall( /** A receiver spelled as a plain access path (`child.stdout`, `out`): its * root symbol and member names, or null for other forms. A `const` alias * of an access path (`const out = child.stdout`) resolves to that path, - * so every spelling of one stream gets the same key. */ + * and so does a method call declared to return `this` + * (`child.stdout.setEncoding("utf8")`), so every spelling of one stream + * gets the same key. */ function accessPathOf( lowerer: Lowerer, expr: ts.Expression, @@ -564,9 +566,84 @@ function accessPathOf( const base = accessPathOf(lowerer, expr.expression, depth + 1); return base === null ? null : { root: base.root, path: `${base.path}.${expr.name.text}` }; } + if (returnsReceiver(lowerer, expr)) { + return accessPathOf( + lowerer, + (expr.expression as ts.PropertyAccessExpression).expression, + depth + 1, + ); + } return null; } +/** A method call (`recv.m(...)`, not optional) whose declaration returns + * `this`, so its value is the receiver itself. */ +function returnsReceiver(lowerer: Lowerer, expr: ts.Expression): expr is ts.CallExpression { + if ( + !ts.isCallExpression(expr) || + expr.questionDotToken || + !ts.isPropertyAccessExpression(expr.expression) || + expr.expression.questionDotToken + ) + return false; + const declaration = lowerer.checker.getResolvedSignature(expr)?.declaration; + return declaration?.type?.kind === ts.SyntaxKind.ThisType; +} + +/** A call of a child_process factory (spawn, fork, exec, execFile), each + * of which returns a new ChildProcess. */ +function createsChildProcess(lowerer: Lowerer, call: ts.CallExpression): boolean { + const declaration = lowerer.checker.getResolvedSignature(call)?.declaration; + if ( + declaration === undefined || + !ts.isFunctionDeclaration(declaration) || + declaration.name === undefined || + !["spawn", "fork", "exec", "execFile"].includes(declaration.name.text) + ) + return false; + for (let node: ts.Node | undefined = declaration.parent; node; node = node.parent) { + if (ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) { + return node.name.text === "child_process" || node.name.text === "node:child_process"; + } + } + return false; +} + +/** The statement that always evaluates `call` when it runs: the call is + * the statement's expression, a variable's initializer, or the receiver of + * a method chain that is one of those. Null for calls under a condition, + * an operator, an argument list or any other construct. */ +function unconditionalStatementOf(call: ts.CallExpression): ts.Statement | null { + let node: ts.Node = call; + for (;;) { + const parent: ts.Node | undefined = node.parent; + if (parent === undefined) return null; + if ( + ts.isParenthesizedExpression(parent) || + ts.isNonNullExpression(parent) || + (ts.isPropertyAccessExpression(parent) && + parent.expression === node && + !parent.questionDotToken) || + (ts.isCallExpression(parent) && parent.expression === node && !parent.questionDotToken) + ) { + node = parent; + continue; + } + if (ts.isExpressionStatement(parent)) return parent; + if (ts.isVariableDeclaration(parent) && parent.initializer === node) { + const list = parent.parent; + const statement = list?.parent; + return list !== undefined && + ts.isVariableDeclarationList(list) && + statement !== undefined && + ts.isVariableStatement(statement) + ? statement + : null; + } + return null; + } +} + /** The initializer of a symbol declared once as a `const` variable, else null. */ function constInitializerOf(symbol: ts.Symbol): ts.Expression | null { const decls = symbol.declarations; @@ -583,18 +660,22 @@ function constInitializerOf(symbol: ts.Symbol): ts.Expression | null { } /** Whether two resolved access paths name different objects: the same root - * with different members, or two `const` roots each initialized by a call - * or `new` (such as two `spawn()` results). */ + * with different members, or two `const` roots each initialized by `new` + * or a child_process factory (such as two `spawn()` results). Any other + * call may return an object another binding already holds. */ function distinctPaths( + lowerer: Lowerer, a: { root: ts.Symbol; path: string }, b: { root: ts.Symbol; path: string }, ): boolean { if (a.root === b.root) return a.path !== b.path; const fresh = (root: ts.Symbol): boolean => { let init = constInitializerOf(root); - while (init !== null && (ts.isParenthesizedExpression(init) || ts.isAwaitExpression(init))) - init = init.expression; - return init !== null && (ts.isCallExpression(init) || ts.isNewExpression(init)); + while (init !== null && ts.isParenthesizedExpression(init)) init = init.expression; + if (init === null) return false; + return ( + ts.isNewExpression(init) || (ts.isCallExpression(init) && createsChildProcess(lowerer, init)) + ); }; return fresh(a.root) && fresh(b.root); } @@ -646,10 +727,11 @@ function childStreamEncodedAt( const target = node.expression.expression; const path = accessPathOf(lowerer, target); if (path !== null && path.root === key.root && path.path === key.path) { - if (ts.isExpressionStatement(node.parent) && preceding.has(node.parent)) before = true; + const statement = unconditionalStatementOf(node); + if (statement !== null && preceding.has(statement)) before = true; else elsewhere = true; } else if ( - (path === null || !distinctPaths(path, key)) && + (path === null || !distinctPaths(lowerer, path, key)) && lowerer.checker.isTypeAssignableTo(receiverType, lowerer.typeOf(target)) ) { // Another spelling that can hold this stream (a parameter, a `let` diff --git a/tests/fixtures/node-types/stream-construction.ts b/tests/fixtures/node-types/stream-construction.ts index 37a1b852e..a28643f7c 100644 --- a/tests/fixtures/node-types/stream-construction.ts +++ b/tests/fixtures/node-types/stream-construction.ts @@ -81,6 +81,26 @@ function run(script: string): Promise { }); } +// setEncoding returns its stream: a listener on the returned value, or on +// the stream after `const out = stream.setEncoding(...)`, receives strings. +function readEncoded(script: string): Promise { + return new Promise((resolve) => { + const child = spawn("/bin/sh", ["-c", script], { stdio: "pipe" }); + let outText = ""; + let errText = ""; + const out = child.stdout.setEncoding("utf8"); + out.on("data", (c: unknown) => { + outText += typeof c + ":" + String(c) + ";"; + }); + const err = child.stderr.setEncoding("utf8"); + child.stderr.on("data", (c: unknown) => { + errText += typeof c + ":" + String(c) + ";"; + }); + void err; + child.on("close", () => resolve(outText + " err " + errText)); + }); +} + async function main(): Promise { console.log("gunzip", await gunzipThrough(gzipSync(Buffer.from("payload")))); const file = path.join(os.tmpdir(), `scriptc-stream-construction-${process.pid}.txt`); @@ -88,6 +108,7 @@ async function main(): Promise { console.log("file", JSON.stringify(await readFileStream(file))); fs.unlinkSync(file); console.log("run", JSON.stringify(await run("echo hi; echo warn >&2"))); + console.log("encoded", JSON.stringify(await readEncoded("printf hi; printf warn >&2"))); for (const script of ["echo bad >&2; exit 3", "kill -TERM $$"]) { try { await run(script); From 1818faef10ebb0281e9eedbec51b307f049ecdb3 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 11:20:28 -0500 Subject: [PATCH 26/31] Keep a process stream open when JavaScript pipes into it pipe() called from untyped JavaScript on a native stream went through scr_stream_pipe directly, so a helper that piped into the converted process.stdout or process.stderr ended it at the source's end, and later writes through the program's NodeJS.WritableStream slots failed. The stream view's pipe now calls scr_stream_pipe_general, which keeps end off for those Writables, as Node's pipe() does for process.stdout and process.stderr. Adds corpus 6469. --- .../main.ts.json | 7 ++++ packages/runtime/src/scr_stream.c | 9 +++-- .../6469-node-stdio-writable-js-pipe/main.ts | 36 +++++++++++++++++++ .../6469-node-stdio-writable-js-pipe/pipe.js | 4 +++ .../main.ts.json | 1 + 5 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6469-node-stdio-writable-js-pipe/main.ts.json create mode 100644 tests/corpus/6469-node-stdio-writable-js-pipe/main.ts create mode 100644 tests/corpus/6469-node-stdio-writable-js-pipe/pipe.js create mode 100644 tests/harness/corpus-costs/6469-node-stdio-writable-js-pipe/main.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6469-node-stdio-writable-js-pipe/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6469-node-stdio-writable-js-pipe/main.ts.json new file mode 100644 index 000000000..60b1360b0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6469-node-stdio-writable-js-pipe/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6469-node-stdio-writable-js-pipe/pipe.js", + "/tests/corpus/6469-node-stdio-writable-js-pipe/main.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_stream.c b/packages/runtime/src/scr_stream.c index 7c7b2279c..56f9fd54b 100644 --- a/packages/runtime/src/scr_stream.c +++ b/packages/runtime/src/scr_stream.c @@ -6451,8 +6451,10 @@ static ScrDyn *scr_stream_view_call(ScrClosure *cb, ScrDyn *const *args, size_t end = option->kind == SCR_DYN_UNDEF || scr_dyn_truthy(option); scr_dyn_release(option); } + /* scr_stream_pipe_general: a JavaScript caller can pipe into the + * converted process.stdout / process.stderr, which a pipe never ends. */ ScrStream *returned = !strcmp(method, "pipe") - ? scr_stream_pipe(stream, destination, end) : scr_stream_unpipe(stream, destination); + ? scr_stream_pipe_general(stream, destination, end) : scr_stream_unpipe(stream, destination); scr_stream_release(returned); if (!strcmp(method, "pipe") && !scr_exc_pending()) result = scr_dyn_retain(first); } @@ -6696,8 +6698,9 @@ ScrStream *scr_stream_stdio_writable(double fd) { } /* readable.pipe() into a Writable slot under the general representation - * (the readable.pipeGeneral libCall): Node never ends process.stdout or - * process.stderr from a pipe, so their converted Writable keeps `end` off. */ + * (the readable.pipeGeneral libCall), and pipe() called from JavaScript on + * a native stream: Node never ends process.stdout or process.stderr from a + * pipe, so their converted Writable keeps `end` off. */ ScrStream *scr_stream_pipe_general(ScrStream *src, ScrStream *dst, bool end) { if (dst == scr_stream_stdio_writables[0] || dst == scr_stream_stdio_writables[1]) end = false; return scr_stream_pipe(src, dst, end); diff --git a/tests/corpus/6469-node-stdio-writable-js-pipe/main.ts b/tests/corpus/6469-node-stdio-writable-js-pipe/main.ts new file mode 100644 index 000000000..258755f83 --- /dev/null +++ b/tests/corpus/6469-node-stdio-writable-js-pipe/main.ts @@ -0,0 +1,36 @@ +// process.stdout and process.stderr reach an untyped JavaScript helper +// through NodeJS.WritableStream slots, and the helper pipes into them. As +// in Node, the pipe leaves the process stream open, so later writes +// through the same slots still work. +import { Readable, Writable } from "node:stream"; +import { pipeAll } from "./pipe.js"; + +class Sink extends Writable { + readonly parts: string[] = []; + _write(chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void { + this.parts.push(chunk.toString("utf8")); + callback(); + } +} + +function into(destination: NodeJS.WritableStream, parts: string[]): Promise { + return new Promise((resolve) => { + pipeAll(Readable.from(parts), destination, () => resolve()); + }); +} + +function write(destination: NodeJS.WritableStream, text: string): boolean { + return destination.write(text); +} + +async function main(): Promise { + const sink = new Sink(); + await into(sink, ["to ", "a sink"]); + console.log("sink", JSON.stringify(sink.parts), sink.writableEnded); + await into(process.stdout, ["piped ", "by javascript\n"]); + console.log("write after pipe", write(process.stdout, "still open\n")); + await into(process.stderr, ["piped to stderr\n"]); + write(process.stderr, "stderr still open\n"); +} + +void main(); diff --git a/tests/corpus/6469-node-stdio-writable-js-pipe/pipe.js b/tests/corpus/6469-node-stdio-writable-js-pipe/pipe.js new file mode 100644 index 000000000..d48d592ad --- /dev/null +++ b/tests/corpus/6469-node-stdio-writable-js-pipe/pipe.js @@ -0,0 +1,4 @@ +export function pipeAll(source, destination, done) { + source.on("end", () => done()); + source.pipe(destination); +} diff --git a/tests/harness/corpus-costs/6469-node-stdio-writable-js-pipe/main.ts.json b/tests/harness/corpus-costs/6469-node-stdio-writable-js-pipe/main.ts.json new file mode 100644 index 000000000..3fafdfc4b --- /dev/null +++ b/tests/harness/corpus-costs/6469-node-stdio-writable-js-pipe/main.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 3500 } From 447f56e5c37193842269a997dadeecd7bbd931da Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 11:28:28 -0500 Subject: [PATCH 27/31] Share defaultMaxListeners with the typed EventEmitter, link instanceof alone, and clean up a pipe once - EventEmitter.defaultMaxListeners on the untyped constructor is Node's accessor over the default the typed EventEmitter reads: assignments validate as validateNumber(arg, 'defaultMaxListeners', 0) does, and getMaxListeners() of emitters without their own limit answers the new value. - dyn.builtinInstanceOf and dyn.functionInstanceOf link the constructor-function unit and the units it calls (dyn-invoke, inspect, symbol, emitter), so a program whose only use is `this instanceof F` links. - The legacy pipe's cleanup runs once: when the destination emits 'close' from end(), the cleanup still queued on the source's 'end' finds the listeners removed and emit('end') answers true. --- .../compiler/src/backend/native/executable.ts | 7 ++- packages/compiler/src/ir/runtime-features.ts | 21 ++++++-- ...7-constructor-function-instanceof.cjs.json | 6 +++ ...tructor-function-emitter-defaults.cjs.json | 6 +++ .../runtime-pack-matrix.mjs | 2 +- packages/runtime/src/scr_dyn_builtin_class.c | 49 +++++++++++++++-- .../5817-constructor-function-instanceof.cjs | 15 ++++++ ...-constructor-function-emitter-defaults.cjs | 53 +++++++++++++++++++ ...7-constructor-function-instanceof.cjs.json | 1 + ...tructor-function-emitter-defaults.cjs.json | 1 + 10 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5817-constructor-function-instanceof.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5818-constructor-function-emitter-defaults.cjs.json create mode 100644 tests/corpus/5817-constructor-function-instanceof.cjs create mode 100644 tests/corpus/5818-constructor-function-emitter-defaults.cjs create mode 100644 tests/harness/corpus-costs/5817-constructor-function-instanceof.cjs.json create mode 100644 tests/harness/corpus-costs/5818-constructor-function-emitter-defaults.cjs.json diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index bbb720cc7..740697323 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -236,7 +236,12 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [ "scr_util_callable.c", ), unit( - (u) => u.opts.dynBuiltinClass && u.opts.inspect && u.opts.dynInvoke && u.opts.symbol, + (u) => + u.opts.dynBuiltinClass && + u.opts.inspect && + u.opts.dynInvoke && + u.opts.symbol && + (u.opts.emitter || u.opts.workers), "scr_dyn_builtin_class.c", ), unit((u) => u.opts.stream, "scr_stream.c", "scr_fs_stream.c"), diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 162bf55d3..02a4dd52a 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -234,10 +234,21 @@ const CHILD_PROCESS_LIB_FNS: ReadonlySet = new Set([ /** libCall name prefixes that link the child-process unit. */ const CHILD_PROCESS_LIB_PREFIXES: readonly string[] = ["child.", "cp.", "spawnRes.", "writer."]; +/** The entry points scr_dyn_builtin_class.c defines. The unit calls + * listeners through the dyn-invoke unit, renders unhandled 'error' + * payloads through the inspect unit, creates EventEmitter's private + * symbols through the symbol unit and shares defaultMaxListeners with the + * emitter unit, so each of them links all five. */ +const BUILTIN_CLASS_LIB_FNS: readonly IrLibFn[] = [ + "dyn.builtinClass", + "dyn.builtinInstanceOf", + "dyn.functionInstanceOf", +]; + /** Entry points that link the symbol unit, besides the sym. and disposable. families. */ const SYMBOL_LIB_FNS: ReadonlySet = new Set([ // EventEmitter's kShapeMode and kCapture properties - "dyn.builtinClass", + ...BUILTIN_CLASS_LIB_FNS, "dyn.usingDispose", "dyn.usingMethod", "util.isDeepStrictEqual", @@ -511,11 +522,11 @@ const RUNTIME_FEATURE_RULES = { * unit, render unhandled 'error' payloads through the inspect unit and * create EventEmitter's private symbols through the symbol unit. */ dynBuiltinClass: rule({ - fns: ["dyn.builtinClass", "dyn.builtinInstanceOf", "dyn.functionInstanceOf"], + fns: [...BUILTIN_CLASS_LIB_FNS], }), dynInvoke: rule({ initial: (mod) => mod.workers === true, - fns: [...DYN_INVOKE_LIB_FNS, ...UTIL_CALLABLE_LIB_FNS, "dyn.builtinClass"], + fns: [...DYN_INVOKE_LIB_FNS, ...UTIL_CALLABLE_LIB_FNS, ...BUILTIN_CLASS_LIB_FNS], // Constant-key reads of Object.prototype member names answer the // inherited member through scr_dyn_invoke.c. expr: (node, _tables) => @@ -532,7 +543,7 @@ const RUNTIME_FEATURE_RULES = { emitter: rule({ initial: (mod) => mod.workers === true || (mod.classes ?? []).some((c) => c.name === RUNTIME_EMITTER_CLASS), - fns: [...STREAM_LIB_FNS], + fns: [...STREAM_LIB_FNS, ...BUILTIN_CLASS_LIB_FNS], prefixes: ["emitter.", ...STREAM_LIB_PREFIXES], }), /** Subclasses of the runtime EventTarget/Event bases tear down and box @@ -567,7 +578,7 @@ const RUNTIME_FEATURE_RULES = { "global.native", "util.styleText", "crypto.cipherNew", - "dyn.builtinClass", + ...BUILTIN_CLASS_LIB_FNS, ...UTIL_CALLABLE_LIB_FNS, ], prefixes: ["insp.", "console.", "test."], diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5817-constructor-function-instanceof.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5817-constructor-function-instanceof.cjs.json new file mode 100644 index 000000000..d9ad97beb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5817-constructor-function-instanceof.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5817-constructor-function-instanceof.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5818-constructor-function-emitter-defaults.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5818-constructor-function-emitter-defaults.cjs.json new file mode 100644 index 000000000..f22e1aaeb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5818-constructor-function-emitter-defaults.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5818-constructor-function-emitter-defaults.cjs" + ], + "diags": [] +} diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index a6b11f53e..8f9a0bc69 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -91,7 +91,7 @@ export const OPTIONAL_RUNTIME_SOURCES = [ // Untyped EventEmitter and stream constructor values call listeners // through the dyn-invoke unit, inspect unhandled 'error' payloads and // create EventEmitter's private symbols. - ["scr_dyn_builtin_class.c", all("dynBuiltinClass", "inspect", "dynInvoke", "symbol")], + ["scr_dyn_builtin_class.c", all("dynBuiltinClass", "inspect", "dynInvoke", "symbol", "emitter")], ["scr_stream.c", "stream"], // The zlib stream classes reference both the zlib engine and the stream unit. ["scr_zlib_stream.c", all("zlib", "stream")], diff --git a/packages/runtime/src/scr_dyn_builtin_class.c b/packages/runtime/src/scr_dyn_builtin_class.c index 76a7ab15e..6d894615b 100644 --- a/packages/runtime/src/scr_dyn_builtin_class.c +++ b/packages/runtime/src/scr_dyn_builtin_class.c @@ -263,9 +263,7 @@ static double bc_max_listeners(ScrDyn *self) { if (!max) return -1; double value; if (bc_undefined(max)) { - ScrDyn *fallback = bc_get(bc_class(BC_EMITTER), "defaultMaxListeners"); - value = fallback ? bc_number(fallback) : 10; - if (fallback) scr_dyn_release(fallback); + value = scr_emitter_get_default_max(); } else { value = bc_number(max); } @@ -1017,6 +1015,42 @@ static ScrDyn *bc_set_max(ScrClosure *closure, ScrDyn *const *args, size_t argc) return self; } +/* EventEmitter.defaultMaxListeners, Node's accessor over the one default + * the typed EventEmitter reads too (scr_events_emitter.c). The setter + * validates as Node's validateNumber(arg, 'defaultMaxListeners', 0). */ +static ScrDyn *bc_default_max_get(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + (void)args; + (void)argc; + return scr_dyn_new_num(scr_emitter_get_default_max()); +} + +static ScrDyn *bc_default_max_set(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + static const char name[] = "defaultMaxListeners"; + ScrStr *key = scr_str_new(name, sizeof name - 1); + scr_emitter_set_default_max_chk(argc ? args[0] : scr_dyn_undefined(), key); + scr_str_release(key); + if (scr_exc_pending()) return NULL; + return scr_dyn_retain(scr_dyn_undefined()); +} + +static void bc_define_default_max(ScrDyn *ctor) { + ScrDyn *descriptor = scr_dyn_new_obj(); + scr_dyn_obj_set(descriptor, "get", 3, + scr_dyn_new_func(scr_closure_new(NULL, 0), bc_default_max_get, 0, "native:events", "get")); + scr_dyn_obj_set(descriptor, "set", 3, + scr_dyn_new_func(scr_closure_new(NULL, 0), bc_default_max_set, 1, "native:events", "set")); + scr_dyn_obj_set(descriptor, "enumerable", 10, scr_dyn_new_bool(true)); + ScrStr *name = scr_str_new("defaultMaxListeners", 19); + ScrDyn *key = scr_dyn_new_str(name); + scr_str_release(name); + ScrDyn *defined = scr_dyn_define_property(ctor, key, descriptor); + if (defined) scr_dyn_release(defined); + scr_dyn_release(key); + scr_dyn_release(descriptor); +} + static ScrDyn *bc_get_max(ScrClosure *closure, ScrDyn *const *args, size_t argc) { (void)closure; (void)args; @@ -1070,6 +1104,13 @@ static bool bc_pipe_on(ScrDyn *emitter, const char *event, ScrClosure *state, in } static bool bc_pipe_cleanup(ScrClosure *state) { + /* A second run (dest emitting 'close' from end(), with cleanup still + * queued on the source's 'end') finds the listeners removed, as Node's + * repeated removeListener calls do. */ + ScrDyn *handlers = bc_cap(state, BC_PIPE_HANDLERS); + bool cleaned = handlers->kind == SCR_DYN_ARR && scr_dyn_arr_len(handlers) == 0; + scr_dyn_release(handlers); + if (cleaned) return true; ScrDyn *source = bc_cap(state, BC_PIPE_SOURCE); ScrDyn *dest = bc_cap(state, BC_PIPE_DEST); bool ok = bc_pipe_remove(source, "data", state, BC_ONDATA) && @@ -1367,7 +1408,7 @@ static ScrDyn *bc_new_class(int kind, ScrDyn *parent) { bc_method(prototype, "listenerCount", bc_listener_count, 2); bc_method(prototype, "eventNames", bc_event_names, 0); bc_set(ctor, "EventEmitter", scr_dyn_retain(ctor)); - bc_set(ctor, "defaultMaxListeners", scr_dyn_new_num(10)); + bc_define_default_max(ctor); ScrDyn *count = scr_dyn_new_func(scr_closure_new(NULL, 0), bc_static_listener_count, 2, "native:events", "listenerCount"); bc_set(ctor, "listenerCount", count); } else if (kind == BC_STREAM) { diff --git a/tests/corpus/5817-constructor-function-instanceof.cjs b/tests/corpus/5817-constructor-function-instanceof.cjs new file mode 100644 index 000000000..355f53a64 --- /dev/null +++ b/tests/corpus/5817-constructor-function-instanceof.cjs @@ -0,0 +1,15 @@ +// `this instanceof F` in a constructor function, in a program that uses no +// EventEmitter or stream constructor value: the call-without-new idiom +// returns a new instance, and instanceof follows the function's prototype. +"use strict"; +function Glob(pattern) { + if (!(this instanceof Glob)) return new Glob(pattern); + this.pattern = pattern; +} +Glob.prototype.describe = function () { + return `glob ${this.pattern}`; +}; +const called = Glob("*.ts"); +const constructed = new Glob("*.js"); +console.log(called.describe(), constructed.describe()); +console.log(called instanceof Glob, {} instanceof Glob, Object.create(Glob.prototype) instanceof Glob); diff --git a/tests/corpus/5818-constructor-function-emitter-defaults.cjs b/tests/corpus/5818-constructor-function-emitter-defaults.cjs new file mode 100644 index 000000000..d89383b3a --- /dev/null +++ b/tests/corpus/5818-constructor-function-emitter-defaults.cjs @@ -0,0 +1,53 @@ +// EventEmitter.defaultMaxListeners read and written through the untyped +// EventEmitter constructor (Node's accessor and its validation), and a +// legacy pipe whose destination emits 'close' synchronously from end(). +"use strict"; +const util = require("util"); +const EventEmitter = require("events"); +const Stream = require("stream"); + +function Bus() { + EventEmitter.call(this); +} +util.inherits(Bus, EventEmitter); + +console.log(EventEmitter.defaultMaxListeners); +console.log(new Bus().getMaxListeners()); +EventEmitter.defaultMaxListeners = 3; +console.log(EventEmitter.defaultMaxListeners, new Bus().getMaxListeners()); +for (const value of [-1, "4", Number.NaN]) { + try { + EventEmitter.defaultMaxListeners = value; + } catch (error) { + console.log(error.name, error.code, error.message); + } +} +console.log(EventEmitter.defaultMaxListeners); +EventEmitter.defaultMaxListeners = 10; + +function Source() { + Stream.call(this); + this.readable = true; +} +util.inherits(Source, Stream); + +function Sink() { + Stream.call(this); + this.writable = true; +} +util.inherits(Sink, Stream); +Sink.prototype.write = function (chunk) { + console.log("write", chunk); + return true; +}; +Sink.prototype.end = function () { + console.log("sink end"); + this.emit("close"); +}; + +const source = new Source(); +const sink = new Sink(); +source.pipe(sink); +source.emit("data", "a"); +console.log("emit end", source.emit("end")); +console.log("listeners", source.listenerCount("end"), source.listenerCount("data"), sink.listenerCount("close")); diff --git a/tests/harness/corpus-costs/5817-constructor-function-instanceof.cjs.json b/tests/harness/corpus-costs/5817-constructor-function-instanceof.cjs.json new file mode 100644 index 000000000..15eedbeaa --- /dev/null +++ b/tests/harness/corpus-costs/5817-constructor-function-instanceof.cjs.json @@ -0,0 +1 @@ +{ "plain": 2655, "san": 7944 } diff --git a/tests/harness/corpus-costs/5818-constructor-function-emitter-defaults.cjs.json b/tests/harness/corpus-costs/5818-constructor-function-emitter-defaults.cjs.json new file mode 100644 index 000000000..0f4eb2e02 --- /dev/null +++ b/tests/harness/corpus-costs/5818-constructor-function-emitter-defaults.cjs.json @@ -0,0 +1 @@ +{ "plain": 2746, "san": 51663 } From 5aa4ccc3f2e1e07c518c91747c5277d97e17bef2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 11:28:48 -0500 Subject: [PATCH 28/31] Update tests for the EvalError and builtin module changes --- .../compiler/test/native-callable-values.test.ts | 12 ++++++------ .../corpus/6126-punycode-dependency/main.cjs.json | 2 +- tests/harness/errors.test.ts | 8 ++++---- tests/harness/npm.test.ts | 2 +- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/compiler/test/native-callable-values.test.ts b/packages/compiler/test/native-callable-values.test.ts index e64a9319e..6e65b0c99 100644 --- a/packages/compiler/test/native-callable-values.test.ts +++ b/packages/compiler/test/native-callable-values.test.ts @@ -102,19 +102,19 @@ test.each(["llvm"] as const)( writeFileSync( join(pkg, "index.js"), ` -const inspect = (value) => eval(value); +const inspect = (value) => process.on("__proto__", value); function stopped(value) { - eval(value); + process.on("__proto__", value); return { done() { return "wrong"; } }; } -try { console.log(stopped("0").done()); } -catch (error) { console.log(String(error).includes("SC2009")); } +try { console.log(stopped(() => {}).done()); } +catch (error) { console.log(String(error).includes("SC2020")); } const first = { inspect }; const second = { inspect }; console.log(typeof first.inspect, first.inspect === second.inspect); for (const callback of [first.inspect, second.inspect]) { - try { callback({}); } - catch (error) { console.log(String(error).includes("SC2009")); } + try { callback(() => {}); } + catch (error) { console.log(String(error).includes("SC2020")); } } `, ); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6126-punycode-dependency/main.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6126-punycode-dependency/main.cjs.json index 5de83361a..36f02246c 100644 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6126-punycode-dependency/main.cjs.json +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6126-punycode-dependency/main.cjs.json @@ -12,7 +12,7 @@ "end": 376 }, "milestone": "M4", - "hint": "relative imports (./file, ../dir/file), package.json-mediated project imports (#alias via the imports field, self-name references via exports), statically compiled npm packages, and the built-in fs, fs/promises, path, os, url, crypto, zlib, child_process, net, http, tls, https, http2, dgram, dns, util, util/types, string_decoder, querystring, readline, events, stream, stream/promises, stream/consumers, buffer, assert, assert/strict, worker_threads, cluster, tty, async_hooks, timers, timers/promises, diagnostics_channel, perf_hooks, module, and console modules (bare or node:-prefixed) and node:test (node:-prefixed only, like in Node) are supported" + "hint": "relative imports (./file, ../dir/file), package.json-mediated project imports (#alias via the imports field, self-name references via exports), statically compiled npm packages, and the built-in fs, fs/promises, path, os, url, crypto, zlib, child_process, net, http, tls, https, http2, dgram, dns, dns/promises, util, util/types, string_decoder, querystring, readline, events, stream, stream/promises, stream/consumers, stream/web, buffer, assert, assert/strict, worker_threads, cluster, tty, async_hooks, timers, timers/promises, diagnostics_channel, perf_hooks, module, v8, and console modules (bare or node:-prefixed) and node:test (node:-prefixed only, like in Node) are supported" } ] } diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index 6d93675ef..c35022f51 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -1040,7 +1040,7 @@ describe("refused statements in speculatively lowered JavaScript functions", () `(function () { var root = this; console.log("before"); - console.log(root === eval("this")); + process.on("__proto__", () => root); console.log("after"); }).call(this); `, @@ -1051,7 +1051,7 @@ describe("refused statements in speculatively lowered JavaScript functions", () "speculative-apply", `(function () { console.log("before"); - console.log(typeof eval("1")); + process.on("__proto__", () => {}); }).apply(null, []); `, "before\n", @@ -1062,7 +1062,7 @@ describe("refused statements in speculatively lowered JavaScript functions", () `class Base {} class Derived extends (function () { console.log("before"); - console.log(typeof eval("1")); + process.on("__proto__", () => {}); return Base; })() {} console.log(typeof Derived); @@ -1076,7 +1076,7 @@ console.log(typeof Derived); expect(r.stdout).toBe(stdout); expect(r.stderr).toMatch( new RegExp( - `^Uncaught Error: values of type '\\(x: string\\) => any' cannot be compiled: .* \\[SC2009 at .*${name}\\.cjs:${line}\\]\\n$`, + `^Uncaught Error: 'process\\.on\\("__proto__", \\.\\.\\.\\)' is part of the standard library types but has no scriptc lowering yet \\[SC2020 at .*${name}\\.cjs:${line}\\]\\n$`, ), ); }); diff --git a/tests/harness/npm.test.ts b/tests/harness/npm.test.ts index 25baaded6..168fdb6bd 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -60,7 +60,7 @@ const KNOWN: Record = { }, "island-web-plumbing": { run: "SC2020" }, "lazy-traps": { build: "SC2013" }, - "misc-shims": { build: "SC2013" }, + "misc-shims": { run: "SC2020" }, "namespace": { build: "SC1013" }, "node24-async-events": { run: "SC2020" }, "node24-commonjs-metadata": { run: "SC2020" }, From 437dc1163f77466486a63d759c57d15b70077b8e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 11:39:23 -0500 Subject: [PATCH 29/31] Refuse recursive unions in comptime baking The bakeability check tracks the unions it is inside as well as the records, so a union reachable from itself through an array (`type Tree = number | Tree[]`) reports SC1090 instead of recursing without end. The message names both recursive records and recursive unions. --- .../src/frontend/lowering/lower-comptime.ts | 28 +++++++++++-------- tests/diagnostics/comptime.ts | 4 +++ tests/harness/__snapshots__/comptime.ts.txt | 11 ++++++-- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-comptime.ts b/packages/compiler/src/frontend/lowering/lower-comptime.ts index bdd020127..f239eea82 100644 --- a/packages/compiler/src/frontend/lowering/lower-comptime.ts +++ b/packages/compiler/src/frontend/lowering/lower-comptime.ts @@ -76,7 +76,7 @@ export function lowerComptime(lowerer: Lowerer, expr: ts.CallExpression): IrExpr "SC1090", expr, `baking comptime results of type '${lowerer.fmt(target)}' into the binary ` + - `(number, string, boolean, and arrays, records and unions of those lower to literals; a record type reachable from its own fields does not)`, + `(number, string, boolean, and arrays, records and unions of those lower to literals; a record or union type reachable from itself does not)`, ); } // 5. The host executes the extracted callback under a finite budget. @@ -112,13 +112,14 @@ function comptimeFailureDetail(error: unknown): string { * expression: number/string/boolean, arrays and record shapes of those, * and unions of those with null or undefined (the mixed rows of a JSON * table: `(string | number)[][]`), nested. Closures, class instances, - * promises, dyn, void, and recursive record types (a record reachable - * from its own fields) cannot be baked. */ + * promises, dyn, void, and recursive types (a record or union reachable + * from itself, directly or through arrays) cannot be baked. */ export function comptimeBakeable(lowerer: Lowerer, t: IrType): boolean { return bakeableWithin(lowerer, t, new Set()); } -/** comptimeBakeable below the records in `open`, whose fields are being +/** comptimeBakeable below the records and unions in `open` (keyed + * `record:` and `union:`), whose members are being * checked: reaching one of them again means the type is recursive. */ function bakeableWithin(lowerer: Lowerer, t: IrType, open: Set): boolean { switch (t.kind) { @@ -130,20 +131,23 @@ function bakeableWithin(lowerer: Lowerer, t: IrType, open: Set): boolean return bakeableWithin(lowerer, t.elem, open); case "record": { const shape = lowerer.shapes.get(t.shapeId); - if (!shape || open.has(t.shapeId)) return false; - open.add(t.shapeId); + const key = `record:${t.shapeId}`; + if (!shape || open.has(key)) return false; + open.add(key); const bakeable = shape.fields.every((f) => bakeableWithin(lowerer, f.type, open)); - open.delete(t.shapeId); + open.delete(key); return bakeable; } case "union": { const arms = lowerer.unions.get(t.unionId)?.arms; - return ( - arms !== undefined && - arms.every( - (arm) => isUnitType(arm) || (arm.kind !== "union" && bakeableWithin(lowerer, arm, open)), - ) + const key = `union:${t.unionId}`; + if (arms === undefined || open.has(key)) return false; + open.add(key); + const bakeable = arms.every( + (arm) => isUnitType(arm) || (arm.kind !== "union" && bakeableWithin(lowerer, arm, open)), ); + open.delete(key); + return bakeable; } default: return false; diff --git a/tests/diagnostics/comptime.ts b/tests/diagnostics/comptime.ts index 9c8239464..645779ea7 100644 --- a/tests/diagnostics/comptime.ts +++ b/tests/diagnostics/comptime.ts @@ -27,3 +27,7 @@ interface Chain { next: Chain | null; } const chain: Chain = comptime(() => ({ value: 1, next: null }) as Chain); + +// A union reachable from itself through an array has no literal form either. +type Tree = number | Tree[]; +const tree: Tree = comptime((): Tree => [1, [2, [3]]]); diff --git a/tests/harness/__snapshots__/comptime.ts.txt b/tests/harness/__snapshots__/comptime.ts.txt index 603c297a8..757c30e6c 100644 --- a/tests/harness/__snapshots__/comptime.ts.txt +++ b/tests/harness/__snapshots__/comptime.ts.txt @@ -47,9 +47,16 @@ comptime.ts:22:23 - error SC1110: comptime evaluation failed: expected 'string' | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~ 23 | -comptime.ts:29:22 - error SC1090: baking comptime results of type '{ next: null | ...; value: number }' into the binary (number, string, boolean, and arrays, records and unions of those lower to literals; a record type reachable from its own fields does not) is not supported yet +comptime.ts:29:22 - error SC1090: baking comptime results of type '{ next: null | ...; value: number }' into the binary (number, string, boolean, and arrays, records and unions of those lower to literals; a record or union type reachable from itself does not) is not supported yet 28 | } 29 | const chain: Chain = comptime(() => ({ value: 1, next: null }) as Chain); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 30 | \ No newline at end of file + 30 | + +comptime.ts:33:20 - error SC1090: baking comptime results of type '(...)[] | number' into the binary (number, string, boolean, and arrays, records and unions of those lower to literals; a record or union type reachable from itself does not) is not supported yet + + 32 | type Tree = number | Tree[]; + 33 | const tree: Tree = comptime((): Tree => [1, [2, [3]]]); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 34 | \ No newline at end of file From f7287e30ff72d988d0f49cb42a7fd3e6bbd8e4e7 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 11:40:52 -0500 Subject: [PATCH 30/31] Keep the fromEntries assertion bridge to entries without native arrays or records Object.fromEntries(...) as T passed the entries through the checked-dynamic bridge, which copies native arrays and records: an array shared by two entries, or held elsewhere, became separate copies, and writes through the asserted object no longer reached it. The bridge now applies only when the entries' values are scalars or checked-dynamic values, which pass by value or by identity; other entries keep the exact-shape fence (SC2002). Corpus 6467 checks that entries held as unknown keep their identity; diagnostics fixture fromentries-aliased-entries.ts pins the fence. --- .../frontend/lowering/expressions/narrowing.ts | 12 +++++++++--- .../fromentries-aliased-entries.ts.json | 6 ++++++ .../corpus/6467-fromentries-record-assertion.ts | 16 +++++++++++++++- tests/diagnostics/fromentries-aliased-entries.ts | 13 +++++++++++++ .../fromentries-aliased-entries.ts.txt | 8 ++++++++ 5 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/fromentries-aliased-entries.ts.json create mode 100644 tests/diagnostics/fromentries-aliased-entries.ts create mode 100644 tests/harness/__snapshots__/fromentries-aliased-entries.ts.txt diff --git a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts index 0397497a8..ef6a30c03 100644 --- a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts +++ b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts @@ -1194,9 +1194,11 @@ function classInstanceOrAbsent(lowerer: Lowerer, type: IrType): boolean { * fresh object nothing else references, so the checked-dynamic bridge, * which keeps keys and their order the same way `JSON.parse(text) as T` * does, cannot be told apart from the original object; a value outside - * T throws the catchable TypeError of every checked cast. Other sources - * may be aliased, where a rebuilt object would sever mutation, so they - * keep the exact-shape fence. */ + * T throws the catchable TypeError of every checked cast. The entries' + * values must be scalars or checked-dynamic values, which the bridge + * passes by value or by identity; native arrays and records would be + * copied. Other sources may be aliased, where a rebuilt object would sever + * mutation, so they keep the exact-shape fence. */ function freshEntriesRecordAssertion( lowerer: Lowerer, expr: ts.AsExpression | ts.TypeAssertion, @@ -1208,6 +1210,10 @@ function freshEntriesRecordAssertion( const dest = lowerer.shapes.get(target.shapeId); if (!source?.indexValue || source.tuple || source.fields.length > 0) return null; if (!dest || dest.tuple || dest.indexValue || shapeHasAccessorSlots(dest)) return null; + // Native arrays and records in the entries would be copied on the way + // through the checked-dynamic bridge, severing their identity: only + // slots of scalars or checked-dynamic values pass. + if (extractionCopiesReference(lowerer, source.indexValue)) return null; let operand: ts.Expression = expr.expression; while (ts.isParenthesizedExpression(operand)) operand = operand.expression; if ( diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/fromentries-aliased-entries.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/fromentries-aliased-entries.ts.json new file mode 100644 index 000000000..7388f2b71 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/fromentries-aliased-entries.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/fromentries-aliased-entries.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6467-fromentries-record-assertion.ts b/tests/corpus/6467-fromentries-record-assertion.ts index 1496292b3..cf8f1c1ff 100644 --- a/tests/corpus/6467-fromentries-record-assertion.ts +++ b/tests/corpus/6467-fromentries-record-assertion.ts @@ -1,6 +1,6 @@ // `Object.fromEntries(...) as T` for a declared record T keeps exactly // the keys the entries created, in their order, as Node keeps the object -// itself. +// itself, as long as the entries hold scalars or checked-dynamic values. type Tags = { "error.type": string; "error.msg": string; "error.cause"?: string }; function scrub(input: string): string { @@ -27,3 +27,17 @@ const p = Object.fromEntries([ ["z", 3], ]) as Point; console.log(p, p.x + p.y, Object.keys(p), JSON.stringify(p)); + +// Entries held as unknown keep their identity: the object's members are +// the same array and record, and writes through either reach both. +type Holder = { list: number[]; point: { x: number } }; +const list = [1, 2]; +const point = { x: 1 }; +const entries: [string, unknown][] = [ + ["list", list], + ["point", point], +]; +const held = Object.fromEntries(entries) as Holder; +held.list.push(3); +held.point.x = 5; +console.log(list.length, point.x, held.list === list, held.point === point); diff --git a/tests/diagnostics/fromentries-aliased-entries.ts b/tests/diagnostics/fromentries-aliased-entries.ts new file mode 100644 index 000000000..ee5cc5338 --- /dev/null +++ b/tests/diagnostics/fromentries-aliased-entries.ts @@ -0,0 +1,13 @@ +// `Object.fromEntries(...) as T` keeps the object only when the entries' +// values are scalars or checked-dynamic values. Native arrays and records +// in the entries would be copied, severing their identity, so the +// assertion keeps the exact-shape fence (corpus 6467 covers the cases that +// lower). +type Scores = { a: number[]; b: number[] }; +const shared = [10]; +const scores = Object.fromEntries([ + ["a", shared], + ["b", shared], +]) as Scores; +scores.a.push(11); +console.log(shared.length, scores.b.length); diff --git a/tests/harness/__snapshots__/fromentries-aliased-entries.ts.txt b/tests/harness/__snapshots__/fromentries-aliased-entries.ts.txt new file mode 100644 index 000000000..7b0f02266 --- /dev/null +++ b/tests/harness/__snapshots__/fromentries-aliased-entries.ts.txt @@ -0,0 +1,8 @@ +fromentries-aliased-entries.ts:8:16 - error SC2002: record shapes must match exactly or width-coerce: expected '{ a: number[]; b: number[] }', got '{ [key: string]: number[] }' — 'a' is not a declared field of the source, and the source's index signature could hold it at runtime (a completed undefined would drop that value) + + 7 | const shared = [10]; + 8 | const scores = Object.fromEntries([ + | ^~~~~~~~~~~~~~~~~~~~ + 9 | ["a", shared], + + hint: width subtyping compiles as a copy (each expected field copied off the source; extra fields drop) — this pair is outside the copy's rules; build a literal with exactly the expected fields instead \ No newline at end of file From 08a3a7cf0624df62a3ed137a2f9969f2ec6e894c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 11:41:40 -0500 Subject: [PATCH 31/31] Read memarg flags through a function so the compiler compiles itself readMemargFlags took its reader as a `{ u32(): number }` record, and the validator (Reader) and translator (WasmCursor) pass class instances. The self-hosted compiler refuses a class instance in a record-typed parameter (SC2002), so the self-hosted LLVM emitter, the native frontend programs and the bootstrap seed builds failed. readMemargFlags now takes the reader's u32 function, and each caller passes `() => r.u32()`. --- packages/compiler/src/wasm/binary.ts | 4 ++-- packages/compiler/src/wasm/module.ts | 11 ++++++----- packages/compiler/src/wasm/translate.ts | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/compiler/src/wasm/binary.ts b/packages/compiler/src/wasm/binary.ts index ad5297739..eaedf25af 100644 --- a/packages/compiler/src/wasm/binary.ts +++ b/packages/compiler/src/wasm/binary.ts @@ -1245,7 +1245,7 @@ class FunctionValidator { /** A memarg. A memory other than 0 stops typing (multi-memory). */ memarg(naturalAlign: number): void { const r = this.r; - const { align, memory } = readMemargFlags(r); + const { align, memory } = readMemargFlags(() => r.u32()); if (align < 0) r.malformed("malformed memop flags"); if (memory !== 0) this.otherMemory(memory); if (this.m.memory64) r.u64(); @@ -1831,7 +1831,7 @@ class FunctionValidator { scanMemarg(): void { const r = this.r; - const { align, memory } = readMemargFlags(r); + const { align, memory } = readMemargFlags(() => r.u32()); if (align < 0) r.malformed("malformed memop flags"); if (memory !== 0) this.m.feature("multi-memory"); if (this.m.memory64) r.u64(); diff --git a/packages/compiler/src/wasm/module.ts b/packages/compiler/src/wasm/module.ts index d7ec38701..587a20d81 100644 --- a/packages/compiler/src/wasm/module.ts +++ b/packages/compiler/src/wasm/module.ts @@ -289,11 +289,12 @@ function decodeUtf8(bytes: Uint8Array, start: number, end: number): string { * 0; flags of 128 or more are malformed and answer align -1 without * reading further. The offset follows. Validation (binary.ts), translation * and skipImmediates all read memargs through this function, so they agree - * on where each instruction ends. */ -export function readMemargFlags(r: { u32(): number }): { align: number; memory: number } { - const flags = r.u32(); + * on where each instruction ends; `u32` reads the next u32 immediate of + * their own reader. */ +export function readMemargFlags(u32: () => number): { align: number; memory: number } { + const flags = u32(); if (flags >= 128) return { align: -1, memory: 0 }; - if (flags >= 64) return { align: flags - 64, memory: r.u32() }; + if (flags >= 64) return { align: flags - 64, memory: u32() }; return { align: flags, memory: 0 }; } @@ -386,7 +387,7 @@ export function skipImmediates(c: WasmCursor, op: number): void { } default: if (op >= 0x28 && op <= 0x3e) { - const flags = readMemargFlags(c); + const flags = readMemargFlags(() => c.u32()); if (flags.align < 0 || flags.memory !== 0) throw new Error(`wasm: memarg of memory ${flags.memory} is not supported`); c.u32(); diff --git a/packages/compiler/src/wasm/translate.ts b/packages/compiler/src/wasm/translate.ts index 813dab84a..f17c5dae3 100644 --- a/packages/compiler/src/wasm/translate.ts +++ b/packages/compiler/src/wasm/translate.ts @@ -851,7 +851,7 @@ class FunctionTranslator { /** The offset of a memarg over memory 0 (the only memory a translatable * module has). */ memargOffset(c: WasmCursor): number { - const flags = readMemargFlags(c); + const flags = readMemargFlags(() => c.u32()); if (flags.align < 0 || flags.memory !== 0) throw new WasmTranslateError(`memarg of memory ${flags.memory} is not supported`); return c.u32();