Merge main

This commit is contained in:
Chris Tate
2026-10-22 10:29:26 -05:00
78 changed files with 3319 additions and 364 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea
| `region-builds` | cpu | a watch-mode build loop over `LifetimeRegion`: each build binds a program in a new region and releases the previous one, which a cache keeps referenced for two builds |
| `template-render` | cpu | template literals, escaping, `map`/`join` |
| `thread-slots`, `thread-slots-cache` | cpu | worker threads reading one published type graph from an `InternTable` and keeping per-thread state for each type they visit: a `ThreadSlots` family, or a direct-mapped cache by id in front of a `Map` by id (the `-cache` entry); under Node.js both are per thread |
| `typed-array-kernels` | cpu | element-wise loops that run to one typed array's length and index others at the same position: `Float64Array` axpy, `Float32Array` blend, `Uint8Array` mask, wrapping `Int32Array` add, `Uint8ClampedArray` brighten, RGBA to gray, and `reduce`/`some`/`map` callbacks |
| `typed-array-kernels` | cpu | element-wise loops that run to one typed array's length and index others at the same position: `Float64Array` axpy, `Float32Array` blend, `Uint8Array` mask, wrapping `Int32Array` add, `Uint8ClampedArray` brighten, RGBA to gray, a row-major `Float32Array` matrix-vector product, and `reduce`/`some`/`map` callbacks |
| `typed-array-scan`, `typed-array-scan-workers` | cpu | element-by-element scan loops over `Uint8Array`, `Uint16Array`, `Int32Array` and `Float64Array`; the second entry is a worker-enabled executable (it can start a worker), so each access also checks for shared storage |
| `unicode-lexer` | cpu | a hand-written lexer over a 2.7M-unit bundled module whose comments and strings are in several languages: `charCodeAt` scans, line starts |
| `validate-errors` | cpu | throw/catch on a fraction of inputs, small call chains |
@@ -1,5 +1,6 @@
// Element-wise loops over several typed arrays: each loop runs to one
// array's length and reads or writes the others at the same index.
// array's length and reads or writes the others at the same index. Also a
// row-major matrix-vector product with four accumulators per row.
function axpy(a: number, x: Float64Array, y: Float64Array, out: Float64Array): void {
for (let i = 0; i < out.length; i++) out[i] = a * x[i]! + y[i]!;
@@ -21,6 +22,23 @@ function brighten(src: Uint8ClampedArray, gain: number, out: Uint8ClampedArray):
for (let i = 0; i < out.length; i++) out[i] = src[i]! * gain + 8;
}
function matvec(w: Float32Array, x: Float32Array, out: Float32Array, cols: number): void {
for (let r = 0; r < out.length; r++) {
const base = r * cols;
let s0 = 0;
let s1 = 0;
let s2 = 0;
let s3 = 0;
for (let j = 0; j < cols; j += 4) {
s0 += w[base + j]! * x[j]!;
s1 += w[base + j + 1]! * x[j + 1]!;
s2 += w[base + j + 2]! * x[j + 2]!;
s3 += w[base + j + 3]! * x[j + 3]!;
}
out[r] = s0 + s1 + s2 + s3;
}
}
// Callback methods over a whole typed array.
function callbacks(samples: Float32Array, limit: number, data: Uint8Array): number {
const energy = samples.reduce((sum, v) => sum + v * v, 0);
@@ -53,6 +71,10 @@ const io = new Int32Array(n);
const pixels = new Uint8ClampedArray(n);
const bright = new Uint8ClampedArray(n);
const rgba = new Uint8Array(n * 4);
const side = 256;
const weights = new Float32Array(side * side);
const vector = new Float32Array(side);
const product = new Float32Array(side);
const luma = new Uint8Array(n);
let state = 2463534242;
const next = (): number => {
@@ -73,6 +95,8 @@ for (let i = 0; i < n; i++) {
pixels[i] = next() & 255;
}
for (let i = 0; i < rgba.length; i++) rgba[i] = next() & 255;
for (let i = 0; i < weights.length; i++) weights[i] = (next() % 2001) / 1000 - 1;
for (let i = 0; i < side; i++) vector[i] = (next() % 1001) / 1000;
const rounds = Math.round(1200 * scale);
let f64Sum = 0;
@@ -81,6 +105,7 @@ let byteSum = 0;
let intSum = 0;
let brightSum = 0;
let lumaSum = 0;
let productSum = 0;
let callbackSum = 0;
for (let r = 0; r < rounds; r++) {
// Change one input element per round so no round repeats the last.
@@ -91,12 +116,14 @@ for (let r = 0; r < rounds; r++) {
ia[at] = r * 65537;
pixels[at] = r & 255;
rgba[at * 4] = r & 255;
vector[r & (side - 1)] = (r % 1001) / 1000;
axpy(1.5, x, y, d);
blend((r & 15) / 16, fa, fb, fo);
mask(data, key, masked);
addWrap(ia, ib, io);
brighten(pixels, 0.75 + (r & 7) / 8, bright);
gray(rgba, luma);
matvec(weights, vector, product, side);
if ((r & 3) === 0) callbackSum += callbacks(fo, 140 + (r & 12), masked);
const probe = (r * 104729) & (n - 1);
f64Sum += d[probe]!;
@@ -105,6 +132,7 @@ for (let r = 0; r < rounds; r++) {
intSum = (intSum + io[probe]!) | 0;
brightSum += bright[probe]!;
lumaSum += luma[probe]!;
productSum += product[probe & (side - 1)]!;
}
console.log(`float64 ${f64Sum.toFixed(3)}`);
console.log(`float32 ${f32Sum.toFixed(3)}`);
@@ -112,4 +140,5 @@ console.log(`uint8 ${byteSum}`);
console.log(`int32 ${intSum}`);
console.log(`clamped ${brightSum}`);
console.log(`gray ${lumaSum}`);
console.log(`matvec ${productSum.toFixed(3)}`);
console.log(`callbacks ${callbackSum.toFixed(3)}`);
+2 -2
View File
@@ -72,7 +72,7 @@ describe.runIf(supported)("macOS arm64 native link info", () => {
minimum_os: "14.0",
},
program: { object, entry_symbol: "main" },
runtime_abi: { version: 26, marker: "scr_runtime_abi_v26" },
runtime_abi: { version: 27, marker: "scr_runtime_abi_v27" },
runtime_pack: {
kind: "precompiled",
package: "@scriptc/runtime-darwin-arm64",
@@ -165,6 +165,6 @@ describe.runIf(supported)("macOS arm64 native link info", () => {
() => null,
(failure: { stderr?: string }) => failure,
);
expect(error?.stderr).toContain("scr_runtime_abi_v26");
expect(error?.stderr).toContain("scr_runtime_abi_v27");
});
});
@@ -134,6 +134,39 @@ test("a counted loop's affine accesses get one guard and exact operand ranges",
expect(version.ranges.get(shifted)).toEqual({ min: 1, max: Number.MAX_SAFE_INTEGER });
});
test("invariant terms without a range are tested by the guard", () => {
// for (let i = 0; i < n; i++) out[i] = src[k + i], with `k` a parameter
const shifted = bin("+", ref("k"), ref("i"));
const loop = counted([set(ref("out", f64s), ref("i"), get(ref("src", f64s), shifted))]);
const kernel = fn([loop]);
const version = match(kernel, loop)!;
expect([...version.guarded]).toEqual(["v1:k"]);
expect(version.affine.map((c) => c.terms.map((t) => t.key))).toEqual([[], ["v1:k"]]);
// In the fast copy the term has the range the guard tests.
expect(version.ranges.get(shifted.kind === "bin" ? shifted.left : shifted)).toEqual({
min: -(2 ** 32),
max: 2 ** 32,
});
const mod: IrModule = {
irVersion: 17,
sourceFile: loc.file,
entry: "__main",
functions: [
{ name: "__main", params: [], returnType: VOID, locals: [], body: [], loc },
kernel,
],
};
const body = /^define internal [^\n]*@sc_bf_kernel[^]*?^}/m.exec(emitLlvmModule(mod))![0];
const guard = body.slice(0, body.search(/^bytes\.version\.(fast|slow)\d+:$/m));
// -2^32 <= k <= 2^32, and k is the double of its frozen integer.
expect(guard).toMatch(/fcmp oge double %\S+, 0xC1F0000000000000/);
expect(guard).toMatch(/fcmp ole double %\S+, 0x41F0000000000000/);
expect(guard).toMatch(/freeze i64/);
const fast = /^bytes\.version\.fast\d+:$[^]*?^bytes\.version\.done\d+:$/m.exec(body)![0];
expect(fast).not.toContain("bytes.index");
expect(body.slice(0, body.indexOf(fast))).toContain("bytes.index.invalid");
});
test("statically ranged indices compare one constant with the length", () => {
const masked = bin("&", bin("^", ref("i"), ref("k")), num(255));
const loop = counted([set(ref("out", f64s), num(0), get(ref("table", i32s), masked))]);
@@ -7,6 +7,7 @@ import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
import { exactInteger, widenInteger } from "./integer-values.js";
import { emitCountedLoopLimit, emitCountedLoopSafety } from "./counted-loops.js";
import { emitBytesHeaderLength, emitBytesSharedStorage } from "./expr-bytes.js";
import { f64Lit } from "./common.js";
type Node = IrExpr | IrStmt;
type VarRef = IrExpr & { kind: "varRef" };
@@ -50,6 +51,9 @@ export interface BytesLoopVersion {
ownedReceivers: number[];
fixed: FixedCheck[];
affine: AffineCheck[];
/** Keys of affine terms the guard tests at run time: exact integers
* within GUARDED_TERM. */
guarded: ReadonlySet<string>;
/** The counted loop whose iteration space the affine checks cover. */
counted: IntegerCountedForLoop | null;
/** The guard also requires every counter value within [-bound, bound]. */
@@ -83,6 +87,9 @@ const MAX_FIXED_END = 2 ** 32;
const MAX_CHECKS = 8;
const MAX_OFFSET = 2 ** 31;
const SAFE = Number.MAX_SAFE_INTEGER;
/** The range the guard requires of an invariant index term without a
* proven one: every index of a typed array of at most 2^32 elements. */
const GUARDED_TERM: IntegerRange = { min: -(2 ** 32), max: 2 ** 32 };
const INVARIANT_OPS = new Set(["+", "-", "*", "&", "|", "^", "<<", ">>", ">>>"]);
/** Loop versioning for typed-array bounds. A typed array's length and data
@@ -95,7 +102,9 @@ const INVARIANT_OPS = new Set(["+", "-", "*", "&", "|", "^", "<<", ">>", ">>>"])
* `hi < length`, such as `table[x & 0xff]` or a constant-trip counter;
* - an index `k * counter + invariant + c` of a counted loop needs its first
* and last values in bounds, such as `out[base + i]` for `i < n` or
* `view.getUint32(i * 4)`.
* `view.getUint32(i * 4)`. An invariant without a proven integer range,
* such as `base` for `const base = r * cols`, must also be an exact
* integer between -2^32 and 2^32, which the guard tests.
*
* In worker programs, every access of a view that may be shared tests its
* shared storage. A view's storage is also fixed at construction, so the
@@ -198,9 +207,8 @@ export function matchBytesLoopVersion(
}
return invariant(e.localId) ? e : null;
};
// A pure integer operand evaluated again before the loop.
const invariantKey = (e: IrExpr): string | null => {
if (!facts.ranges.get(e)) return null;
// A pure loop-invariant number, evaluated again before the loop.
const pureKey = (e: IrExpr): string | null => {
if (e.kind === "numLit") return Number.isSafeInteger(e.value) ? `n${e.value}` : null;
if (e.kind === "varRef")
return invariant(e.localId) ? `v${e.localId.length}:${e.localId}` : null;
@@ -210,12 +218,27 @@ export function matchBytesLoopVersion(
e.left.type.kind === "f64" &&
e.right.type.kind === "f64"
) {
const left = invariantKey(e.left),
right = invariantKey(e.right);
const left = pureKey(e.left),
right = pureKey(e.right);
return left !== null && right !== null ? `(${e.op} ${left} ${right})` : null;
}
return null;
};
// Invariant terms without a proven range, such as `base` in
// `w[base + j]` with `const base = r * cols` for a parameter `cols`. The
// guard tests each one: an exact integer (not -0) within GUARDED_TERM.
const guardedRanges = new Map<IrExpr, IntegerRange>();
const guardedNodes = new Map<IrExpr, string>();
// The key of a loop-invariant index term, with a proven range or guarded.
const invariantKey = (e: IrExpr): string | null => {
const key = pureKey(e);
if (key === null || facts.ranges.get(e)) return key;
if (e.kind === "numLit") return null;
guardedRanges.set(e, GUARDED_TERM);
guardedNodes.set(e, key);
return key;
};
const termRange = (e: IrExpr): IntegerRange => facts.ranges.get(e) ?? guardedRanges.get(e)!;
interface Affine {
/** The counter's coefficient; 0 when the value is loop-invariant. */
counter: number;
@@ -223,9 +246,10 @@ export function matchBytesLoopVersion(
offset: number;
}
// Every operand of an affine index is `counter * k + Σ terms + offset`.
// Invariant terms need proven ranges. Operands that involve the counter
// need none: the guard bounds the counter so that each of them is a safe
// integer, which makes the index's double arithmetic exact.
// Invariant terms need proven ranges or the guard's test (guarded terms).
// Operands that involve the counter need none: the guard bounds the
// counter so that each of them is a safe integer, which makes the index's
// double arithmetic exact.
const shapes = new Map<IrExpr, Affine | null>();
const affine = (e: IrExpr): Affine | null => {
if (shapes.has(e)) return shapes.get(e)!;
@@ -309,7 +333,7 @@ export function matchBytesLoopVersion(
};
const termMagnitude = (terms: Term[]): number =>
terms.reduce((sum, t) => {
const r = facts.ranges.get(t.expr)!;
const r = termRange(t.expr);
return sum + Math.max(Math.abs(r.min), Math.abs(r.max));
}, 0);
@@ -433,7 +457,7 @@ export function matchBytesLoopVersion(
let min = Math.min(a, b) + shape.offset,
max = Math.max(a, b) + shape.offset;
for (const term of shape.terms) {
const r = facts.ranges.get(term.expr)!;
const r = termRange(term.expr);
min += term.sign > 0 ? r.min : -r.max;
max += term.sign > 0 ? r.max : -r.min;
}
@@ -447,6 +471,12 @@ export function matchBytesLoopVersion(
ranges.set(operand, { min: Math.max(min, known.min), max: Math.min(max, known.max) });
}
}
// Guarded terms of the kept checks, and the range the guard proves for
// every node of each in the fast copy.
const guarded = new Set<string>();
for (const group of affineKept)
for (const term of group.check.terms) if (guardedRanges.has(term.expr)) guarded.add(term.key);
for (const [node, key] of guardedNodes) if (guarded.has(key)) ranges.set(node, GUARDED_TERM);
// Renumber the receivers the kept checks use.
const order = [...used].sort((a, b) => a - b);
const renumber = new Map(order.map((old, index) => [old, index]));
@@ -470,6 +500,7 @@ export function matchBytesLoopVersion(
minOffset: g.check.minOffset,
maxEnd: g.check.maxEnd,
})),
guarded,
counted: affineKept.length > 0 ? counted : null,
counterBound: needsBound ? bound : null,
ranges,
@@ -567,6 +598,14 @@ export function emitBytesLoopVersionGuard(
let base = "0";
for (const term of check.terms) {
const value = host.emitExpr(term.expr);
if (version.guarded.has(term.key)) {
const tested = emitGuardedTerm(host, value.name);
conditions.push(tested.ok);
const sum = B.tmp();
B.line(`${sum} = ${term.sign > 0 ? "add" : "sub"} i64 ${base}, ${tested.integer}`);
base = sum;
continue;
}
const integer = exactInteger(host, value, term.expr);
if (!integer) {
// Unreachable: every term has a proven range. Stay checked.
@@ -614,6 +653,34 @@ export function emitBytesLoopVersionGuard(
else B.condBr(valid, fast, slow);
}
/** Tests that a guarded term is an exact integer within GUARDED_TERM (not
* -0; NaN fails the ordered comparisons) and gives its i64. The integer is
* frozen, so it is a fixed value even when the test fails. */
function emitGuardedTerm(host: LlvmEmitterContext, value: string): { ok: string; integer: string } {
const B = host.B;
const low = B.tmp(),
high = B.tmp(),
inRange = B.tmp(),
raw = B.tmp(),
integer = B.tmp(),
back = B.tmp(),
bits = B.tmp(),
backBits = B.tmp(),
exact = B.tmp(),
ok = B.tmp();
B.line(`${low} = fcmp oge double ${value}, ${f64Lit(GUARDED_TERM.min)}`);
B.line(`${high} = fcmp ole double ${value}, ${f64Lit(GUARDED_TERM.max)}`);
B.line(`${inRange} = and i1 ${low}, ${high}`);
B.line(`${raw} = fptosi double ${value} to i64`);
B.line(`${integer} = freeze i64 ${raw}`);
B.line(`${back} = sitofp i64 ${integer} to double`);
B.line(`${bits} = bitcast double ${value} to i64`);
B.line(`${backBits} = bitcast double ${back} to i64`);
B.line(`${exact} = icmp eq i64 ${bits}, ${backBits}`);
B.line(`${ok} = and i1 ${inRange}, ${exact}`);
return { ok, integer };
}
function scaleCounter(host: LlvmEmitterContext, value: string, scale: number): string {
if (scale === 1) return value;
const scaled = host.B.tmp();
@@ -1,4 +1,9 @@
import type { IntegerCountedForLoop, IntegerPairedForLoop } from "../../ir/integer-loops.js";
import type {
IntegerCountedForLoop,
IntegerLoopFollower,
IntegerPairedForLoop,
} from "../../ir/integer-loops.js";
import { FOLLOWER_GUARD_LIMIT } from "../../ir/integer-loops.js";
import type { IrExpr } from "../../ir/ir.js";
import type { LlvmEmitterContext } from "./expr-context.js";
import { f64Lit } from "./common.js";
@@ -61,9 +66,47 @@ export function emitCountedLoopSafety(
);
}
if (loop.startGuarded) safe = andSafe(host, safe, emitExactStart(host, loop.start));
for (const follower of loop.followers) {
if (follower.startGuarded) safe = andSafe(host, safe, emitExactStart(host, follower.start));
if (follower.tripGuarded) safe = andSafe(host, safe, emitFollowerReach(host, loop, follower));
}
return safe;
}
/** `|start| + |step| * span / |driver step| <= 2^52` for a follower, where
* `span` is the distance from the driver's start to the limit. The body runs
* at most `span / |driver step| + 1` times, so every value the follower holds
* on a body entry is within the bound. NaN and infinities fail the ordered
* test. */
function emitFollowerReach(
host: LlvmEmitterContext,
loop: IntegerCountedForLoop,
follower: IntegerLoopFollower,
): string {
const B = host.B;
const start = host.emitExpr(loop.start),
limit = host.emitExpr(loop.limit),
from = host.emitExpr(follower.start);
const span = B.tmp(),
reach = B.tmp(),
magnitude = B.tmp(),
total = B.tmp(),
ok = B.tmp();
B.line(
loop.step > 0
? `${span} = fsub double ${limit.name}, ${start.name}`
: `${span} = fsub double ${start.name}, ${limit.name}`,
);
B.line(
`${reach} = fmul double ${span}, ${f64Lit(Math.abs(follower.step) / Math.abs(loop.step))}`,
);
host.declare(`declare double @llvm.fabs.f64(double)`);
B.line(`${magnitude} = call double @llvm.fabs.f64(double ${from.name})`);
B.line(`${total} = fadd double ${magnitude}, ${reach}`);
B.line(`${ok} = fcmp ole double ${total}, ${f64Lit(FOLLOWER_GUARD_LIMIT)}`);
return ok;
}
/** The run-time test of a guarded paired loop: each unproven start is an
* exact safe integer. */
export function emitPairedLoopSafety(host: LlvmEmitterContext, loop: IntegerPairedForLoop): string {
@@ -120,6 +120,7 @@ const counted = (
limitGuarded: false,
startGuarded: false,
guardLimit: 0,
followers: [],
...fields,
});
+302
View File
@@ -108,6 +108,15 @@ const DYN_HANDLE_UNBOX_FN: Record<string, string> = {
dgramSocket: "scr_dgram_from_dyn",
};
/** ScrMessageKind (scr_message.h): the node kinds message readers test. */
const MESSAGE_KIND = {
UNDEFINED: 0,
NULL: 1,
BOOL: 2,
NUMBER: 3,
STRING: 4,
} as const;
export const DYN_KIND = {
NULL: 0,
BOOL: 1,
@@ -173,6 +182,8 @@ export class LlDyn {
private readonly dynFuncThunks = new Map<string, string>();
private readonly dynFuncBoxes = new Map<string, string>();
private readonly dynFuncAdapters = new Map<string, string>();
private readonly messageListeners = new Map<string, string | null>();
private readonly messageReaders = new Map<string, string>();
private readonly promiseDynAdapters = new Map<string, string>();
private readonly mapDynOps = new Map<string, string>();
private dynToStrFn: string | null = null;
@@ -4610,6 +4621,297 @@ export class LlDyn {
return name;
}
/** `sc_dml_<n>(ptr c, ptr message) -> i1`, the typed message entry of a
* closure registered as a 'message' listener (ScrDynMessageListener):
* builds the closure's parameter from the message's nodes and calls the
* closure. It answers false, without calling it, when the message does
* not have the parameter's exact layout, and the listener then runs
* through its thunk on the decoded message, as before. Null when the
* signature's parameter has no message reader (messageReadable). */
messageListenerHelper(t: IrType & { kind: "func" }): string | null {
const key = typeKey(t);
const existing = this.messageListeners.get(key);
if (existing !== undefined) return existing;
const param = t.params[0];
const supported =
t.params.length === 1 &&
t.rest !== true &&
param !== undefined &&
(t.ret.kind === "void" || t.ret.kind === "f64" || t.ret.kind === "bool") &&
this.messageReadable(param, new Set());
if (!supported) {
this.messageListeners.set(key, null);
return null;
}
const name = `sc_dml_${this.messageListeners.size}`;
this.messageListeners.set(key, name);
const B = new BlockBuilder();
B.entryAllocas.push(`%fail = alloca i1`);
B.line(`store i1 false, ptr %fail`);
const value = B.tmp();
B.line(
`${value} = call ${this.valTy(param)} @${this.messageReaderHelper(param)}(ptr %m, ${this.host.sizeType} 0, ptr %fail)`,
);
const failed = B.tmp();
B.line(`${failed} = load i1, ptr %fail`);
const lMiss = B.newLabel("dml.miss");
const lCall = B.newLabel("dml.call");
B.condBr(failed, lMiss, lCall);
B.startBlock(lMiss);
B.terminate(`ret i1 false`);
B.startBlock(lCall);
// The closure consumes its parameter; a pending exception stays for
// the emitter, as after the thunk.
const fnp = B.tmp();
const fn = B.tmp();
B.line(`${fnp} = getelementptr inbounds %ScrClosure, ptr %c, i64 0, i32 1`);
B.line(`${fn} = load ptr, ptr ${fnp}`);
const args = `ptr %c, ${this.valTy(param)} ${value}`;
if (t.ret.kind === "void") B.line(`call void ${fn}(${args})`);
else B.line(`${B.tmp()} = call ${this.valTy(t.ret)} ${fn}(${args})`);
B.terminate(`ret i1 true`);
this.defs.push(
`define internal zeroext i1 @${name}(ptr %c, ptr %m) ${FN_ATTRS} { ; message entry for ${llvmCommentText(key)}`,
B.render(),
`}`,
``,
);
return name;
}
/** Whether a message reader can build T: numbers, booleans and strings;
* records of them with only declared fields, none of union type; and
* boxed unions of those arms and null or undefined without a literal
* discriminator, with at most one number, boolean and string arm. */
private messageReadable(t: IrType, seen: Set<string>): boolean {
switch (t.kind) {
case "f64":
case "bool":
case "string":
return true;
case "record": {
const shape = this.host.recordsById.get(t.shapeId);
if (!shape || shape.tuple || shape.indexValue || shape.fields.length === 0) return false;
if (seen.has(t.shapeId) || this.isRecursiveShape(t)) return false;
seen.add(t.shapeId);
const readable = shape.fields.every(
(f) =>
!f.name.startsWith("%") &&
f.type.kind !== "union" &&
llFieldType(f.type, this.host.inlineUnions.record(f.type)) !== INLINE_UNION_FIELD &&
this.messageReadable(f.type, seen),
);
seen.delete(t.shapeId);
return readable;
}
case "union": {
const def = this.host.unionsById.get(t.unionId);
// A discriminated union's checked conversion also tests the arm's
// literal discriminator, which the readers do not.
if (!def || def.discriminant || this.host.nullableUnions.has(t.unionId)) return false;
const scalars = new Set<string>();
for (const arm of def.arms) {
if (arm.kind === "nullT" || arm.kind === "undefinedT") continue;
if (arm.kind === "f64" || arm.kind === "bool" || arm.kind === "string") {
if (scalars.has(arm.kind)) return false;
scalars.add(arm.kind);
continue;
}
if (arm.kind !== "record" || !this.messageReadable(arm, seen)) return false;
}
return true;
}
default:
return false;
}
}
/** `sc_dmr_<n>(ptr message, size node, ptr fail) -> T`: T (+1) built
* from a message node, for a type messageReadable accepts. Sets *fail
* and returns a dummy when the node does not have T's layout: the arm
* the checked conversion would choose, a record's declared keys in
* declaration order and no others. */
private messageReaderHelper(t: IrType): string {
const key = typeKey(t);
const existing = this.messageReaders.get(key);
if (existing) return existing;
const name = `sc_dmr_${this.messageReaders.size}`;
this.messageReaders.set(key, name);
const host = this.host;
const S = host.sizeType;
if (t.kind !== "record") host.declare(`declare i32 @scr_message_kind(ptr, ${S})`);
const retTy = this.valTy(t);
const dummy = retTy === "double" ? f64Lit(0) : retTy === "i1" ? "false" : "null";
const B = new BlockBuilder();
const lFail = B.newLabel("dmr.fail");
// scr_message_fields tests a record's kind itself.
const kind = B.tmp();
if (t.kind !== "record") B.line(`${kind} = call i32 @scr_message_kind(ptr %m, ${S} %n)`);
const requireKind = (want: number): void => {
const ok = B.tmp();
B.line(`${ok} = icmp eq i32 ${kind}, ${want}`);
const lOk = B.newLabel("dmr.kind");
B.condBr(ok, lOk, lFail);
B.startBlock(lOk);
};
switch (t.kind) {
case "f64":
case "bool": {
host.declare(`declare double @scr_message_number(ptr, ${S})`);
requireKind(t.kind === "f64" ? MESSAGE_KIND.NUMBER : MESSAGE_KIND.BOOL);
const n = B.tmp();
B.line(`${n} = call double @scr_message_number(ptr %m, ${S} %n)`);
if (t.kind === "f64") B.terminate(`ret double ${n}`);
else {
const b = B.tmp();
B.line(`${b} = fcmp une double ${n}, ${f64Lit(0)}`);
B.terminate(`ret i1 ${b}`);
}
break;
}
case "string": {
host.declare(`declare ptr @scr_message_string(ptr, ${S})`);
requireKind(MESSAGE_KIND.STRING);
const str = B.tmp();
B.line(`${str} = call ptr @scr_message_string(ptr %m, ${S} %n)`);
B.terminate(`ret ptr ${str}`);
break;
}
case "record": {
const shape = host.recordsById.get(t.shapeId)!;
const order = recordSourceFieldOrder(shape);
const fields = new Map(shape.fields.map((f, i) => [f.name, { field: f, index: i }]));
const base = host.nullableUnions.fieldBase(t.shapeId);
const struct = mangleRecordStruct(t.shapeId);
const names = this.recordNameTable(t.shapeId, shape);
host.declare(`declare zeroext i1 @scr_message_fields(ptr, ${S}, ptr, ptr, ${S}, ptr)`);
B.entryAllocas.push(`%kids = alloca [${order.length} x ${S}]`);
const matched = B.tmp();
B.line(
`${matched} = call zeroext i1 @scr_message_fields(ptr %m, ${S} %n, ptr ${names.names}, ptr ${names.lens}, ${S} ${order.length}, ptr %kids)`,
);
const lBuild = B.newLabel("dmr.build");
B.condBr(matched, lBuild, lFail);
B.startBlock(lBuild);
B.line(`%r0 = call ptr @${mangleRecordNew(t.shapeId)}()`);
order.forEach((fieldName, i) => {
const { field, index } = fields.get(fieldName)!;
const kidp = B.tmp();
const kid = B.tmp();
B.line(
`${kidp} = getelementptr inbounds [${order.length} x ${S}], ptr %kids, i64 0, i64 ${i}`,
);
B.line(`${kid} = load ${S}, ptr ${kidp}`);
const v = B.tmp();
B.line(
`${v} = call ${this.valTy(field.type)} @${this.messageReaderHelper(field.type)}(ptr %m, ${S} ${kid}, ptr %fail)`,
);
const failed = B.tmp();
B.line(`${failed} = load i1, ptr %fail`);
const lDrop = B.newLabel("dmr.drop");
const lStore = B.newLabel("dmr.store");
B.condBr(failed, lDrop, lStore);
B.startBlock(lDrop);
B.line(`call void ${releaseSym(host, t)}(ptr %r0)`);
B.terminate(`ret ptr null`);
B.startBlock(lStore);
const p = B.tmp();
B.line(
`${p} = getelementptr inbounds %${struct}, ptr %r0, i64 0, i32 ${index + base} ; .${llvmCommentText(fieldName)}`,
);
const fieldTy = llFieldType(field.type, host.inlineUnions.record(field.type));
if (fieldTy === "i8") {
const z = B.tmp();
B.line(`${z} = zext i1 ${v} to i8`);
B.line(`store i8 ${z}, ptr ${p}`);
} else {
B.line(`store ${fieldTy} ${v}, ptr ${p}`);
}
});
B.terminate(`ret ptr %r0`);
break;
}
case "union": {
const def = host.unionsById.get(t.unionId)!;
def.arms.forEach((arm, i) => {
const lNext = B.newLabel("dmr.next");
if (arm.kind === "nullT" || arm.kind === "undefinedT") {
const hit = B.tmp();
B.line(
`${hit} = icmp eq i32 ${kind}, ${arm.kind === "nullT" ? MESSAGE_KIND.NULL : MESSAGE_KIND.UNDEFINED}`,
);
const lHit = B.newLabel("dmr.unit");
B.condBr(hit, lHit, lNext);
B.startBlock(lHit);
B.terminate(`ret ptr ${host.unitInstanceRef(t.unionId, i)}`);
B.startBlock(lNext);
return;
}
if (arm.kind !== "record") {
const want =
arm.kind === "f64"
? MESSAGE_KIND.NUMBER
: arm.kind === "bool"
? MESSAGE_KIND.BOOL
: MESSAGE_KIND.STRING;
const hit = B.tmp();
B.line(`${hit} = icmp eq i32 ${kind}, ${want}`);
const lHit = B.newLabel("dmr.arm");
B.condBr(hit, lHit, lNext);
B.startBlock(lHit);
}
// A record arm that does not match leaves *fail clear for the
// next arm, like the checked conversion's next-arm match.
const v = B.tmp();
B.line(
`${v} = call ${this.valTy(arm)} @${this.messageReaderHelper(arm)}(ptr %m, ${S} %n, ptr %fail)`,
);
if (arm.kind === "record") {
const failed = B.tmp();
B.line(`${failed} = load i1, ptr %fail`);
const lHit = B.newLabel("dmr.arm");
const lMiss = B.newLabel("dmr.miss");
B.condBr(failed, lMiss, lHit);
B.startBlock(lMiss);
B.line(`store i1 false, ptr %fail`);
B.br(lNext);
B.startBlock(lHit);
}
const u = B.tmp();
if (arm.kind === "f64") {
host.declare(`declare ptr @scr_union_new_f64(i32, double)`);
B.line(`${u} = call ptr @scr_union_new_f64(i32 ${i}, double ${v})`);
} else if (arm.kind === "bool") {
host.declare(`declare ptr @scr_union_new_bool(i32, i1 zeroext)`);
B.line(`${u} = call ptr @scr_union_new_bool(i32 ${i}, i1 ${v})`);
} else {
const rc = vAdapters(host, arm);
host.declare(`declare ptr @scr_union_new_ref(i32, ptr, ptr, ptr, ptr)`);
B.line(
`${u} = call ptr @scr_union_new_ref(i32 ${i}, ptr ${v}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(host, arm)})`,
);
}
B.terminate(`ret ptr ${u}`);
B.startBlock(lNext);
});
B.br(lFail);
break;
}
default:
throw new InternalCompilerError(`llvm emitter bug: message reader of ${key}`);
}
B.startBlock(lFail);
B.line(`store i1 true, ptr %fail`);
B.terminate(`ret ${retTy} ${dummy}`);
this.defs.push(
`define internal ${retTy === "i1" ? "zeroext i1" : retTy} @${name}(ptr %m, ${S} %n, ptr %fail) ${FN_ATTRS} { ; message reader for ${llvmCommentText(key)}`,
B.render(),
`}`,
``,
);
return name;
}
/** The adapter closure body for one TARGET signature: caps[0] is an
* untraced obj-box owning the dyn function value. */
dynFuncAdapterHelper(t: IrType & { kind: "func" }): string {
@@ -85,6 +85,8 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
emitter.runningExtrema = emitter.debug === null ? runningExtremumUpdates(fn) : new Set();
emitter.currentConstantCallbacks =
emitter.debug === null ? (emitter.constantCallbacks.get(fn.name) ?? new Map()) : new Map();
emitter.knownCallbacks.clear();
emitter.callbackVersionsEnabled = emitter.debug === null && !fn.async && !fn.generator;
// Preserve concrete source bindings for debugger inspection. Suspended
// functions keep the established array lifetime across continuations.
emitter.streamingSplitsEnabled = emitter.debug === null && !fn.async && !fn.generator;
@@ -55,6 +55,7 @@ import { emitBytesLoopVersionGuard } from "./bytes-loop-versions.js";
import { mangleLocal } from "../mangle.js";
import { LlvmUnsupportedError } from "./unsupported.js";
import type { LlValue } from "./expr-context.js";
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
import { widenInteger } from "./integer-values.js";
import { SCALAR_UNION_RESULT } from "./int32-abi.js";
import { emitLiteralSwitch } from "./switch-dispatch.js";
@@ -76,6 +77,9 @@ import {
} from "./string-construction.js";
import { emitStringInputs } from "./string-lifetimes.js";
/** A callback loop version copies the loop; keep the copies small. */
const MAX_CALLBACK_VERSION_COST = 400;
export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
const B = emitter.B;
emitter.frames.push([]);
@@ -800,6 +804,11 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
break;
}
case "while": {
const callbacks = callbackVersionBindings(emitter, s);
if (callbacks) {
emitCallbackVersionedLoop(emitter, s, callbacks);
break;
}
const workerBudget = emitter.workerLoopBudget(s);
const le = B.newLabel("loop.e");
emitter.emitLoopCopies(s, true, le, (exit) => {
@@ -808,6 +817,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
const lp = workerBudget !== null ? B.newLabel("loop.p") : lc;
B.br(lc);
B.startBlock(lc);
enterLoopHeader(emitter, lc);
B.condBr(emitter.emitCondition(s.cond), lb, exit);
B.startBlock(lb);
emitter.jumpTargets.push({
@@ -832,6 +842,11 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
break;
}
case "doWhile": {
const callbacks = callbackVersionBindings(emitter, s);
if (callbacks) {
emitCallbackVersionedLoop(emitter, s, callbacks);
break;
}
const workerBudget = emitter.workerLoopBudget(s);
// Body first (runs at least once); continue jumps to the CONDITION.
const le = B.newLabel("loop.e");
@@ -840,6 +855,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
const lc = B.newLabel("loop.c");
B.br(lb);
B.startBlock(lb);
enterLoopHeader(emitter, lb);
emitter.jumpTargets.push({
kind: "loop",
brkLabel: exit,
@@ -860,6 +876,11 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
break;
}
case "for": {
const callbacks = callbackVersionBindings(emitter, s);
if (callbacks) {
emitCallbackVersionedLoop(emitter, s, callbacks);
break;
}
// The init's scope wraps the whole loop (break/continue must NOT
// release it — scopeDepth captured after the push, C parity).
const integerArrayLoop =
@@ -884,8 +905,9 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
!integerLoop && !countedLoop && emitter.countedLoopsEnabled
? matchIntegerPairedForLoop(s, emitter.numericLocals, emitter.integerRanges)
: null;
// Byte windows and dense loops reason about one counter.
const window =
countedLoop && emitter.byteWindowsEnabled
countedLoop && countedLoop.followers.length === 0 && emitter.byteWindowsEnabled
? matchByteWindow(
s,
countedLoop,
@@ -903,7 +925,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
length: integerArrayLoop.limitReceiver as IrExpr & { kind: "varRef" },
range: { min: 0, max: 4294967294 },
}
: countedLoop && !countedLoop.guarded
: countedLoop && !countedLoop.guarded && countedLoop.followers.length === 0
? countedDenseShape(s, countedLoop)
: null;
const dense =
@@ -1035,6 +1057,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
let integerSlot: string | null = null;
let countedLimit: string | null = null;
const pairedSlots: string[] = [];
const followerSlots: string[] = [];
if (integerLoop) {
integerSlot = B.slot();
B.entryAllocas.push(
@@ -1078,6 +1101,23 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
? emitter.denseLoop.range
: countedLoop.range,
});
for (const follower of countedLoop.followers) {
const slot = B.slot();
B.entryAllocas.push(
`${slot} = alloca i64 ; integer induction ${emitter.currentLocals.get(follower.localId)!.name}`,
);
const value = emitter.emitExpr(follower.start);
const followerStart = B.tmp();
B.line(`${followerStart} = fptosi double ${value.name} to i64`);
B.line(`store i64 ${followerStart}, ptr ${slot}`);
followerSlots.push(slot);
emitter.integerLoopBindings.set(follower.localId, {
slot,
type: "i64",
signed: true,
range: follower.range,
});
}
countedLimit = emitCountedLoopLimit(emitter, countedLoop);
} else if (pairedLoop) {
for (const counter of [pairedLoop.low, pairedLoop.high]) {
@@ -1140,6 +1180,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
s.update || capturedLets.length > 0 || workerBudget !== null ? B.newLabel("loop.u") : lc;
B.br(lc);
B.startBlock(lc);
enterLoopHeader(emitter, lc);
if (integerLoop && integerSlot) {
const receiver = emitter.emitStableReceiver(integerLoop.limitReceiver, []);
const lenPtr = B.tmp();
@@ -1217,6 +1258,13 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
B.line(`${old} = load i64, ptr ${integerSlot}`);
B.line(`${next} = add nsw i64 ${old}, ${countedLoop.step}`);
B.line(`store i64 ${next}, ptr ${integerSlot}`);
countedLoop.followers.forEach((follower, i) => {
const was = B.tmp();
const now = B.tmp();
B.line(`${was} = load i64, ptr ${followerSlots[i]}`);
B.line(`${now} = add nsw i64 ${was}, ${follower.step}`);
B.line(`store i64 ${now}, ptr ${followerSlots[i]}`);
});
} else if (pairedLoop) {
[pairedLoop.low, pairedLoop.high].forEach((counter, i) => {
const old = B.tmp();
@@ -1232,7 +1280,11 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
B.startBlock(le);
emitter.releaseScope(emitter.scopes.pop()!);
if (integerLoop) emitter.integerLoopBindings.delete(integerLoop.localId);
if (countedLoop) emitter.integerLoopBindings.delete(countedLoop.localId);
if (countedLoop) {
emitter.integerLoopBindings.delete(countedLoop.localId);
for (const follower of countedLoop.followers)
emitter.integerLoopBindings.delete(follower.localId);
}
if (pairedLoop) {
emitter.integerLoopBindings.delete(pairedLoop.low.localId);
emitter.integerLoopBindings.delete(pairedLoop.high.localId);
@@ -1890,6 +1942,105 @@ export function emitSwitch(emitter: LlEmitter, s: IrStmt & { kind: "switch" }):
B.startBlock(end);
}
/** The loop copies a callback loop version needs: the bindings the loop
* calls that the constant-callback census resolves to one function, each
* with that function's direct entry. Only small loops of optimized
* synchronous functions, and only bindings declared before the loop,
* qualify: the guard reads each binding before the loop runs. Versions do
* not nest: a loop inside either copy of a version (including the
* versioned loop itself, emitted again for each copy) keeps its calls as
* they are, so a loop nest is emitted at most twice. */
function callbackVersionBindings(
emitter: LlEmitter,
s: IrStmt & { kind: "for" | "while" | "doWhile" },
): { ref: IrExpr & { kind: "varRef" }; direct: string }[] | null {
if (
!emitter.callbackVersionsEnabled ||
emitter.currentConstantCallbacks.size === 0 ||
emitter.callbackVersioning.size > 0
)
return null;
const found = new Map<string, { ref: IrExpr & { kind: "varRef" }; direct: string }>();
// Bindings the loop declares or writes (a callback declared in its body).
const bound = new Set<string>();
let cost = 0;
const expr = (e: IrExpr): boolean => {
if (++cost > MAX_CALLBACK_VERSION_COST) return false;
if (e.kind === "assignExpr") bound.add(e.localId);
if (e.kind === "callValue" && e.callee.kind === "varRef" && !found.has(e.callee.localId)) {
const local = emitter.currentLocals.get(e.callee.localId);
const target =
local && local.tdz !== true ? emitter.knownCallbackTarget(e.callee) : undefined;
if (target) found.set(e.callee.localId, { ref: e.callee, direct: target.direct });
}
return everyExprChild(e, expr, stmt);
};
const stmt = (x: IrStmt): boolean => {
if (++cost > MAX_CALLBACK_VERSION_COST) return false;
if (x.kind === "varDecl" || x.kind === "assign" || x.kind === "forOf") bound.add(x.localId);
else if (x.kind === "tryCatch" && x.catchLocalId !== null) bound.add(x.catchLocalId);
return everyStmtChild(x, expr, stmt);
};
if (!stmt(s)) return null;
const bindings = [...found.values()].filter(({ ref }) => !bound.has(ref.localId));
return bindings.length > 0 ? bindings : null;
}
/** A callback loop version. The guard tests, once before the loop, that
* each binding's closure holds its known function: a closure's code
* pointer is written only when the closure is built, and the census
* resolves only bindings the function never rebinds. The copy where the
* guard holds calls those functions directly, with no indirect fallback
* that could rewrite the closure or what it captures, so LLVM can keep a
* captured scalar the callback updates in a register. The other copy is the
* ordinary loop. */
function emitCallbackVersionedLoop(
emitter: LlEmitter,
s: IrStmt & { kind: "for" | "while" | "doWhile" },
bindings: { ref: IrExpr & { kind: "varRef" }; direct: string }[],
): void {
const B = emitter.B;
let ok = "true";
for (const { ref, direct } of bindings) {
// A borrowed read: the guard only compares the code pointer.
const closure = emitter.emitReadReceiver(ref).name;
const fnp = B.tmp(),
fn = B.tmp(),
same = B.tmp();
B.line(`${fnp} = getelementptr inbounds %ScrClosure, ptr ${closure}, i64 0, i32 1`);
B.line(`${fn} = load ptr, ptr ${fnp}`);
B.line(`${same} = icmp eq ptr ${fn}, ${direct}`);
if (ok === "true") ok = same;
else {
const both = B.tmp();
B.line(`${both} = and i1 ${ok}, ${same}`);
ok = both;
}
}
const fast = B.newLabel("callback.version.fast"),
slow = B.newLabel("callback.version.slow"),
done = B.newLabel("callback.version.done");
B.condBr(ok, fast, slow);
emitter.callbackVersioning.add(s);
try {
B.startBlock(slow);
emitter.emitStmt(s);
B.br(done);
B.startBlock(fast);
for (const { ref } of bindings) emitter.knownCallbacks.add(ref.localId);
emitter.emitStmt(s);
B.br(done);
} finally {
for (const { ref } of bindings) emitter.knownCallbacks.delete(ref.localId);
emitter.callbackVersioning.delete(s);
}
B.startBlock(done);
}
/** Entries in a row whose fast copy bailed before a speculative loop site
* stops trying it. */
const SPECULATION_BAILS = 8;
/** Emit a loop's condition, body and update through `emitCopy`, twice
* when speculative-integers.ts plans a fast integer copy. Both copies
* share the loop's setup and exit; the ordinary copy comes first so the
@@ -1944,23 +2095,66 @@ export function emitLoopCopies(
ok = both;
}
}
// Each entry whose fast copy bails counts at the loop site, and a fast
// copy that completes a speculated write and leaves the loop clears the
// count. After SPECULATION_BAILS bailing entries in a row, the site runs
// the ordinary copy only. The count is a hint: both copies give the same
// results, and a race between workers loses at most an update.
const bails = `@scr.spec.bails.${emitter.speculativeLoopSites++}`;
emitter.declare(`${bails} = internal global i8 0`);
const count = B.tmp(),
trying = B.tmp();
B.line(`${count} = load atomic i8, ptr ${bails} unordered, align 1`);
B.line(`${trying} = icmp ult i8 ${count}, ${SPECULATION_BAILS}`);
if (ok === "true") ok = trying;
else {
const both = B.tmp();
B.line(`${both} = and i1 ${ok}, ${trying}`);
ok = both;
}
const fast = B.newLabel("spec.fast"),
slow = B.newLabel("spec.slow");
B.condBr(ok, fast, slow);
const resume = new Map<IrStmt, { label: string; placed: boolean }>();
for (const s of plan.writes) resume.set(s, { label: B.newLabel("spec.resume"), placed: false });
// A failed check finishes its iteration in a third copy, entered only
// there: it re-executes the statement and the rest of the body and the
// update, then enters the ordinary copy through its header. Jumping
// into the middle of the ordinary copy instead would give that loop a
// second entry, and LLVM does not optimize an irreducible loop.
const points = [...resume.values()];
let header: string | null = null;
B.startBlock(slow);
emitter.speculationHeader = (label) => {
header = label;
};
try {
emitCopy(exit);
} finally {
emitter.speculationHeader = null;
}
if (header === null)
throw new InternalCompilerError("llvm emitter bug: speculative loop copy without a header");
B.startBlock(B.newLabel("spec.tail"));
emitter.speculationResume = resume;
emitter.speculationHeader = () => {
B.br(header!);
B.startBlock(B.newLabel("spec.unused"));
};
try {
emitCopy(exit);
} finally {
emitter.speculationResume = null;
emitter.speculationHeader = null;
}
B.startBlock(fast);
if (![...resume.values()].every((r) => r.placed)) {
if (!points.every((r) => r.placed)) {
B.br(slow);
return;
}
const ran = B.slot();
B.entryAllocas.push(`${ran} = alloca i1 ; speculated write completed`);
B.line(`store i1 false, ptr ${ran}`);
for (const id of plan.locals.keys()) {
const shadow = B.slot();
B.entryAllocas.push(
@@ -1997,22 +2191,43 @@ export function emitLoopCopies(
writes: plan.writes,
resume: new Map([...resume].map(([s, r]) => [s, r.label])),
deferred,
bails,
ran,
};
try {
if (deferred.size === 0) emitCopy(exit);
else {
const leave = B.newLabel("spec.leave");
emitCopy(leave);
B.startBlock(leave);
emitter.writeBackDeferred(deferred);
B.br(exit);
}
const leave = B.newLabel("spec.leave");
emitCopy(leave);
B.startBlock(leave);
emitter.writeBackDeferred(deferred);
// An entry that never ran a speculated write (a loop of no iterations)
// says nothing about the site.
const wrote = B.tmp(),
left = B.tmp(),
counted = B.tmp(),
both = B.tmp(),
clear = B.newLabel("spec.clear");
B.line(`${wrote} = load i1, ptr ${ran}`);
B.line(`${left} = load atomic i8, ptr ${bails} unordered, align 1`);
B.line(`${counted} = icmp ne i8 ${left}, 0`);
B.line(`${both} = and i1 ${wrote}, ${counted}`);
B.condBr(both, clear, exit);
B.startBlock(clear);
B.line(`store atomic i8 0, ptr ${bails} unordered, align 1`);
B.br(exit);
} finally {
emitter.speculation = null;
for (const id of plan.locals.keys()) emitter.integerLoopBindings.delete(id);
}
}
/** At the top of a loop copy's header: lets a speculative loop find its
* ordinary copy's header, and keeps its third copy out of the loop. */
function enterLoopHeader(emitter: LlEmitter, header: string): void {
const dispatch = emitter.speculationHeader;
emitter.speculationHeader = null;
dispatch?.(header);
}
/** A proven integer loop (speculative-integers.ts) for this loop, or null.
* A shadowed local's double is stale until the loop is left, which only
* the loop's exit, a return and an exception do: the loop is planned only
@@ -2139,7 +2354,8 @@ export function emitSpeculativeWrite(
if (emitter.frames.pop()!.length > 0)
throw new InternalCompilerError("llvm emitter bug: speculative write owns a temporary");
B.line(`store i64 ${result}, ptr ${binding.slot}`);
const deferred = emitter.speculation!.deferred;
const { deferred, bails: site, ran } = emitter.speculation!;
if (ran !== undefined) B.line(`store i1 true, ptr ${ran}`);
if (!deferred.has(target.localId)) {
const number = B.tmp();
B.line(`${number} = sitofp i64 ${result} to double`);
@@ -2150,6 +2366,14 @@ export function emitSpeculativeWrite(
B.br(done);
B.startBlock(failed);
emitter.writeBackDeferred(deferred);
if (site !== undefined) {
const count = B.tmp(),
next = B.tmp();
emitter.declare("declare i8 @llvm.uadd.sat.i8(i8, i8)");
B.line(`${count} = load atomic i8, ptr ${site} unordered, align 1`);
B.line(`${next} = call i8 @llvm.uadd.sat.i8(i8 ${count}, i8 1)`);
B.line(`store atomic i8 ${next}, ptr ${site} unordered, align 1`);
}
B.br(resume!);
B.startBlock(done);
}
+51 -5
View File
@@ -834,6 +834,12 @@ export class LlEmitter {
/** The current function's running maximum and minimum updates, whose
* branches are weighted as rarely taken (running-extrema.ts). */
runningExtrema: ReadonlySet<IrStmt> = new Set();
/** Callback bindings whose closures an enclosing callback loop version
* proved to hold their known function: calls take the direct entry with
* no fallback (emitCallbackVersionedLoop). */
knownCallbacks = new Set<string>();
readonly callbackVersioning = new Set<IrStmt>();
callbackVersionsEnabled = false;
readonly stackCallbacks: StackCallbacks;
readonly receiverReaders: AmbientReceiverReaders;
readonly stackCaptures: StackCaptures;
@@ -1105,9 +1111,20 @@ export class LlEmitter {
resume: Map<IrStmt, string>;
/** Globals whose double is written back only when the copy is left. */
deferred: Set<string>;
/** The loop site's count of bailing entries in a row (a global), and
* whether this entry completed a speculated write (a slot). A proven
* integer loop has neither. */
bails?: string;
ran?: string;
} | null = null;
/** Speculative loop sites numbered so far in the module. */
speculativeLoopSites = 0;
/** The ordinary copy being emitted: resume labels still to place. */
speculationResume: Map<IrStmt, { label: string; placed: boolean }> | null = null;
/** Run once at the top of the next loop copy's header
* (`enterLoopHeader`): records the ordinary copy's header, or sends the
* third copy's header there. */
speculationHeader: ((header: string) => void) | null = null;
/** The function being emitted, for speculative globals. */
speculativeFunction = "";
/** Every function that writes each number global (computed on demand). */
@@ -3904,6 +3921,25 @@ export class LlEmitter {
return b;
}
/** The function a call through `callee` is known to reach (a literal
* closure, or a binding the constant-callback census resolves) and the
* entry it calls directly. Async and generator functions stay indirect. */
knownCallbackTarget(callee: IrExpr): { known: IrFunction; direct: string } | undefined {
const knownName =
callee.kind === "closure"
? callee.fnName
: callee.kind === "varRef"
? this.currentConstantCallbacks.get(callee.localId)
: undefined;
const known = knownName ? this.fnByName.get(knownName) : undefined;
if (!known || known.async || known.generator) return undefined;
if (known.captures === undefined) {
this.fnValues.add(known.name);
return { known, direct: `@${mangleWrapper(known.name)}` };
}
return { known, direct: `@${this.callTarget(known.name)}` };
}
/** The payload slot of a capture box (ScrBox.slot, field 5). */
boxSlot(box: string): string {
const slot = this.B.tmp();
@@ -3917,12 +3953,18 @@ export class LlEmitter {
const r = B.tmp();
// Scalar payloads are read in place (scr_box_get_f64/bool are a
// memcpy and a nonzero test of the same slot), so LLVM can keep a
// captured number in a register across a loop of calls.
// captured number in a register across a loop of calls. They have an
// alias category of their own: every other access to a box's payload
// is untagged or a runtime call.
if (acc === "f64") {
B.line(`${r} = load double, ptr ${this.boxSlot(box)}`);
B.line(
`${r} = load double, ptr ${this.boxSlot(box)}${this.memoryAliasAttachment("box:scalar")}`,
);
} else if (acc === "bool") {
const raw = B.tmp();
B.line(`${raw} = load i64, ptr ${this.boxSlot(box)}`);
B.line(
`${raw} = load i64, ptr ${this.boxSlot(box)}${this.memoryAliasAttachment("box:scalar")}`,
);
B.line(`${r} = icmp ne i64 ${raw}, 0`);
} else {
this.declare(`declare ptr @scr_box_get_ref(ptr)`);
@@ -3936,11 +3978,15 @@ export class LlEmitter {
const B = this.B;
const acc = boxAccess(t);
if (acc === "f64") {
B.line(`store double ${value}, ptr ${this.boxSlot(box)}`);
B.line(
`store double ${value}, ptr ${this.boxSlot(box)}${this.memoryAliasAttachment("box:scalar")}`,
);
} else if (acc === "bool") {
const raw = B.tmp();
B.line(`${raw} = zext i1 ${value} to i64`);
B.line(`store i64 ${raw}, ptr ${this.boxSlot(box)}`);
B.line(
`store i64 ${raw}, ptr ${this.boxSlot(box)}${this.memoryAliasAttachment("box:scalar")}`,
);
} else {
this.declare(`declare void @scr_box_set_ref(ptr, ptr)`);
B.line(`call void @scr_box_set_ref(ptr ${box}, ptr ${value})`);
@@ -29,7 +29,6 @@ import {
mangleClassStruct,
mangleFnClosure,
mangleFunction,
mangleWrapper,
mangleVtStruct,
mangleAsyncDirectSpawn,
} from "../mangle.js";
@@ -839,21 +838,9 @@ export function emitCallExpr(
host.declare("declare void @scr_dyn_this_push_dyn(ptr)");
B.line(`call void @scr_dyn_this_push_dyn(ptr ${receiver?.name ?? "null"})`);
};
const knownName =
e.callee.kind === "closure"
? e.callee.fnName
: e.callee.kind === "varRef"
? host.currentConstantCallbacks.get(e.callee.localId)
: undefined;
const candidate = knownName ? host.fnByName.get(knownName) : undefined;
const known = candidate && !candidate.async && !candidate.generator ? candidate : undefined;
let direct: string | undefined;
if (known) {
if (known.captures === undefined) {
host.fnValues.add(known.name);
direct = `@${mangleWrapper(known.name)}`;
} else direct = `@${host.callTarget(known.name)}`;
}
const target = host.knownCallbackTarget(e.callee);
const known = target?.known;
const direct = target?.direct;
// A known closure body that borrows reference parameters is entered
// directly with lent arguments, as a direct call to a borrowing body
// is: an argument is read without a reference when neither a later
@@ -939,7 +926,13 @@ export function emitCallExpr(
let pending = host.indirectMayThrow;
let pendingGuard: string | undefined;
let t: string;
if (direct && e.callee.kind === "closure") {
// A literal closure, or a binding an enclosing callback loop version
// proved to hold its known function, enters it directly.
if (
direct &&
(e.callee.kind === "closure" ||
(e.callee.kind === "varRef" && host.knownCallbacks.has(e.callee.localId)))
) {
t = invoke(lending ?? direct, !host.receiverReaders.ignores(known!));
if (quiet) pending = false;
} else {
@@ -16,6 +16,7 @@ import { streamTypedRefEligible } from "../../ir/analysis.js";
import {
DYN,
type IrExpr,
type IrType,
isDynTypedRefType,
isRefCounted,
isUnitType,
@@ -221,6 +222,19 @@ export function emitDynamicExpr(
host.own({ name: prepared, type: { kind: "dyn" } });
host.emitPendingCheck();
const args = e.args.map((a) => emitBorrowedInput(host, a));
// A closure registered as a 'message' listener gets a typed message
// entry: ports deliver messages of its parameter's layout without
// decoding them to checked-dynamic values first.
const listener = messageListenerArg(e);
if (listener !== null) {
const entry = host.dyn.messageListenerHelper(listener);
if (entry !== null) {
host.declare(`declare void @scr_dyn_func_set_message(ptr, ptr, ptr)`);
B.line(
`call void @scr_dyn_func_set_message(ptr ${args[1]!.name}, ptr @${host.dyn.dynFuncThunkHelper(listener)}, ptr @${entry})`,
);
}
}
let argsPtr = "null";
if (args.length > 0) {
const arr = B.slot();
@@ -1323,3 +1337,24 @@ export const INHERITED_OBJECT_MEMBERS: ReadonlySet<string> = new Set([
"hasOwnProperty",
"propertyIsEnumerable",
]);
/** EventEmitter methods that register their second argument as a listener. */
const LISTENER_METHODS: ReadonlySet<string> = new Set([
"on",
"once",
"addListener",
"prependListener",
"prependOnceListener",
]);
/** The closure type of `recv.on("message", listener)` and the other
* listener registrations with a typed closure for the 'message' event, or
* null. */
function messageListenerArg(e: ExprOf<"dynInvoke">): (IrType & { kind: "func" }) | null {
if (!LISTENER_METHODS.has(e.method) || e.args.length !== 2) return null;
const [event, listener] = e.args;
if (event?.kind !== "dynFrom" || event.value.kind !== "strLit" || event.value.value !== "message")
return null;
if (listener?.kind !== "dynFrom" || listener.value.type.kind !== "func") return null;
return listener.value.type;
}
@@ -110,6 +110,25 @@ describe("regex literal matcher code", () => {
expect(body("a\\d{1,3}")).not.toContain("<16 x i8>");
});
test("fixed leading characters filter 16 start positions at once", () => {
// Both alternatives' first characters are tested at every offset and
// combined, and the reads reach seven characters past the block.
const text = body("agggtaaa|tttaccct", "gi");
expect(text.match(/load <16 x i8>/g)!.length).toBeGreaterThanOrEqual(16);
expect(text).toMatch(/and i16 /);
expect(text).toMatch(/or i16 /);
expect(text).toMatch(/add i32 %t\d+, 7\n/);
// A class sequence with a fixed count extends the filter.
expect(body("[cg]x{3}y")).toMatch(/and i16 /);
// An optional first character, a single first character and a
// variable-length start keep the first-character search.
expect(body("[ab]?cd")).not.toMatch(/and i16 /);
expect(body("[ab]*cd")).not.toMatch(/and i16 /);
expect(body("xy|zw")).toMatch(/and i16 /);
expect(body("[ab]|cd")).not.toMatch(/and i16 /);
expect(body("agggtaaa|tttaccct", "gi", false)).not.toContain("<16 x i8>");
});
test("loops no continuation can share push no backtrack entries", () => {
// \d+ then a dot; [^\]]+ then ]; the run at the end of the pattern.
expect(body("\\d+\\.\\d+")).not.toContain("@scr_regex_stack_grow");
@@ -589,6 +589,90 @@ function firstOf(node: RxNode): FirstInfo {
}
}
/** Positions the start filter tests and alternatives it keeps. */
const PREFIX_LENGTH = 8;
const PREFIX_ALTERNATIVES = 8;
/** The character sets a match's first characters must belong to: every
* match of the node begins with the characters of some entry of `alts`,
* one set per character. `complete` means every match is exactly as long as
* its entry (so a following node continues the sequences). Null when the
* node gives no usable condition (an alternative that can start with
* anything, or too many alternatives). Assertions add no characters: the
* result stays a necessary condition. */
interface Prefix {
alts: CharSet[][];
complete: boolean;
}
/** Each head followed by each tail, cut to PREFIX_LENGTH sets; null past
* PREFIX_ALTERNATIVES entries. */
function crossPrefixes(heads: CharSet[][], tails: CharSet[][]): CharSet[][] | null {
if (heads.length * tails.length > PREFIX_ALTERNATIVES) return null;
const out: CharSet[][] = [];
for (const head of heads)
for (const tail of tails) out.push([...head, ...tail].slice(0, PREFIX_LENGTH));
return out;
}
/** Whether no entry was cut at PREFIX_LENGTH. */
function uncut(alts: CharSet[][]): boolean {
return alts.every((alt) => alt.length < PREFIX_LENGTH);
}
function prefixOf(node: RxNode): Prefix | null {
switch (node.kind) {
case "set":
return { alts: [[node.set]], complete: true };
case "assert":
return { alts: [[]], complete: true };
case "cap":
return prefixOf(node.body);
case "alt": {
const alts: CharSet[][] = [];
let complete = true;
for (const alt of node.alts) {
const p = prefixOf(alt);
if (p === null) return null;
alts.push(...p.alts);
complete &&= p.complete;
}
return alts.length <= PREFIX_ALTERNATIVES ? { alts, complete } : null;
}
case "seq": {
let alts: CharSet[][] = [[]];
for (const item of node.items) {
const p = prefixOf(item);
if (p === null) return { alts, complete: false };
const joined = crossPrefixes(alts, p.alts);
if (joined === null) return { alts, complete: false };
alts = joined;
if (!p.complete || !uncut(alts)) return { alts, complete: false };
}
return { alts, complete: true };
}
case "rep": {
if (node.max === 0) return { alts: [[]], complete: true };
if (node.min === 0) return { alts: [[]], complete: false };
const body = prefixOf(node.body);
if (body === null) return null;
// The first `min` iterations are required; they extend the sequences
// while the body has a fixed length.
let alts = body.alts;
let covered = 1;
while (covered < node.min && body.complete && uncut(alts)) {
const joined = crossPrefixes(alts, body.alts);
if (joined === null) break;
alts = joined;
covered++;
}
const complete =
body.complete && covered === node.min && node.min === node.max && uncut(alts);
return { alts, complete };
}
}
}
/** Whether a node can match without consuming (assertions included). */
function canMatchEmpty(node: RxNode): boolean {
switch (node.kind) {
@@ -721,16 +805,74 @@ class RxGen {
return wide;
}
/** The vector plans of a start filter (prefixOf): per alternative, the
* plans of its first sets up to the first set without one. Null unless
* every alternative keeps at least two positions, where the filter is
* more selective than the first character alone. */
prefixPlans(prefix: Prefix | null): { ranges: number[][]; negate: boolean }[][] | null {
if (prefix === null || !this.vector) return null;
const plans: { ranges: number[][]; negate: boolean }[][] = [];
for (const alt of prefix.alts) {
const row: { ranges: number[][]; negate: boolean }[] = [];
for (const set of alt) {
const plan = this.vectorPlan(set);
if (plan === null) break;
row.push(plan);
}
if (row.length < 2) return null;
plans.push(row);
}
return plans.length === 0 ? null : plans;
}
/** Start positions s[index..index+16) where some alternative of the
* filter matches its first sets: an i16 mask. */
testPrefix(plans: { ranges: number[][]; negate: boolean }[][], index: string): string {
let any = "";
for (const row of plans) {
let all = "";
for (let k = 0; k < row.length; k++) {
let at = index;
if (k > 0) {
at = this.tmp();
this.line(`${at} = add i32 ${index}, ${k}`);
}
const bits = this.testVector(row[k]!, at);
if (all === "") all = bits;
else {
const both = this.tmp();
this.line(`${both} = and i16 ${all}, ${bits}`);
all = both;
}
}
if (any === "") any = all;
else {
const either = this.tmp();
this.line(`${either} = or i16 ${any}, ${all}`);
any = either;
}
}
return any;
}
/** The first-character search: from %at, store the next position whose
* character is in `set` and branch to %attempt, or branch to %nomatch. */
emitSeek(set: CharSet): void {
* character is in `set` and branch to %attempt, or branch to %nomatch.
* With a start filter (`prefix`), the vector loop tests the filter's
* first sets of each alternative at 16 start positions at once and stops
* only where one of them matches all of its sets; a match can start
* nowhere else, so the attempts that remain are the ones that can
* succeed, in the same order. */
emitSeek(set: CharSet, prefix: Prefix | null = null): void {
this.block("seek");
const a = this.tmp();
this.line(`${a} = load i32, ptr %at`);
const filter = this.prefixPlans(prefix);
const plan = this.vectorPlan(set);
// The filter reads s[i + reach + 15] at its farthest.
const reach = filter === null ? 0 : Math.max(...filter.map((row) => row.length)) - 1;
let from = "seek",
start = a;
if (plan !== null) {
if (plan !== null || filter !== null) {
const head = this.label("vsh"),
load = this.label("vsl"),
next = this.label("vsn"),
@@ -740,17 +882,21 @@ class RxGen {
this.block(head);
const i = this.tmp(),
i16 = this.tmp(),
rest = this.tmp(),
fits = this.tmp();
this.line(`${i} = phi i32 [ ${a}, %seek ], [ ${i16}, %${next} ]`);
this.line(`${i16} = add i32 ${i}, 16`);
this.line(`${fits} = icmp sle i32 ${i16}, %len`);
// The characters left (0 <= i <= len, so this cannot wrap; i + 16
// can for subjects near 2^31 characters).
this.line(`${rest} = sub i32 %len, ${i}`);
this.line(`${fits} = icmp sge i32 ${rest}, ${16 + reach}`);
this.line(`br i1 ${fits}, label %${load}, label %${tail}`);
this.block(load);
const mask = this.testVector(plan, i),
const mask = filter !== null ? this.testPrefix(filter, i) : this.testVector(plan!, i),
any = this.tmp();
this.line(`${any} = icmp ne i16 ${mask}, 0`);
this.line(`br i1 ${any}, label %${hit}, label %${next}`);
this.block(next);
this.line(`${i16} = add i32 ${i}, 16`);
this.line(`br label %${head}`);
this.block(hit);
const k = this.lowestBit(mask),
@@ -1001,10 +1147,10 @@ class RxGen {
check = this.label("chk");
this.block(entry);
const p = this.loadPos(),
end = this.tmp(),
rest = this.tmp(),
inb = this.tmp();
this.line(`${end} = add i32 ${p}, ${sets.length}`);
this.line(`${inb} = icmp sle i32 ${end}, %len`);
this.line(`${rest} = sub i32 %len, ${p}`);
this.line(`${inb} = icmp sge i32 ${rest}, ${sets.length}`);
this.line(`br i1 ${inb}, label %${check}, label %fail`);
this.block(check);
let acc = "";
@@ -1023,6 +1169,8 @@ class RxGen {
acc = both;
}
}
const end = this.tmp();
this.line(`${end} = add i32 ${p}, ${sets.length}`);
// Storing before the branch is safe: every resume sets the position.
this.storePos(end);
this.line(`br i1 ${acc}, label %${succ}, label %fail`);
@@ -1229,10 +1377,11 @@ class RxGen {
this.block(vhead);
const kv = this.tmp(),
kv16 = this.tmp(),
left = this.tmp(),
fits = this.tmp();
this.line(`${kv} = phi i32 [ 0, %${entry} ], [ ${kv16}, %${vnext} ]`);
this.line(`${kv16} = add i32 ${kv}, 16`);
this.line(`${fits} = icmp sle i32 ${kv16}, ${lim}`);
this.line(`${left} = sub i32 ${lim}, ${kv}`);
this.line(`${fits} = icmp sge i32 ${left}, 16`);
this.line(`br i1 ${fits}, label %${vload}, label %${tail}`);
this.block(vload);
const vat = this.tmp();
@@ -1242,6 +1391,7 @@ class RxGen {
this.line(`${all} = icmp eq i16 ${mask}, -1`);
this.line(`br i1 ${all}, label %${vnext}, label %${vexit}`);
this.block(vnext);
this.line(`${kv16} = add i32 ${kv}, 16`);
this.line(`br label %${vhead}`);
this.block(vexit);
const missing = this.tmp();
@@ -1769,7 +1919,10 @@ export function compileRegexMatcher(
fn.push(` store i32 %off.n, ptr %at`);
fn.push(` br label %attempt`);
} else {
gen.emitSeek(first.set);
// A start filter over the first few characters (a single first byte
// keeps memchr).
const prefix = prefixOf(root);
gen.emitSeek(first.set, gen.prefixPlans(prefix) === null ? null : prefix);
}
}
+2 -2
View File
@@ -2,8 +2,8 @@
* of the link contract: a program object keeps this symbol undefined and the
* matching runtime defines it, so incompatible manual links fail before the
* program can start. */
export const RUNTIME_ABI_VERSION = 26 as const;
export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v26" as const;
export const RUNTIME_ABI_VERSION = 27 as const;
export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v27" as const;
/** Object consumption is useful today, but the complete scr_* surface may
* still change before 1.0. The versioned marker prevents accidental mixing;
@@ -52,7 +52,7 @@ async function fixture(target: NativeTargetSpec, workers = false) {
object_format: target.objectFormat,
minimum_os: target.minimumOs,
},
runtime_abi: { version: 26, marker: "scr_runtime_abi_v26" },
runtime_abi: { version: 27, marker: "scr_runtime_abi_v27" },
compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple },
macros: { executable: [], excluded: ["SCR_LIB"], sanitizer: "external-toolchain-required" },
flavors: {
@@ -127,7 +127,7 @@ async function fixture(target: NativeTargetSpec = MACOS_ARM64_TARGET) {
object_format: target.objectFormat,
minimum_os: target.minimumOs,
},
runtime_abi: { version: 26, marker: "scr_runtime_abi_v26" },
runtime_abi: { version: 27, marker: "scr_runtime_abi_v27" },
compiler: {
command: "clang",
identity: "fixture clang",
@@ -11,6 +11,7 @@ import {
} from "../../ir/ir.js";
import { everyStmtList, transformStmtList } from "../../ir/traverse.js";
import { classInstanceOf } from "./class-dynamic-dispatch.js";
import type { DispatchSites } from "./dispatch-sites.js";
import type { ClassInfo } from "./lower-classes.js";
import type { Lowerer } from "./lowerer.js";
@@ -26,25 +27,16 @@ export class CaughtValueDispatch {
private readonly payloadHelpers = new Map<string, IrFunction>();
private readonly payloadHelperFns = new Set<IrFunction>();
process(lowerer: Lowerer, functions: readonly IrFunction[]): boolean {
process(lowerer: Lowerer, functions: readonly IrFunction[], sites: DispatchSites): boolean {
const consumers: IrFunction[] = [];
for (const fn of functions) {
if (fn === this.helper) continue;
let consumes = false;
everyStmtList(fn.body, {
stmt: (stmt) => {
if (stmt.kind === "throw" && stmt.value.type.kind === "object") {
const info = lowerer.classes.get(stmt.value.type.className);
if (info) this.thrownClasses.add(info.def.name);
}
return true;
},
expr: (expr) => {
if (expr.kind === "caughtToDyn") consumes = true;
return true;
},
});
if (consumes) consumers.push(fn);
const found = sites.of(fn);
for (const className of found.thrown) {
const info = lowerer.classes.get(className);
if (info) this.thrownClasses.add(info.def.name);
}
if (found.caughtValues) consumers.push(fn);
}
if (!this.helper && consumers.length === 0) return false;
let changed = false;
@@ -13,7 +13,8 @@ import {
type IrType,
type SrcLoc,
} from "../../ir/ir.js";
import { everyStmtList, transformExpr, transformStmtList } from "../../ir/traverse.js";
import { transformExpr, transformStmtList } from "../../ir/traverse.js";
import type { DispatchSites } from "./dispatch-sites.js";
import type { Lowerer } from "./lowerer.js";
import type { ParamShape } from "./call-signatures.js";
import { classPrototypeData } from "./class-prototypes.js";
@@ -254,22 +255,17 @@ export class ClassConstructionDispatch {
private prototypeHelper: IrFunction | null = null;
private readonly prototypes = new Set<string>();
process(lowerer: Lowerer, functions: readonly IrFunction[]): boolean {
process(lowerer: Lowerer, functions: readonly IrFunction[], sites: DispatchSites): boolean {
const discover = (type: IrType): void => {
if (type.kind === "classval") this.constructors.add(type.className);
else if (type.kind === "union") lowerer.unions.get(type.unionId)?.arms.forEach(discover);
};
const prototypeReads = new Set<IrFunction>();
for (const fn of functions)
everyStmtList(fn.body, {
stmt: () => true,
expr: (expr) => {
if (expr.kind === "dynFrom") discover(expr.value.type);
if (expr.kind === "libCall" && expr.fn === "dyn.classBasePrototype")
prototypeReads.add(fn);
return true;
},
});
for (const fn of functions) {
const found = sites.of(fn);
found.values.forEach(discover);
if (found.basePrototypes) prototypeReads.add(fn);
}
let changed = false;
for (const fn of functions) {
if (fn === this.prototypeHelper || !prototypeReads.has(fn)) continue;
@@ -1,4 +1,5 @@
import { primitiveConstructorOr } from "./lower-exprs.js";
import { InternalCompilerError } from "../../errors.js";
import { dynUndefinedExpr, helperLoc, nodeThrowExpr, varRef } from "../../ir/build.js";
import { constructedValue } from "./constructor-returns.js";
import { eventTargetApiMembers, eventTargetInheritedMember } from "./lower-event-target.js";
@@ -77,6 +78,7 @@ import { isClassCallback } from "./class-callbacks.js";
import { CaughtValueDispatch } from "./caught-value-dispatch.js";
import { refreshDescriptorGuards } from "./class-descriptors.js";
import { ClassConstructionDispatch } from "./class-construction.js";
import { DispatchSites } from "./dispatch-sites.js";
import {
emitterRooted,
lowerDynamicEmitterEmit,
@@ -226,7 +228,7 @@ const KEY_CALLS = new Map<string, "object" | "any" | "value">([
["dyn.keySetComputed", "value"],
]);
function toPrimitiveHint(expr: IrExpr): ToPrimitiveHint | null {
export function toPrimitiveHint(expr: IrExpr): ToPrimitiveHint | null {
if (expr.kind === "toString") return expr.operand.type.kind === "dyn" ? "string" : null;
if (expr.kind === "dynKeyGet") return expr.key.type.kind === "dyn" ? "string" : null;
if (expr.kind !== "libCall") return null;
@@ -467,6 +469,79 @@ function sortAdded(lowerer: Lowerer, set: Set<string>, from: number): void {
for (const name of added) set.add(name);
}
/** The receivers of one property-receiver list (propertyReceivers) by the
* names that can make them answer a member, as positions in the list. A
* receiver whose root answers names of its own (an emitter, a stream, an
* EventTarget or a Date) is in `rooted` instead. */
interface ReceiverIndex {
plans: readonly PropertyReceiver[];
/** By a method or generic method its base chain declares. */
methods: Map<string, number[]>;
/** By a method, generic method or accessor its base chain declares (an
* accessor under its key and under its property name), or a field it has. */
members: Map<string, number[]>;
rooted: number[];
}
function receiverIndex(lowerer: Lowerer, plans: readonly PropertyReceiver[]): ReceiverIndex {
const methods = new Map<string, number[]>();
const members = new Map<string, number[]>();
const rooted: number[] = [];
const add = (table: Map<string, number[]>, name: string, position: number): void => {
const positions = table.get(name);
if (positions === undefined) table.set(name, [position]);
else if (positions[positions.length - 1] !== position) positions.push(position);
};
plans.forEach(({ info }, position) => {
if (
emitterRooted(lowerer, info) ||
info.builtinStream !== undefined ||
eventTargetApiMembers(info) !== null ||
dateRootOf(info) !== null
) {
rooted.push(position);
return;
}
for (let c: ClassInfo | null = info; c; c = c.base) {
for (const name of c.methods.keys()) {
add(methods, name, position);
// A `get:`/`set:` key is an accessor of the property after the
// prefix, or a method whose quoted name has that prefix; the test
// looks up both.
add(members, name, position);
if (name.startsWith("get:") || name.startsWith("set:"))
add(members, name.slice(4), position);
}
if (c.genericMethods)
for (const name of c.genericMethods.keys()) {
add(methods, name, position);
add(members, name, position);
}
}
for (const name of info.fields.keys()) add(members, name, position);
});
for (const table of [methods, members])
for (const positions of table.values()) positions.sort((a, b) => a - b);
return { plans, methods, members, rooted };
}
/** The ascending union of two ascending position lists. */
function mergePositions(left: readonly number[], right: readonly number[]): number[] {
const out: number[] = [];
let i = 0;
let j = 0;
while (i < left.length || j < right.length) {
const next =
j >= right.length || (i < left.length && left[i]! <= right[j]!) ? left[i++]! : right[j++]!;
if (out[out.length - 1] !== next) out.push(next);
}
return out;
}
/** SCRIPTC_CHECK_DISPATCH_CANDIDATES=1 checks every indexed receiver selection
* against a test of every receiver. */
const CHECK_CANDIDATES = process.env["SCRIPTC_CHECK_DISPATCH_CANDIDATES"] === "1";
export function classPropertiesHelper(lowerer: Lowerer, loc: SrcLoc, readOnly = false): IrFunction {
const name = readOnly ? READ_PROPERTIES : "%dyn.class.properties";
lowerer.shareHelper(name);
@@ -780,6 +855,8 @@ export class ClassDynamicDispatch {
private readonly generatorDispatch = new GeneratorDynamicDispatch();
private readonly caughtDispatch = new CaughtValueDispatch();
private readonly constructionDispatch = new ClassConstructionDispatch();
/** What each function's body holds for this pass and its sub-passes. */
readonly sites = new DispatchSites();
/** Property names a delete can remove (null: any name), for the field
* creation tracking decision. */
private deletedNames: Set<string> | null | undefined;
@@ -862,12 +939,13 @@ export class ClassDynamicDispatch {
)
fn.body.shift();
}
let changed = this.caughtDispatch.process(lowerer, functions);
let changed = this.caughtDispatch.process(lowerer, functions, this.sites);
changed = refreshErrorMethodDispatch(lowerer, functions) || changed;
changed = refreshClassMethodValueSelections(lowerer) || changed;
changed = refreshErrorPropertyDispatch(lowerer, functions) || changed;
changed = this.generatorDispatch.process(lowerer, functions, this.generated) || changed;
changed = this.constructionDispatch.process(lowerer, functions) || changed;
changed =
this.generatorDispatch.process(lowerer, functions, this.generated, this.sites) || changed;
changed = this.constructionDispatch.process(lowerer, functions, this.sites) || changed;
const seenTypes = new Set<string>();
const rewrite = new Set<IrFunction>();
const toPrimitiveSites = new Set<IrFunction>();
@@ -891,100 +969,18 @@ export class ClassDynamicDispatch {
};
const boxedBefore = this.boxed.size;
const boxedConstructorsBefore = this.boxedConstructors.size;
for (const fn of functions)
everyStmtList(fn.body, {
stmt: (statement) => {
// Field-creation instrumentation can box a base-constructor receiver
// even when only its derived instances otherwise cross the boundary.
if (
statement.kind === "fieldSet" &&
lowerer.classes.get(statement.className)?.def.tracksOwnFields &&
isClassOwnEnumerableFieldName(statement.field)
)
discover(statement.obj.type);
return true;
},
expr: (expr) => {
if (
expr.kind === "jsonStringify" ||
(expr.kind === "libCall" &&
(expr.fn === "json.stringifyValue" || expr.fn === "json.stringifyReplacer"))
) {
this.serializesClasses = true;
}
if (
toPrimitiveHint(expr) !== null ||
(expr.kind === "libCall" && (expr.fn === "dyn.valueOf" || expr.fn === "dyn.toString"))
)
toPrimitiveSites.add(fn);
switch (expr.kind) {
case "dynFrom":
discover(expr.value.type);
break;
case "fieldIncDec":
if (
lowerer.classes.get(expr.className)?.def.tracksOwnFields &&
isClassOwnEnumerableFieldName(expr.field)
)
discover(expr.obj.type);
break;
case "call":
if (
(expr.callee === "%dyn.class.properties" ||
expr.callee === "%dyn.class.readProperties") &&
expr.args[0]?.kind === "dynFrom" &&
isDynTypedRefType(expr.args[0].value.type)
)
rewrite.add(fn);
break;
case "dynHasKey":
hasKeySites.add(fn);
break;
case "caughtTest":
if (expr.test === "instanceof") caughtSites.add(fn);
break;
case "caughtNarrow":
case "caughtCheck":
if (expr.type.kind === "object") caughtSites.add(fn);
break;
case "dynKeyGet":
case "dynInvoke":
rewrite.add(fn);
break;
case "dynCheck":
if (isDynTypedRefType(expr.type)) rewrite.add(fn);
// A dynamic function checked into a native function type
// receives its arguments as dynamic values: class instances
// passed to it cross the boundary when it is called (a JS
// `list.map((rec) => rec.match(x))` callback with an untyped
// parameter).
if (expr.type.kind === "func") expr.type.params.forEach(discover);
break;
case "libCall":
if (
expr.fn === "bytes.construct" ||
expr.fn === "dyn.keySet" ||
expr.fn === "dyn.keySetComputed" ||
expr.fn === "dyn.iterator" ||
expr.fn === "dyn.arrayFromIterator" ||
expr.fn === "dyn.toString" ||
expr.fn === "dyn.stringConstructor" ||
expr.fn === "dyn.forInKeys" ||
expr.fn === "dyn.forInKeyStrings" ||
expr.fn === "dyn.hasKeyComputed" ||
expr.fn === "dyn.hasOwnComputed" ||
expr.fn === "dyn.propertyIsEnumerableComputed" ||
expr.fn === "dyn.defineProperty" ||
expr.fn === "dyn.getPrototype" ||
expr.fn === "dyn.reflectGet" ||
expr.fn === "dyn.reflectSet"
)
rewrite.add(fn);
break;
}
return true;
},
});
for (const fn of functions) {
const found = this.sites.of(fn);
// A class starts tracking its own fields during this pass.
for (const { type, fieldClass } of found.boxing)
if (fieldClass === undefined || lowerer.classes.get(fieldClass)?.def.tracksOwnFields)
discover(type);
if (found.serializes) this.serializesClasses = true;
if (found.conversions) toPrimitiveSites.add(fn);
if (found.dispatches) rewrite.add(fn);
if (found.keyTests) hasKeySites.add(fn);
if (found.caughtClassTests) caughtSites.add(fn);
}
// The classes this pass found join the tables in a fixed order, not in
// the order the scan met them.
sortAdded(lowerer, this.boxed, boxedBefore);
@@ -1342,10 +1338,30 @@ export class ClassDynamicDispatch {
}
refreshDescriptorGuards(lowerer);
const byMethod = new Map<string, ClassInfo[]>();
// A query asks every receiver about one name, and the program's dispatch
// sites ask about thousands: index the receivers by name once per list.
let index: ReceiverIndex | null = null;
const indexOf = (plans: readonly PropertyReceiver[]): ReceiverIndex =>
index?.plans === plans ? index : (index = receiverIndex(lowerer, plans));
/** The receivers of `plans` that can answer `name`, in list order: those
* `table` lists under it and the rooted ones. Emit-override
* specializations (`emit:<event>`) join a class's methods while bodies
* lower, so they keep every receiver. */
const receiversFor = (
plans: readonly PropertyReceiver[],
name: string,
table: "methods" | "members",
): readonly PropertyReceiver[] => {
if (name.startsWith("emit:")) return plans;
const found = indexOf(plans);
const positions = (table === "methods" ? found.methods : found.members).get(name);
return mergePositions(positions ?? [], found.rooted).map((position) => plans[position]!);
};
const candidates = (method: string): ClassInfo[] => {
const found = byMethod.get(method);
if (found) return found;
const matching = this.propertyReceivers(lowerer, true).flatMap(({ info }) => {
const plans = this.propertyReceivers(lowerer, true);
const test = ({ info }: PropertyReceiver): ClassInfo[] => {
if (!info || info.fields.has(method) || info.builtinError) return [];
if (emitterRooted(lowerer, info) && EMITTER_METHODS.has(method)) return [info];
if (
@@ -1364,7 +1380,13 @@ export class ClassDynamicDispatch {
return findMethodOn(lowerer, info, method) || findGenericMethodOn(lowerer, info, method)
? [info]
: [];
});
};
const matching = receiversFor(plans, method, "methods").flatMap(test);
if (CHECK_CANDIDATES) {
const every = plans.flatMap(test);
if (every.length !== matching.length || every.some((info, i) => info !== matching[i]))
throw new InternalCompilerError(`indexed candidates for ${method} differ`);
}
byMethod.set(method, matching);
return matching;
};
@@ -2898,24 +2920,40 @@ export class ClassDynamicDispatch {
});
changed = true;
}
for (const plan of this.propertyReceivers(lowerer, true)) {
// Only the receivers that declare or hold the name, and the rooted
// ones, can pass the test, except for names it decides on every
// receiver. Fields added while bodies lower (the property bag and the
// attribute words) have compiler names, which take every receiver.
const plans = this.propertyReceivers(lowerer, true);
const anyReceiver =
!isClassOwnEnumerableFieldName(dispatch.name) ||
dispatch.name === "constructor" ||
dispatch.name === "stack";
const skips = (plan: PropertyReceiver): boolean =>
!isClassOwnEnumerableFieldName(dispatch.name) ||
(!(plan.info.builtinStream !== undefined && streamProperty(dispatch.name)) &&
!eventTargetInheritedMember(plan.info, dispatch.name) &&
!dateInheritedMember(lowerer, plan.info, dispatch.name) &&
!findMethodOn(lowerer, plan.info, dispatch.name) &&
!findGenericMethodOn(lowerer, plan.info, dispatch.name) &&
dispatch.name !== "constructor" &&
!plan.info.fields.has(dispatch.name) &&
!(dispatch.name === "stack" && lowerer.isSubclassOf(plan.info.def.name, "%Error")) &&
!findMethodOn(lowerer, plan.info, `get:${dispatch.name}`) &&
!findMethodOn(lowerer, plan.info, `set:${dispatch.name}`));
const selected = anyReceiver ? plans : receiversFor(plans, dispatch.name, "members");
if (
CHECK_CANDIDATES &&
plans.some(
(plan) => !dispatch.classes.has(plan.key) && !skips(plan) && !selected.includes(plan),
)
)
throw new InternalCompilerError(`indexed receivers for ${dispatch.name} differ`);
for (const plan of selected) {
const { info } = plan;
// Classes answered in an earlier pass skip the member lookups.
if (dispatch.classes.has(plan.key)) continue;
if (
!isClassOwnEnumerableFieldName(dispatch.name) ||
(!(info.builtinStream !== undefined && streamProperty(dispatch.name)) &&
!eventTargetInheritedMember(info, dispatch.name) &&
!dateInheritedMember(lowerer, info, dispatch.name) &&
!findMethodOn(lowerer, info, dispatch.name) &&
!findGenericMethodOn(lowerer, info, dispatch.name) &&
dispatch.name !== "constructor" &&
!info.fields.has(dispatch.name) &&
!(dispatch.name === "stack" && lowerer.isSubclassOf(info.def.name, "%Error")) &&
!findMethodOn(lowerer, info, `get:${dispatch.name}`) &&
!findMethodOn(lowerer, info, `set:${dispatch.name}`))
)
continue;
if (skips(plan)) continue;
dispatch.classes.add(plan.key);
const loc = dispatch.fn.loc;
const { receiver, condition } = this.propertyReceiver(plan, loc);
@@ -159,6 +159,15 @@ test("fills every empty copy with the exact file and span while preserving popul
for (const untouched of [otherFile, otherStart, otherEnd]) expect(untouched.body).toEqual([]);
});
test("reports the functions whose placeholders it fills", () => {
const { initializers, pending, lowerer } = fixture();
const functions = [fn("init", [block(loc)]), fn("use", [read("registry")])];
pending.set("registry", [{ loc, lower: () => [value(4)] }]);
const filled: IrFunction[] = [];
expect(initializers.process(lowerer, functions, (f) => filled.push(f))).toBe(true);
expect(filled).toEqual([functions[0]]);
});
test("an update of the binding demands its initializer", () => {
const { initializers, pending, lowerer } = fixture();
const updated = block(loc),
@@ -129,7 +129,14 @@ export class DeferredModuleInitializers {
return block;
}
process(lowerer: Lowerer, functions: readonly IrFunction[]): boolean {
/** Materializes the initializers that reached bodies observe. `filled`
* receives each function whose body changes inside an unchanged statement
* list. */
process(
lowerer: Lowerer,
functions: readonly IrFunction[],
filled?: (fn: IrFunction) => void,
): boolean {
if (this.pending.size === 0) return false;
// Scans keep only the reads of deferred bindings.
for (const key of this.pending.keys()) {
@@ -192,7 +199,10 @@ export class DeferredModuleInitializers {
}
if (holders.length === 0) continue;
// These bodies changed inside unchanged statement lists.
for (const fn of holders) this.scans.delete(fn);
for (const fn of holders) {
this.scans.delete(fn);
filled?.(fn);
}
// An inserted body can introduce a placeholder needed by another
// action in this pass. Unattached bodies are not part of the live IR.
for (const nested of scanStatements(body).placeholders) add(nested, holders);
@@ -0,0 +1,60 @@
import { expect, test, vi } from "vitest";
import { DYN, F64, VOID, type IrFunction, type IrStmt, type SrcLoc } from "../../ir/ir.js";
import * as traversal from "../../ir/traverse.js";
import { DispatchSites } from "./dispatch-sites.js";
const loc: SrcLoc = { file: "sites.ts", start: 0, end: 1 };
const keyRead = (key: string): IrStmt => ({
kind: "exprStmt",
expr: {
kind: "dynKeyGet",
value: { kind: "varRef", localId: "value", type: DYN, loc },
key: { kind: "strLit", value: key, type: DYN, loc },
type: DYN,
loc,
},
loc,
});
const value = (n: number): IrStmt => ({
kind: "exprStmt",
expr: { kind: "numLit", value: n, type: F64, loc },
loc,
});
const fn = (body: IrStmt[]): IrFunction => ({
name: "f",
body,
params: [],
locals: [],
returnType: VOID,
loc,
});
test("reuses a function's sites while its statement list is unchanged", () => {
const sites = new DispatchSites();
const f = fn([value(1)]);
const walk = vi.spyOn(traversal, "everyStmtList");
try {
expect(sites.of(f).dispatches).toBe(false);
expect(sites.of(f).dispatches).toBe(false);
expect(walk).toHaveBeenCalledTimes(1);
// A statement added to the same list is found.
f.body.push(keyRead("next"));
expect(sites.of(f)).toMatchObject({ dispatches: true, generatorSteps: true });
// So is a replaced list.
f.body = [value(2)];
expect(sites.of(f)).toMatchObject({ dispatches: false, generatorSteps: false });
expect(walk).toHaveBeenCalledTimes(3);
} finally {
walk.mockRestore();
}
});
test("scans a body again once a change inside an unchanged list is reported", () => {
const sites = new DispatchSites();
const placeholder: Extract<IrStmt, { kind: "block" }> = { kind: "block", body: [], loc };
const f = fn([placeholder]);
expect(sites.of(f).dispatches).toBe(false);
placeholder.body.push(keyRead("map"));
sites.forget(f);
expect(sites.of(f)).toMatchObject({ dispatches: true, runtimeSteps: true });
});
@@ -0,0 +1,272 @@
import { InternalCompilerError } from "../../errors.js";
import {
isClassOwnEnumerableFieldName,
isDynTypedRefType,
typeKey,
type IrFunction,
type IrStmt,
type IrType,
} from "../../ir/ir.js";
import { everyStmtList } from "../../ir/traverse.js";
import { toPrimitiveHint } from "./class-dynamic-dispatch.js";
import { GENERATOR_STEP_METHODS, HELPER_METHODS } from "./generator-dynamic-dispatch.js";
/** What one function's body holds for the class dispatch pass and its
* caught-value, generator and class construction sub-passes, collected in
* one walk. Lists keep the order the walk meets their entries. */
export interface FunctionSites {
statements: IrStmt[];
length: number;
first: IrStmt | undefined;
last: IrStmt | undefined;
/** The value types of `dynFrom` expressions, once per type key. */
values: IrType[];
/** The types the class dispatch scan boxes, once per type key and class:
* `dynFrom` values, the parameter types of dynamic functions checked into
* native function types, and the receivers of own enumerable field writes
* and updates. A receiver counts only while its class (`fieldClass`)
* tracks its own fields. */
boxing: { type: IrType; fieldClass?: string }[];
/** JSON serialization of dynamic values. */
serializes: boolean;
/** ToPrimitive conversions, and `dyn.valueOf` and `dyn.toString` calls. */
conversions: boolean;
/** Sites the class dispatch rewrite can change. */
dispatches: boolean;
/** `dynHasKey` tests. */
keyTests: boolean;
/** `instanceof` tests of caught values, and caught values narrowed or
* checked to class types. */
caughtClassTests: boolean;
/** Calls and reads of generator steps, and reads of iterator helper
* methods. */
generatorSteps: boolean;
/** Values the runtime steps: iterator helper calls and reads, `dynIterN`,
* and rest destructuring. */
runtimeSteps: boolean;
/** `dyn.classBasePrototype` calls. */
basePrototypes: boolean;
/** The classes of thrown class instances, once each. */
thrown: string[];
/** `caughtToDyn` conversions. */
caughtValues: boolean;
}
/** The `libCall`s the class dispatch rewrite can change. */
const DISPATCH_LIB_CALLS = new Set<string>([
"bytes.construct",
"dyn.keySet",
"dyn.keySetComputed",
"dyn.iterator",
"dyn.arrayFromIterator",
"dyn.toString",
"dyn.stringConstructor",
"dyn.forInKeys",
"dyn.forInKeyStrings",
"dyn.hasKeyComputed",
"dyn.hasOwnComputed",
"dyn.propertyIsEnumerableComputed",
"dyn.defineProperty",
"dyn.getPrototype",
"dyn.reflectGet",
"dyn.reflectSet",
]);
/** Prototype data helpers (classPrototypeData) gain method slots inside
* their initialization block, so their summaries are not reused. */
const PROTOTYPE_DATA_PREFIX = "%prototype.data.";
/** SCRIPTC_CHECK_DISPATCH_SCANS=1 checks each reused summary against a fresh walk. */
const CHECK_SCANS = process.env["SCRIPTC_CHECK_DISPATCH_SCANS"] === "1";
/** The function summaries (FunctionSites) of one class dispatch pass, kept
* across its runs. */
export class DispatchSites {
private readonly sites = new Map<IrFunction, FunctionSites>();
/** The sites of `fn`. A summary is reused while the function's statement
* list is the same array with the same length and the same first and last
* statements: the dispatch passes replace the lists they rewrite and add
* statements to the helpers they extend. Other changes inside an
* unchanged list are reported with `forget`, except in prototype data
* helpers, which are scanned on every use. */
of(fn: IrFunction): FunctionSites {
const cached = this.sites.get(fn);
if (
cached !== undefined &&
!fn.name.startsWith(PROTOTYPE_DATA_PREFIX) &&
cached.statements === fn.body &&
cached.length === fn.body.length &&
cached.first === fn.body[0] &&
cached.last === fn.body[fn.body.length - 1]
) {
if (CHECK_SCANS && !sameSites(cached, scanSites(fn.body)))
throw new InternalCompilerError(
`reused dispatch sites of ${fn.name} disagree with a fresh scan`,
);
return cached;
}
const sites = scanSites(fn.body);
this.sites.set(fn, sites);
return sites;
}
/** Drops the summary of a function whose body changed inside an unchanged
* statement list. */
forget(fn: IrFunction): void {
this.sites.delete(fn);
}
}
function scanSites(statements: IrStmt[]): FunctionSites {
const sites: FunctionSites = {
statements,
length: statements.length,
first: statements[0],
last: statements[statements.length - 1],
values: [],
boxing: [],
serializes: false,
conversions: false,
dispatches: false,
keyTests: false,
caughtClassTests: false,
generatorSteps: false,
runtimeSteps: false,
basePrototypes: false,
thrown: [],
caughtValues: false,
};
const valueKeys = new Set<string>();
const boxingKeys = new Set<string>();
const thrown = new Set<string>();
const box = (type: IrType, fieldClass?: string): void => {
const key = `${fieldClass ?? ""}\0${typeKey(type)}`;
if (boxingKeys.has(key)) return;
boxingKeys.add(key);
sites.boxing.push(fieldClass === undefined ? { type } : { type, fieldClass });
};
everyStmtList(statements, {
stmt: (statement) => {
if (statement.kind === "throw" && statement.value.type.kind === "object") {
const className = statement.value.type.className;
if (!thrown.has(className)) {
thrown.add(className);
sites.thrown.push(className);
}
}
// Field-creation instrumentation can box a base-constructor receiver
// even when only its derived instances otherwise cross the boundary.
if (statement.kind === "fieldSet" && isClassOwnEnumerableFieldName(statement.field))
box(statement.obj.type, statement.className);
return true;
},
expr: (expr) => {
if (
expr.kind === "jsonStringify" ||
(expr.kind === "libCall" &&
(expr.fn === "json.stringifyValue" || expr.fn === "json.stringifyReplacer"))
)
sites.serializes = true;
if (
toPrimitiveHint(expr) !== null ||
(expr.kind === "libCall" && (expr.fn === "dyn.valueOf" || expr.fn === "dyn.toString"))
)
sites.conversions = true;
if (
(expr.kind === "dynInvoke" && GENERATOR_STEP_METHODS.has(expr.method)) ||
(expr.kind === "dynKeyGet" &&
expr.key.kind === "strLit" &&
(GENERATOR_STEP_METHODS.has(expr.key.value) || HELPER_METHODS.has(expr.key.value)))
)
sites.generatorSteps = true;
if (
(expr.kind === "dynInvoke" && HELPER_METHODS.has(expr.method)) ||
(expr.kind === "dynKeyGet" &&
expr.key.kind === "strLit" &&
HELPER_METHODS.has(expr.key.value)) ||
expr.kind === "dynIterN" ||
(expr.kind === "libCall" && expr.fn === "dyn.destructureRest")
)
sites.runtimeSteps = true;
switch (expr.kind) {
case "dynFrom": {
const key = typeKey(expr.value.type);
if (!valueKeys.has(key)) {
valueKeys.add(key);
sites.values.push(expr.value.type);
}
box(expr.value.type);
break;
}
case "fieldIncDec":
if (isClassOwnEnumerableFieldName(expr.field)) box(expr.obj.type, expr.className);
break;
case "call":
if (
(expr.callee === "%dyn.class.properties" ||
expr.callee === "%dyn.class.readProperties") &&
expr.args[0]?.kind === "dynFrom" &&
isDynTypedRefType(expr.args[0].value.type)
)
sites.dispatches = true;
break;
case "dynHasKey":
sites.keyTests = true;
break;
case "caughtTest":
if (expr.test === "instanceof") sites.caughtClassTests = true;
break;
case "caughtNarrow":
case "caughtCheck":
if (expr.type.kind === "object") sites.caughtClassTests = true;
break;
case "caughtToDyn":
sites.caughtValues = true;
break;
case "dynKeyGet":
case "dynInvoke":
sites.dispatches = true;
break;
case "dynCheck":
if (isDynTypedRefType(expr.type)) sites.dispatches = true;
// A dynamic function checked into a native function type
// receives its arguments as dynamic values: class instances
// passed to it cross the boundary when it is called (a JS
// `list.map((rec) => rec.match(x))` callback with an untyped
// parameter).
if (expr.type.kind === "func") expr.type.params.forEach((type) => box(type));
break;
case "libCall":
if (expr.fn === "dyn.classBasePrototype") sites.basePrototypes = true;
if (DISPATCH_LIB_CALLS.has(expr.fn)) sites.dispatches = true;
break;
}
return true;
},
});
return sites;
}
function sameSites(left: FunctionSites, right: FunctionSites): boolean {
const sameList = <T>(a: readonly T[], b: readonly T[], same: (x: T, y: T) => boolean): boolean =>
a.length === b.length && a.every((item, index) => same(item, b[index]!));
return (
sameList(left.values, right.values, (a, b) => a === b) &&
sameList(
left.boxing,
right.boxing,
(a, b) => a.type === b.type && a.fieldClass === b.fieldClass,
) &&
sameList(left.thrown, right.thrown, (a, b) => a === b) &&
left.serializes === right.serializes &&
left.conversions === right.conversions &&
left.dispatches === right.dispatches &&
left.keyTests === right.keyTests &&
left.caughtClassTests === right.caughtClassTests &&
left.generatorSteps === right.generatorSteps &&
left.runtimeSteps === right.runtimeSteps &&
left.basePrototypes === right.basePrototypes &&
left.caughtValues === right.caughtValues
);
}
@@ -13,16 +13,20 @@ import {
type IrStmt,
type IrType,
} from "../../ir/ir.js";
import { everyStmtList, transformStmtList } from "../../ir/traverse.js";
import { transformStmtList } from "../../ir/traverse.js";
import { genResultRecord } from "../type-mapper.js";
import type { DispatchSites } from "./dispatch-sites.js";
import type { Lowerer } from "./lowerer.js";
type GeneratorType = Extract<IrType, { kind: "generator" }>;
type Invoke = Extract<IrExpr, { kind: "dynInvoke" }>;
type KeyGet = Extract<IrExpr, { kind: "dynKeyGet" }>;
/** The generator methods a dynamic call or read steps. */
export const GENERATOR_STEP_METHODS = new Set(["next", "return", "throw"]);
/** The iterator helper methods the runtime answers on checked iterators. */
const HELPER_METHODS = new Set([
export const HELPER_METHODS = new Set([
"map",
"filter",
"take",
@@ -101,6 +105,7 @@ export class GeneratorDynamicDispatch {
lowerer: Lowerer,
functions: readonly IrFunction[],
classHelpers: ReadonlySet<IrFunction>,
sites: DispatchSites,
): boolean {
const seen = new Set<string>();
const rewrite = new Set<IrFunction>();
@@ -117,34 +122,14 @@ export class GeneratorDynamicDispatch {
lowerer.shapes.get(type.shapeId)?.fields.forEach((field) => discover(field.type));
else if (type.kind === "union") lowerer.unions.get(type.unionId)?.arms.forEach(discover);
};
for (const fn of functions)
everyStmtList(fn.body, {
stmt: () => true,
expr: (expr) => {
if (expr.kind === "dynFrom") discover(expr.value.type);
if (expr.kind === "dynInvoke" && ["next", "return", "throw"].includes(expr.method))
rewrite.add(fn);
// The runtime steps the values iterator helpers and destructuring
// receive.
if (
(expr.kind === "dynInvoke" && HELPER_METHODS.has(expr.method)) ||
(expr.kind === "dynKeyGet" &&
expr.key.kind === "strLit" &&
HELPER_METHODS.has(expr.key.value)) ||
expr.kind === "dynIterN" ||
(expr.kind === "libCall" && expr.fn === "dyn.destructureRest")
)
runtimeSteps = true;
if (
expr.kind === "dynKeyGet" &&
expr.key.kind === "strLit" &&
(["next", "return", "throw"].includes(expr.key.value) ||
HELPER_METHODS.has(expr.key.value))
)
rewrite.add(fn);
return true;
},
});
for (const fn of functions) {
const found = sites.of(fn);
found.values.forEach(discover);
// The runtime steps the values iterator helpers and destructuring
// receive.
if (found.runtimeSteps) runtimeSteps = true;
if (found.generatorSteps) rewrite.add(fn);
}
let changed = false;
for (const fn of functions) {
// The generator helper's fallback may itself dispatch native classes.
@@ -159,7 +144,7 @@ export class GeneratorDynamicDispatch {
if (
expr.kind === "dynKeyGet" &&
expr.key.kind === "strLit" &&
(["next", "return", "throw"].includes(expr.key.value) ||
(GENERATOR_STEP_METHODS.has(expr.key.value) ||
(HELPER_METHODS.has(expr.key.value) && this.generators.size > 0))
) {
const method = expr.key.value;
@@ -173,8 +158,7 @@ export class GeneratorDynamicDispatch {
loc: expr.loc,
};
}
if (expr.kind !== "dynInvoke" || !["next", "return", "throw"].includes(expr.method))
return expr;
if (expr.kind !== "dynInvoke" || !GENERATOR_STEP_METHODS.has(expr.method)) return expr;
const key = JSON.stringify([expr.method, expr.args.length, expr.calleeName]);
let dispatch = this.dispatches.get(key);
if (!dispatch) {
@@ -289,6 +289,7 @@ function lowerStreamCallback(
node: ts.MethodDeclaration | ts.FunctionExpression | ts.ArrowFunction,
thisType: IrType,
fullTuple: IrType[],
chunkValues = true,
): IrExpr {
const loc = locOf(node);
const { shapes, funcType } = lowerer.lambdaSignature(node);
@@ -309,11 +310,15 @@ function lowerStreamCallback(
// callable dyn whose glue dispatches to the runtime's *_done).
if (funcType.params[i]!.kind === "dyn") continue;
// A write or transform chunk of another type converts from the
// chunk's value when the callback runs (an object-mode side's value,
// or the Buffer), or throws a TypeError there; an encoding of another
// type (`string | null | undefined`) from the encoding argument's.
// chunk's value when the callback runs (an object-mode side's value),
// or throws a TypeError there — on a side known to be byte mode the
// chunk is a Buffer, so the declared type must be Node's or a
// supertype of it (`Buffer | string`); an encoding of another type
// (`string | null | undefined`) converts from the encoding argument's
// value.
if (
(i === 0 || i === 1) &&
((i === 0 && (chunkValues || widensInto(lowerer, fullTuple[0]!, funcType.params[0]!, loc))) ||
i === 1) &&
(which === "write" || which === "transform") &&
funcType.params[i]!.kind !== "func" &&
canConvertToDyn(
@@ -465,6 +470,20 @@ function streamObjectModes(
return out;
}
/** Whether an options literal sets the writable side's object mode with a
* `true` literal (objectMode, or writableObjectMode on a duplex stream).
* Reports nothing: streamObjectModes fences the other value forms. */
function literalWritesValues(opts: ts.ObjectLiteralExpression, duplexish: boolean): boolean {
for (const prop of opts.properties?.nodes ?? []) {
if (!ts.isPropertyAssignment(prop)) continue;
const name =
ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name) ? prop.name.text : null;
if (name !== "objectMode" && !(duplexish && name === "writableObjectMode")) continue;
if (prop.initializer.kind !== ts.SyntaxKind.FalseKeyword) return true;
}
return false;
}
/** The full Node parameter tuples of the option callbacks, given the
* concrete stream's `this` type. The trailing completion callbacks keep
* whatever func type the checker contextually gave the user's parameter
@@ -802,7 +821,10 @@ function parseStreamOptions(
}
return declared ?? funcOf([], VOID);
});
present.set(name, lowerStreamCallback(lowerer, name, fnNode, thisType, tuple));
present.set(
name,
lowerStreamCallback(lowerer, name, fnNode, thisType, tuple, out.writableObject),
);
} finally {
if (pinned) lowerer.stageSourceTypes.delete(pinned);
}
@@ -1151,6 +1173,7 @@ function streamMethodWrapper(
thisType: IrType,
loc: SrcLoc,
blame: ts.Node,
chunkValues = true,
): IrExpr | null {
const found = lowerer.findMethodOn(info, methodName);
if (!found) {
@@ -1235,10 +1258,11 @@ function streamMethodWrapper(
continue;
}
// A write or transform chunk of another type converts from the
// chunk's value when the method runs (an object-mode side's value,
// or the Buffer), or throws a TypeError there.
// chunk's value when the method runs (an object-mode side's value),
// or throws a TypeError there; a byte-mode side's chunk is a Buffer.
if (
i === 0 &&
chunkValues &&
(which === "write" || which === "transform") &&
got.type.kind !== "func" &&
canConvertToDyn(
@@ -1343,10 +1367,22 @@ export function lowerStreamSuperCall(
);
}
const shape = streamCtorShape(cls);
// Whether the writable side holds values: options read at run time may
// set objectMode, and a literal says so with objectMode (or a Duplex's
// writableObjectMode) true; otherwise _write and _transform receive
// Buffers.
// super(undefined) and super(null) pass no options (followOptionsArg
// answers null), so their sides are byte mode too.
const optsNode = argNodes[0] !== undefined ? followOptionsArg(lowerer, argNodes[0]) : null;
const chunkValues =
optsNode !== null &&
optsNode !== undefined &&
(!ts.isObjectLiteralExpression(optsNode) ||
literalWritesValues(optsNode, cls !== "%Readable" && cls !== "%Writable"));
const fallback = (name: string): IrExpr | null => {
const methodName = UNDERSCORE_METHODS.get(name);
return methodName
? streamMethodWrapper(lowerer, info, name, methodName, thisType, loc, blame)
? streamMethodWrapper(lowerer, info, name, methodName, thisType, loc, blame, chunkValues)
: null;
};
const thisRef = (): IrExpr => ({ kind: "varRef", localId: thisLocal.id, type: thisType, loc });
@@ -181,7 +181,7 @@ export { newFnCtx, type FnCtx } from "./function-context.js";
* - Lexical scoping is resolved here: locals get function-unique ids
* ("x.0", "x.1" for shadowing); the IR is scope-flat.
*/
import { dirname, resolve } from "node:path";
import { dirname, relative, resolve } from "node:path";
import { bindingSource } from "../binding-source.js";
import { tsgoPath } from "../dts-paths.js";
import * as ts from "../ts7/adapter.js";
@@ -3192,12 +3192,14 @@ export class Lowerer {
errorPropertyDispatchRevision: string | null = null;
/** Structural source positions (structural-positions.ts) for names that
* stay the same when unrelated code moves. */
/** Default library files are named by their base name: two checkers
* install them in different directories. */
/** Files are named by their path relative to the entry's directory, so
* names do not depend on where the program sits, and default library
* files by their base name: two checkers install them in different
* directories. */
readonly positions = new StructuralPositions((sf) =>
this.program.isSourceFileDefaultLibrary(sf)
? `lib:${sf.fileName.slice(sf.fileName.lastIndexOf("/") + 1)}`
: sf.fileName,
: relative(dirname(this.entry.fileName), sf.fileName),
);
/** The outermost node whose token span is exactly `loc`, or null. */
@@ -8231,13 +8233,17 @@ export class Lowerer {
for (;;) {
drainInstances();
const declared = unitsInOrder();
const initializerChanged = this.deferredModuleInitializers.process(this, [
...declared,
...initFunctions,
...instanceFunctions,
...byName(this.liftedFns),
...byName(this.implicitFns),
]);
const initializerChanged = this.deferredModuleInitializers.process(
this,
[
...declared,
...initFunctions,
...instanceFunctions,
...byName(this.liftedFns),
...byName(this.implicitFns),
],
(fn) => classDispatch.sites.forget(fn),
);
const dispatchChanged = classDispatch.process(this, [
...declared,
...initFunctions,
@@ -426,3 +426,119 @@ test("paired counters closing in on each other run as integers", () => {
unused.body = [];
expect(matchIntegerPairedForLoop(unused, vars)).toBeNull();
});
test("counters stepped beside the tested one run as integers", () => {
const vars = new Map(locals);
for (const id of ["j", "k", "limit", "from"])
vars.set(id, { id, name: id, type: F64, mutable: true });
vars.set("s", { id: "s", name: "s", type: STRING, mutable: false });
const step = (id: string, op: "+" | "-", value = 1): IrStmt => ({
kind: "assign",
localId: id,
value: { kind: "bin", op, left: ref(id), right: number(value), type: F64, loc },
loc,
});
const read = (index: IrExpr): IrStmt => ({
kind: "exprStmt",
expr: {
kind: "strIntrinsic",
method: "charCodeAt",
receiver: { kind: "varRef", localId: "s", type: STRING, loc },
args: [index],
type: F64,
loc,
},
loc,
});
const comoving = (
limit: IrExpr,
followerStart: IrExpr = number(0),
update: IrStmt = { kind: "block", body: [step("i", "+"), step("j", "+", 4)], loc },
) =>
({
kind: "for",
loc,
init: {
kind: "block",
body: [
{ kind: "varDecl", localId: "i", init: number(0), loc },
{ kind: "varDecl", localId: "j", init: followerStart, loc },
],
loc,
},
cond: { kind: "bin", op: "<", left: ref("i"), right: limit, type: BOOL, loc },
update,
body: [read(ref("j"))],
}) as IrStmt & { kind: "for" };
// A literal limit bounds the follower: at most 9 updates of 4 before the
// last body entry.
expect(matchIntegerCountedForLoop(comoving(number(10)), vars)).toMatchObject({
localId: "i",
guarded: false,
range: { min: 0, max: 9 },
followers: [
{
localId: "j",
step: 4,
startGuarded: false,
tripGuarded: false,
range: { min: 0, max: 36 },
},
],
});
// An unknown limit or start is tested at run time.
expect(matchIntegerCountedForLoop(comoving(ref("limit")), vars)).toMatchObject({
guarded: true,
followers: [{ tripGuarded: true, range: { min: 0, max: 2 ** 52 + 2 } }],
});
expect(matchIntegerCountedForLoop(comoving(number(10), ref("from")), vars)).toMatchObject({
guarded: true,
followers: [{ startGuarded: true, tripGuarded: true }],
});
// A literal limit with known starts that can pass 2^53 is refused.
expect(matchIntegerCountedForLoop(comoving(number(10), number(2 ** 53 - 9)), vars)).toBeNull();
// Nested update blocks (`i++, j += 4, k--`) step three counters.
const three = comoving(number(10), number(0), {
kind: "block",
body: [{ kind: "block", body: [step("i", "+"), step("j", "+", 4)], loc }, step("k", "-")],
loc,
});
if (three.init?.kind === "block")
three.init.body.push({ kind: "varDecl", localId: "k", init: number(5), loc });
expect(matchIntegerCountedForLoop(three, vars)?.followers).toMatchObject([
{ localId: "j", range: { min: 0, max: 36 } },
{ localId: "k", step: -1, range: { min: -4, max: 5 } },
]);
// A follower without a step, one the body writes, one the limit reads, a
// captured one, and a start that reads another counter are refused.
expect(
matchIntegerCountedForLoop(
comoving(number(10), number(0), { kind: "block", body: [step("i", "+")], loc }),
vars,
),
).toBeNull();
const writing = comoving(number(10));
writing.body.push(step("j", "+"));
expect(matchIntegerCountedForLoop(writing, vars)).toBeNull();
expect(matchIntegerCountedForLoop(comoving(ref("j")), vars)).toBeNull();
const boxed = new Map(vars);
boxed.set("j", { ...vars.get("j")!, boxed: true });
expect(matchIntegerCountedForLoop(comoving(number(10)), boxed)).toBeNull();
expect(matchIntegerCountedForLoop(comoving(number(10), ref("i")), vars)).toBeNull();
// A driver span above 2^53 - 1 bounds no trip count. From -(2^53 - 1) to
// below 2^52 - 1 the span 3 * 2^52 - 3 rounds down to 3 * 2^52 - 4, which
// would put this follower's last value at 2^53 - 1 instead of 2^53.
const wide = comoving(number(2 ** 52 - 1), number(-(2 ** 52) + 3), {
kind: "block",
body: [step("i", "+"), step("j", "+")],
loc,
});
if (wide.init?.kind === "block" && wide.init.body[0]?.kind === "varDecl")
wide.init.body[0].init = number(-Number.MAX_SAFE_INTEGER);
expect(matchIntegerCountedForLoop(wide, vars)).toBeNull();
if (wide.init?.kind === "block" && wide.init.body[1]?.kind === "varDecl")
wide.init.body[1].init = ref("from");
expect(matchIntegerCountedForLoop(wide, vars)?.followers).toMatchObject([
{ startGuarded: true, tripGuarded: true },
]);
});
+140 -50
View File
@@ -28,8 +28,36 @@ export interface IntegerCountedForLoop {
startGuarded: boolean;
guardLimit: number;
range: IntegerRange;
/** Counters declared and stepped beside this one that the condition does
* not test, such as `j` in `for (let i = 0, j = 0; i < n; i++, j += 4)`. */
followers: IntegerLoopFollower[];
}
/** A counter that steps by a constant on every update of a counted loop.
* After `k` updates it holds `start + step * k`. */
export interface IntegerLoopFollower {
localId: string;
start: IrExpr;
step: number;
/** As the driver's `startGuarded` (part of the loop's `guarded`). */
startGuarded: boolean;
/** The follower's range depends on the run-time trip count: the versioned
* loop also tests `|start| + |step| * span / |driver step|` against
* `FOLLOWER_GUARD_LIMIT`, where `span` is the distance from the driver's
* start to the limit (part of the loop's `guarded`). */
tripGuarded: boolean;
/** The follower's values on every body entry. */
range: IntegerRange;
}
/** The bound of the run-time follower test. */
export const FOLLOWER_GUARD_LIMIT = 2 ** 52;
/** The magnitude a follower that passed the test can reach. The test's four
* f64 operations (the step ratio, the span, its product and the sum) each
* round down by at most a factor of 1 - 2^-53, so the exact sum is below
* `2^52 / (1 - 2^-53)^4`, which is less than 2^52 + 3. */
const FOLLOWER_GUARD_RANGE = FOLLOWER_GUARD_LIMIT + 2;
function constantStep(update: IrStmt | null, id: string): number | null {
if (update?.kind === "exprStmt" && update.expr.kind === "incDec" && update.expr.localId === id)
return update.expr.op === "+" ? 1 : -1;
@@ -115,33 +143,61 @@ export function matchIntegerCountedForLoop(
ranges: IntegerRanges = new Map(),
unaliased: ReadonlySet<string> | null = null,
): IntegerCountedForLoop | null {
const init = stmt.init;
if (init?.kind !== "varDecl" || !init.init) return null;
const local = locals.get(init.localId);
if (local?.type.kind !== "f64" || !local.mutable || local.boxed || local.tdz) return null;
const start = init.init;
let startRange =
start.kind === "numLit" && Number.isSafeInteger(start.value) && !Object.is(start.value, -0)
? { min: start.value, max: start.value }
: ranges.get(start);
// An unproven start is tested at run time, on a versioned loop.
const startGuarded = !startRange && start.kind !== "numLit" && start.type.kind === "f64";
if (startGuarded) startRange = { min: -Number.MAX_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER };
if (
!startRange ||
startRange.min < -Number.MAX_SAFE_INTEGER ||
startRange.max > Number.MAX_SAFE_INTEGER ||
!stableLoopValue(start, locals, stmt.body, false, local.id)
)
return null;
const step = constantStep(stmt.update, local.id);
if (step === null || writesLocal(stmt.body, local.id)) return null;
const cond = stmt.cond;
if (cond?.kind !== "bin") return null;
const decls = stmt.init?.kind === "block" ? stmt.init.body : stmt.init ? [stmt.init] : [];
// `i++, j += 2, k--` lowers to nested blocks of steps.
const flat = (u: IrStmt | null): (IrStmt | null)[] =>
u?.kind === "block" ? u.body.flatMap(flat) : [u];
const updates = flat(stmt.update);
if (decls.length === 0 || updates.length !== decls.length) return null;
const declared = (e: IrExpr): string | null =>
e.kind === "varRef" && decls.some((d) => d.kind === "varDecl" && d.localId === e.localId)
? e.localId
: null;
// The condition tests the driving counter; every other declaration
// follows it.
const driver = declared(cond.left) ?? declared(cond.right);
const counterIds = decls.map((d) => (d.kind === "varDecl" ? d.localId : ""));
const stepOf = (id: string): number | null => {
const steps = updates.map((u) => constantStep(u, id)).filter((v) => v !== null);
return steps.length === 1 ? steps[0]! : null;
};
// A counter's start is a stable integer that refers to no counter. An
// unproven start is tested at run time, on a versioned loop.
const counterStart = (
id: string,
): { start: IrExpr; range: IntegerRange; guarded: boolean; step: number } | null => {
const decl = decls.find((d) => d.kind === "varDecl" && d.localId === id);
if (decl?.kind !== "varDecl" || !decl.init) return null;
const local = locals.get(id);
if (local?.type.kind !== "f64" || !local.mutable || local.boxed || local.tdz) return null;
const start = decl.init;
const known =
start.kind === "numLit" && Number.isSafeInteger(start.value) && !Object.is(start.value, -0)
? { min: start.value, max: start.value }
: ranges.get(start);
const guarded = !known && start.kind !== "numLit" && start.type.kind === "f64";
const range = guarded ? { min: -Number.MAX_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER } : known;
const step = stepOf(id);
if (
!range ||
range.min < -Number.MAX_SAFE_INTEGER ||
range.max > Number.MAX_SAFE_INTEGER ||
!counterIds.every((counter) => stableLoopValue(start, locals, stmt.body, false, counter)) ||
step === null ||
writesLocal(stmt.body, id)
)
return null;
return { start, range, guarded, step };
};
const head = driver === null ? null : counterStart(driver);
if (!head) return null;
const local = locals.get(driver!)!;
const { start, step, guarded: startGuarded, range: startRange } = head;
let limit = cond.right,
op: string = cond.op;
if (cond.left.kind !== "varRef" || cond.left.localId !== local.id) {
if (cond.right.kind !== "varRef" || cond.right.localId !== local.id) return null;
limit = cond.left;
op = ({ "<": ">", "<=": ">=", ">": "<", ">=": "<=" } as Record<string, string>)[op] ?? "";
}
@@ -185,11 +241,70 @@ export function matchIntegerCountedForLoop(
const limitGuarded =
!boundRange || (step > 0 ? !(boundRange.max <= guardLimit) : !(boundRange.min >= guardLimit));
if (limitGuarded && limit.kind === "numLit") return null;
const guarded = limitGuarded || startGuarded;
const range =
step > 0
? {
min: startRange.min,
max: Math.max(
startRange.min,
Math.min(
Number.MAX_SAFE_INTEGER,
boundRange
? Math.ceil(boundRange.max) - (inclusive ? 0 : 1)
: Number.MAX_SAFE_INTEGER,
),
),
}
: {
min: Math.min(
startRange.max,
Math.max(
-Number.MAX_SAFE_INTEGER,
boundRange
? Math.floor(boundRange.min) + (inclusive ? 0 : 1)
: -Number.MAX_SAFE_INTEGER,
),
),
max: startRange.max,
};
// On body entry k the driver holds `start + step * k`, so k is at most the
// driver's span over |step|, and a follower holds its start plus its own
// step times k. A span above 2^53 - 1 can round down, so it bounds no
// trip count. Below it the quotient rounds to at least its floor, and the
// follower's products and sums below are exact while they stay within
// ±(2^53 - 1) and round outside it otherwise.
const span = step > 0 ? range.max - startRange.min : startRange.max - range.min;
const trips = span <= Number.MAX_SAFE_INTEGER ? Math.floor(span / Math.abs(step)) : Infinity;
const followers: IntegerLoopFollower[] = [];
for (const id of counterIds) {
if (id === local.id) continue;
const follower = counterStart(id);
// The limit is evaluated once; a follower changes on every update.
if (!follower || !stableLoopValue(limit, locals, stmt.body, false, id)) return null;
const reach = follower.step * trips;
const min = follower.range.min + Math.min(0, reach),
max = follower.range.max + Math.max(0, reach);
const tripGuarded = min < -Number.MAX_SAFE_INTEGER || max > Number.MAX_SAFE_INTEGER;
// Known starts and a literal limit are not tested at run time.
if (tripGuarded && !follower.guarded && !startGuarded && limit.kind === "numLit") return null;
followers.push({
localId: id,
start: follower.start,
step: follower.step,
startGuarded: follower.guarded,
tripGuarded,
range: tripGuarded
? { min: Math.max(-FOLLOWER_GUARD_RANGE, min), max: Math.min(FOLLOWER_GUARD_RANGE, max) }
: { min, max },
});
}
const guarded =
limitGuarded || startGuarded || followers.some((f) => f.startGuarded || f.tripGuarded);
let useful = false;
let cost = 0;
const counter = (e: IrExpr): boolean =>
(e.kind === "varRef" && e.localId === local.id) ||
(e.kind === "varRef" &&
(e.localId === local.id || followers.some((f) => f.localId === e.localId))) ||
(e.kind === "bin" &&
(e.op === "+" || e.op === "-" || e.op === "*") &&
(counter(e.left) || counter(e.right)));
@@ -241,32 +356,6 @@ export function matchIntegerCountedForLoop(
return (!guarded || ++cost <= 80) && everyStmtChild(s, expr, bodyStmt);
};
if (!stmt.body.every(bodyStmt) || !useful) return null;
const range =
step > 0
? {
min: startRange.min,
max: Math.max(
startRange.min,
Math.min(
Number.MAX_SAFE_INTEGER,
boundRange
? Math.ceil(boundRange.max) - (inclusive ? 0 : 1)
: Number.MAX_SAFE_INTEGER,
),
),
}
: {
min: Math.min(
startRange.max,
Math.max(
-Number.MAX_SAFE_INTEGER,
boundRange
? Math.floor(boundRange.min) + (inclusive ? 0 : 1)
: -Number.MAX_SAFE_INTEGER,
),
),
max: startRange.max,
};
return {
localId: local.id,
start,
@@ -278,6 +367,7 @@ export function matchIntegerCountedForLoop(
startGuarded,
guardLimit,
range,
followers,
};
}
@@ -0,0 +1,114 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile } from "../src/index.js";
async function emitLlvm(source: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-callback-versions-"));
try {
const entry = join(dir, "main.ts");
const outPath = join(dir, "main.ll");
await writeFile(entry, source);
const result = await compile(entry, { outDir: dir, outPath, outputKind: "llvm" });
if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n"));
return await readFile(outPath, "utf8");
} finally {
await rm(dir, { recursive: true, force: true });
}
}
const body = (llvm: string, marker: string): string => {
const match = new RegExp(`^define internal [^\\n]*${marker}[^]*?^}`, "m").exec(llvm);
if (!match) throw new Error(`no function ${marker}`);
return match[0];
};
test("loops that call a known callback test its closure once and call it directly", async () => {
const llvm = await emitLlvm(`
function each(values: number[], visit: (v: number) => void): void {
for (let i = 0; i < values.length; i++) visit(values[i]!);
}
let sum = 0;
each([1, 2, 3], (v) => { sum += v; });
console.log(sum);
`);
const fn = body(llvm, "@sc_bf_each");
const fast = /^callback\.version\.fast\d+:$[^]*?^callback\.version\.done\d+:$/m.exec(fn)![0];
// The guard compares the closure's code pointer once, before the loop.
expect(fn.slice(0, fn.indexOf(fast))).toMatch(/icmp eq ptr %\S+, @/);
expect(fast).not.toContain("callback.indirect");
expect(fast).not.toContain("scr_dyn_this_push_dyn");
// The ordinary copy keeps the fallback through the closure's pointer.
expect(fn).toContain("callback.indirect");
});
test("captured number payloads carry their own alias tag", async () => {
const llvm = await emitLlvm(`
function total(values: number[]): number {
let sum = 0;
values.forEach((v) => { sum += v; });
return sum;
}
console.log(total([1, 2, 3]));
`);
// Every read and write through a box's payload slot (field 5) is tagged.
const slots = [
...llvm.matchAll(/(%\S+) = getelementptr inbounds %ScrBox, ptr %\S+, i32 0, i32 5/g),
];
expect(slots.length).toBeGreaterThan(0);
for (const [, slot] of slots) {
for (const access of llvm.matchAll(
new RegExp(`(load double, ptr|store double %\\S+, ptr) \\${slot}\\b[^\\n]*`, "g"),
))
expect(access[0]).toMatch(/!tbaa !\d+$/);
}
expect(llvm).toMatch(/load double, ptr %\S+, !tbaa !\d+/);
});
test("a callback declared in the loop body is not read before the loop", async () => {
const llvm = await emitLlvm(`
function strides(): number {
let calls = 0;
for (let i = 0; i < 10; i++) {
const bump = (): void => { i += 2; };
bump();
calls++;
}
return calls;
}
console.log(strides());
`);
expect(llvm).not.toContain("callback.version");
});
test("loops inside a callback version are not versioned again", async () => {
// Each inner loop calls a callback a factory builds just before it; without
// the limit, every copy of a version would version the next loop again.
const llvm = await emitLlvm(`
function adder(k: number): (v: number) => number {
return (v: number) => v + k;
}
function nest(n: number, f: (v: number) => number): number {
let s = 0;
for (let i = 0; i < n; i++) {
s += f(i);
const g = adder(i);
for (let j = 0; j < n; j++) {
s += g(j);
const h = adder(j);
for (let k = 0; k < n; k++) s += h(k);
}
}
return s;
}
console.log(nest(3, (v) => v - 1), nest(2, (v) => v * 3));
`);
// One version for the outer loop of each compiled `nest`; its two copies
// keep the inner loops whole.
const versions = [...llvm.matchAll(/^define [^\n]*@sc_bf_nest[^]*?^}/gm)].map(
([body]) => body.match(/^callback\.version\.fast\d+:$/gm)?.length ?? 0,
);
expect(versions.every((count) => count <= 1)).toBe(true);
expect(versions.some((count) => count === 1)).toBe(true);
});
@@ -378,7 +378,7 @@ done:
expect(await symbols(helperObject, ["-u"])).toEqual(await symbols(clangObject, ["-u"]));
expect(await symbols(helperObject, ["-gU"])).toEqual(await symbols(clangObject, ["-gU"]));
expect(await symbols(helperObject, ["-gU"])).toEqual(["0000000000000000 T _main"]);
expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v26");
expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v27");
const clangExe = join(dir, "clang-program");
const linkDriver = join(dir, "clang-driver.c");
@@ -712,7 +712,7 @@ done:
.split("\n");
expect(undefinedSymbols).toContain("_sf_scale");
expect(undefinedSymbols).toContain("_sf_callback_mix");
expect(undefinedSymbols).toContain("_scr_runtime_abi_v26");
expect(undefinedSymbols).toContain("_scr_runtime_abi_v27");
});
test("helper and clang object paths retain the same outbound FFI C ABI", async () => {
@@ -0,0 +1,91 @@
/* Compiled regex matchers bound every read by the characters left in the
* subject, so subjects near the runtime's 2^31 - 2 character limit are
* read only inside the subject. Each matcher runs on a subject of exactly
* that length whose last byte sits just before an inaccessible page: a
* bounds check that wraps past INT32_MAX reads that page and faults. The
* subject is a lazily mapped zero region (no memory is committed for it)
* with the characters each case needs written near its end. */
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compileRegexMatcher, REGEX_MATCHER_DECLS } from "../src/backend/llvm/regex-matchers.js";
const LIMIT = 2 ** 31 - 2;
/** [symbol, pattern, flags, start offset from the end, tail text]. */
const CASES: [string, string, string, number, string][] = [
// The first-character vector search, 10 characters before the end.
["seek", "[b-d]", "", 10, "c"],
// The start filter (two 8-character alternatives), 20 before the end.
["filter", "agggtaaa|tttaccct", "i", 20, "TTTACCCT"],
// A run of 8 literal characters beginning 2 before the end.
["run", "xy234567", "", 30, "xy"],
// A greedy class over the whole subject, 16 characters per step.
["greedy", "[^x]+", "", LIMIT, ""],
];
const DRIVER = `
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
void scr_regex_stack_grow(void *a, void *b, void *c, int32_t d) { (void)a; (void)b; (void)c; (void)d; _exit(3); }
void scr_regex_stack_free(void *p) { (void)p; }
${CASES.map(([sym]) => `int32_t ${sym}(const char *, int32_t, int32_t, const char **);`).join("\n")}
static void check(const char *name, int32_t (*m)(const char *, int32_t, int32_t, const char **),
int32_t start, const char *tail) {
const int32_t len = ${LIMIT};
size_t page = (size_t)sysconf(_SC_PAGESIZE);
size_t body = ((size_t)len + page - 1) / page * page;
char *base = mmap(NULL, body + page, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0);
if (base == MAP_FAILED || mprotect(base + body, page, PROT_NONE) != 0) _exit(4);
char *s = base + (body - (size_t)len);
memcpy(s + len - strlen(tail), tail, strlen(tail));
const char *out[16] = {0};
int32_t found = m(s, len, start, out);
if (found) printf("%s %ld %ld\\n", name, (long)(out[0] - s), (long)(out[1] - s));
else printf("%s none\\n", name);
fflush(stdout);
munmap(base, body + page);
}
int main(void) {
${CASES.map(([sym, , , back, tail]) => ` check("${sym}", ${sym}, ${LIMIT - back}, ${JSON.stringify(tail)});`).join("\n")}
return 0;
}
`;
test.skipIf(process.platform === "win32")(
"compiled regex matchers stay inside subjects near 2^31 characters",
{ timeout: 120_000 },
() => {
const lines: string[] = [];
const decls = new Set(REGEX_MATCHER_DECLS);
for (const [sym, pattern, flags] of CASES) {
const code = compileRegexMatcher(sym, pattern, flags, "i64", true);
if (code === null) throw new Error(`/${pattern}/${flags} did not compile`);
lines.push(...code.lines.map((line) => line.replace(/^define internal /, "define ")));
for (const decl of code.decls) decls.add(decl);
}
const dir = mkdtempSync(join(tmpdir(), "scriptc-regex-bounds-"));
try {
const ir = join(dir, "matchers.ll");
const driver = join(dir, "driver.c");
const binary = join(dir, "bounds");
writeFileSync(ir, [...decls, ...lines, "attributes #0 = { nounwind }", ""].join("\n"));
writeFileSync(driver, DRIVER);
execFileSync("clang", ["-O2", "-Wno-override-module", ir, driver, "-o", binary]);
const output = execFileSync(binary, { encoding: "utf8" });
expect(output.trim().split("\n")).toEqual([
`seek ${LIMIT - 1} ${LIMIT}`,
`filter ${LIMIT - 8} ${LIMIT}`,
"run none",
`greedy 0 ${LIMIT}`,
]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
@@ -0,0 +1,94 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile } from "../src/index.js";
async function emitLlvm(source: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-speculative-loop-"));
try {
const entry = join(dir, "main.ts");
const outPath = join(dir, "main.ll");
await writeFile(entry, source);
const result = await compile(entry, { outDir: dir, outPath, outputKind: "llvm" });
expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true);
return await readFile(outPath, "utf8");
} finally {
await rm(dir, { recursive: true, force: true });
}
}
const source = `
class Point {
x: number;
w = 0;
constructor(x: number) {
this.x = x;
}
}
function scaled(points: Point[], scale: number): number {
let s = 0;
for (let i = 0; i < points.length; i++) {
const p = points[i]!;
p.w += 1;
const k = p.x * scale;
s += k * k;
}
return s;
}
console.log(scaled([new Point(1), new Point(2)], 2), scaled([new Point(1)], 0.5));
`;
function labels(fn: string): string[] {
return [...fn.matchAll(/^([\w.]+):/gm)].map((m) => m[1]!);
}
test("a failed check finishes its iteration outside the ordinary copy", async () => {
const llvm = await emitLlvm(source);
const fn = /^define internal double @sc_bf_scaled\([^]*?^}/m.exec(llvm)?.[0] ?? "";
const order = labels(fn);
// The loop may have several speculative sites (one in each copy of a
// dense-array loop). Each site emits its ordinary copy, its third copy
// and its fast copy in that order.
const slows = order.flatMap((l, i) => (l.startsWith("spec.slow") ? [i] : []));
expect(slows.length).toBeGreaterThan(0);
let resumes = 0;
for (const slow of slows) {
const next = (prefix: string): number =>
order.findIndex((l, i) => i > slow && l.startsWith(prefix));
const tail = next("spec.tail");
const fast = next("spec.fast");
expect(tail).toBeGreaterThan(slow);
expect(fast).toBeGreaterThan(tail);
// Every resume point of the site is in its third copy, between that
// copy's start and the fast copy, so the ordinary copy has one entry.
for (let i = slow + 1; i < fast; i++) {
if (!order[i]!.startsWith("spec.resume")) continue;
expect(i).toBeGreaterThan(tail);
resumes++;
}
// The third copy's header leaves for the ordinary copy's header.
const header = order[slow + 1]!;
const third = fn.slice(fn.indexOf(`\n${order[tail]}:`), fn.indexOf(`\n${order[fast]}:`));
expect(third).toContain(`br label %${header}`);
}
expect(resumes).toBe(order.filter((l) => l.startsWith("spec.resume")).length);
expect(resumes).toBeGreaterThan(0);
});
test("a loop site stops entering its fast copy after repeated bails", async () => {
const llvm = await emitLlvm(source);
const site = /^(@scr\.spec\.bails\.\d+) = internal global i8 0$/m.exec(llvm)?.[1];
expect(site).toBeDefined();
const fn = /^define internal double @sc_bf_scaled\([^]*?^}/m.exec(llvm)?.[0] ?? "";
const escaped = site!.replace(/\./g, "\\.");
// The entry test reads the count, a bail adds one, and a completed fast
// copy clears it.
expect(fn).toMatch(
new RegExp(
`(%\\S+) = load atomic i8, ptr ${escaped} unordered, align 1\\n\\s*%\\S+ = icmp ult i8 \\1, 8`,
),
);
expect(fn).toMatch(new RegExp(`call i8 @llvm\\.uadd\\.sat\\.i8\\(i8 %\\S+, i8 1\\)`));
expect(fn).toContain(`store atomic i8 0, ptr ${site} unordered, align 1`);
});
@@ -110,3 +110,10 @@ console.log(box.value);
expect(locals).toHaveLength(2);
expect(locals[0]!.name).not.toBe(locals[1]!.name);
});
test("names do not depend on the directory the program sits in", async () => {
// Each call lowers in its own temporary directory.
const [first] = await lower(recursive);
const [second] = await lower(recursive);
expect(names(second!)).toEqual(names(first!));
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6872-regex-start-filter.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6997-typed-array-invariant-index-terms.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7130-typed-message-listeners.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7131-typed-worker-messages.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7141-comoving-loop-counters.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7300-callback-captured-scalars.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7321-speculative-loop-bail-sites.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7361-message-listeners-share-value.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/class-quoted-accessor-keys.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/stream-byte-mode-chunk-fences.ts"
],
"diags": []
}
@@ -340,7 +340,7 @@ async function build() {
package: packageManifest.name,
version: packageManifest.version,
target: RUNTIME_PACK_MATRIX.target,
runtime_abi: { version: 26, marker: "scr_runtime_abi_v26" },
runtime_abi: { version: 27, marker: "scr_runtime_abi_v27" },
compiler: {
command: compiler,
identity: compilerVersion,
@@ -437,7 +437,7 @@ async function build() {
package: packageManifest.name,
version: packageManifest.version,
target: matrix.target,
runtime_abi: { version: 26, marker: "scr_runtime_abi_v26" },
runtime_abi: { version: 27, marker: "scr_runtime_abi_v27" },
compiler: {
command: compiler,
identity: compilerVersion,
+1 -1
View File
@@ -148,7 +148,7 @@ static void scr_enable_utf8_console_output(void) {
}
#endif
void scr_runtime_abi_v26(void) {}
void scr_runtime_abi_v27(void) {}
void scr_init(void) {
#ifdef SCR_WORKERS
+1 -1
View File
@@ -81,7 +81,7 @@ void scr_init(void);
/* Program objects emitted by the bundled LLVM helper reference this symbol.
* Its versioned spelling makes a mismatched manual runtime link fail before
* the program can start. */
void scr_runtime_abi_v26(void);
void scr_runtime_abi_v27(void);
void scr_runtime_workers_v8(void);
/* ── the trap funnel (scr_console.c; scr_library.c under -DSCR_LIB) ──────
+55 -2
View File
@@ -1,6 +1,7 @@
/* scriptc runtime — dynamic values: the checked-dynamic tree, native handles,
* dyn promises, the ambient receiver and listener adapters (scr_json.c,
* scr_dyn_handle.c, scr_async_dyn.c). Included by scr_runtime.h, which says
* scr_dyn_handle.c, scr_async_dyn.c), and the message readers of typed
* 'message' listeners (scr_message.c). Included by scr_runtime.h, which says
* where new declarations go. */
#ifndef SCR_DYN_H
#define SCR_DYN_H
@@ -254,6 +255,55 @@ typedef struct ScrDynTypedCast {
* result as an owned (+1) dyn value. `args` entries are BORROWED. */
typedef ScrDyn *(*ScrDynThunk)(ScrClosure *clo, ScrDyn *const *args, size_t argc);
/* A compiled 'message' listener's typed entry: builds the closure's
* parameter from the message's nodes (the readers below) and calls
* the closure. Answers false, without calling it, when the message does
* not have the parameter's layout; the caller then decodes the message
* and calls the thunk. */
struct ScrMessage;
typedef bool (*ScrDynMessageListener)(ScrClosure *clo, const struct ScrMessage *message);
/* The kinds of a message's nodes (scr_message.c). */
typedef enum {
SCR_MESSAGE_UNDEFINED,
SCR_MESSAGE_NULL,
SCR_MESSAGE_BOOL,
SCR_MESSAGE_NUMBER,
SCR_MESSAGE_STRING,
SCR_MESSAGE_BIGINT,
SCR_MESSAGE_ARRAY,
SCR_MESSAGE_OBJECT,
SCR_MESSAGE_STORAGE,
SCR_MESSAGE_BYTES,
SCR_MESSAGE_ARRAY_BUFFER,
SCR_MESSAGE_ERROR,
/* A deeply frozen plain-data graph shared by pointer (see publication). */
SCR_MESSAGE_PUBLISHED,
/* A typed value published with @scriptc/threads (immortal), delivered as
* a fresh typed-ref capsule over the same pointer. */
SCR_MESSAGE_TYPED_REF,
/* A transferred MessagePort: its channel end (+1) and side. */
SCR_MESSAGE_PORT,
/* A native handle cloned through its unit's ScrDynHandleTransfer. */
SCR_MESSAGE_HANDLE,
} ScrMessageKind;
/* Readers for compiled 'message' listeners (ScrDynMessageListener), which
* build their typed parameter from the nodes. Node 0 is the root, and a
* node's values have higher numbers. scr_message_readable answers whether
* the message can reach these listeners: no node is reached twice, and it
* holds no ports or native handles, which the receiver adopts when it
* decodes the message. kind answers a ScrMessageKind; number reads a
* NUMBER or BOOL node, string a STRING node (+1). fields answers whether an
* OBJECT node has exactly the given keys, in order, and writes their value
* nodes. */
bool scr_message_readable(const struct ScrMessage *message);
int32_t scr_message_kind(const struct ScrMessage *message, size_t node);
double scr_message_number(const struct ScrMessage *message, size_t node);
ScrStr *scr_message_string(const struct ScrMessage *message, size_t node);
bool scr_message_fields(const struct ScrMessage *message, size_t node, const char *const *names,
const size_t *lengths, size_t count, size_t *values);
/* Object member. Keys own shared immutable UTF-8 bytes (NUL-terminated for
* convenience; key_len excludes the NUL) — duplicate keys were already
* collapsed at parse time (later wins, like JS JSON.parse). */
@@ -391,7 +441,7 @@ struct ScrDyn {
* and `name` are static compiler-emitted literals (never freed); name
* may be NULL (anonymous — inspect prints [Function (anonymous)]).
* The dyn→closure ownership edge is traced by the cycle collector. */
struct { ScrClosure *clo; ScrDynThunk thunk; const char *sig; const char *name; uint32_t arity; ScrClassObj *class_obj; } fn;
struct { ScrClosure *clo; ScrDynThunk thunk; const char *sig; const char *name; uint32_t arity; ScrClassObj *class_obj; ScrDynMessageListener message; } fn;
/* Native handles normally keep conservative ownership edges. Sets
* backed by cycle-capable maps expose their edge to the collector.
* Cache that allocation property: during collection the map may be
@@ -1246,6 +1296,9 @@ ScrDyn *scr_dyn_define_prototype_props(ScrDyn *target, ScrDyn *descs, ScrDyn *pr
* (callers retain first when they keep their own reference); `sig`/`name`
* 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);
/* Gives a function box made with thunk its typed message entry (an
* EventEmitter listener registered from it keeps the entry). */
void scr_dyn_func_set_message(ScrDyn *fn, ScrDynThunk thunk, ScrDynMessageListener listener);
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
+47 -10
View File
@@ -82,6 +82,8 @@ typedef struct ScrEeEntry {
* the meta events. */
ScrClosure *flex_cb;
ScrDynThunk flex_invoke;
/* The function box's typed message entry (scr_emitter_emit_message). */
ScrDynMessageListener flex_message;
const char *flex_sig;
const char *flex_name;
uint32_t flex_arity;
@@ -512,6 +514,7 @@ static ScrEmitter *scr_ee_add(ScrEmitter *em, ScrStr *name, ScrClosure *cb /*mov
if (dyn) {
e->flex_cb = scr_closure_retain(dyn->v.fn.clo);
e->flex_invoke = dyn->v.fn.thunk;
e->flex_message = dyn->v.fn.message;
e->flex_sig = dyn->v.fn.sig;
e->flex_name = dyn->v.fn.name;
e->flex_arity = dyn->v.fn.arity;
@@ -1013,15 +1016,16 @@ static ScrDyn *const scr_ee_no_args[1] = { NULL };
* a nested emit's snapshot still holds it (the shared fired flag — Node's
* wrapper.fired). A listener throw stops the pass (the exception stays
* pending for the caller). Returns whether the bucket had listeners. */
typedef struct ScrEeMessage ScrEeMessage;
static bool scr_ee_emit_core(ScrEmitter *em, ScrStr *name, va_list *ap_template,
ScrDyn *const *flex_args, size_t flex_argc);
ScrDyn *const *flex_args, size_t flex_argc, ScrEeMessage *message);
/* emit('error', ...) first emits the same arguments to errorMonitor. */
static void scr_ee_emit_error_monitor(ScrEmitter *em, va_list *ap_template,
ScrDyn *const *flex_args, size_t flex_argc) {
ScrEeBucket *b = scr_ee_bucket_of(em->reg, SCR_EE_ERROR_MONITOR);
if (!b || b->n == 0) return;
scr_ee_emit_core(em, SCR_EE_ERROR_MONITOR, ap_template, flex_args, flex_argc);
scr_ee_emit_core(em, SCR_EE_ERROR_MONITOR, ap_template, flex_args, flex_argc, NULL);
}
/* A listener this emit has no argument form for: an untyped emit whose
@@ -1044,8 +1048,17 @@ SCR_COLD static void scr_ee_listener_mismatch(const ScrStr *name, bool typed_emi
scr_throw_error(typed_emit ? SCR_ERR_ERROR : SCR_ERR_TYPE, message);
}
/* A message emit (scr_emitter_emit_message): listeners with a typed message
* entry read the message until a listener without one runs; that listener
* and every later one share one decoded argument. */
struct ScrEeMessage {
const struct ScrMessage *message;
ScrDyn *(*decode)(const struct ScrMessage *);
ScrDyn *decoded;
};
static bool scr_ee_emit_core(ScrEmitter *em, ScrStr *name, va_list *ap_template,
ScrDyn *const *flex_args, size_t flex_argc) {
ScrDyn *const *flex_args, size_t flex_argc, ScrEeMessage *message) {
if (name->len == 5 && memcmp(name->data, "error", 5) == 0) {
scr_ee_emit_error_monitor(em, ap_template, flex_args, flex_argc);
if (scr_exc_pending()) return true;
@@ -1088,6 +1101,22 @@ static bool scr_ee_emit_core(ScrEmitter *em, ScrStr *name, va_list *ap_template,
continue;
}
}
/* Once a listener has the decoded value, later listeners read that
* value: an earlier listener may have changed it, as in Node. */
if (message && !message->decoded && e->flex_message &&
e->flex_message(e->flex_cb, message->message)) {
scr_ee_entry_unref(e);
continue;
}
if (message && !message->decoded) {
message->decoded = message->decode(message->message);
if (!message->decoded) {
scr_ee_entry_unref(e);
continue;
}
flex_args = &message->decoded;
flex_argc = 1;
}
if (flex_args && e->flex_invoke) {
ScrDyn *answer = e->flex_invoke(e->flex_cb, flex_args, flex_argc);
scr_dyn_release(answer); /* EventEmitter ignores callback results. */
@@ -1108,7 +1137,7 @@ static bool scr_ee_emit_core(ScrEmitter *em, ScrStr *name, va_list *ap_template,
static bool scr_ee_emit_va(ScrEmitter *em, ScrStr *name, ...) {
va_list ap;
va_start(ap, name);
bool had = scr_ee_emit_core(em, name, &ap, NULL, 0);
bool had = scr_ee_emit_core(em, name, &ap, NULL, 0, NULL);
va_end(ap);
return had;
}
@@ -1119,7 +1148,7 @@ static bool scr_ee_emit_mixed_va(ScrEmitter *em, ScrStr *name, ScrDyn *const *ar
int typed, ...) {
va_list ap;
va_start(ap, typed);
bool had = scr_ee_emit_core(em, name, typed ? &ap : NULL, args ? args : scr_ee_no_args, argc);
bool had = scr_ee_emit_core(em, name, typed ? &ap : NULL, args ? args : scr_ee_no_args, argc, NULL);
va_end(ap);
return had;
}
@@ -1131,7 +1160,7 @@ static bool scr_ee_emit_mixed_va(ScrEmitter *em, ScrStr *name, ScrDyn *const *ar
bool scr_emitter_emit(ScrEmitter *em, ScrStr *name, ...) {
va_list ap;
va_start(ap, name);
bool had = scr_ee_emit_core(em, name, &ap, NULL, 0);
bool had = scr_ee_emit_core(em, name, &ap, NULL, 0, NULL);
va_end(ap);
return had;
}
@@ -1143,7 +1172,7 @@ bool scr_emitter_emit(ScrEmitter *em, ScrStr *name, ...) {
bool scr_emitter_emit_mixed(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc, ...) {
va_list ap;
va_start(ap, argc);
bool had = scr_ee_emit_core(em, name, &ap, args ? args : scr_ee_no_args, argc);
bool had = scr_ee_emit_core(em, name, &ap, args ? args : scr_ee_no_args, argc, NULL);
va_end(ap);
return had;
}
@@ -1241,7 +1270,15 @@ static bool scr_ee_emit_error_dyn(ScrEmitter *em, ScrStr *name, ScrDyn *const *a
/* An untyped emit of a name typed code never fixes a tuple for (computed
* names, runtime-internal registries): the arguments as given. */
bool scr_emitter_emit_flex(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc) {
return scr_ee_emit_core(em, name, NULL, args ? args : scr_ee_no_args, argc);
return scr_ee_emit_core(em, name, NULL, args ? args : scr_ee_no_args, argc, NULL);
}
bool scr_emitter_emit_message(ScrEmitter *em, ScrStr *name, const struct ScrMessage *message,
ScrDyn *(*decode)(const struct ScrMessage *)) {
ScrEeMessage state = {message, decode, NULL};
bool had = scr_ee_emit_core(em, name, NULL, NULL, 0, &state);
scr_dyn_release(state.decoded);
return had;
}
/* emit(name, ...args) through an untyped value: the arguments as given.
@@ -1255,7 +1292,7 @@ bool scr_emitter_emit_any(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, siz
return scr_ee_emit_mixed_va(em, name, args, argc, s != NULL, s);
}
}
return scr_ee_emit_core(em, name, NULL, args ? args : scr_ee_no_args, argc);
return scr_ee_emit_core(em, name, NULL, args ? args : scr_ee_no_args, argc, NULL);
}
/* Handle event registries use checked callbacks for every tuple, including
@@ -1268,7 +1305,7 @@ bool scr_emitter_emit_error_flex(ScrEmitter *em, ScrDyn *error) {
return false;
}
ScrStr *name = scr_str_new("error", 5);
bool had = scr_ee_emit_core(em, name, NULL, &error, 1);
bool had = scr_ee_emit_core(em, name, NULL, &error, 1, NULL);
scr_str_release(name);
return had;
}
@@ -154,6 +154,14 @@ ScrEmitter *scr_emitter_remove_all(ScrEmitter *em, ScrStr *name, bool all);
* emit('error', err): no listener ⇒ THROWS err (borrowed; +1 taken). */
bool scr_emitter_emit(ScrEmitter *em, ScrStr *name, ...);
bool scr_emitter_emit_flex(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc);
/* An untyped emit of one message: a listener registered from a function
* box with a typed message entry (scr_dyn_func_set_message) reads the
* message itself while no listener has needed the decoded value; from the
* first listener without an entry on, every listener gets decode(message),
* made once. */
struct ScrMessage;
bool scr_emitter_emit_message(ScrEmitter *em, ScrStr *name, const struct ScrMessage *message,
ScrDyn *(*decode)(const struct ScrMessage *));
/* An untyped emit of an event with a fixed tuple: the arguments as given
* for dynamic listeners, plus the tuple converted by the caller as
* scr_emitter_emit's variadics for typed listeners. has_typed answers
+7
View File
@@ -4122,9 +4122,16 @@ ScrDyn *scr_dyn_new_func(ScrClosure *clo, ScrDynThunk thunk, uint32_t arity, con
/* A signature adapter reports the length of the function it adapts. */
d->v.fn.arity = clo->identity != NULL ? clo->identity_length : arity;
d->v.fn.class_obj = NULL;
d->v.fn.message = NULL;
return d;
}
void scr_dyn_func_set_message(ScrDyn *fn, ScrDynThunk thunk, ScrDynMessageListener listener) {
/* scr_dyn_new_func answers an adapted function's original box, whose
* closure has other parameter types. */
if (fn && fn->kind == SCR_DYN_FUNC && fn->v.fn.thunk == thunk) fn->v.fn.message = listener;
}
/* A class is function-shaped for typeof and identity, but calling it without
* new always throws. The closure owns the class object and its lexical boxes. */
static ScrDyn *scr_dyn_class_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) {
+39 -30
View File
@@ -3,30 +3,6 @@
#include <stdio.h>
#include <stdlib.h>
typedef enum {
SCR_MESSAGE_UNDEFINED,
SCR_MESSAGE_NULL,
SCR_MESSAGE_BOOL,
SCR_MESSAGE_NUMBER,
SCR_MESSAGE_STRING,
SCR_MESSAGE_BIGINT,
SCR_MESSAGE_ARRAY,
SCR_MESSAGE_OBJECT,
SCR_MESSAGE_STORAGE,
SCR_MESSAGE_BYTES,
SCR_MESSAGE_ARRAY_BUFFER,
SCR_MESSAGE_ERROR,
/* A deeply frozen plain-data graph shared by pointer (see publication). */
SCR_MESSAGE_PUBLISHED,
/* A typed value published with @scriptc/threads (immortal), delivered as
* a fresh typed-ref capsule over the same pointer. */
SCR_MESSAGE_TYPED_REF,
/* A transferred MessagePort: its channel end (+1) and side. */
SCR_MESSAGE_PORT,
/* A native handle cloned through its unit's ScrDynHandleTransfer. */
SCR_MESSAGE_HANDLE,
} ScrMessageKind;
const ScrMessagePortOps *scr_message_port_ops = NULL;
typedef struct {
@@ -95,6 +71,7 @@ struct ScrMessage {
size_t count;
bool storage; /* has STORAGE nodes */
bool releases; /* has STORAGE, PORT or HANDLE nodes, which own resources */
bool readable; /* see scr_message_readable */
};
typedef struct {
@@ -143,6 +120,8 @@ typedef struct {
bool uncaught; /* the next value is an uncaught exception or its cause */
bool storage;
bool releases;
bool shared; /* a node was reached twice */
bool adopts; /* has PORT or HANDLE nodes */
ScrMessageNode inline_nodes[SCR_MESSAGE_INLINE];
ScrMessageVisit inline_visits[SCR_MESSAGE_INLINE];
ScrMessagePending inline_properties[SCR_MESSAGE_INLINE];
@@ -168,7 +147,7 @@ static void scr_message_encoder_init(ScrMessageEncoder *encoder, const ScrDyn *t
encoder->stack_capacity = SCR_MESSAGE_INLINE;
encoder->transfer = transfer;
encoder->uncaught = uncaught;
encoder->storage = encoder->releases = false;
encoder->storage = encoder->releases = encoder->shared = encoder->adopts = false;
}
static bool scr_message_listed(const ScrMessageEncoder *encoder, const ScrDyn *value) {
@@ -290,7 +269,10 @@ static void scr_message_push(ScrMessageEncoder *encoder, size_t node) {
static size_t scr_message_storage(ScrMessageEncoder *encoder, const ScrBytes *bytes) {
const ScrBytes *owner = bytes->backing ? bytes->backing : bytes;
size_t found = scr_message_find(encoder, owner, SCR_MESSAGE_STORAGE + 1);
if (found != SIZE_MAX) return found;
if (found != SIZE_MAX) {
encoder->shared = true;
return found;
}
size_t node = scr_message_node(encoder);
scr_message_identify(encoder, owner, SCR_MESSAGE_STORAGE + 1, node);
ScrMessageNode *storage = &encoder->nodes[node];
@@ -360,7 +342,10 @@ static size_t scr_message_value(ScrMessageEncoder *encoder, const ScrDyn *input)
return SIZE_MAX;
}
size_t found = scr_message_find(encoder, identity, domain);
if (found != SIZE_MAX) return found;
if (found != SIZE_MAX) {
encoder->shared = true;
return found;
}
size_t id = scr_message_node(encoder);
scr_message_identify(encoder, identity, domain, id);
encoder->visits[id].input = scr_dyn_retain((ScrDyn *)input);
@@ -482,7 +467,7 @@ static size_t scr_message_value(ScrMessageEncoder *encoder, const ScrDyn *input)
node->kind = SCR_MESSAGE_PORT;
node->v.port.channel = channel;
node->v.port.side = side;
encoder->releases = true;
encoder->releases = encoder->adopts = true;
break;
}
bool buffer = scr_buffer_storage_is(value);
@@ -498,7 +483,7 @@ static size_t scr_message_value(ScrMessageEncoder *encoder, const ScrDyn *input)
node->kind = SCR_MESSAGE_HANDLE;
node->v.handle.transfer = transfer;
node->v.handle.payload = payload;
encoder->releases = true;
encoder->releases = encoder->adopts = true;
break;
}
const ScrBytes *bytes = buffer ? value->v.handle.ptr : value->v.bytes;
@@ -678,6 +663,7 @@ static ScrMessage *scr_message_try_publish(const ScrDyn *value) {
message = scr_message_alloc(1, sizeof(*message) + sizeof(*message->nodes));
message->nodes = (ScrMessageNode *)(message + 1);
message->count = 1;
message->readable = true;
message->nodes[0].kind = SCR_MESSAGE_PUBLISHED;
message->nodes[0].v.published = (ScrDyn *)value;
if (getenv("SCRIPTC_PUBLISH_TRACE"))
@@ -770,7 +756,8 @@ static ScrMessage *scr_message_pack(ScrMessageEncoder *encoder) {
ScrMessageProperty *slots = (ScrMessageProperty *)(nodes + count);
ScrMessageTyped *records = (ScrMessageTyped *)(slots + properties);
char *text = (char *)(records + typed);
*message = (ScrMessage){nodes, count, encoder->storage, encoder->releases};
*message = (ScrMessage){nodes, count, encoder->storage, encoder->releases,
!encoder->shared && !encoder->adopts};
memcpy(nodes, encoder->nodes, count * sizeof(*nodes));
if (typed) memcpy(records, encoder->typed, typed * sizeof(*records));
size_t first = 0;
@@ -1014,6 +1001,28 @@ done:;
return result;
}
bool scr_message_readable(const ScrMessage *message) { return message->readable; }
int32_t scr_message_kind(const ScrMessage *message, size_t node) { return message->nodes[node].kind; }
double scr_message_number(const ScrMessage *message, size_t node) { return message->nodes[node].v.number; }
ScrStr *scr_message_string(const ScrMessage *message, size_t node) {
return scr_str_new(message->nodes[node].v.text.data, message->nodes[node].v.text.length);
}
bool scr_message_fields(const ScrMessage *message, size_t node, const char *const *names,
const size_t *lengths, size_t count, size_t *values) {
const ScrMessageNode *object = &message->nodes[node];
if (object->kind != SCR_MESSAGE_OBJECT || object->count != count) return false;
for (size_t i = 0; i < count; i++) {
const ScrMessageProperty *property = &object->properties[i];
if (property->length != lengths[i] || memcmp(property->key, names[i], lengths[i])) return false;
values[i] = property->value;
}
return true;
}
void scr_message_free(ScrMessage *message) {
if (!message) return;
if (message->releases)
+47
View File
@@ -250,6 +250,52 @@ static void check_large(void) {
scr_message_free(message);
}
/* The readers compiled 'message' listeners use: node kinds and values, a
* record's keys in order, and which messages they may read. */
static void check_readers(void) {
ScrDyn *root = scr_dyn_new_obj();
put_number(root, "id", 7);
ScrStr *text = scr_str_new("seven", 5);
scr_dyn_obj_set(root, "name", 4, scr_dyn_new_str(text));
scr_str_release(text);
scr_dyn_obj_set(root, "done", 4, scr_dyn_new_bool(true));
ScrMessage *message = scr_message_encode(root);
assert(message && scr_message_readable(message));
assert(scr_message_kind(message, 0) == SCR_MESSAGE_OBJECT);
static const char *const names[] = {"id", "name", "done"};
static const size_t lengths[] = {2, 4, 4};
size_t values[3];
assert(scr_message_fields(message, 0, names, lengths, 3, values));
assert(scr_message_kind(message, values[0]) == SCR_MESSAGE_NUMBER);
assert(scr_message_number(message, values[0]) == 7);
assert(scr_message_kind(message, values[1]) == SCR_MESSAGE_STRING);
ScrStr *name = scr_message_string(message, values[1]);
assert(name->len == 5 && !memcmp(name->data, "seven", 5));
scr_str_release(name);
assert(scr_message_kind(message, values[2]) == SCR_MESSAGE_BOOL);
assert(scr_message_number(message, values[2]) == 1);
/* Another order, a missing key or an extra key does not match. */
static const char *const reordered[] = {"name", "id", "done"};
static const size_t reordered_lengths[] = {4, 2, 4};
assert(!scr_message_fields(message, 0, reordered, reordered_lengths, 3, values));
assert(!scr_message_fields(message, 0, names, lengths, 2, values));
assert(!scr_message_fields(message, values[0], names, lengths, 3, values));
scr_message_free(message);
put_number(root, "extra", 1);
message = scr_message_encode(root);
assert(!scr_message_fields(message, 0, names, lengths, 3, values));
scr_message_free(message);
/* A value reached twice makes the message unreadable. */
ScrDyn *shared = scr_dyn_new_obj();
scr_dyn_obj_set(root, "first", 5, scr_dyn_retain(shared));
scr_dyn_obj_set(root, "second", 6, shared);
message = scr_message_encode(root);
assert(message && !scr_message_readable(message));
scr_message_free(message);
scr_dyn_release(root);
scr_collect_cycles();
}
/* A link-gated unit's handle crosses threads through the transfer hooks in
* its ops: share on the sender, adopt on the receiver, drop with the
* message. The test borrows the last capacity slot, which no unit uses, so
@@ -376,6 +422,7 @@ int main(void) {
check_rejection();
check_documents();
check_large();
check_readers();
check_handle_transfer();
scr_context_cleanup();
puts("message graphs preserve identity, sparse values, byte aliases and getter order");
+24
View File
@@ -665,6 +665,17 @@ static bool scr_channel_dispatch(void) {
continue;
}
scr_worker_view_retain(view);
if (event->message && !view->broadcast && scr_message_readable(event->message)) {
/* Compiled listeners with a typed message entry read the message. */
ScrStr *name = scr_str_new("message", 7);
scr_dyn_this_push(view, SCR_DYNH_MESSAGE_PORT);
scr_emitter_emit_message(view->events, name, event->message, scr_message_decode);
scr_dyn_this_pop();
scr_str_release(name);
scr_mail_event_free(event);
scr_worker_view_release(view);
return true;
}
ScrDyn *argument = event->message ? scr_message_decode(event->message) : NULL;
if (argument && view->broadcast) {
ScrDyn *self = scr_dyn_new_handle(view, SCR_DYNH_BROADCAST_CHANNEL);
@@ -815,6 +826,19 @@ static void scr_worker_dispatch(void) {
}
}
scr_worker_view_retain(view);
if (event->kind == SCR_WORKER_MESSAGE && event->message && scr_message_readable(event->message)) {
/* Compiled listeners with a typed message entry read the message. */
if (!scr_exc_pending()) {
ScrStr *name = scr_str_new("message", 7);
scr_dyn_this_push(view, view->port ? SCR_DYNH_MESSAGE_PORT : SCR_DYNH_WORKER);
scr_emitter_emit_message(view->events, name, event->message, scr_message_decode);
scr_dyn_this_pop();
scr_str_release(name);
}
scr_worker_view_release(view);
scr_mail_event_free(event);
return;
}
const char *event_name = event->kind == SCR_WORKER_ONLINE ? "online" :
event->kind == SCR_WORKER_EXIT ? "exit" : event->kind == SCR_WORKER_ERROR ? "error" : "message";
ScrDyn *argument = event->message ? scr_message_decode(event->message) :
+79
View File
@@ -0,0 +1,79 @@
// Searches whose patterns begin with fixed sequences of characters and
// classes (literal alternations, classes, fixed repetitions), over subjects
// of every length from 0 to 80 and with matches at every offset: match,
// matchAll, search, exec with captures, global and sticky lastIndex,
// replace, case-insensitive letters, multiline anchors and word boundaries.
// Results must not depend on where a match falls relative to 16-character
// blocks or the end of the subject.
const patterns: RegExp[] = [
/agggtaaa|tttaccct/gi,
/[cgt]gggtaaa|tttaccc[acg]/gi,
/ab|abc|b/g,
/(ab)(c|d)x/g,
/x[0-9]{3}y/g,
/(?:no|yes)(!+)/gi,
/^ab/gm,
/\bcat\b/g,
/a{2}(b+)c/g,
/[A-Z][a-z]{2}-[0-9]/g,
];
function describe(re: RegExp, subject: string): string {
const parts: string[] = [];
const all = subject.match(re);
parts.push(`match=${all === null ? "null" : JSON.stringify(all)}`);
parts.push(`search=${subject.search(re)}`);
const rows: string[] = [];
for (const m of subject.matchAll(re)) rows.push(`${m.index}:${JSON.stringify(m.slice(0))}`);
parts.push(`all=[${rows.join(" ")}]`);
parts.push(`replace=${JSON.stringify(subject.replace(re, "<$&>"))}`);
return parts.join(" ");
}
const filler = "qwertyuiopasdfghjklzxcvbnm";
const needles = ["agggtaaa", "TTTACCCT", "tttacccg", "abcx", "abdx", "x123y", "NO!!", "yes!", "cat", "aabbbc", "Abc-1", "ab"];
let checksum = 0;
let lines = 0;
for (let length = 0; length <= 80; length += 7) {
for (const needle of needles) {
for (const offset of [0, 1, 14, 15, 16, 17, 31, 32, 33]) {
if (offset + needle.length > length) continue;
let subject = "";
while (subject.length < length) subject += filler;
subject = subject.slice(0, offset) + needle + subject.slice(offset + needle.length, length);
for (const re of patterns) {
const text = describe(re, subject);
for (let i = 0; i < text.length; i++) checksum = (checksum * 31 + text.charCodeAt(i)) >>> 0;
lines++;
if (length === 35 && (offset === 15 || offset === 16)) console.log(re.source, offset, needle, text);
}
}
}
}
console.log("lines", lines, "checksum", checksum);
// Overlapping candidates: every start position looks like a match for the
// first characters, and only some complete.
const dense = "agggtaa".repeat(9) + "agggtaaa" + "tttaccc".repeat(5) + "tttaccct";
console.log(describe(/agggtaaa|tttaccct/g, dense));
// exec with lastIndex, global and sticky.
const re = /x[0-9]{3}y/g;
const text = "zzx111yzzzzzzzzzzzzzzzzzzx222yx333y" + "z".repeat(20) + "x444y";
let m: RegExpExecArray | null;
const found: string[] = [];
while ((m = re.exec(text)) !== null) found.push(`${m[0]}@${m.index}->${re.lastIndex}`);
console.log("exec", found.join(" "));
const sticky = /x[0-9]{3}y/y;
sticky.lastIndex = 2;
console.log("sticky", sticky.test(text), sticky.lastIndex, sticky.test(text), sticky.lastIndex);
// Short subjects and the tail.
for (const subject of ["", "a", "agggtaa", "agggtaaa", "zagggtaaa", "z".repeat(16) + "agggtaaa", "z".repeat(23) + "tttaccct"]) {
console.log(JSON.stringify(subject.slice(-12)), subject.length, describe(/agggtaaa|tttaccct/gi, subject));
}
// A long subject with matches spread through it.
let long = "";
for (let i = 0; i < 2000; i++) long += i % 97 === 0 ? "aGgGtAaA" : i % 89 === 0 ? "tttACCCt" : filler[i % filler.length];
console.log("long", (long.match(/agggtaaa|tttaccct/gi) ?? []).length, long.search(/tttaccct/i));
@@ -0,0 +1,78 @@
// Typed-array loops whose index adds a loop-invariant number with no known
// integer range, such as the row base of a matrix-vector product. Rows
// with integer, fractional, negative, -0 and NaN bases, windows at 2^32 and
// beyond, and matrices too short for their last rows, read and write as
// Node does.
function matvec(w: Float32Array, v: Float32Array, out: Float32Array, cols: number): void {
for (let r = 0; r < out.length; r++) {
const base = r * cols;
let s0 = 0, s1 = 0, s2 = 0, s3 = 0;
for (let j = 0; j < cols; j += 4) {
s0 += w[base + j]! * v[j]!;
s1 += w[base + j + 1]! * v[j + 1]!;
s2 += w[base + j + 2]! * v[j + 2]!;
s3 += w[base + j + 3]! * v[j + 3]!;
}
out[r] = s0 + s1 + s2 + s3;
}
}
function rowSums(m: Float64Array, rows: number, cols: number, first: number): number[] {
const sums: number[] = [];
for (let r = first; r < first + rows; r++) {
let s = 0;
for (let c = 0; c < cols; c++) s += m[r * cols + c]!;
sums.push(s);
}
return sums;
}
function shiftedCopy(src: Int16Array, out: Int16Array, offset: number): void {
for (let i = 0; i < out.length; i++) out[i] = src[i - offset]! + 1;
}
function window(src: Uint8Array, start: number, k: number): number {
let s = 0;
for (let i = 0; i < k; i++) s += src[start + i]! * (i + 1);
return s;
}
const rows = 6, cols = 8;
const w = new Float32Array(rows * cols);
for (let i = 0; i < w.length; i++) w[i] = ((i * 37) % 23) / 4 - 2;
const v = new Float32Array(cols);
for (let i = 0; i < cols; i++) v[i] = (i % 5) - 1.5;
const out = new Float32Array(rows);
matvec(w, v, out, cols);
console.log("matvec", out.join(","));
const tall = new Float32Array(rows + 2);
matvec(w, v, tall, cols);
console.log("matvec past rows", tall.join(","));
const odd = new Float32Array(3);
matvec(w, v, odd, 6.5);
console.log("matvec fractional cols", odd.join(","));
matvec(w, v, odd, NaN);
console.log("matvec NaN cols", odd.join(","));
const m = new Float64Array(30);
for (let i = 0; i < m.length; i++) m[i] = i * 1.5 - 7;
console.log("rows", rowSums(m, 5, 6, 0).join(","));
console.log("rows from -1", rowSums(m, 3, 6, -1).join(","));
console.log("rows fractional", rowSums(m, 2, 2.5, 1).join(","));
console.log("rows past end", rowSums(m, 3, 6, 4).join(","));
console.log("rows -0 base", rowSums(m, 1, 6, -0).join(","));
const src16 = new Int16Array([10, 20, 30, 40, 50, 60]);
const shifted = new Int16Array(6);
shiftedCopy(src16, shifted, 2);
console.log("shifted", shifted.join(","));
shiftedCopy(src16, shifted, -3);
console.log("shifted back", shifted.join(","));
shiftedCopy(src16, shifted, 0.5);
console.log("shifted half", shifted.join(","));
const bytes = new Uint8Array(40);
for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 29) & 255;
console.log("window", window(bytes, 3, 8), window(bytes, 36, 8), window(bytes, -2, 4));
console.log("window far", window(bytes, 2 ** 32, 3), window(bytes, 2 ** 32 + 1, 3), window(bytes, 2 ** 40, 2), window(bytes, -(2 ** 32), 2));
@@ -0,0 +1,80 @@
import { MessageChannel } from "node:worker_threads";
// 'message' listeners with typed parameters on MessageChannel ports. A
// message with the parameter's exact layout, and one with other keys,
// shared values or other kinds, reach the listener with the same values as
// in Node.
type Item = { id: number; name: string; done: boolean };
type Nested = { label: string; item: Item };
const { port1, port2 } = new MessageChannel();
// Each port's lines print in delivery order once every message arrived.
const lines: string[] = [];
const nestedLines: string[] = [];
const otherLines: string[] = [];
let pending = 0;
function finish(): void {
if (--pending === 0) {
for (const line of [...lines, ...nestedLines, ...otherLines]) console.log(line);
port1.close();
channel.port1.close();
other.port1.close();
}
}
port1.on("message", (value: number | string | Item | null | undefined) => {
if (value === null || value === undefined) lines.push(`unit ${String(value)}`);
else if (typeof value === "number") lines.push(`number ${value.toFixed(2)}`);
else if (typeof value === "string") lines.push(`string ${value.length} ${value}`);
else lines.push(`item ${value.id} ${value.name} ${value.done} ${JSON.stringify(value)}`);
finish();
});
const sent: unknown[] = [
1.5,
-0,
"",
"héllo wörld",
null,
undefined,
{ id: 1, name: "first", done: false },
{ id: 2, name: "", done: true },
// An extra key stays beside the record.
{ id: 4, name: "extra", done: false, note: "kept" },
];
pending += sent.length;
for (const value of sent) port2.postMessage(value);
// Nested records, a value shared by two fields, and a second listener
// that reads the message untyped.
const channel = new MessageChannel();
channel.port1.on("message", (value: Nested) => {
nestedLines.push(`nested ${value.label} ${value.item.id} ${value.item.name} ${JSON.stringify(value)}`);
finish();
});
channel.port1.on("message", (value: unknown) => {
nestedLines.push(`untyped ${JSON.stringify(value)}`);
finish();
});
const shared = { id: 5, name: "shared", done: true };
const nested: unknown[] = [
{ label: "plain", item: { id: 6, name: "six", done: false } },
{ label: "shared", item: shared, again: shared },
];
pending += 2 * nested.length;
for (const value of nested) channel.port2.postMessage(value);
// once listeners, and a listener added while the port delivers.
const other = new MessageChannel();
other.port1.once("message", (value: Item) => {
otherLines.push(`once ${value.id} ${value.name}`);
other.port1.on("message", (later: number) => {
otherLines.push(`later ${later}`);
finish();
});
finish();
});
pending += 3;
other.port2.postMessage({ id: 7, name: "seven", done: true });
other.port2.postMessage(8);
other.port2.postMessage(9);
@@ -0,0 +1,54 @@
import { MessageChannel, MessagePort, Worker, isMainThread, parentPort } from "node:worker_threads";
// 'message' listeners with typed parameters on a worker and its
// parentPort: records and numbers in both directions, and a message that
// carries a transferred MessagePort.
type Job = { id: number; text: string; urgent: boolean };
type Reply = { id: number; length: number; upper: string };
if (isMainThread) {
const worker = new Worker(new URL(import.meta.url));
// The worker's replies, then the port's, each in delivery order.
const replies: string[] = [];
const portLines: string[] = [];
let expected = 5;
const done = (): void => {
if (--expected > 0) return;
for (const line of [...replies, ...portLines]) console.log(line);
void worker.terminate();
};
worker.on("message", (reply: Reply | number) => {
if (typeof reply === "number") replies.push(`count ${reply}`);
else replies.push(`reply ${reply.id} ${reply.length} ${reply.upper}`);
done();
});
const jobs: Job[] = [
{ id: 1, text: "alpha", urgent: false },
{ id: 2, text: "βeta", urgent: true },
{ id: 3, text: "", urgent: false },
];
for (const job of jobs) worker.postMessage(job);
worker.postMessage(jobs.length);
// A port in the message: the worker answers through it.
const { port1, port2 } = new MessageChannel();
port1.on("message", (text: string) => {
portLines.push(`port ${text}`);
port1.close();
done();
});
worker.postMessage({ port: port2 }, [port2]);
} else {
let count = 0;
parentPort!.on("message", (message: Job | number | { port: MessagePort }) => {
if (typeof message === "number") {
parentPort!.postMessage(count + message);
} else if ("port" in message) {
message.port.postMessage("through the port");
message.port.close();
} else {
count++;
const text = message.urgent ? message.text.toUpperCase() : message.text;
parentPort!.postMessage({ id: message.id, length: message.text.length, upper: text });
}
});
}
+144
View File
@@ -0,0 +1,144 @@
// For loops with a second counter that steps beside the tested one
// (`i++, j += 4`): pixel strides over typed arrays, interleaving into number
// arrays, descending and inclusive loops, three counters, early exits,
// starts and limits that are fractions, negative zero, NaN, infinite or
// near 2^53, counters that live across a yield or an await, and counters a
// closure captures, the body writes or the condition reads.
function luminance(px: Uint8Array, count: number): number[] {
const out: number[] = [];
for (let i = 0, p = 0; i < count; i++, p += 4) {
out.push((px[p]! * 77 + px[p + 1]! * 150 + px[p + 2]! * 29) >> 8);
}
return out;
}
const px = new Uint8Array(32);
for (let k = 0; k < px.length; k++) px[k] = (k * 37 + 11) & 255;
console.log(luminance(px, 8).join(","));
// Past the end, reads are undefined and the sums NaN, which `>> 8` makes 0.
console.log(luminance(px, 10).join(","));
console.log(luminance(px, 0).length, luminance(px, -3).length, luminance(px, 2.5).join(","));
console.log(luminance(px, NaN).length);
function interleave(a: number[], b: number[]): number[] {
const out: number[] = new Array(a.length * 2).fill(-1);
const n = a.length;
for (let i = 0, j = 0; i < n; i++, j += 2) {
out[j] = a[i]!;
out[j + 1] = b[i]!;
}
return out;
}
console.log(interleave([1, 2, 3, 4], [10, 20, 30, 40]).join(","));
console.log(interleave([], []).length);
// The second counter's start is not proven: charCodeAt truncates its index,
// and the counter's own values keep JS semantics.
function codes(s: string, count: number, from: number, step: number): string {
const out: (number | string)[] = [];
if (step === 3) {
for (let i = 0, j = from; i < count; i++, j += 3) out.push(s.charCodeAt(j), j);
} else {
for (let i = 0, j = from; i < count; i++, j -= 2) out.push(s.charCodeAt(j), j);
}
return out.join(",");
}
const word = "abcdefghijklmnop";
console.log(codes(word, 4, 0, 3), "|", codes(word, 4, 15, -2));
console.log(codes(word, 4, 0.5, 3), "|", codes(word, 3, 9.75, -2));
const negativeZero = -0;
console.log(codes(word, 2, negativeZero, 3), Object.is(negativeZero, -0));
console.log(codes(word, 2, NaN, 3), "|", codes(word, 2, Infinity, -2), "|", codes(word, 2, -Infinity, 3));
// Near 2^52 the run-time test passes or fails on the trip count; near 2^53
// the double counter rounds (odd values above 2^53 are not representable).
console.log(codes(word, 4, 2 ** 52 - 16, 3));
console.log(codes(word, 6, 2 ** 52 - 4, 3));
console.log(codes(word, 6, 2 ** 53 - 10, 3));
console.log(codes(word, 4, -(2 ** 53) + 3, -2));
// A limit that is not finite or not an integer.
function capped(s: string, limit: number): string {
const out: number[] = [];
for (let i = 0, j = 1; i < limit; i++, j += 2) {
if (j >= s.length) break;
out.push(s.charCodeAt(j));
}
return out.join(",");
}
console.log(capped(word, Infinity), "|", capped(word, 3.5), "|", capped(word, NaN), "|", capped(word, -Infinity));
// A descending driver with an ascending follower, inclusive bounds and
// larger steps.
function mirror(s: string): string {
const out: string[] = [];
for (let i = s.length - 1, j = 0; i >= 0; i--, j += 2) out.push(`${s.charCodeAt(i)}@${j}`);
return out.join(" ");
}
console.log(mirror("scriptc"));
function stepped(bytes: Uint8Array, last: number): string {
const out: number[] = [];
for (let i = 0, j = bytes.length - 1; i <= last; i += 3, j -= 5)
out.push(bytes[i]! | 0, bytes[j]! | 0, j);
return out.join(",");
}
console.log(stepped(px, 9), "|", stepped(px, 30));
// Three counters, continue and break.
function triple(a: Uint8Array, n: number): string {
const out: number[] = [];
for (let i = 0, j = 10, k = 100; i < n; i++, j -= 1, k += 7) {
if (i % 3 === 1) continue;
if (k > 150) break;
out.push(a[i]!, a[j]!, k);
}
return out.join(",");
}
console.log(triple(px, 12), "|", triple(px, 4));
// A counted limit with a known range and a follower used by a remainder.
let rotated = 0;
for (let i = 0, j = 5; i < 1000; i++, j += 3) rotated = (rotated * 31 + px[j % 32]!) % 1000003;
console.log(rotated);
// The follower starting from a driver's start of unproven exactness.
function window(bytes: Uint8Array, from: number, to: number): string {
const out: number[] = [];
for (let i = from, j = 0; i < to; i++, j += 2) out.push(bytes[i]! | 0, bytes[j]! | 0, i, j);
return out.join(",");
}
console.log(window(px, 2, 5), "|", window(px, 1.5, 4), "|", window(px, -0, 2));
// Counters a closure captures keep one binding per iteration; a follower the
// body writes, one the condition reads, and a start that reads another
// counter keep their JS values.
const reads: (() => number)[] = [];
for (let i = 0, j = 0; i < 3; i++, j += 4) {
reads.push(() => j);
void px[j];
}
console.log(reads.map((read) => read()).join(","));
const written: number[] = [];
for (let i = 0, j = 0; i < 5; i++, j += 2) {
if (i === 2) j += 7;
written.push(px[j]!, j);
}
console.log(written.join(","));
const chased: number[] = [];
for (let i = 0, j = 5; i < j; i += 2, j++) chased.push(px[i]!, i, j);
console.log(chased.join(","));
const offset: number[] = [];
for (let i = 1, j = i + 1; i < 4; i++, j += 2) offset.push(px[j]!, j);
console.log(offset.join(","));
// Counters that live across a yield or an await.
function* strided(bytes: Uint8Array, n: number): Generator<number> {
for (let i = 0, j = 1; i < n; i++, j += 3) yield bytes[j]! + i;
}
console.log([...strided(px, 6)].join(","));
async function weighted(bytes: Uint8Array, n: number): Promise<number> {
let sum = 0;
for (let i = 0, j = 2; i < n; i++, j += 5) {
await null;
sum += bytes[j]! * (i + 1);
}
return sum;
}
void (async () => console.log(await weighted(px, 6), await weighted(px, 3)))();
+32 -1
View File
@@ -2,7 +2,8 @@
// and Writable in pipeline(), typed 'data' listeners, a Transform with
// readableObjectMode parsing bytes into records, an object-mode Duplex,
// a PassThrough's object-counted high-water mark, and Readable.from over
// a Map, a Set of records and an array mixing strings and Buffers.
// a Map, a Set of records and an array mixing strings and Buffers, and
// byte-mode chunk parameters typed Buffer | string.
import { Duplex, PassThrough, Readable, Transform, Writable } from "node:stream";
import { pipeline } from "node:stream/promises";
@@ -119,6 +120,34 @@ async function collections(): Promise<void> {
for await (const t of Readable.from(tags)) console.log("set", JSON.stringify(t));
}
// Byte-mode sides pass Buffers: a chunk parameter typed as a supertype
// of Buffer takes them, inline and as an underscore method, and
// super(undefined) leaves the stream in byte mode.
class Collector extends Writable {
constructor() {
super(undefined);
}
_write(chunk: Buffer | string, _encoding: BufferEncoding, cb: (err?: Error | null) => void): void {
console.log("collector", Buffer.isBuffer(chunk), typeof chunk === "string" ? chunk : chunk.toString());
cb();
}
}
async function byteChunks(): Promise<void> {
const inline = new Writable({
write(chunk: Buffer | string, _encoding: BufferEncoding, cb: (err?: Error | null) => void): void {
console.log("inline", Buffer.isBuffer(chunk), typeof chunk === "string" ? chunk : chunk.toString());
cb();
},
});
inline.write("one");
inline.end(Buffer.from("two"));
await new Promise<void>((resolve) => inline.on("finish", () => resolve()));
const collector = new Collector();
collector.end("three");
await new Promise<void>((resolve) => collector.on("finish", () => resolve()));
}
async function main(): Promise<void> {
await records();
await collections();
@@ -126,5 +155,7 @@ async function main(): Promise<void> {
sided();
duplex();
passthrough();
await new Promise<void>((resolve) => setTimeout(resolve, 5));
await byteChunks();
}
main();
@@ -0,0 +1,147 @@
// Callbacks that update captured `let` numbers and booleans from loops: typed
// array and array methods, an authored higher-order function called with one
// function at several calls and with two functions at one, closures of one
// function over different bindings, a callback that throws part way, nested
// callbacks, callbacks declared in the loop body, and reads after the loop.
function each(values: number[], visit: (v: number, i: number) => void): void {
for (let i = 0; i < values.length; i++) visit(values[i]!, i);
}
function until(limit: number, step: (i: number) => boolean): number {
let i = 0;
while (i < limit) {
if (!step(i)) break;
i++;
}
return i;
}
function twice(values: Int16Array, f: (v: number) => void): void {
let i = 0;
do {
for (let j = 0; j < values.length; j++) f(values[j]!);
i++;
} while (i < 2);
}
const ints = new Int32Array([5, -3, 8, 0, 12, -7, 4]);
let total = 0;
let negatives = 0;
let sawZero = false;
ints.forEach((v) => {
total = (total + v) | 0;
if (v < 0) negatives++;
if (v === 0) sawZero = true;
});
console.log("forEach", total, negatives, sawZero);
let max = -Infinity;
let maxAt = -1;
const floats = new Float64Array([0.5, 9.25, -2, 9.25, 3]);
floats.forEach((v, i) => {
if (v > max) {
max = v;
maxAt = i;
}
});
console.log("max", max, maxAt);
let calls = 0;
const doubled = ints.map((v) => {
calls++;
return v * 2 + calls;
});
console.log("map", doubled.join(","), calls);
let seen = 0;
const big = [3, 14, 15, 92, 65].filter((v) => {
seen += v;
return v > 10;
});
console.log("filter", big.join(","), seen);
let sum = 0;
let count = 0;
const summing = (v: number): void => {
sum += v;
count++;
};
each([1, 2, 3], summing);
each([10, 20], summing);
console.log("each shared", sum, count);
let a = 0;
let b = 0;
each([1, 2], (v) => {
a += v;
});
each([1, 2], (v, i) => {
b += v * (i + 1);
});
console.log("each two", a, b);
const counters: (() => number)[] = [];
for (let k = 0; k < 3; k++) {
let local = k * 100;
each([1, 1, 1], () => {
local++;
});
counters.push(() => local);
}
console.log("closures", counters.map((c) => c()).join(","));
let steps = 0;
console.log("until", until(10, (i) => (steps += i, steps < 12)), steps);
let shorts = 0;
twice(new Int16Array([7, -8, 9]), (v) => {
shorts = shorts * 2 + v;
});
console.log("twice", shorts);
let partial = 0;
try {
floats.forEach((v) => {
if (v < 0) throw new RangeError(`at ${partial}`);
partial += v;
});
} catch (error) {
console.log("throw", (error as Error).message, partial);
}
let outer = 0;
let inner = 0;
[1, 2, 3].forEach((x) => {
outer += x;
ints.forEach((y) => {
if (y > x) inner++;
});
});
console.log("nested", outer, inner);
let flag = false;
let flips = 0;
new Uint8Array([1, 0, 1, 1]).forEach((v) => {
if (v === 1) flag = !flag;
if (flag) flips++;
});
console.log("bool", flag, flips);
// Callbacks declared in the loop body: each iteration binds its own.
let strides = 0;
for (let i = 0; i < 10; i++) {
const bump = (): void => {
i += 2;
strides++;
};
bump();
}
let countdown = 0;
let k = 3;
while (k > 0) {
const tick = (n: number): number => (n > 0 ? tick(n - 1) + 1 : 0);
countdown += tick(k);
k--;
}
console.log("declared in loop", strides, countdown);
@@ -0,0 +1,111 @@
// Speculative integer loops whose checks fail: on the first statement of
// every entry (fractional field data), after an effect earlier in the same
// iteration, before a `continue`, `break` or `return`, and on entries
// that alternate between integer and fractional data. A site that keeps
// failing stops trying its integer copy; every result is Node's.
class Point {
x: number;
y: number;
w = 0;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
function show(label: string, value: number): void {
console.log(label, Object.is(value, -0) ? "-0" : String(value));
}
// Each inner entry fails at its first statement when the fields hold
// fractions, and runs no iteration at all for the last point.
function pairs(points: Point[]): number {
let total = 0;
for (let i = 0; i < points.length; i++) {
const p = points[i]!;
let acc = p.w;
for (let j = i + 1; j < points.length; j++) {
const q = points[j]!;
const dx = p.x - q.x;
const dy = p.y - q.y;
const d2 = dx * dx + dy * dy;
acc += d2;
q.w += d2 * 0.5;
}
p.w = acc;
total += acc;
}
return total;
}
const fractional = [
new Point(0.5, 1),
new Point(1.25, -2),
new Point(3, 4.75),
new Point(-1.5, 0),
];
const integral = [new Point(0, 1), new Point(2, -2), new Point(3, 4), new Point(-1, 0)];
for (let round = 0; round < 12; round++) {
show(`pairs fractional ${round}`, pairs(fractional));
}
// The same site, now with integer data after it stopped speculating.
show("pairs integral", pairs(integral));
show("pairs integral again", pairs(integral));
// A field write comes before the failing statement: the iteration resumes
// after it and must not repeat it.
function afterEffect(points: Point[], scale: number): number {
let s = 0;
for (let i = 0; i < points.length; i++) {
const p = points[i]!;
p.w += 1;
const k = p.x * scale;
s += k * k;
}
return s;
}
const marks = [new Point(1, 0), new Point(2, 0), new Point(3, 0)];
show("after effect 2", afterEffect(marks, 2));
show("after effect 0.5", afterEffect(marks, 0.5));
show("after effect 3", afterEffect(marks, 3));
console.log(marks.map((p) => p.w).join(" "));
// The failing statement is followed by `continue`, `break` and `return`.
function control(n: number, step: number): number {
let acc = 0;
let i = 0;
while (i < n) {
const v = i * step;
i++;
if (v * v > 400) break;
if ((i & 1) === 0) continue;
acc += v * 3;
if (acc > 1000) return -acc;
}
return acc;
}
for (const step of [1, 0.5, 2, 0.75, 7, 1.5, 3, 0.25, 5, 1.75, 4]) {
show(`control ${step}`, control(40, step));
}
// do-while: the check fails part-way through, on some entries only.
function steps(start: number, factor: number): number {
let v = start;
let k = 0;
do {
v = v * factor - k;
k++;
} while (k < 6);
return v;
}
for (let r = 0; r < 20; r++) show(`steps ${r}`, steps(r % 3 === 0 ? r + 0.5 : r, 3));
// Overflow past 2^53 on later entries only.
function powers(base: number, n: number): number {
let p = 1;
for (let i = 0; i < n; i++) p = p * base;
return p;
}
for (const n of [3, 10, 40, 80, 5, 60, 2, 70, 1, 90, 0, 4]) show(`powers ${n}`, powers(3, n));
@@ -0,0 +1,45 @@
import { MessageChannel, type MessagePort } from "node:worker_threads";
// Listeners of one message share its value, as in Node: a typed listener
// that runs after an untyped one sees the changes the untyped one made,
// and a listener on a discriminated union reads the arm its discriminator
// names.
type Item = { id: number; name: string };
type Shape = { kind: "circle"; size: number } | { kind: "square"; size: number; corner: string };
const lines: string[] = [];
let pending = 0;
const ports: MessagePort[] = [];
function finish(): void {
if (--pending > 0) return;
for (const line of lines) console.log(line);
for (const port of ports) port.close();
}
const shared = new MessageChannel();
ports.push(shared.port1);
shared.port1.on("message", (value: Item) => {
lines.push(`typed first ${value.id} ${value.name}`);
});
shared.port1.on("message", (value: any) => {
value.id = value.id * 10;
value.name = `${value.name}!`;
});
shared.port1.on("message", (value: Item) => {
lines.push(`typed after ${value.id} ${value.name}`);
finish();
});
pending += 2;
shared.port2.postMessage({ id: 1, name: "a" });
shared.port2.postMessage({ id: 2, name: "b" });
const shapes = new MessageChannel();
ports.push(shapes.port1);
shapes.port1.on("message", (shape: Shape) => {
if (shape.kind === "circle") lines.push(`circle ${shape.size}`);
else lines.push(`square ${shape.size} ${shape.corner}`);
finish();
});
pending += 2;
shapes.port2.postMessage({ kind: "circle", size: 3 });
shapes.port2.postMessage({ kind: "square", size: 4, corner: "round" });
@@ -0,0 +1,26 @@
// Methods whose quoted names begin with "get:" or "set:", next to an
// accessor of the property after the prefix, read and called through
// untyped values.
class Quoted {
"get:size"() {
return "method get:size";
}
"set:size"(value) {
return `method set:size ${value}`;
}
get size() {
return 3;
}
}
class Sized {
constructor() {
this.size = 5;
}
}
const values = [new Quoted(), new Sized(), { "get:size": () => "record get:size", size: 1 }];
for (const value of values) {
const getter = value["get:size"];
console.log(typeof getter, getter?.call(value));
console.log(typeof value["set:size"], value.size);
if (value instanceof Quoted) console.log(value["get:size"](), value["set:size"](7));
}
@@ -0,0 +1,36 @@
// A byte-mode stream's write chunks are Buffers: an underscore method
// whose chunk parameter has another type is fenced, and super(undefined)
// passes no options, so the stream is in byte mode. A parameter that
// admits the Buffer (Buffer | string) is not fenced, inline or as a
// method.
import { Writable } from "node:stream";
class Numbers extends Writable {
constructor() {
super(undefined);
}
_write(chunk: number, _encoding: BufferEncoding, cb: (err?: Error | null) => void): void {
console.log(chunk);
cb();
}
}
new Numbers().end("x");
const accepted = new Writable({
write(chunk: Buffer | string, _encoding: BufferEncoding, cb: (err?: Error | null) => void): void {
console.log(typeof chunk === "string" ? chunk : chunk.toString());
cb();
},
});
accepted.end("z");
class Wide extends Writable {
constructor() {
super(undefined);
}
_write(chunk: Buffer | string, _encoding: BufferEncoding, cb: (err?: Error | null) => void): void {
console.log(typeof chunk === "string" ? chunk : chunk.toString());
cb();
}
}
new Wide().end("w");
@@ -0,0 +1,8 @@
stream-byte-mode-chunk-fences.ts:10:5 - error SC2020: 'a '_write' method whose parameter 0 is 'number' where Node passes 'Uint8Array'' is part of the standard library types but has no scriptc lowering yet
9 | constructor() {
10 | super(undefined);
| ^~~~~~~~~~~~~~~~
11 | }
hint: annotate the parameter with the Node signature's type or a supertype of it (chunks are Buffers here)
@@ -0,0 +1 @@
{ "plain": 2430, "san": 8860 }
@@ -0,0 +1 @@
{ "plain": 3400, "san": 4800 }
@@ -0,0 +1 @@
{ "plain": 2200, "san": 2200 }
@@ -0,0 +1 @@
{ "plain": 1700, "san": 1600 }
@@ -0,0 +1 @@
{ "plain": 4260, "san": 6600 }
@@ -0,0 +1 @@
{ "plain": 6000, "san": 7900 }
@@ -0,0 +1 @@
{ "plain": 2000, "san": 2600 }
@@ -0,0 +1 @@
{ "plain": 3000, "san": 4600 }
@@ -0,0 +1 @@
{ "plain": 3900, "san": 4000 }
@@ -108,7 +108,7 @@ for (const backend of ["llvm"] as const) {
object_format: target.objectFormat,
minimum_os: target.minimumOs,
},
runtime_abi: { version: 26, marker: "scr_runtime_abi_v26" },
runtime_abi: { version: 27, marker: "scr_runtime_abi_v27" },
compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple },
macros: { executable: [], excluded: [], sanitizer: "external-toolchain-required" },
flavors: {