Merge main

This commit is contained in:
Chris Tate
2026-10-17 22:42:10 -05:00
13 changed files with 401 additions and 13 deletions
+4
View File
@@ -208,6 +208,10 @@ title: "Limitations"
<include>../limitations/dns-lookup-errors.mdx</include>
### Socket data encoding
<include>../limitations/socket-data-encoding.mdx</include>
### UDP socket options
<include>../limitations/udp-socket-options.mdx</include>
@@ -0,0 +1 @@
After `socket.setEncoding("utf8")`, an inline `'data'` listener receives strings, as in Node, whatever type its chunk parameter declares. A listener passed by reference, such as a named function whose parameter is typed `Buffer`, keeps its `Buffer` parameter, so a string chunk throws `TypeError: expected Uint8Array at $[0], got string`. Declare that parameter as `string` or `string | Buffer`, or pass an inline function.
@@ -94,6 +94,11 @@ export interface GenericFnInfo {
* checker types key an instantiation, exactly the generic machinery —
* the untyped params ARE the type parameters (see implicitCallInstance). */
implicitParams?: (ts.Symbol | null)[];
/** Implicit-any monomorphization of destructured object parameters
* (implicitPatternsOf): parallel to decl.parameters, the destructured
* names where the slot binds the call site's object type. Computed on
* first use. */
implicitPatterns?: (ImplicitPattern | null)[];
}
export interface GenericInstance {
@@ -1125,6 +1130,114 @@ function broadPromiseParam(lowerer: Lowerer, type: ts.Type): boolean {
);
}
/** A destructured object parameter (`function f({ Base, name })`) whose
* names bind the call site argument's property types. */
export interface ImplicitPattern {
elements: { property: string; symbol: ts.Symbol }[];
}
/** The destructured object parameters of a JS function-like that bind like
* implicit type parameters: every element a plain name (optionally renamed
* from an identifier or string key) with no default, no rest element, and
* no write in the body, under a broad inferred type. Parallel to
* decl.parameters. */
export function implicitPatternsOf(
lowerer: Lowerer,
decl: ts.FunctionDeclaration | ts.MethodDeclaration | ts.FunctionExpression | ts.ArrowFunction,
): (ImplicitPattern | null)[] {
return decl.parameters.map((param): ImplicitPattern | null => {
if (!ts.isObjectBindingPattern(param.name) || !decl.body) return null;
if (param.dotDotDotToken || param.questionToken || param.initializer) return null;
const elements: ImplicitPattern["elements"] = [];
for (const element of param.name.elements) {
if (element.dotDotDotToken || element.initializer || !ts.isIdentifier(element.name))
return null;
const key = element.propertyName ?? element.name;
if (!ts.isIdentifier(key) && !ts.isStringLiteral(key)) return null;
const symbol = lowerer.checker.getSymbolAtLocation(element.name);
if (!symbol || paramWrittenInBody(lowerer, decl.body, symbol, element.name.text)) return null;
// A JSDoc or contextual type names the properties: keep its ABI.
if ((lowerer.typeOf(element.name).flags & ts.TypeFlags.Any) === 0) return null;
elements.push({ property: key.text, symbol });
}
return elements.length > 0 ? { elements } : null;
});
}
/** The type a destructured parameter takes at one call site (the object's
* own representation, a record or a checked value) and the checker type
* each destructured name binds. An object literal argument types the names
* from its property expressions, which see the enclosing instance's
* bindings (`makeClass({ Base, name })` inside a specialized
* `extend(Base, name)`); any other argument uses its checker type. Null
* when the argument lacks a destructured property, or passes a class the
* callee itself defines: each such class would instantiate the callee
* again (a static `extend` calling `makeClass({ Base: this })`). */
function implicitPatternBinding(
lowerer: Lowerer,
callee: ts.Node,
pattern: ImplicitPattern,
arg: ts.Expression,
): { record: IrType; types: Map<ts.Symbol, ts.Type> } | null {
let literal: ts.Expression = arg;
while (ts.isParenthesizedExpression(literal)) literal = literal.expression;
const types = new Map<ts.Symbol, ts.Type>();
if (ts.isObjectLiteralExpression(literal)) {
const values = new Map<string, ts.Expression>();
/** Shorthand properties name the binding they read. */
const shorthand = new Map<string, ts.Symbol>();
for (const property of literal.properties) {
if (ts.isShorthandPropertyAssignment(property) && ts.isIdentifier(property.name)) {
values.set(property.name.text, property.name);
const read = lowerer.checker.getShorthandAssignmentValueSymbol(property);
if (read) shorthand.set(property.name.text, read);
} else if (
ts.isPropertyAssignment(property) &&
(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name))
)
values.set(property.name.text, property.initializer);
else return null;
}
for (const element of pattern.elements) {
const value = values.get(element.property);
if (!value) return null;
const read = shorthand.get(element.property);
const type = (read && lowerer.implicitParamTypes?.get(read)) ?? lowerer.typeOf(value);
types.set(element.symbol, lowerer.checker.getBaseTypeOfLiteralType(type));
}
// The object keeps the representation the call site lowers it to; the
// destructured names read their static types back out of it.
const lowered = tryLowerExpression(lowerer, literal);
if (lowered?.type.kind !== "record" && lowered?.type.kind !== "dyn") return null;
if (lowered.type.kind === "record") {
for (const field of lowerer.shapes.get(lowered.type.shapeId)?.fields ?? [])
if (field.type.kind === "classval" && classDefinedIn(lowerer, field.type.className, callee))
return null;
}
return { record: lowered.type, types };
}
const argType = lowerer.checker.getBaseTypeOfLiteralType(lowerer.typeOf(arg));
const record = lowerer.mapTypeOf(argType);
if (record?.kind !== "record") return null;
for (const element of pattern.elements) {
const property = lowerer.checker.getPropertyOfType(argType, element.property);
if (!property) return null;
types.set(element.symbol, lowerer.checker.getTypeOfSymbol(property));
}
return { record, types };
}
/** The class is declared inside `scope`. */
function classDefinedIn(lowerer: Lowerer, className: string, scope: ts.Node): boolean {
for (
let n: ts.Node | undefined = lowerer.classes.get(className)?.decl ?? undefined;
n;
n = n.parent
)
if (n === scope) return true;
return false;
}
/** The implicit-type-parameter slots of a JS function-like: parallel to
* decl.parameters, the param SYMBOL where the slot is a bindable
* implicit param (identifier-named, broad inferred/JSDoc type, not
@@ -1169,6 +1282,10 @@ export function implicitAnyParamSymbolsOf(
hasImplicitParams = true;
return sym;
});
// Destructured object parameters monomorphize too (implicitPatternsOf);
// their slots stay null here.
if (!hasImplicitParams && isJsSourceFile(decl.getSourceFile()))
hasImplicitParams = implicitPatternsOf(lowerer, decl).some((pattern) => pattern !== null);
return hasImplicitParams ? out : null;
}
@@ -1440,7 +1557,27 @@ export function implicitCallInstance(
): GenericInstance {
const shapes: ParamShape[] = [];
const argTypes = new Map<ts.Symbol, ts.Type>();
const patterns = (info.implicitPatterns ??= implicitPatternsOf(lowerer, info.decl));
/** The destructured names' bound types, which the object's own
* representation does not always carry. */
const patternKey: string[] = [];
info.decl.parameters.forEach((param, i) => {
const pattern = patterns[i];
const patternArg = call.arguments[i];
if (pattern && patternArg && !ts.isSpreadElement(patternArg)) {
// The argument's object type binds each destructured name, so a
// class passed as `{ Base }` keeps its static class type.
const bound = implicitPatternBinding(lowerer, info.decl, pattern, patternArg);
if (bound) {
for (const [symbol, type] of bound.types) {
argTypes.set(symbol, type);
const mapped = lowerer.mapTypeOf(type);
patternKey.push(mapped ? typeKey(mapped) : "?");
}
shapes.push({ type: bound.record, mode: "required" });
return;
}
}
const sym = info.implicitParams![i];
if (!sym) {
shapes.push(lowerer.paramShape(param));
@@ -1578,7 +1715,7 @@ export function implicitCallInstance(
}
shapes.push({ type: bound, mode: "required" });
});
return internImplicitInstance(lowerer, call, info, shapes, argTypes);
return internImplicitInstance(lowerer, call, info, shapes, argTypes, patternKey.join(","));
}
/** The all-dyn DEFAULT instance — today's compiled body exactly: what a
@@ -1611,8 +1748,9 @@ function internImplicitInstance(
info: GenericFnInfo,
shapes: ParamShape[],
argTypes: Map<ts.Symbol, ts.Type>,
patternKey = "",
): GenericInstance {
const key = shapes.map((s) => typeKey(s.type)).join(",");
const key = shapes.map((s) => typeKey(s.type)).join(",") + (patternKey ? `|${patternKey}` : "");
let inst = info.instances.get(key);
if (inst?.implicitState === "failed")
lowerer.unsupported("SC1090", blame, `the failed specialization of '${info.baseName}'`);
@@ -2111,6 +2111,67 @@ function lowerNetServerMethodCall(
);
}
/** Whether a setEncoding call can reach some net.Socket in this program:
* `x.setEncoding(...)` or `x["setEncoding"](...)` where `x` is a socket
* or an untyped value. Sockets that never get an encoding keep the Buffer
* chunk path; with one, Node's 'data' chunks are strings. */
function socketEncodingReachable(lowerer: Lowerer): boolean {
if (lowerer.socketEncodingReachable !== null) return lowerer.socketEncodingReachable;
let reachable = false;
for (const file of lowerer.program.getSourceFiles()) {
if (file.isDeclarationFile || !file.text.includes("setEncoding")) continue;
ts.walkPreorder(file, (node) => {
if (reachable) return "skip";
if (!ts.isCallExpression(node)) return undefined;
const callee = node.expression;
const receiver =
ts.isPropertyAccessExpression(callee) && callee.name.text === "setEncoding"
? callee.expression
: ts.isElementAccessExpression(callee) &&
ts.isStringLiteralLike(callee.argumentExpression) &&
callee.argumentExpression.text === "setEncoding"
? callee.expression
: null;
if (receiver === null) return undefined;
const kind = lowerer.mapTypeOf(lowerer.checker.getTypeAtLocation(receiver))?.kind;
if (kind === undefined || kind === "netSocket" || kind === "dyn") reachable = true;
return undefined;
});
if (reachable) break;
}
lowerer.socketEncodingReachable = reachable;
return reachable;
}
/** An inline 'data' listener whose chunk parameter is typed Buffer, for a
* socket that may have an encoding set: the chunk binds as a checked
* dynamic value, so the body sees what Node passes (a string after
* setEncoding, a Buffer otherwise) whatever the annotation says. Null
* for other listener shapes. */
function lowerEncodedDataListener(lowerer: Lowerer, node: ts.Expression): IrExpr | null {
let fn = node;
while (ts.isParenthesizedExpression(fn)) fn = fn.expression;
if (!ts.isArrowFunction(fn) && !ts.isFunctionExpression(fn)) return null;
if (fn.parameters.length !== 1) return null;
const param = fn.parameters[0]!;
if (!ts.isIdentifier(param.name) || param.initializer || param.dotDotDotToken) return null;
const symbol = lowerer.checker.getSymbolAtLocation(param.name);
if (symbol === undefined) return null;
const previousBinding = lowerer.runtimeOptionalBindingTypes.get(symbol);
const previousImplicit = lowerer.implicitParamTypes;
const implicit = new Map(previousImplicit ?? []);
implicit.set(symbol, lowerer.checker.getUnknownType());
lowerer.runtimeOptionalBindingTypes.set(symbol, DYN);
lowerer.implicitParamTypes = implicit;
try {
return lowerer.lowerExpr(fn);
} finally {
if (previousBinding === undefined) lowerer.runtimeOptionalBindingTypes.delete(symbol);
else lowerer.runtimeOptionalBindingTypes.set(symbol, previousBinding);
lowerer.implicitParamTypes = previousImplicit;
}
}
/** Method calls on net.Socket receivers: write/end/destroy/pipe and
* on/once("data" | "end" | "close" | "error" | "connect"). Null for
* other receivers. */
@@ -2565,6 +2626,27 @@ function lowerNetSocketMethodCall(
loc,
};
}
if (param !== null && param.kind === "bytes" && socketEncodingReachable(lowerer)) {
const encoded = lowerEncodedDataListener(lowerer, args[1]!);
// A listener defined elsewhere keeps its Buffer parameter; it takes
// the chunk through the checked boundary, so a string chunk fails
// the check instead of arriving as a Buffer.
const cb: IrExpr =
encoded ??
({
kind: "dynCheck",
value: lowerSocketDynListener(lowerer, args[1]!, "data listeners"),
type: funcOf([DYN], VOID),
loc,
} as IrExpr);
return {
kind: "libCall",
fn: "net.sockOnData",
args: [receiver, cb, once],
type: VOID,
loc,
};
}
const { cb } = lowerCallbackArg(
lowerer,
args[1]!,
@@ -341,6 +341,7 @@ import {
} from "./call-signatures.js";
import {
implicitAnyParamSymbolsOf,
implicitLocalFnNodeOf,
type GenericFnInfo,
type GenericInstance,
implicitMonoFile,
@@ -1525,6 +1526,28 @@ function panicSafe<T>(query: () => T, fallback: T): T {
}
}
/** The function's class expressions lower once per implicit-any instance.
* A function whose only implicit slots are destructured options and that
* is bound to a variable the implicit-instance probe rejects runs as one
* shared body, so its classes keep a single name. */
function monomorphizedImplicit(
lowerer: Lowerer,
fn: ts.FunctionDeclaration | ts.FunctionExpression | ts.ArrowFunction | ts.MethodDeclaration,
): boolean {
const slots = implicitAnyParamSymbolsOf(lowerer, fn);
if (!slots) return false;
if (slots.some((slot) => slot !== null)) return true;
let binding: ts.Node | undefined = fn.parent;
while (binding && ts.isParenthesizedExpression(binding)) binding = binding.parent;
if (
(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) &&
binding &&
ts.isVariableDeclaration(binding)
)
return implicitLocalFnNodeOf(lowerer, binding) !== null;
return true;
}
export class Lowerer {
readonly frontendServices: FrontendServices | undefined;
readonly checker: ts.TypeChecker;
@@ -1769,6 +1792,10 @@ export class Lowerer {
* typeParamBindings — the checker has no `T` to substitute, so the
* binding rides the node-type accessor instead of mapType. */
implicitParamTypes: Map<ts.Symbol, ts.Type> | null = null;
/** Whether some net.Socket in this program may get an encoding: a
* setEncoding call whose receiver is a socket or an untyped value.
* Computed once on first use (null until then). */
socketEncodingReachable: boolean | null = null;
/** IMPLICIT-ANY instances lowered EAGERLY at first demand (their return
* types are inferred from the body — the call site needs them settled),
* collected here for run()'s function list (the liftedFns discipline). */
@@ -2787,7 +2814,7 @@ export class Lowerer {
if (this.localClassInstantiations.some((entry) => entry.owner === parent)) break;
if (
parent.typeParameters?.length ||
(implicitMonoFile(parent.getSourceFile()) && implicitAnyParamSymbolsOf(this, parent))
(implicitMonoFile(parent.getSourceFile()) && monomorphizedImplicit(this, parent))
)
return null;
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5972-net-set-encoding-listeners.ts"
],
"diags": []
}
+11 -10
View File
@@ -56,9 +56,10 @@ Each checkout must have a built CLI (packages/cli/dist/bootstrap.js) and native
artifacts for this host. Without --baseline, reports absolute times only. Node is
timed in the same interleaved loop as the reference to beat (--no-node skips it).
--layouts=N (macOS/Linux) links every executable N times, shifting the program
object by 0, 16, 32, ... bytes of padding, and spreads the timed runs evenly over
those layouts, so verdicts cover code-placement luck instead of one placement.
--layouts=N (macOS/Linux) links every executable N times, shifting the runtime
and program code by 0, 16, 32, ... bytes of padding, and spreads the timed runs
evenly over those layouts, so verdicts cover code-placement luck instead of one
placement.
Use it for small codegen changes (e.g. --layouts=4 --runs=16).
Workload names are listed in benchmarks/runtime/workloads.json; for example
@@ -103,10 +104,11 @@ const work = mkdtempSync(
);
/* ── code-layout perturbation ──────────────────────────────────────────── */
// Layout k > 0 links a retained k*16-byte padding object directly before the
// program object, through a SCRIPTC_LINKER wrapper that forwards to clang (the
// default linker driver on macOS and Linux). Code after that point moves as if
// earlier code had grown; any checkout's compiler can be perturbed this way.
// Layout k > 0 links a retained k*16-byte padding object before every other
// input, through a SCRIPTC_LINKER wrapper that forwards to clang (the default
// linker driver on macOS and Linux). The runtime is linked before the program
// object, so both move as if earlier code had grown; any checkout's compiler can
// be perturbed this way.
function layoutTooling() {
if (layouts === 1) return null;
const root = join(work, "layouts");
@@ -137,12 +139,11 @@ const args = process.argv.slice(2);
const pad = process.env.SCRIPTC_BENCH_LAYOUT_PAD;
let out = args;
if (pad && !args.some((a) => a.startsWith("-print-prog-name"))) {
const at = args.findIndex((a) => basename(a).startsWith(".scriptc-native-program-object-"));
if (at < 0) {
if (!args.some((a) => basename(a).startsWith(".scriptc-native-program-object-"))) {
process.stderr.write("bench layout wrapper: no program object on the link line\\n");
process.exit(2);
}
out = ["-Wl,-u,${symbol}", ...args.slice(0, at), pad, ...args.slice(at)];
out = ["-Wl,-u,${symbol}", pad, ...args];
}
const result = spawnSync("clang", out, { stdio: "inherit" });
process.exit(result.status ?? 1);
@@ -0,0 +1,42 @@
// After socket.setEncoding("utf8"), every 'data' listener receives the
// string Node passes, whether its chunk parameter is unannotated,
// annotated Buffer, string or string | Buffer. A Buffer-only method on
// the string throws Node's TypeError. A socket without an encoding in
// the same program still receives Buffers.
import * as net from "node:net";
const server = net.createServer((socket) => {
socket.on("data", (chunk) => {
console.log("server chunk:", typeof chunk, chunk.length, "bytes:", chunk[0]);
socket.end("hello wörld");
});
});
server.listen(0, "127.0.0.1", () => {
const { port } = server.address() as net.AddressInfo;
const client = net.connect(port, "127.0.0.1", () => client.write("hi"));
client.setEncoding("utf8");
let text = "";
client.on("data", (chunk) => {
text += chunk;
console.log("unannotated:", typeof chunk, chunk.length, chunk);
console.log("unannotated upper:", chunk.toString().toUpperCase());
});
client.on("data", (chunk: Buffer) => {
console.log("annotated Buffer:", typeof chunk, chunk);
console.log("annotated concat:", "<" + chunk + ">");
try {
console.log(chunk.readUInt8(0));
} catch (error) {
console.log("readUInt8:", (error as Error).name, (error as Error).message);
}
});
client.once("data", (chunk: string) => console.log("string:", chunk.split(" ")));
client.on("data", (chunk: string | Buffer) =>
console.log("union:", typeof chunk === "string" ? "string " + chunk : "buffer"),
);
client.on("close", () => {
console.log("text:", text);
server.close();
});
});
+15
View File
@@ -0,0 +1,15 @@
// A package factory that receives its base class through a destructured
// options object builds subclasses of program classes that construct and
// dispatch like Node.
import { Class, extend } from "class-options/classes.js";
class Animal extends Class("Animal") {}
class Dog extends extend(Animal, "Dog") {}
class Puppy extends extend(Dog, "Puppy") {}
const rex = new Dog("Rex");
const ada = new Animal("Ada");
const bit = new Puppy("Bit");
console.log(rex instanceof Dog, rex instanceof Animal, ada instanceof Dog, bit instanceof Animal);
console.log(rex.describe(), ada.describe(), bit.describe());
console.log(rex.kind, rex.value, ada.kind, bit.label);
+31
View File
@@ -0,0 +1,31 @@
// A class factory that takes its base and settings as one options object,
// as Effect's makeClass does: `makeClass({ Base, name })` destructures them
// and builds `class extends Base`.
function makeClass({ Base, name, tag: label }) {
return class extends Base {
constructor(value) {
super(value);
this.kind = name;
this.label = label;
}
describe() {
return `${this.label}:${this.kind}:${this.value}`;
}
// Each class passes itself back as a base: a call site must not
// specialize makeClass on the classes makeClass itself defines.
static extend(name) {
return makeClass({ Base: this, name, tag: label });
}
};
}
class Root {
constructor(value) {
this.value = value;
}
}
export function Class(name) {
return makeClass({ Base: Root, name, tag: "root" });
}
export function extend(Base, name) {
return makeClass({ Base, name, tag: "sub" });
}
+1
View File
@@ -0,0 +1 @@
{ "name": "class-options", "version": "1.0.0", "type": "module", "exports": { "./classes.js": "./classes.js" } }
+6
View File
@@ -1850,6 +1850,9 @@
"5886-inspect-dates.ts": 2559,
"5887-inspect-typed-arrays.ts": 3448,
"5888-inspect-sorted.ts": 2998,
"5970-net-connect-host-name.ts": 2400,
"5971-buffer-chunks-to-string.ts": 2700,
"5972-net-set-encoding-listeners.ts": 2550,
"600-closures-basic.ts": 2349,
"601-closures-loops.ts": 1788,
"602-closures-identity-recursion.ts": 2372,
@@ -4483,6 +4486,9 @@
"5886-inspect-dates.ts": 2736,
"5887-inspect-typed-arrays.ts": 2997,
"5888-inspect-sorted.ts": 2861,
"5970-net-connect-host-name.ts": 2800,
"5971-buffer-chunks-to-string.ts": 3100,
"5972-net-set-encoding-listeners.ts": 3000,
"600-closures-basic.ts": 2534,
"601-closures-loops.ts": 2297,
"602-closures-identity-recursion.ts": 2427,
+34
View File
@@ -618,6 +618,40 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => {
},
);
test.each(["release", "dev"] as const)(
"a factory that destructures its base class from an options object builds static subclasses (%s)",
async (optimization) => {
const entry = join(pilotRoot, "class-options-cli.ts");
const outDir = join(cacheDir, `class-options-${optimization}-${sanitize ? "san" : "plain"}`);
const result = await compile(entry, {
backend: "llvm",
npmStatic: ["class-options"],
sanitize,
optimization,
outDir,
outPath: join(outDir, "program"),
emitIr: true,
});
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const [reference, native] = await Promise.all([
runBinary(process.execPath, [entry]),
runBinary(result.binaryPath, []),
]);
expect(reference.exitCode).toBe(0);
expect(native.stdout).toEqual(reference.stdout);
expect(comparableStderr(native.stderr)).toEqual(reference.stderr);
expect(native.exitCode).toBe(reference.exitCode);
// One makeClass instance per distinct base (Root, Animal, Dog) plus
// the generic one a class's own `extend` passing itself back reuses.
const ir = JSON.parse(readFileSync(result.irPath!, "utf8")) as {
functions: { name: string }[];
};
const instances = ir.functions.filter((fn) => /^%[\w.]*makeClass%\d+$/.test(fn.name));
expect(instances.length).toBeLessThanOrEqual(4);
},
);
test.for([
["escape-string-regexp", "escape-cli.ts"],
["slash", "slash-cli.ts"],