Skip the receiver bracket for adapters of receiver-free callbacks (#2564)

This commit is contained in:
Chris Tate
2026-10-29 15:03:34 -05:00
committed by GitHub
7 changed files with 313 additions and 4 deletions
@@ -1,7 +1,10 @@
import { expect, test } from "vitest";
import {
BOOL,
BYTES_U8,
DYN,
F64,
STRING,
VOID,
funcOf,
type IrExpr,
@@ -260,6 +263,59 @@ test("receiver elision admits only synchronous receiver-free work", () => {
expect(callbackIgnoresReceiver(fn)).toBe(false);
});
test("receiver elision admits library work that runs no user code", () => {
const str: IrExpr = { kind: "strLit", value: "a", type: STRING, loc };
const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc });
const dyn = ref("d", DYN);
const ignores = (expr: IrExpr): boolean => {
const fn = fixture().functions[0]!;
fn.body.unshift({ kind: "exprStmt", expr, loc });
return callbackIgnoresReceiver(fn);
};
const lib = (fn: string, args: IrExpr[]): IrExpr =>
({ kind: "libCall", fn, args, type: F64, loc }) as IrExpr;
const intrinsic = (name: string, args: IrExpr[]): IrExpr =>
({ kind: "intrinsic", name, args, type: VOID, loc }) as IrExpr;
// String and number members over primitives; a callback operand may run.
expect(ignores(lib("string.lastIndexOfFrom", [str, str, num(0)]))).toBe(true);
expect(ignores(lib("num.sameValue", [num(0), num(1)]))).toBe(true);
expect(ignores(lib("string.lastIndexOf", [str, closure("predicate")]))).toBe(false);
expect(ignores(lib("perf.now", []))).toBe(true);
// `new Error(message, options)` with a primitive message and no options.
const message: IrExpr = { kind: "dynFrom", value: str, type: DYN, loc };
expect(ignores(lib("error.newOptions", [message, message]))).toBe(true);
expect(ignores(lib("error.newOptions", [message, dyn]))).toBe(false);
// The module binding check only throws; console output may inspect.
const initialized: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc };
expect(ignores(intrinsic("module.tdzCheck", [initialized, str]))).toBe(true);
expect(ignores(intrinsic("console.log", [dyn]))).toBe(false);
// A typed array built from nothing or a typed source; a dynamic source
// may be an iterable.
const bytes = (source: IrExpr | null): IrExpr => ({
kind: "bytesNew",
source,
type: BYTES_U8,
loc,
});
expect(ignores(bytes(null))).toBe(true);
expect(ignores(bytes(dyn))).toBe(false);
});
test("receiver elision admits array for-of loops", () => {
const fn = fixture().functions[0]!;
const numbers = { kind: "array", elem: F64 } as const;
const one: IrExpr = { kind: "numLit", value: 1, type: F64, loc };
fn.locals.push({ id: "x", name: "x", type: F64, mutable: false });
fn.body.unshift({
kind: "forOf",
localId: "x",
iterable: { kind: "arrayLit", elems: [one], type: numbers, loc },
body: [],
loc,
});
expect(callbackIgnoresReceiver(fn)).toBe(true);
});
function callsOf(name: string, callee: string, extra: IrStmt[] = []): IrFunction {
return {
name,
@@ -1003,6 +1003,16 @@ export function emitCallExpr(
} else if (needsReceiver) B.line("call void @scr_dyn_this_pop()");
return result;
};
// The direct entry of a known target brackets the call when the target
// may observe the receiver. A signature adapter that only forwards the
// receiver to its original carries the original's RECEIVER_FREE bit
// (set where the adapter closure is built), so its entry tests the bit,
// like the entry through the closure's own pointer.
const invokeKnown = (target: string): string => {
const readers = host.receiverReaders;
if (readers.ignores(known!)) return invoke(target, false);
return invoke(target, true, args, readers.forwardsOnly(known!));
};
const lending =
lent && !dynamic
? known!.captures !== undefined
@@ -1025,7 +1035,7 @@ export function emitCallExpr(
(e.callee.kind === "closure" ||
(e.callee.kind === "varRef" && host.knownCallbacks.has(e.callee.localId)))
) {
t = invoke(lending ?? direct, !host.receiverReaders.ignores(known!));
t = invokeKnown(lending ?? direct);
if (quiet) pending = false;
} else {
const fnp = B.tmp(),
@@ -1072,7 +1082,7 @@ export function emitCallExpr(
B.line(`${matches} = icmp eq ptr ${fn}, ${direct}`);
B.condBr(matches, fast, fallback);
B.startBlock(fast);
const specialized = invoke(lending ?? direct, !host.receiverReaders.ignores(known!));
const specialized = invokeKnown(lending ?? direct);
// `fast` may have split for exception checks; join from the end.
const fastEnd = B.currentLabel();
B.br(done);
@@ -0,0 +1,68 @@
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, deserializeModule } from "../../index.js";
import { emitLlvmModule } from "./emitter.js";
async function llvmFor(source: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-receiver-brackets-"));
try {
const entry = join(dir, "main.ts"),
output = join(dir, "main.ir.json");
await writeFile(entry, source);
const result = await compile(entry, { outDir: dir, outPath: output, outputKind: "ir" });
if (!result.ok)
throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n"));
return emitLlvmModule(deserializeModule(await readFile(output, "utf8")));
} finally {
await rm(dir, { recursive: true, force: true });
}
}
function body(ir: string, name: string): string {
const match = new RegExp(`^define [^\\n]*@${name}\\(.*?^}`, "ms").exec(ir);
if (!match) throw new Error(`no function ${name}`);
return match[0];
}
test("a known signature adapter's direct entry brackets the receiver only when its original reads it", async () => {
const ir = await llvmFor(`
class Node {
kids: Node[] = [];
constructor(readonly kind: number) {}
}
function walk(root: Node, cb: (node: Node, depth: number) => void | "skip" | "stop"): void {
const stack: Node[] = [root];
while (stack.length > 0) {
const node = stack.pop()!;
const verdict = cb(node, stack.length);
if (verdict === "stop") return;
if (verdict === "skip") continue;
for (const kid of node.kids) stack.push(kid);
}
}
class Counter {
count = 0;
run(root: Node): number {
walk(root, (node) => {
if (node.kind === 3) this.count++;
});
return this.count;
}
}
const root = new Node(0);
root.kids.push(new Node(3));
console.log(new Counter().run(root));
`);
const walk = body(ir, "sc_bf_walk");
const blocks = walk.split(/^(?=[\w.]+:$)/m);
const direct = blocks.find((block) => block.startsWith("callback.direct"));
// The adapter forwards the receiver to the arrow function, which never
// reads it: the direct entry tests the closure's RECEIVER_FREE bit, and
// every receiver push is behind such a test.
expect(direct).toMatch(/ = and i32 %\S+, 16\n/);
const pushes = blocks.filter((block) => block.includes("@scr_dyn_this_push_dyn"));
expect(pushes.length).toBeGreaterThan(0);
for (const block of pushes) expect(block).toMatch(/^receiver\.push\d+:/);
});
+58 -2
View File
@@ -27,6 +27,8 @@ const RECEIVER_FREE_EXPRS = new Set<IrExpr["kind"]>([
"strCmp",
"nullish",
"orDefault",
"optChain",
"chainRecv",
"unionWrap",
"unionRetype",
"unionNarrow",
@@ -35,9 +37,12 @@ const RECEIVER_FREE_EXPRS = new Set<IrExpr["kind"]>([
"unionDisc",
"upcast",
"downcast",
"dynFrom",
"instanceOf",
"fieldGet",
"fieldAbsent",
"recordGet",
"recordHas",
"recordLit",
"recordClone",
"arrayGet",
@@ -58,6 +63,9 @@ const RECEIVER_FREE_EXPRS = new Set<IrExpr["kind"]>([
"setIntrinsic",
"mapNew",
"setNew",
"bytesIntrinsic",
"bytesNew",
"intrinsic",
]);
const RECEIVER_FREE_STMTS = new Set<IrStmt["kind"]>([
@@ -69,6 +77,7 @@ const RECEIVER_FREE_STMTS = new Set<IrStmt["kind"]>([
"while",
"doWhile",
"for",
"forOf",
"switch",
"block",
"break",
@@ -78,6 +87,7 @@ const RECEIVER_FREE_STMTS = new Set<IrStmt["kind"]>([
"arraySet",
"arraySetUndefined",
"arraySetLength",
"bytesSet",
"throw",
"rethrow",
"tryCatch",
@@ -101,7 +111,10 @@ const TRIVIALLY_RECEIVER_FREE = new Set<string>([
kind !== "arrIntrinsic" &&
kind !== "strIntrinsic" &&
kind !== "mapIntrinsic" &&
kind !== "setIntrinsic",
kind !== "setIntrinsic" &&
kind !== "bytesIntrinsic" &&
kind !== "bytesNew" &&
kind !== "intrinsic",
),
]);
@@ -114,8 +127,44 @@ const RECEIVER_FREE_LIB_FNS = new Set<IrLibFn>([
"math.minArr",
"error.new",
"error.nodeThrow",
"perf.now",
"atomics.op",
]);
/** Intrinsics that only check a flag or switch the current lifetime
* region: the module binding check (which throws a fixed ReferenceError)
* and the LifetimeRegion operations. */
const RECEIVER_FREE_INTRINSICS = new Set<string>([
"module.tdzCheck",
"region.create",
"region.enter",
"region.leave",
"region.release",
]);
function primitiveOperand(e: IrExpr): boolean {
const kind = e.type.kind;
return kind === "string" || kind === "f64" || kind === "bool";
}
/** A dynamic value boxed from a primitive, which converts to a string
* without user code. */
function primitiveDynOperand(e: IrExpr): boolean {
if (e.kind !== "dynFrom") return false;
const kind = e.value.type.kind;
return kind === "string" || kind === "f64" || kind === "bool" || kind === "undefinedT";
}
/** Whether a library call runs no user code: scalar Math, the members
* above, `new Error(message, options)` over a primitive message without an
* options object, and string and number members over primitive operands. */
function receiverFreeLibCall(e: Extract<IrExpr, { kind: "libCall" }>): boolean {
if (isBorrowSafeMath(e.fn) || RECEIVER_FREE_LIB_FNS.has(e.fn)) return true;
if (e.fn === "error.newOptions") return e.args.every(primitiveDynOperand);
if (e.fn.startsWith("string.") || e.fn.startsWith("num.")) return e.args.every(primitiveOperand);
return false;
}
/** Typed container and string operations. Their runtime paths compare and
* copy values but never call back into compiled code, as long as no
* operand is a function or a checked value. */
@@ -128,7 +177,12 @@ function dataIntrinsicOperands(e: IrExpr): IrExpr[] | null {
case "strIntrinsic":
case "mapIntrinsic":
case "setIntrinsic":
case "bytesIntrinsic":
return [e.receiver, ...e.args];
// A typed source is copied element by element; a dynamic one may be an
// iterable whose iterator is user code.
case "bytesNew":
return e.source === null ? [] : [e.source];
case "mapNew":
case "setNew":
return [];
@@ -210,7 +264,9 @@ export class AmbientReceiverReaders {
return targets.length > 0 && targets.every(edge);
}
case "libCall":
return isBorrowSafeMath(expr.fn) || RECEIVER_FREE_LIB_FNS.has(expr.fn);
return receiverFreeLibCall(expr);
case "intrinsic":
return RECEIVER_FREE_INTRINSICS.has(expr.name);
case "toString":
return !opaqueOperand(expr.operand);
default: {
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/receiver-free-adapter.ts"
],
"diags": []
}
+112
View File
@@ -0,0 +1,112 @@
// Callbacks that reach a walker through a signature adapter (the walker's
// callback takes more parameters and returns a verdict), some reading
// `this` and some not. A plain call leaves `this` undefined; an arrow keeps
// its enclosing `this`.
class Tree {
readonly kind: number;
readonly kids: Tree[] = [];
constructor(kind: number) {
this.kind = kind;
}
}
function walk(root: Tree, visit: (node: Tree, depth: number) => void | "skip" | "stop"): number {
const stack: [Tree, number][] = [[root, 0]];
let visited = 0;
while (stack.length > 0) {
const [node, depth] = stack.pop()!;
visited++;
const verdict = visit(node, depth);
if (verdict === "stop") break;
if (verdict === "skip") continue;
for (let i = node.kids.length - 1; i >= 0; i--) stack.push([node.kids[i]!, depth + 1]);
}
return visited;
}
const root = new Tree(0);
for (let i = 1; i <= 4; i++) {
const kid = new Tree(i);
for (let j = 0; j < 3; j++) kid.kids.push(new Tree(i * 10 + j));
root.kids.push(kid);
}
const out: string[] = [];
// An arrow that ignores the receiver and one that captures its class's.
class Counter {
count = 0;
label = "counter";
run(): string {
walk(root, (node) => {
if (node.kind % 2 === 0) this.count++;
});
const labels: string[] = [];
walk(root, (node) => {
if (node.kind === 2) return "skip";
labels.push(`${this.label}:${node.kind}`);
return undefined;
});
return `${this.count} ${labels.length}`;
}
}
out.push(new Counter().run());
// Function expressions that read `this`: a plain call binds undefined.
const receivers: string[] = [];
walk(root, function (this: unknown, node: Tree) {
if (node.kind < 2) receivers.push(this === undefined ? "undefined" : typeof this);
});
out.push(receivers.join(","));
// An unbound method: its `this` is undefined when the walker calls it.
const holder = {
name: "holder",
visit(this: { name: string } | undefined, node: Tree): void {
if (node.kind === 1) out.push(this === undefined ? "unbound" : this.name);
},
};
walk(root, holder.visit);
// A bound method keeps its receiver.
walk(root, holder.visit.bind(holder));
// Receiver-free bodies with optional chains, record presence and array loops.
type Info = { name?: string; tags: string[] };
const infos = new Map<number, Info>([
[1, { name: "one", tags: ["a", "b"] }],
[3, { tags: [] }],
]);
const described: string[] = [];
walk(root, (node) => {
const info = infos.get(node.kind);
let tags = 0;
for (const tag of info?.tags ?? []) tags += tag.length;
if (info !== undefined) described.push(`${info.name?.toUpperCase() ?? "anonymous"}/${"name" in info}/${tags}`);
return node.kind === 3 ? "stop" : undefined;
});
out.push(described.join(" "));
// Walkers with one callback each, which their calls enter directly: one
// reads `this`, the other ignores it.
function eachKid(node: Tree, visit: (kid: Tree, index: number) => boolean | void): number {
let calls = 0;
for (let i = 0; i < node.kids.length; i++) {
calls++;
if (visit(node.kids[i]!, i) === false) break;
}
return calls;
}
const kidReceivers: string[] = [];
eachKid(root, function (this: unknown, kid: Tree) {
kidReceivers.push(`${kid.kind}:${this === undefined ? "undefined" : typeof this}`);
});
out.push(kidReceivers.join(","));
function sumKinds(node: Tree, add: (kind: number, depth: number) => number | void): number {
let total = 0;
for (const kid of node.kids) total += add(kid.kind, 1) ?? 0;
return total;
}
let added = 0;
out.push(String(sumKinds(root.kids[2]!, (kind) => (added += kind))), String(added));
console.log(out.join("\n"));
@@ -0,0 +1 @@
{ "plain": 4000, "san": 63000 }