mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-06 08:23:19 +08:00
Skip the receiver bracket for adapters of receiver-free callbacks (#2564)
This commit is contained in:
@@ -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+:/);
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/receiver-free-adapter.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user