diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx
index fc3ae2790..4470bad77 100644
--- a/docs/content/docs/cli.mdx
+++ b/docs/content/docs/cli.mdx
@@ -373,6 +373,8 @@ $ scriptc build app.ts --optimization=speed --cpu-dispatch -o app
The second copy makes the executable larger and the first build of the program slower, since LLVM compiles the program's code twice. Unchanged rebuilds are not affected. Both copies compute the same results: neither contracts floating-point operations into fused multiply-adds.
+A function's `x86-64-v3` copy runs its baseline code when its optimized code has vectors of at most 128 bits, none wider, and no rounding or bit counting in a loop. In such code, `x86-64-v3` only lets LLVM's vectorizers move scalar work onto vector instructions, which can run slower. Its calls still go to the `x86-64-v3` copies of the functions it calls.
+
The `SCRIPTC_CPU_LEVEL` environment variable caps the level the executable uses, for example to test the baseline code on a newer machine. It takes a level from `0` to `4`, `baseline`, or a level name such as `x86-64-v3`; any other value selects the baseline code.
```console
diff --git a/native/llvm-codegen/CMakeLists.txt b/native/llvm-codegen/CMakeLists.txt
index fc4c07210..14263ad79 100644
--- a/native/llvm-codegen/CMakeLists.txt
+++ b/native/llvm-codegen/CMakeLists.txt
@@ -84,6 +84,7 @@ endif()
add_executable(scriptc-llvm-codegen
src/cache.cpp
+ src/cpu-copies.cpp
src/diagnostics.cpp
src/emit.cpp
src/main.cpp
diff --git a/native/llvm-codegen/README.md b/native/llvm-codegen/README.md
index 7305db21d..f0afcfd26 100644
--- a/native/llvm-codegen/README.md
+++ b/native/llvm-codegen/README.md
@@ -37,6 +37,8 @@ Repeating `--output` divides the program into that many partitions, which are co
With `--split codegen`, several outputs divide only code generation. The helper optimizes the module whole, as it does for a single output, then divides its functions in module order into runs of about the same instruction count, one per output, and generates code for every output on its own thread. Linking the outputs in order lays the functions out as one output would. A local definition that another output uses becomes hidden so the objects can reference each other; the others stay local. Every output after the first is generated from a copy of the optimized module read back from bitcode with its use-list order, so each function's machine code matches a single output except for jumps to definitions that another output also uses. When one run would hold more than three quarters of the module's instructions, the first output holds the whole module and the other outputs are empty objects. Placement depends only on the optimized module and the output count. `--split codegen` requires an optimized build and no `--cache-dir`; scriptc passes it for optimized executables with more than 128 KiB of LLVM text that stay one partition.
+In a `--cpu-dispatch` module, each function has a baseline copy and an x86-64-v3 copy marked `scriptc-cpu-copy`. Partitioning places each x86-64-v3 copy with its baseline copy. After optimization, an x86-64-v3 copy whose code has vectors of at most 128 bits, none wider, and no rounding or bit-count intrinsic in a loop takes its baseline copy's optimized code, and its calls to baseline copies call their x86-64-v3 copies (`src/cpu-copies.h`).
+
WebAssembly code generation differs from LLVM's default pipeline in two ways that do not change its output. The pass that gives multiple-entry loops a single entry runs only on functions whose control flow is irreducible; its reachability analysis grows with the square of a function's block count even when it has nothing to change. Object emission also leaves out the module pass that collects external symbols for assembly declarations, so each function's machine code is freed once it is printed instead of when the whole module is compiled.
`--cache-dir
` names a private directory the caller owns for one helper build. Simplified partitions are stored under the hash of their exact source and objects under the hash of the exact module after importing, each with a trailing digest and published by rename, so a rebuild recompiles only the partitions an edit can affect. Partitions drop the names of their struct types before the thin link, which would otherwise number same-named types from different partitions in an order that any edit can shift, changing the module after importing in partitions the edit did not touch.
diff --git a/native/llvm-codegen/src/cpu-copies.cpp b/native/llvm-codegen/src/cpu-copies.cpp
new file mode 100644
index 000000000..5bbf41ff8
--- /dev/null
+++ b/native/llvm-codegen/src/cpu-copies.cpp
@@ -0,0 +1,182 @@
+#include "cpu-copies.h"
+
+#include "llvm/ADT/STLExtras.h"
+#include "llvm/ADT/SmallVector.h"
+#include "llvm/ADT/StringExtras.h"
+#include "llvm/ADT/StringMap.h"
+#include "llvm/Analysis/LoopInfo.h"
+#include "llvm/IR/DerivedTypes.h"
+#include "llvm/IR/Dominators.h"
+#include "llvm/IR/Function.h"
+#include "llvm/IR/GlobalVariable.h"
+#include "llvm/IR/InstrTypes.h"
+#include "llvm/IR/Instructions.h"
+#include "llvm/IR/IntrinsicInst.h"
+#include "llvm/IR/Intrinsics.h"
+#include "llvm/Transforms/Utils/Cloning.h"
+#include "llvm/Transforms/Utils/ValueMapper.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+using namespace llvm;
+
+namespace scriptc {
+namespace {
+
+// A function's name without the `.llvm.` suffix of the thin link.
+StringRef stem(StringRef Name) {
+ size_t At = Name.rfind(".llvm.");
+ if (At == StringRef::npos)
+ return Name;
+ StringRef Suffix = Name.drop_front(At + 6);
+ if (Suffix.empty() || !all_of(Suffix, isDigit))
+ return Name;
+ return Name.take_front(At);
+}
+
+// The size of a vector type in bits, or 0 for another type.
+uint64_t vectorBits(Type *T) {
+ auto *Vector = dyn_cast(T);
+ return Vector ? Vector->getPrimitiveSizeInBits().getFixedValue() : 0;
+}
+
+// Whether x86-64-v3 changes only the body's vector code of at most 128 bits:
+// the body has such vectors, and nothing that the level compiles to faster
+// code than the baseline, which is vectors of 256 bits (AVX2), and in a
+// loop, rounding (SSE4.1; the baseline calls the C library) and bit counts
+// (POPCNT, LZCNT, TZCNT). Scalar code keeps its x86-64-v3 code, whose VEX
+// encodings need fewer register copies.
+bool onlyNarrowVectors(Function &F) {
+ DominatorTree Dominators(F);
+ LoopInfo Loops(Dominators);
+ bool Narrow = false;
+ for (const BasicBlock &Block : F)
+ for (const Instruction &I : Block) {
+ uint64_t Bits = vectorBits(I.getType());
+ for (const Use &Operand : I.operands())
+ Bits = std::max(Bits, vectorBits(Operand->getType()));
+ if (Bits > 128)
+ return false;
+ Narrow |= Bits != 0;
+ const auto *Call = dyn_cast(&I);
+ if (!Call || !Loops.getLoopFor(&Block))
+ continue;
+ switch (Call->getIntrinsicID()) {
+ case Intrinsic::floor:
+ case Intrinsic::ceil:
+ case Intrinsic::trunc:
+ case Intrinsic::rint:
+ case Intrinsic::nearbyint:
+ case Intrinsic::round:
+ case Intrinsic::roundeven:
+ case Intrinsic::ctpop:
+ case Intrinsic::ctlz:
+ case Intrinsic::cttz:
+ return false;
+ default:
+ break;
+ }
+ }
+ return Narrow;
+}
+
+// Replaces the body of Copy with a clone of Base's. The clone's direct calls
+// to baseline copies call the matching x86-64-v3 copies in Tier.
+void useBaselineBody(Function &Copy, Function &Base,
+ const StringMap &Tier) {
+ GlobalValue::LinkageTypes Linkage = Copy.getLinkage();
+ GlobalValue::VisibilityTypes Visibility = Copy.getVisibility();
+ GlobalValue::UnnamedAddr UnnamedAddr = Copy.getUnnamedAddr();
+ Copy.deleteBody();
+ ValueToValueMapTy Map;
+ for (auto [From, To] : zip(Base.args(), Copy.args()))
+ Map[&From] = &To;
+ SmallVector Returns;
+ CloneFunctionInto(&Copy, &Base, Map,
+ CloneFunctionChangeType::LocalChangesOnly, Returns);
+ Copy.setLinkage(Linkage);
+ Copy.setVisibility(Visibility);
+ Copy.setUnnamedAddr(UnnamedAddr);
+ for (BasicBlock &Block : Copy)
+ for (Instruction &I : Block) {
+ auto *Call = dyn_cast(&I);
+ Function *Callee = Call ? Call->getCalledFunction() : nullptr;
+ if (!Callee)
+ continue;
+ StringRef Name = stem(Callee->getName());
+ if (!Name.consume_back(".base"))
+ continue;
+ auto It = Tier.find((Name + ".v3").str());
+ if (It != Tier.end() &&
+ It->second->getFunctionType() == Callee->getFunctionType())
+ Call->setCalledFunction(It->second);
+ }
+}
+
+// Whether the module counts executions for a profile (`--profile-generate`,
+// also after inlining in a context-sensitive second phase): its functions
+// increment counters named after themselves (`__profc_`).
+bool countsExecutions(const Module &M) {
+ for (const GlobalVariable &Global : M.globals())
+ if (Global.getName().starts_with("__profc_"))
+ return true;
+ for (const Function &F : M)
+ if (F.isIntrinsic() && F.getName().starts_with("llvm.instrprof."))
+ return true;
+ return false;
+}
+
+// Whether Base's body was optimized for fewer runs than Copy's: a profile
+// collected where the executable ran its x86-64-v3 copies gives their
+// baseline copies no runs, and the optimizer treats those as cold code.
+bool ranLess(const Function &Base, const Function &Copy) {
+ std::optional Runs = Copy.getEntryCount();
+ if (!Runs)
+ return false;
+ std::optional BaseRuns = Base.getEntryCount();
+ return !BaseRuns || BaseRuns->getCount() < Runs->getCount();
+}
+
+} // namespace
+
+void useBaselineBodies(Module &M) {
+ // Every function by its stem, so the copies of one function find each
+ // other whichever of them the thin link renamed.
+ StringMap Functions;
+ bool HasCopies = false;
+ for (Function &F : M) {
+ Functions[stem(F.getName())] = &F;
+ HasCopies |= F.hasFnAttribute(CpuCopyAttribute);
+ }
+ // A copy that took its baseline copy's body would count its executions
+ // against the baseline copy, and a build with the profile would then
+ // optimize the x86-64-v3 copy as if it never ran.
+ if (!HasCopies || countsExecutions(M))
+ return;
+ std::vector> Replaced;
+ for (Function &F : M) {
+ if (F.isDeclaration() || !F.hasFnAttribute(CpuCopyAttribute))
+ continue;
+ StringRef Name = stem(F.getName());
+ if (!Name.consume_back(".v3"))
+ continue;
+ auto It = Functions.find((Name + ".base").str());
+ if (It == Functions.end())
+ continue;
+ Function *Base = It->second;
+ if (Base->isDeclaration() || Base->hasAvailableExternallyLinkage() ||
+ Base->getFunctionType() != F.getFunctionType() ||
+ ranLess(*Base, F) || !onlyNarrowVectors(F))
+ continue;
+ Replaced.emplace_back(&F, Base);
+ }
+ for (auto [Copy, Base] : Replaced)
+ useBaselineBody(*Copy, *Base, Functions);
+}
+
+} // namespace scriptc
diff --git a/native/llvm-codegen/src/cpu-copies.h b/native/llvm-codegen/src/cpu-copies.h
new file mode 100644
index 000000000..c9323c976
--- /dev/null
+++ b/native/llvm-codegen/src/cpu-copies.h
@@ -0,0 +1,29 @@
+#pragma once
+
+#include "llvm/IR/Module.h"
+
+namespace scriptc {
+
+// The string attribute that marks the x86-64-v3 copies of a `--cpu-dispatch`
+// build. The compiler names a function's copies `.base` and
+// `.v3`; the thin link may rename either to `.llvm.`.
+inline constexpr const char *CpuCopyAttribute = "scriptc-cpu-copy";
+
+// Runs after optimization, before code generation. An x86-64-v3 copy whose
+// optimized body has vectors of at most 128 bits takes the optimized body of
+// its baseline copy instead, unless it has something else the baseline
+// compiles to slower code: a vector wider than 128 bits, or a rounding or
+// bit-count intrinsic in a loop. In 128-bit vector code the higher level
+// only adds instructions that the vectorizers prefer over scalar ones, and
+// these can run slower. Scalar code keeps its x86-64-v3 code. The copy keeps
+// its name, so its callers do not change, and the body's direct calls to
+// baseline copies call their x86-64-v3 copies, so that callees still run at
+// the level the executable chose.
+//
+// A copy is left as it is when its baseline copy is not defined in the
+// module, the two copies' types differ, or the profile gives the baseline
+// copy fewer runs (its body is then optimized as colder code). Every copy is
+// left as it is in a module that counts executions for a profile.
+void useBaselineBodies(llvm::Module &M);
+
+} // namespace scriptc
diff --git a/native/llvm-codegen/src/emit.cpp b/native/llvm-codegen/src/emit.cpp
index 52333ca59..f5136c2e5 100644
--- a/native/llvm-codegen/src/emit.cpp
+++ b/native/llvm-codegen/src/emit.cpp
@@ -1,6 +1,7 @@
#include "emit.h"
#include "cache.h"
+#include "cpu-copies.h"
#include "diagnostics.h"
#include "partition.h"
#include "profile-blocks.h"
@@ -825,6 +826,7 @@ emitModule(Module &M, TargetMachine &Machine, OptimizationLevel Level,
P.PB.buildPerModuleDefaultPipeline(Level).run(M, P.MAM);
if (Annotating)
Weigher.weigh(ProfileUse, Match);
+ useBaselineBodies(M);
if (std::optional Failure =
verify(M, "post_optimization_verification_failed"))
return Failure;
@@ -1158,6 +1160,13 @@ compileOptimizedPartitions(const EmitOptions &Options,
CodeGeneration.run(Mod);
return false;
};
+ else
+ // The backend passes the module it owns; only the hook's signature is
+ // const.
+ Config.PreCodeGenModuleHook = [](unsigned, const Module &Optimized) {
+ useBaselineBodies(const_cast(Optimized));
+ return true;
+ };
// The link refers to module identifiers for its whole lifetime.
std::vector Names(Count);
@@ -1573,6 +1582,7 @@ emitSplitCodegen(const EmitOptions &Options, Module &M, TargetMachine &Machine,
}
if (Annotating)
Weigher.weigh(Options.ProfileUse, Match);
+ useBaselineBodies(M);
if (std::optional Failure =
verify(M, "post_optimization_verification_failed"))
return Failure;
diff --git a/native/llvm-codegen/src/partition.cpp b/native/llvm-codegen/src/partition.cpp
index 9ab1ce657..7af4681e7 100644
--- a/native/llvm-codegen/src/partition.cpp
+++ b/native/llvm-codegen/src/partition.cpp
@@ -824,8 +824,30 @@ std::optional partitionProgramText(StringRef Text,
Symbols[Root].Kind != SymbolKind::Function)
Root = I;
Roots[I] = Root;
+ }
+ // The x86-64-v3 copy of a --cpu-dispatch build's function (`.v3`),
+ // with the chain that ends at it, is placed with the chain of its baseline
+ // copy (`.base`), so that the helper can compare the two copies
+ // after optimization (cpu-copies.h).
+ DenseMap CopyRoots;
+ for (unsigned I = 0; I < Symbols.size(); ++I) {
+ StringRef Name = Symbols[I].Name;
+ if (Symbols[I].Kind != SymbolKind::Function || !Live[I] ||
+ Roots[I] != I || !Name.consume_back(".v3"))
+ continue;
+ auto Base = Index.find((Name + ".base").str());
+ if (Base != Index.end() && Live[Base->second] &&
+ Symbols[Base->second].Kind == SymbolKind::Function)
+ CopyRoots[I] = Roots[Base->second];
+ }
+ for (unsigned I = 0; I < Symbols.size(); ++I) {
+ if (Symbols[I].Kind == SymbolKind::Declaration || !Live[I])
+ continue;
+ auto Copy = CopyRoots.find(Roots[I]);
+ if (Copy != CopyRoots.end())
+ Roots[I] = Copy->second;
if (Symbols[I].Kind == SymbolKind::Function)
- Symbols[I].Partition = stableHash(Symbols[Root].Name) % Count;
+ Symbols[I].Partition = stableHash(Symbols[Roots[I]].Name) % Count;
}
placeGroups(Symbols, Roots, Count);
balanceRoots(Symbols, Roots, Live, Count);
diff --git a/packages/cli/test/native-link-info.test.ts b/packages/cli/test/native-link-info.test.ts
index b838b3155..162165f28 100644
--- a/packages/cli/test/native-link-info.test.ts
+++ b/packages/cli/test/native-link-info.test.ts
@@ -72,7 +72,7 @@ describe.runIf(supported)("macOS arm64 native link info", () => {
minimum_os: "14.0",
},
program: { object, entry_symbol: "main" },
- runtime_abi: { version: 47, marker: "scr_runtime_abi_v47" },
+ runtime_abi: { version: 48, marker: "scr_runtime_abi_v48" },
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_v47");
+ expect(error?.stderr).toContain("scr_runtime_abi_v48");
});
});
diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts
index f5e9382b4..1e668bfa9 100644
--- a/packages/compiler/src/backend/executable-features.ts
+++ b/packages/compiler/src/backend/executable-features.ts
@@ -19,6 +19,7 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ
...(features.eventTarget ? { eventTarget: true } : {}),
...(features.wasm ? { wasm: true } : {}),
...(features.warningReplace ? { warningReplace: true } : {}),
+ ...(features.recordViewPartial ? { recordViewPartial: true } : {}),
...(features.stdioExtended ? { stdioExtended: true } : {}),
...(features.streamWriteErrors ? { streamWriteErrors: true } : {}),
...(features.stdioWriteSlot ? { stdioWriteSlot: true } : {}),
diff --git a/packages/compiler/src/backend/llvm/cpu-dispatch.ts b/packages/compiler/src/backend/llvm/cpu-dispatch.ts
index ced356cca..a2a66479d 100644
--- a/packages/compiler/src/backend/llvm/cpu-dispatch.ts
+++ b/packages/compiler/src/backend/llvm/cpu-dispatch.ts
@@ -12,17 +12,18 @@ export const CPU_DISPATCH_TIERS: readonly TargetCpu[] = ["x86-64-v3"];
const LEVEL = "@sc_cpu_level";
/** The attribute groups of the x86-64-v3 copies: #0's and #2's (see
- * emit-module.ts functionAttributes) with the level's `target-cpu`. */
+ * emit-module.ts functionAttributes) with the level's `target-cpu`, and
+ * `scriptc-cpu-copy`, by which the LLVM helper finds the copies. After
+ * optimization, a copy whose code gains nothing from x86-64-v3 takes its
+ * baseline copy's code (native/llvm-codegen/src/cpu-copies.h). */
const COPY_GROUPS: ReadonlyMap = new Map([
["0", "3"],
["2", "4"],
]);
export function cpuDispatchAttributes(common: string): string[] {
- return [
- `attributes #3 = {${common} "target-cpu"="x86-64-v3" }`,
- `attributes #4 = { noinline${common} "target-cpu"="x86-64-v3" }`,
- ];
+ const copy = `"target-cpu"="x86-64-v3" "scriptc-cpu-copy"`;
+ return [`attributes #3 = {${common} ${copy} }`, `attributes #4 = { noinline${common} ${copy} }`];
}
/** Bits of CPUID leaf 1's ECX that x86-64-v3 needs: SSE3 (0), SSSE3 (9),
diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts
index 58bad60af..e9dc81c87 100644
--- a/packages/compiler/src/backend/llvm/dyn.ts
+++ b/packages/compiler/src/backend/llvm/dyn.ts
@@ -57,6 +57,7 @@ import { dynDesc, streamTypedRefEligible } from "../../ir/analysis.js";
import { mangleRecordNew, mangleRecordStruct } from "../mangle.js";
import { BlockBuilder } from "./blocks.js";
import { llvmCommentText } from "./common.js";
+import { JS_BITS } from "./jsval-encoding.js";
import {
arrNewCall,
elemAccess,
@@ -219,6 +220,21 @@ export interface DynHost extends WalkerHost {
classValueSubtypes(className: string): readonly string[];
/** emitter.ts's pendingTestLines: the inline pending-exception test. */
pendingTestLines(dest: string): string[];
+ /** The closures with a jsval body (IrFunction.jsEntry) on targets with
+ * jsval words, which the box builders give a jsval entry. */
+ jsEntryClosures?(): readonly JsEntryClosure[];
+}
+
+/** A closure with a jsval body (IrFunction.jsEntry), for the box builders. */
+export interface JsEntryClosure {
+ /** The closure's entry symbol (ScrClosure.fn) and signature. */
+ closure: string;
+ params: readonly IrType[];
+ ret: IrType;
+ /** The jsval body's entry symbol and signature. */
+ body: string;
+ bodyParams: readonly IrType[];
+ bodyRet: IrType;
}
const FN_ATTRS = "#0";
@@ -247,6 +263,7 @@ export class LlDyn {
private readonly toDynFns = new Map();
private readonly dynFuncThunks = new Map();
private readonly dynFuncBoxes = new Map();
+ private readonly jsEntryThunks = new Map();
private readonly dynFuncAdapters = new Map();
private readonly messageListeners = new Map();
private readonly messageReaders = new Map();
@@ -290,6 +307,8 @@ export class LlDyn {
return this.dynFuncThunkHelper(args[0] as IrType & { kind: "func" });
case "dyn.dynFuncBoxHelper":
return this.dynFuncBoxHelper(args[0] as IrType & { kind: "func" });
+ case "dyn.jsEntryThunk":
+ return this.jsEntryThunk(args[0] as string, args[1] as IrType[], args[2] as IrType);
case "dyn.dynFuncAdapterHelper":
return this.dynFuncAdapterHelper(args[0] as IrType & { kind: "func" });
case "dyn.dynToStrHelper":
@@ -1179,8 +1198,11 @@ export class LlDyn {
const struct = mangleRecordStruct(sourceId);
const base = host.nullableUnions.fieldBase(sourceId);
const offsets = target.fields.map((field) => {
- const index = source.fields.findIndex((f) => f.name === field.name) + base;
- return `${S} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${index}) to ${S})`;
+ const index = source.fields.findIndex((f) => f.name === field.name);
+ // A partial view's target field the source does not declare has
+ // no slot: all ones (SIZE_MAX) marks it absent (record-views.ts).
+ if (index < 0) return `${S} -1`;
+ return `${S} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${index + base}) to ${S})`;
});
const offsetTable = `${table}_${k}`;
this.defs.push(
@@ -5534,6 +5556,101 @@ export class LlDyn {
return name;
}
+ /** `sc_jt_(ptr c, ptr args, size argc) -> ptr`, a function value's
+ * jsval entry (ScrDynJsEntry) into a closure's jsval body `target`
+ * (IrFunction.jsEntry, 64-bit targets): passes each argument word to the
+ * body, a missing one as undefined and a dyn parameter's boxed, and
+ * returns the body's result as an owned word. Like the closure's own
+ * entry, the body consumes its arguments. With an exception pending the
+ * result is undefined. */
+ jsEntryThunk(target: string, params: readonly IrType[], ret: IrType): string {
+ const existing = this.jsEntryThunks.get(target);
+ if (existing) return existing;
+ const name = `sc_jt_${this.jsEntryThunks.size}`;
+ this.jsEntryThunks.set(target, name);
+ this.host.journalAllocated?.(name, "dyn.jsEntryThunk", [target, params, ret]);
+ const host = this.host;
+ const B = new BlockBuilder();
+ const undefinedWord = `inttoptr (i64 ${JS_BITS.undefined} to ptr)`;
+ const args = params.map((p, i) => {
+ const slot = B.slot();
+ B.entryAllocas.push(`${slot} = alloca ptr`);
+ B.line(`store ptr ${undefinedWord}, ptr ${slot}`);
+ const has = B.tmp();
+ B.line(`${has} = icmp ult ${host.sizeType} ${i}, %argc`);
+ const lHas = B.newLabel("jt.h");
+ const lj = B.newLabel("jt.j");
+ B.condBr(has, lHas, lj);
+ B.startBlock(lHas);
+ const ap = B.tmp();
+ const av = B.tmp();
+ B.line(`${ap} = getelementptr inbounds ptr, ptr %args, ${host.sizeType} ${i}`);
+ B.line(`${av} = load ptr, ptr ${ap}`);
+ B.line(`store ptr ${av}, ptr ${slot}`);
+ B.br(lj);
+ B.startBlock(lj);
+ const word = B.tmp();
+ B.line(`${word} = load ptr, ptr ${slot}`);
+ const owned = B.tmp();
+ if (p.kind === "jsval") {
+ host.declare(`declare ptr @scr_js_retain(ptr)`);
+ B.line(`${owned} = call ptr @scr_js_retain(ptr ${word})`);
+ } else if (p.kind === "dyn") {
+ host.declare(`declare ptr @scr_js_to_dyn_borrow(ptr)`);
+ B.line(`${owned} = call ptr @scr_js_to_dyn_borrow(ptr ${word})`);
+ } else {
+ throw new InternalCompilerError(`llvm emitter bug: jsval entry parameter ${p.kind}`);
+ }
+ return `ptr ${owned}`;
+ });
+ const callArgs = [`ptr %c`, ...args].join(", ");
+ if (ret.kind === "void") {
+ B.line(`call void @${target}(${callArgs})`);
+ this.pendingBail(B, "jtr", () => {}, `ptr ${undefinedWord}`);
+ B.terminate(`ret ptr ${undefinedWord}`);
+ } else {
+ const r = B.tmp();
+ B.line(`${r} = call ${this.valTy(ret)} @${target}(${callArgs})`);
+ this.pendingBail(B, "jtr", () => {}, `ptr ${undefinedWord}`);
+ if (ret.kind === "jsval") {
+ B.terminate(`ret ptr ${r}`);
+ } else if (ret.kind === "f64") {
+ const raw = B.tmp();
+ const nan = B.tmp();
+ const bits = B.tmp();
+ const word = B.tmp();
+ B.line(`${raw} = bitcast double ${r} to i64`);
+ B.line(`${nan} = fcmp uno double ${r}, ${r}`);
+ B.line(`${bits} = select i1 ${nan}, i64 ${JS_BITS.canonicalNan}, i64 ${raw}`);
+ B.line(`${word} = inttoptr i64 ${bits} to ptr`);
+ B.terminate(`ret ptr ${word}`);
+ } else if (ret.kind === "bool") {
+ const bits = B.tmp();
+ const word = B.tmp();
+ B.line(`${bits} = select i1 ${r}, i64 ${JS_BITS.true}, i64 ${JS_BITS.false}`);
+ B.line(`${word} = inttoptr i64 ${bits} to ptr`);
+ B.terminate(`ret ptr ${word}`);
+ } else {
+ // dyn, or a typed result boxed as the closure's own entry would.
+ const d = ret.kind === "dyn" ? r : this.toDynExpr(B, ret, r);
+ if (ret.kind !== "dyn" && isRefCounted(ret))
+ B.line(`call void ${releaseSym(host, ret)}(ptr ${r})`);
+ host.declare(`declare ptr @scr_js_from_dyn(ptr)`);
+ const word = B.tmp();
+ B.line(`${word} = call ptr @scr_js_from_dyn(ptr ${d})`);
+ B.terminate(`ret ptr ${word}`);
+ }
+ }
+ this.defs.push(
+ `define internal ptr @${name}(ptr %c, ptr %args, ${host.sizeType} %argc) ${FN_ATTRS} { ; jsval entry into ${llvmCommentText(target)}`,
+ B.render(),
+ `}`,
+ ``,
+ );
+ this.host.journalBuilt?.();
+ return name;
+ }
+
/** The box builder dynFrom emits for one closure signature. */
dynFuncBoxHelper(t: IrType & { kind: "func" }): string {
const key = typeKey(t);
@@ -5547,11 +5664,43 @@ export class LlDyn {
host.declare(`declare ptr @scr_closure_retain_v(ptr)`);
host.declare(`declare ptr @scr_dyn_new_func(ptr, ptr, i32, ptr, ptr)`);
const sigLit = host.cstr(key);
+ // A closure of this signature with a jsval body gets the function
+ // value's jsval entry, however the value is boxed: the closure's
+ // entry pointer names it.
+ const entries =
+ t.rest === true
+ ? []
+ : (host.jsEntryClosures?.() ?? []).filter(
+ (c) =>
+ c.params.length === t.params.length &&
+ c.params.every((p, i) => typeKey(p) === typeKey(t.params[i]!)) &&
+ typeKey(c.ret) === typeKey(t.ret),
+ );
+ const register: string[] = [];
+ if (entries.length > 0) {
+ host.declare(`declare void @scr_dyn_func_set_js(ptr, ptr, ptr)`);
+ register.push(
+ ` %fnp = getelementptr inbounds %ScrClosure, ptr %c, i64 0, i32 1`,
+ ` %fn = load ptr, ptr %fnp`,
+ );
+ entries.forEach((c, i) => {
+ const jsEntry = this.jsEntryThunk(c.body, c.bodyParams, c.bodyRet);
+ register.push(
+ ` %is${i} = icmp eq ptr %fn, @${c.closure}`,
+ ` br i1 %is${i}, label %set${i}, label %next${i}`,
+ `set${i}:`,
+ ` call void @scr_dyn_func_set_js(ptr %r, ptr @${thunk}, ptr @${jsEntry})`,
+ ` ret ptr %r`,
+ `next${i}:`,
+ );
+ });
+ }
this.defs.push(
`define internal ptr @${name}(ptr %v, ptr %fname) ${FN_ATTRS} { ; box ${key} into dyn`,
`entry:`,
` %c = call ptr @scr_closure_retain_v(ptr %v)`,
` %r = call ptr @scr_dyn_new_func(ptr %c, ptr @${thunk}, i32 ${t.params.length - (t.restAbi === "typed" ? 1 : 0)}, ptr ${sigLit}, ptr %fname)`,
+ ...register,
` ret ptr %r`,
`}`,
``,
diff --git a/packages/compiler/src/backend/llvm/emit-statements.ts b/packages/compiler/src/backend/llvm/emit-statements.ts
index e72afdbf0..b0340a36f 100644
--- a/packages/compiler/src/backend/llvm/emit-statements.ts
+++ b/packages/compiler/src/backend/llvm/emit-statements.ts
@@ -1286,26 +1286,26 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
}
};
// A counted loop over typed arrays, or a loop to a typed array's
- // length, polls between blocks of WORKER_POLL_BLOCK iterations instead
+ // length, polls between blocks of workerPollBlock iterations instead
// of in its body: the inner loop over a block then has no exit
// besides its own bound, which LLVM can vectorize. A typed
// array's length is fixed, so each block can take it as its limit when
// the loop does not assign the array's binding.
//
// Inside another loop of this function, which polls at least once
- // every WORKER_POLL_BLOCK of its own iterations, a loop needs less:
+ // every workerPollBlock of its own iterations, a loop needs less:
// - a loop whose counters' ranges bound its runs, and those of the
- // loops in its body, to WORKER_POLL_BLOCK^2 needs no poll;
+ // loops in its body, to workerPollBlock^2 needs no poll;
// - an innermost counted loop is strip-mined, typed arrays or not,
// and does not poll after its last block. A run of at most
- // WORKER_POLL_BLOCK iterations then never polls, and the countdown
+ // workerPollBlock iterations then never polls, and the countdown
// leaves its body. A run of at most WORKER_UNPOLLED_RUN iterations
// is one block, polled once as it leaves.
+ const pollBlock = workerPollBlock(emitter);
const enclosed =
emitter.mod.workers === true &&
emitter.jumpTargets.some((target) => target.kind === "loop");
- const bounded =
- enclosed && boundedLoopRuns(emitter, s) <= WORKER_POLL_BLOCK * WORKER_POLL_BLOCK;
+ const bounded = enclosed && boundedLoopRuns(emitter, s) <= pollBlock * pollBlock;
const innermost = enclosed && !containsLoop(s.body);
const stripMined =
!bounded &&
@@ -1313,7 +1313,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
integerSlot !== null &&
((countedLoop !== null &&
countedLimit !== null &&
- Math.abs(countedLoop.step) * WORKER_POLL_BLOCK <= 2 ** 52) ||
+ Math.abs(countedLoop.step) * pollBlock <= 2 ** 52) ||
(integerLoop !== null && integerArrayLoop === null && integerLoop.stableReceiver)) &&
emitter.workerLoopPolls(s) &&
(loopAccessesTypedArrays(s) || (countedLoop !== null && innermost));
@@ -1322,7 +1322,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
// step on each side, has a run whose length its counters fix on entry.
// With a small body, a run of at most WORKER_UNPOLLED_RUN steps takes a
// plain copy of the loop without a poll, which polls once as it leaves
- // when it ran more than WORKER_POLL_BLOCK steps, like a strip-mined run;
+ // when it ran more than workerPollBlock steps, like a strip-mined run;
// longer runs take the loop as it is emitted otherwise.
const shortRuns: { stride: number; left: () => string } | null =
!innermost ||
@@ -1500,7 +1500,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
emitPlainLoop(shortEnd, null);
// A run longer than one block polls once as it leaves.
B.startBlock(shortEnd);
- B.line(`${pollsAfter} = icmp sgt i64 ${left}, ${shortRuns.stride * WORKER_POLL_BLOCK}`);
+ B.line(`${pollsAfter} = icmp sgt i64 ${left}, ${shortRuns.stride * pollBlock}`);
B.condBr(pollsAfter, shortPoll, exit);
B.startBlock(shortPoll);
emitter.emitWorkerPoll();
@@ -2820,8 +2820,15 @@ function emitInt32KindLoop(
/** 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. */
-/** Iterations between cancellation polls of a strip-mined counted loop. */
-const WORKER_POLL_BLOCK = 64;
+/** Iterations between cancellation polls of a strip-mined counted loop. A
+ * block holds at least one step of the vectorized loop over bytes, so that
+ * a full block runs vector code: 64 iterations at the baseline (16-byte
+ * vectors, interleaved 4 times), and 128 when the program's code is compiled
+ * for x86-64-v3 or above, whose vectors take 32 bytes. */
+function workerPollBlock(emitter: LlEmitter): number {
+ const cpu = emitter.targetCpu;
+ return emitter.cpuDispatch || cpu === "x86-64-v3" || cpu === "x86-64-v4" ? 128 : 64;
+}
/** The longest run of a strip-mined loop inside another loop that polls
* only as it leaves. */
@@ -2864,7 +2871,8 @@ function boundedLoopRuns(emitter: LlEmitter, loop: IrStmt): number {
if (stepped && range && exactSteps(range, stepped.step))
trips = Math.floor((range.max - range.min) / Math.abs(stepped.step)) + 1;
}
- if (!(trips <= WORKER_POLL_BLOCK * WORKER_POLL_BLOCK)) return Infinity;
+ const pollBlock = workerPollBlock(emitter);
+ if (!(trips <= pollBlock * pollBlock)) return Infinity;
let nested = 0;
const visit = (s: IrStmt): boolean => {
if (s.kind === "while" || s.kind === "doWhile" || s.kind === "for" || s.kind === "forOf") {
@@ -2918,17 +2926,17 @@ interface StripMinedCounter {
limit: () => string;
}
-/** A counted `for` loop of a worker program as blocks of WORKER_POLL_BLOCK
+/** A counted `for` loop of a worker program as blocks of workerPollBlock
* iterations, with the cancellation poll between blocks: the outer header
* tests the counter against the loop's limit, each block runs the body up
- * to the nearer of the limit and WORKER_POLL_BLOCK steps on, and the poll
+ * to the nearer of the limit and workerPollBlock steps on, and the poll
* follows the block. It polls once per block, and the inner loop's only
* exit besides `break` and `return` is its bound.
*
* With `runSlot` (for a loop that another loop of the function encloses), a
* block that ends at the limit leaves the loop without polling, and a run
* of at most WORKER_UNPOLLED_RUN iterations is one block, polled once as
- * it leaves when it took more than WORKER_POLL_BLOCK iterations. `runSlot`
+ * it leaves when it took more than workerPollBlock iterations. `runSlot`
* holds whether the current block is such a run. */
function emitStripMinedCountedLoop(
emitter: LlEmitter,
@@ -2941,6 +2949,7 @@ function emitStripMinedCountedLoop(
const B = emitter.B;
const { slot, type, signed, step } = counter;
const ascending = step > 0;
+ const pollBlock = workerPollBlock(emitter);
const compare = !signed ? "ult" : ascending ? "slt" : "sgt";
const saturating = `llvm.${signed ? "sadd" : "uadd"}.sat.${type}`;
const nearer = `llvm.${!signed ? "umin" : ascending ? "smin" : "smax"}.${type}`;
@@ -2965,9 +2974,7 @@ function emitStripMinedCountedLoop(
B.startBlock(block);
const stepped = B.tmp();
const bound = B.tmp();
- B.line(
- `${stepped} = call ${type} @${saturating}(${type} ${start}, ${type} ${step * WORKER_POLL_BLOCK})`,
- );
+ B.line(`${stepped} = call ${type} @${saturating}(${type} ${start}, ${type} ${step * pollBlock})`);
B.line(`${bound} = call ${type} @${nearer}(${type} ${stepped}, ${type} ${limit})`);
if (runSlot === null) B.line(`store ${type} ${bound}, ptr ${boundSlot}`);
else {
@@ -2984,7 +2991,7 @@ function emitStripMinedCountedLoop(
: `${left} = sub ${wrap} ${type} ${start}, ${limit}`,
);
B.line(`${whole} = icmp ule ${type} ${left}, ${stride * WORKER_UNPOLLED_RUN}`);
- B.line(`${long} = icmp ugt ${type} ${left}, ${stride * WORKER_POLL_BLOCK}`);
+ B.line(`${long} = icmp ugt ${type} ${left}, ${stride * pollBlock}`);
B.line(`${polled} = and i1 ${whole}, ${long}`);
B.line(`${end} = select i1 ${whole}, ${type} ${limit}, ${type} ${bound}`);
B.line(`store ${type} ${end}, ptr ${boundSlot}`);
diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts
index 7263554bf..8bee15a41 100644
--- a/packages/compiler/src/backend/llvm/emitter.ts
+++ b/packages/compiler/src/backend/llvm/emitter.ts
@@ -5,7 +5,7 @@ import type { CompilationTiming } from "../../timing.js";
import { relieveHeapPressure } from "../../heap-pressure.js";
import { analyzeFrameEscapes, type FrameEscapes } from "./frame-escapes.js";
import { wasmBoundsChecks } from "./wasm-modules.js";
-import { jsTruthy } from "./jsval.js";
+import { jsImmediates, jsTruthy } from "./jsval.js";
import { JsIcSites } from "../../ir/js-ic.js";
import { type ArraySearchKind } from "./array-search.js";
import { emitStringInputs } from "./string-lifetimes.js";
@@ -376,7 +376,7 @@ import {
type ArenaPlan,
} from "./arenas.js";
import { children, type Node } from "./region-ir.js";
-import { LlDyn, recordDynKeepsIdentity, type DynHost } from "./dyn.js";
+import { LlDyn, recordDynKeepsIdentity, type DynHost, type JsEntryClosure } from "./dyn.js";
import { VirtualBorrows } from "./virtual-borrows.js";
import { planRecordViews, recordViewFieldPtr } from "./record-views.js";
import { LlvmUnsupportedError } from "./unsupported.js";
@@ -1487,6 +1487,10 @@ export class LlEmitter {
private readonly rcHelpersNoinline: boolean;
readonly fnByName = new Map();
+ /** Whether some closure has a jsval body (IrFunction.jsEntry): the
+ * dynamic tier's dynCall and dynInvoke sites then call a function value's
+ * jsval entry when it has one. */
+ jsEntries = false;
/** Empty literals that construct with inline slots (appended-array-literals.ts). */
appendedArrayLiterals: ReadonlySet = new Set();
/** Emitted LLVM text of every IR function body, by function name: the
@@ -2182,7 +2186,10 @@ export class LlEmitter {
this.ffiCallbackAdapters = allocateFfiCallbackAdapters(mod.ffiImports ?? []);
this.ffiHasRetainedCallback = hasRetainedFfiCallback(mod.ffiImports ?? []);
this.ffiHasForeignCallback = hasForeignFfiCallback(mod.ffiImports ?? []);
- for (const fn of mod.functions) this.fnByName.set(fn.name, fn);
+ for (const fn of mod.functions) {
+ this.fnByName.set(fn.name, fn);
+ if (fn.jsEntry !== undefined) this.jsEntries = true;
+ }
this.appendedArrayLiterals = findAppendedArrayLiterals(mod, this.nodeIndex);
for (const entry of mod.ffiImports ?? []) {
this.ffiByName.set(entry.name, entry);
@@ -2398,6 +2405,7 @@ export class LlEmitter {
? undefined
: (table, key, value) => this.journal?.entry(table, key, value),
compactArrayElem: (elem) => this.compactArrayElem(elem),
+ jsEntryClosures: () => this.jsEntryClosures(),
compactRegion: () => this.compactRegion,
sizeType: this.sizeType,
cycleBufOffset: this.cycleBufOffset,
@@ -5066,6 +5074,8 @@ export class LlEmitter {
idx,
(decl) => this.declare(decl),
this.rcMode === undefined,
+ (text) => this.cstr(text),
+ this.shapeHost.recordViews?.partialTargets.has(shapeId) === true,
);
} else {
p = this.B.tmp();
@@ -5570,6 +5580,30 @@ export class LlEmitter {
* the fiber eagerly to its first suspension and returns the promise);
* generator bodies via theirs (which only ALLOCATES the suspended
* fiber and returns the generator object). */
+ private jsEntryClosureList: readonly JsEntryClosure[] | null = null;
+
+ /** The closures with a jsval body (IrFunction.jsEntry), on targets with
+ * jsval words. */
+ jsEntryClosures(): readonly JsEntryClosure[] {
+ if (this.jsEntryClosureList !== null) return this.jsEntryClosureList;
+ const list: JsEntryClosure[] = [];
+ if (this.jsEntries && jsImmediates(this))
+ for (const fn of this.fnByName.values()) {
+ const body = fn.jsEntry === undefined ? undefined : this.fnByName.get(fn.jsEntry);
+ if (body === undefined) continue;
+ list.push({
+ closure: this.callTarget(fn.name),
+ params: fn.params.map((p) => p.type),
+ ret: fn.returnType,
+ body: this.callTarget(body.name),
+ bodyParams: body.params.map((p) => p.type),
+ bodyRet: body.returnType,
+ });
+ }
+ this.jsEntryClosureList = list;
+ return list;
+ }
+
callTarget(fnName: string): string {
const fn = this.fnByName.get(fnName);
if (fn?.generator !== undefined) return mangleGenSpawn(fnName);
@@ -8563,6 +8597,7 @@ for (const op of [
"dyn.promiseDynAdapterHelper",
"dyn.dynFuncThunkHelper",
"dyn.dynFuncBoxHelper",
+ "dyn.jsEntryThunk",
"dyn.dynFuncAdapterHelper",
"dyn.dynToStrHelper",
"dyn.dynIterNHelper",
diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts
index 7f4b7e48a..4272d3a6e 100644
--- a/packages/compiler/src/backend/llvm/expr-dynamic.ts
+++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts
@@ -27,11 +27,13 @@ import {
} from "../../ir/ir.js";
import { BYTES_ELEM_NUM, closureIdentityEqual } from "./common.js";
import { DYN_KIND } from "./dyn.js";
-import { elemAccess, vAdapters } from "./shapes.js";
+import { elemAccess, releaseSym, vAdapters } from "./shapes.js";
+import { JS_BITS } from "./jsval-encoding.js";
import { LlvmUnsupportedError } from "./unsupported.js";
import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js";
import { emitUnionWiden } from "./expr-records.js";
import { emitJsIcLiteralMember, emitJsIcMethod, jsIcMethods } from "./js-ic.js";
+import { jsImmediates } from "./jsval.js";
import { jsIcLiteralKey } from "../../ir/js-ic.js";
import { DYN_BUFFER_METHODS } from "../../ir/runtime-features.js";
@@ -189,6 +191,30 @@ export function emitDynamicExpr(
return out;
}
const args = e.args.map((a) => emitBorrowedInput(host, a));
+ host.declare(`declare ptr @scr_dyn_call(ptr, ptr, ${host.sizeType}, ptr)`);
+ const fast = jsEntrySites(host);
+ if (fast || args.some((a) => a.type.kind === "jsval")) {
+ const name = calleeName();
+ const t = emitJsvalArgumentsCall(
+ host,
+ callee.name,
+ receiver?.name ?? "null",
+ args,
+ fast,
+ (argsPtr) => {
+ const r = B.tmp();
+ B.line(`call void @scr_dyn_this_push_dyn(ptr ${receiver?.name ?? "null"})`);
+ B.line(
+ `${r} = call ptr @scr_dyn_call(ptr ${callee.name}, ptr ${argsPtr}, ${host.sizeType} ${args.length}, ptr ${name})`,
+ );
+ B.line("call void @scr_dyn_this_pop()");
+ return r;
+ },
+ );
+ const out = host.own({ name: t, type: e.type });
+ host.emitPendingCheck();
+ return out;
+ }
let argsPtr = "null";
if (args.length > 0) {
const arr = B.slot();
@@ -202,7 +228,6 @@ export function emitDynamicExpr(
});
argsPtr = arr;
}
- host.declare(`declare ptr @scr_dyn_call(ptr, ptr, ${host.sizeType}, ptr)`);
const t = B.tmp();
const name = calleeName();
B.line(`call void @scr_dyn_this_push_dyn(ptr ${receiver?.name ?? "null"})`);
@@ -246,6 +271,39 @@ export function emitDynamicExpr(
);
}
}
+ // Buffer.prototype method names take their scr_bytes_dyn.c entry, which
+ // runs them on Buffer receivers and dispatches any other receiver.
+ // Each call passes its method name's id slot, so only the first call
+ // looks the name up.
+ const invoke = DYN_BUFFER_METHODS.get(e.method) ?? "scr_dyn_invoke_prepared_id";
+ const discardedPush =
+ discarded && e.method === "push" && invoke === "scr_dyn_invoke_prepared_id";
+ const fast = !discardedPush && invoke === "scr_dyn_invoke_prepared_id" && jsEntrySites(host);
+ if (fast || args.some((a) => a.type.kind === "jsval")) {
+ const name = discardedPush ? host.cstr(e.calleeName) : dynInvokeCalleeName(host, e);
+ if (discardedPush)
+ host.declare(
+ `declare void @scr_dyn_invoke_discard_id(ptr, ptr, ptr, ptr, ptr, ${host.sizeType}, ptr)`,
+ );
+ else host.declare(`declare ptr @${invoke}(ptr, ptr, ptr, ptr, ptr, ${host.sizeType}, ptr)`);
+ const t = emitJsvalArgumentsCall(host, prepared, recv.name, args, fast, (argsPtr) => {
+ const site = `ptr ${recv.name}, ptr ${prepared}, ptr ${host.cstr(e.method)}, ptr ${host.methodIdSlot(e.method)}, ptr ${argsPtr}, ${host.sizeType} ${args.length}, ptr ${name}`;
+ if (discardedPush) {
+ B.line(`call void @scr_dyn_invoke_discard_id(${site})`);
+ return "null";
+ }
+ const r = B.tmp();
+ B.line(`${r} = call ptr @${invoke}(${site})`);
+ return r;
+ });
+ if (discardedPush) {
+ host.emitPendingCheck();
+ return { name: "", type: VOID };
+ }
+ const out = host.own({ name: t, type: e.type });
+ host.emitPendingCheck();
+ return out;
+ }
let argsPtr = "null";
if (args.length > 0) {
const arr = B.slot();
@@ -259,11 +317,6 @@ export function emitDynamicExpr(
});
argsPtr = arr;
}
- // Buffer.prototype method names take their scr_bytes_dyn.c entry, which
- // runs them on Buffer receivers and dispatches any other receiver.
- // Each call passes its method name's id slot, so only the first call
- // looks the name up.
- const invoke = DYN_BUFFER_METHODS.get(e.method) ?? "scr_dyn_invoke_prepared_id";
// A statement-position push drops the new length, so its entry
// need not box it.
if (discarded && e.method === "push" && invoke === "scr_dyn_invoke_prepared_id") {
@@ -277,12 +330,7 @@ export function emitDynamicExpr(
return { name: "", type: VOID };
}
host.declare(`declare ptr @${invoke}(ptr, ptr, ptr, ptr, ptr, ${host.sizeType}, ptr)`);
- let name = host.cstr(e.calleeName);
- if (e.calleeNameValue !== undefined) {
- const value = emitBorrowedInput(host, e.calleeNameValue);
- name = B.tmp();
- B.line(`${name} = getelementptr inbounds %ScrStr, ptr ${value.name}, i32 1`);
- }
+ const name = dynInvokeCalleeName(host, e);
const t = B.tmp();
B.line(
`${t} = call ptr @${invoke}(ptr ${recv.name}, ptr ${prepared}, ptr ${host.cstr(e.method)}, ptr ${host.methodIdSlot(e.method)}, ptr ${argsPtr}, ${host.sizeType} ${args.length}, ptr ${name})`,
@@ -1389,3 +1437,135 @@ function messageListenerArg(e: ExprOf<"dynInvoke">): (IrType & { kind: "func" })
if (listener?.kind !== "dynFrom" || listener.value.type.kind !== "func") return null;
return listener.value.type;
}
+
+/** Whether a dynCall or dynInvoke site of the current function tests its
+ * callee for a jsval entry (emitJsvalArgumentsCall): dynamic-tier code of
+ * a module with closure jsval bodies, on targets with jsval words. */
+function jsEntrySites(host: LlvmEmitterContext): boolean {
+ return (
+ host.jsEntries &&
+ jsImmediates(host) &&
+ host.fnByName.get(host.currentFunctionName)?.tier === "dynamic"
+ );
+}
+
+/** A call of the function value `callee` (null for a site without one)
+ * with `args`, which may include jsval words (ir/jsval.ts). With `fast`,
+ * a callee boxed from a closure with a jsval body (ScrDyn.v.fn.js, +72)
+ * takes the arguments as words, a dyn argument under the cell tag, with
+ * `receiver` bound as `this` around the call. Every other call goes
+ * through `slow`, which takes an array of dyn arguments: the words boxed
+ * for it and released after it. Returns the owned dyn result (null on
+ * the slow path with an exception pending). */
+function emitJsvalArgumentsCall(
+ host: LlvmEmitterContext,
+ callee: string | null,
+ receiver: string,
+ args: readonly LlValue[],
+ fast: boolean,
+ slow: (argsPtr: string) => string,
+): string {
+ const B = host.B;
+ const n = args.length;
+ const result = B.slot();
+ B.entryAllocas.push(`${result} = alloca ptr`);
+ const lSlow = B.newLabel("jse.slow");
+ const lJoin = B.newLabel("jse.join");
+ if (fast && callee !== null) {
+ let words = "null";
+ if (n > 0) {
+ words = B.slot();
+ B.entryAllocas.push(`${words} = alloca [${n} x ptr]`);
+ args.forEach((a, i) => {
+ let word = a.name;
+ if (a.type.kind !== "jsval") {
+ const bits = B.tmp();
+ const tagged = B.tmp();
+ word = B.tmp();
+ B.line(`${bits} = ptrtoint ptr ${a.name} to i64`);
+ B.line(`${tagged} = or i64 ${bits}, ${JS_BITS.cellTag}`);
+ B.line(`${word} = inttoptr i64 ${tagged} to ptr`);
+ }
+ const p = B.tmp();
+ B.line(`${p} = getelementptr inbounds [${n} x ptr], ptr ${words}, i64 0, i64 ${i}`);
+ B.line(`store ptr ${word}, ptr ${p}`);
+ });
+ }
+ const lKind = B.newLabel("jse.kind");
+ const lFn = B.newLabel("jse.fn");
+ const lEntry = B.newLabel("jse.entry");
+ const present = B.tmp();
+ B.line(`${present} = icmp ne ptr ${callee}, null`);
+ B.condBr(present, lKind, lSlow);
+ B.startBlock(lKind);
+ const kindPtr = B.tmp();
+ const kind = B.tmp();
+ const isFn = B.tmp();
+ B.line(`${kindPtr} = getelementptr inbounds i8, ptr ${callee}, i64 8`);
+ B.line(`${kind} = load i32, ptr ${kindPtr}`);
+ B.line(`${isFn} = icmp eq i32 ${kind}, ${DYN_KIND.FUNC}`);
+ B.condBr(isFn, lFn, lSlow);
+ B.startBlock(lFn);
+ const entryPtr = B.tmp();
+ const entry = B.tmp();
+ const hasEntry = B.tmp();
+ B.line(`${entryPtr} = getelementptr inbounds i8, ptr ${callee}, i64 72 ; ->v.fn.js`);
+ B.line(`${entry} = load ptr, ptr ${entryPtr}`);
+ B.line(`${hasEntry} = icmp ne ptr ${entry}, null`);
+ B.condBr(hasEntry, lEntry, lSlow);
+ B.startBlock(lEntry);
+ const cloPtr = B.tmp();
+ const clo = B.tmp();
+ const word = B.tmp();
+ const value = B.tmp();
+ host.declare("declare void @scr_dyn_this_push_dyn(ptr)");
+ host.declare("declare void @scr_dyn_this_pop()");
+ host.declare("declare ptr @scr_js_to_dyn(ptr)");
+ B.line(`${cloPtr} = getelementptr inbounds i8, ptr ${callee}, i64 16 ; ->v.fn.clo`);
+ B.line(`${clo} = load ptr, ptr ${cloPtr}`);
+ B.line(`call void @scr_dyn_this_push_dyn(ptr ${receiver})`);
+ B.line(`${word} = call ptr ${entry}(ptr ${clo}, ptr ${words}, ${host.sizeType} ${n})`);
+ B.line("call void @scr_dyn_this_pop()");
+ B.line(`${value} = call ptr @scr_js_to_dyn(ptr ${word})`);
+ B.line(`store ptr ${value}, ptr ${result}`);
+ B.br(lJoin);
+ } else {
+ B.br(lSlow);
+ }
+ B.startBlock(lSlow);
+ let cells = "null";
+ const boxed: string[] = [];
+ if (n > 0) {
+ cells = B.slot();
+ B.entryAllocas.push(`${cells} = alloca [${n} x ptr]`);
+ args.forEach((a, i) => {
+ let cell = a.name;
+ if (a.type.kind === "jsval") {
+ host.declare("declare ptr @scr_js_to_dyn_borrow(ptr)");
+ cell = B.tmp();
+ B.line(`${cell} = call ptr @scr_js_to_dyn_borrow(ptr ${a.name})`);
+ boxed.push(cell);
+ }
+ const p = B.tmp();
+ B.line(`${p} = getelementptr inbounds [${n} x ptr], ptr ${cells}, i64 0, i64 ${i}`);
+ B.line(`store ptr ${cell}, ptr ${p}`);
+ });
+ }
+ const r = slow(cells);
+ for (const cell of boxed) B.line(`call void ${releaseSym(host.shapeHost, DYN)}(ptr ${cell})`);
+ B.line(`store ptr ${r}, ptr ${result}`);
+ B.br(lJoin);
+ B.startBlock(lJoin);
+ const out = B.tmp();
+ B.line(`${out} = load ptr, ptr ${result}`);
+ return out;
+}
+
+/** A dynInvoke's callee spelling for the runtime's error texts. */
+function dynInvokeCalleeName(host: LlvmEmitterContext, e: ExprOf<"dynInvoke">): string {
+ if (e.calleeNameValue === undefined) return host.cstr(e.calleeName);
+ const value = emitBorrowedInput(host, e.calleeNameValue);
+ const name = host.B.tmp();
+ host.B.line(`${name} = getelementptr inbounds %ScrStr, ptr ${value.name}, i32 1`);
+ return name;
+}
diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts
index 7599a65de..439f49919 100644
--- a/packages/compiler/src/backend/llvm/expr-primitives.ts
+++ b/packages/compiler/src/backend/llvm/expr-primitives.ts
@@ -10,6 +10,7 @@ import { matchStringSelfConcat, undefinedArmTag } from "../../ir/analysis.js";
import { isRefCounted, type IrExpr, type IrStrCmpOp, type IrType } from "../../ir/ir.js";
import { mangleRecordClone, mangleRecordNew } from "../mangle.js";
import { arrNewCall, elemAccess } from "./shapes.js";
+import { recordViewTestLines } from "./record-views.js";
import { LlvmUnsupportedError } from "./unsupported.js";
import { offsetIndex } from "./expr-bytes.js";
import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js";
@@ -1473,16 +1474,70 @@ export function emitRecordExpr(
return host.emitRecordKeyGet(e);
case "recordHas": {
// Own-property presence: only an undefined-armed union slot can
- // lack its property, by holding the ABSENT state.
+ // lack its property, by holding the ABSENT state, and a PARTIAL
+ // record view (record-views.ts) can lack a field its viewed record
+ // does not declare.
+ const fieldType = host.recordsById
+ .get(e.shapeId)
+ ?.fields.find((f) => f.name === e.field)?.type;
const obj = host.emitReadReceiver(e.obj);
+ if (host.shapeHost.recordViews?.partialMissing(e.shapeId, e.field) !== true) {
+ const { ptr, type, inline } = host.recordFieldPtr(obj.name, e.shapeId, e.field);
+ if (type.kind !== "union" || undefinedArmTag(type, host.unionsById) < 0)
+ return { name: "true", type: e.type };
+ const v = host.loadField(ptr, type, inline);
+ const absent = host.fieldAbsentTest(v, type.unionId);
+ const t = B.tmp();
+ B.line(`${t} = xor i1 ${absent}, true`);
+ return { name: t, type: e.type };
+ }
+ // A field some partial view of this shape lacks: ask the view first.
+ // Only a present field's slot may be looked up — a partial view's
+ // absent field answers no slot at all (scr_record_view_slot).
+ const position = host.recordsById.get(e.shapeId)!.fields.findIndex((f) => f.name === e.field);
+ host.declare(`declare ptr @scr_record_view_slot(ptr, ${host.sizeType})`);
+ const isView = B.tmp();
+ const rhFrom = B.currentLabel();
+ const view = B.newLabel("rh.view");
+ const done = B.newLabel("rh.done");
+ for (const line of recordViewTestLines(host.sizeType, obj.name, isView))
+ B.line(line.trimStart());
+ // Not a view: every field of the target has its slot.
+ B.condBr(isView, view, done, `, !prof !{!"branch_weights", i32 1, i32 1000}`);
+ B.startBlock(view);
+ const slot = B.tmp();
+ B.line(
+ `${slot} = call ptr @scr_record_view_slot(ptr ${obj.name}, ${host.sizeType} ${position})`,
+ );
+ const hasSlot = B.tmp();
+ B.line(`${hasSlot} = icmp ne ptr ${slot}, null`);
+ B.br(done);
+ B.startBlock(done);
+ const hasView = B.tmp();
+ B.line(`${hasView} = phi i1 [ true, %${rhFrom} ], [ ${hasSlot}, %${view} ]`);
+ if (fieldType === undefined || fieldType.kind !== "union")
+ return { name: hasView, type: e.type };
+ const unionId = fieldType.unionId;
+ if (undefinedArmTag(fieldType, host.unionsById) < 0) return { name: hasView, type: e.type };
+ // The field is also undefined-armed: a present slot still answers by
+ // its ABSENT state, a field the view lacks by the view.
+ const check = B.newLabel("rh.present");
+ const join = B.newLabel("rh.join");
+ const from = B.currentLabel();
+ B.condBr(hasView, check, join);
+ B.startBlock(check);
const { ptr, type, inline } = host.recordFieldPtr(obj.name, e.shapeId, e.field);
- if (type.kind !== "union" || undefinedArmTag(type, host.unionsById) < 0)
- return { name: "true", type: e.type };
const v = host.loadField(ptr, type, inline);
- const absent = host.fieldAbsentTest(v, type.unionId);
- const t = B.tmp();
- B.line(`${t} = xor i1 ${absent}, true`);
- return { name: t, type: e.type };
+ const absent = host.fieldAbsentTest(v, unionId);
+ const present = B.tmp();
+ B.line(`${present} = xor i1 ${absent}, true`);
+ // The field load can end in a later block (a view's slot read).
+ const checked = B.currentLabel();
+ B.br(join);
+ B.startBlock(join);
+ const answer = B.tmp();
+ B.line(`${answer} = phi i1 [ false, %${from} ], [ ${present}, %${checked} ]`);
+ return { name: answer, type: e.type };
}
case "fieldAbsent":
return { name: host.absentInstanceRef(e.unionId), type: e.type };
diff --git a/packages/compiler/src/backend/llvm/js-ic.ts b/packages/compiler/src/backend/llvm/js-ic.ts
index c9786c9f2..8b7ed10f9 100644
--- a/packages/compiler/src/backend/llvm/js-ic.ts
+++ b/packages/compiler/src/backend/llvm/js-ic.ts
@@ -57,7 +57,7 @@ export function jsIcMethods(host: Host): boolean {
return (
jsImmediates(host) &&
host.currentTier === "dynamic" &&
- jsIcMethodSites(host.currentFunctionName)
+ jsIcMethodSites(host.currentFunctionName, host.loopDepth > 0)
);
}
diff --git a/packages/compiler/src/backend/llvm/record-views.test.ts b/packages/compiler/src/backend/llvm/record-views.test.ts
index 02a82d873..0cfec7b1e 100644
--- a/packages/compiler/src/backend/llvm/record-views.test.ts
+++ b/packages/compiler/src/backend/llvm/record-views.test.ts
@@ -3,8 +3,11 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile, deserializeModule } from "../../index.js";
-import type { IrModule } from "../../ir/ir.js";
+import { DYN } from "../../ir/ir.js";
+import type { IrExpr, IrModule } from "../../ir/ir.js";
import { computeTraced } from "../cycle-analysis.js";
+import { everyStmtList } from "../../ir/traverse.js";
+import { emitLlvmModule } from "./emitter.js";
import { planRecordViews } from "./record-views.js";
/** The views planned for `source`'s module, as target field names → source
@@ -128,3 +131,98 @@ ${conversions}
Object.freeze(wide);`);
expect(views).toEqual(new Map());
});
+
+/** `source`'s module shaped the way the frontend lowers a partial
+ * assertion (C2): the checked cast of the source record literal carries
+ * `partialView` over a `dynFrom{liveRef}` of that literal. */
+async function partialModule(
+ source: string,
+ sourceFields: string,
+ targetFields: string,
+): Promise {
+ const dir = await mkdtemp(join(tmpdir(), "scriptc-record-views-"));
+ try {
+ const entry = join(dir, "main.ts"),
+ outPath = join(dir, "main.ir.json");
+ await writeFile(entry, source);
+ const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
+ if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n"));
+ const mod: IrModule = deserializeModule(await readFile(outPath, "utf8"));
+ const idOf = (fields: string): string =>
+ (mod.records ?? []).find((shape) => shape.fields.map((f) => f.name).join(",") === fields)!.id;
+ const exprs: IrExpr[] = [];
+ for (const fn of mod.functions)
+ everyStmtList(fn.body, {
+ stmt: () => true,
+ expr: (e) => {
+ exprs.push(e);
+ return true;
+ },
+ });
+ const lit = exprs.find(
+ (e) =>
+ e.kind === "recordLit" && e.type.kind === "record" && e.type.shapeId === idOf(sourceFields),
+ );
+ const check = exprs.find(
+ (e) =>
+ e.kind === "dynCheck" && e.type.kind === "record" && e.type.shapeId === idOf(targetFields),
+ );
+ if (!lit || !check || check.kind !== "dynCheck") throw new Error("shapes not found");
+ check.partialView = true;
+ check.value = { kind: "dynFrom", liveRef: true, value: lit, type: DYN, loc: check.loc };
+ return mod;
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+}
+
+test("a partial assertion plans a partial view with an absent-field sentinel", async () => {
+ const mod = await partialModule(
+ `
+type Wide = { a: number; b: string };
+type Narrow = { a: number; z: boolean };
+const wide: Wide = { a: 1, b: "x" };
+const o: unknown = wide;
+const v = o as Narrow;
+console.log(v.a);
+`,
+ "a,b",
+ "a,z",
+ );
+ const idOf = (fields: string): string =>
+ (mod.records ?? []).find((shape) => shape.fields.map((f) => f.name).join(",") === fields)!.id;
+ const target = idOf("a,z");
+ const source = idOf("a,b");
+ const views = planRecordViews(mod, { traced: () => false, excluded: () => false });
+ expect(views.targets.has(target)).toBe(true);
+ expect(views.sources(target)).toEqual([source]);
+ expect(views.partial(target, source)).toBe(true);
+ expect(views.partialTargets.has(target)).toBe(true);
+ expect(views.partialMissing(target, "z")).toBe(true);
+ expect(views.partialMissing(target, "a")).toBe(false);
+ // The offset table marks the target field the source does not declare
+ // with the all-ones sentinel.
+ const llvm = emitLlvmModule(mod);
+ expect(llvm).toMatch(/@sc_rv_\d+_0 = internal constant \[2 x i64\] \[i64 ptrtoint .*, i64 -1\]/);
+});
+
+test("a full source keeps an ordinary view, not a partial one", async () => {
+ const mod = await partialModule(
+ `
+type Wide = { a: number; b: string };
+type Narrow = { a: number };
+const wide: Wide = { a: 1, b: "x" };
+const o: unknown = wide;
+const v = o as Narrow;
+console.log(v.a);
+`,
+ "a,b",
+ "a",
+ );
+ const idOf = (fields: string): string =>
+ (mod.records ?? []).find((shape) => shape.fields.map((f) => f.name).join(",") === fields)!.id;
+ const views = planRecordViews(mod, { traced: () => false, excluded: () => false });
+ expect(views.sources(idOf("a"))).toEqual([idOf("a,b")]);
+ expect(views.partial(idOf("a"), idOf("a,b"))).toBe(false);
+ expect(views.partialTargets.size).toBe(0);
+});
diff --git a/packages/compiler/src/backend/llvm/record-views.ts b/packages/compiler/src/backend/llvm/record-views.ts
index 0f4d18370..5c5d161e2 100644
--- a/packages/compiler/src/backend/llvm/record-views.ts
+++ b/packages/compiler/src/backend/llvm/record-views.ts
@@ -15,7 +15,15 @@
* checked conversion can produce (the closure of dynCheck targets), and
* some other record type whose values reach untyped code as live capsules
* (the closure of live dynFrom types) has all of B's fields. A program
- * without checked conversions has none, so its code is unchanged. */
+ * without checked conversions has none, so its code is unchanged.
+ *
+ * A PARTIAL view (`dynCheck.partialView`) pairs a record literal's shape A
+ * with the wider record B it was asserted to, though A lacks some of B's
+ * fields. The view is a B value whose record-source entry names A, with
+ * the sentinel SIZE_MAX for every B field A does not declare: a slot lookup
+ * answers NULL there (scr_record_view_slot), so a presence test answers false
+ * and an address lookup stops the program. The frontend's checked reads of
+ * those fields throw their TypeError first. */
import {
isDirentShape,
KEY_ORDER_FIELD,
@@ -34,9 +42,28 @@ export interface RecordViews {
readonly targets: ReadonlySet;
/** The record shapes whose capsules convert to a view of `target`. */
sources(target: string): readonly string[];
+ /** Whether `target` may be a PARTIAL view of `source`: some of its
+ * fields are absent, because `source` does not declare them (a partial
+ * record literal asserted to a wider type). */
+ partial(target: string, source: string): boolean;
+ /** Shape ids a partial record assertion may produce a view of. */
+ readonly partialTargets: ReadonlySet;
+ /** Whether some partial source of view target `target` lacks `field`. */
+ partialMissing(target: string, field: string): boolean;
}
-export const NO_RECORD_VIEWS: RecordViews = { targets: new Set(), sources: () => [] };
+/** The trap of an internal reader of a field a partial view's viewed record
+ * lacks (a width copy, say): a source-level read of the field threw first. */
+export const PARTIAL_VIEW_ABSENT =
+ "scriptc: a value asserted as a wider record has no property at this field (copy it explicitly)\n";
+
+export const NO_RECORD_VIEWS: RecordViews = {
+ targets: new Set(),
+ sources: () => [],
+ partial: () => false,
+ partialTargets: new Set(),
+ partialMissing: () => false,
+};
interface ViewOptions {
readonly traced: (shapeId: string) => boolean;
@@ -156,16 +183,30 @@ export function planRecordViews(mod: IrModule, options: ViewOptions): RecordView
};
const checked: IrType[] = [];
const boxed: IrType[] = [];
+ // (target B, source A) of a partial record assertion's checked cast.
+ const partialPairs: { target: string; source: string }[] = [];
for (const fn of mod.functions)
everyStmtList(fn.body, {
stmt: () => true,
expr: (e) => {
- if (e.kind === "dynCheck") checked.push(e.type);
- else if (e.kind === "dynFrom" && e.liveRef === true) boxed.push(e.value.type);
+ if (e.kind === "dynCheck") {
+ checked.push(e.type);
+ if (
+ e.partialView === true &&
+ e.type.kind === "record" &&
+ e.value.kind === "dynFrom" &&
+ e.value.value.type.kind === "record"
+ )
+ partialPairs.push({
+ target: e.type.shapeId,
+ source: e.value.value.type.shapeId,
+ });
+ } else if (e.kind === "dynFrom" && e.liveRef === true) boxed.push(e.value.type);
return true;
},
});
- if (checked.length === 0 || boxed.length === 0) return NO_RECORD_VIEWS;
+ if ((checked.length === 0 || boxed.length === 0) && partialPairs.length === 0)
+ return NO_RECORD_VIEWS;
const reachable = closure(checked);
const live = closure(boxed);
if (reachable.size === 0) return NO_RECORD_VIEWS;
@@ -183,6 +224,24 @@ export function planRecordViews(mod: IrModule, options: ViewOptions): RecordView
}
if (found.length > 0) sources.set(id, found);
}
+ // Partial pairs join the plan before the cycle analysis, which owns their
+ // view's capsule reference like any other. A pair whose source has every
+ // target field is an ordinary full view and is already planned above.
+ const partials = new Map>();
+ for (const pair of partialPairs) {
+ const target = shapes.get(pair.target);
+ const source = shapes.get(pair.source);
+ if (!target || !source || !plainShape(target) || !plainShape(source)) continue;
+ if (target.freezable === true || source.freezable === true) continue;
+ if (options.traced(pair.target) || options.excluded(pair.target)) continue;
+ if (covers(source, target)) continue;
+ const list = sources.get(pair.target) ?? [];
+ if (!list.includes(pair.source)) list.push(pair.source);
+ sources.set(pair.target, list);
+ const set = partials.get(pair.target);
+ if (set) set.add(pair.source);
+ else partials.set(pair.target, new Set([pair.source]));
+ }
// A view's record-source entry owns the source's capsule, an edge the
// cycle analysis does not see. A view stays off every cycle only when its
// source cannot hold a value of the target type, such as the view itself,
@@ -200,11 +259,34 @@ export function planRecordViews(mod: IrModule, options: ViewOptions): RecordView
changed = true;
if (kept.length > 0) sources.set(id, kept);
else sources.delete(id);
+ const partial = partials.get(id);
+ if (!partial) continue;
+ for (const source of [...partial]) if (!kept.includes(source)) partial.delete(source);
+ if (partial.size === 0) partials.delete(id);
}
}
if (sources.size === 0) return NO_RECORD_VIEWS;
const targets = new Set(sources.keys());
- return { targets, sources: (target) => sources.get(target) ?? [] };
+ // The target fields some partial source lacks: their presence test is a
+ // runtime view probe, and their slot lookup traps.
+ const missing = new Map>();
+ for (const [id, list] of partials) {
+ const absent = new Set();
+ const target = shapes.get(id);
+ for (const source of list) {
+ const declared = new Set((shapes.get(source)?.fields ?? []).map((field) => field.name));
+ for (const field of target?.fields ?? [])
+ if (!declared.has(field.name)) absent.add(field.name);
+ }
+ missing.set(id, absent);
+ }
+ return {
+ targets,
+ sources: (target) => sources.get(target) ?? [],
+ partial: (target, source) => partials.get(target)?.has(source) === true,
+ partialTargets: new Set(partials.keys()),
+ partialMissing: (target, field) => missing.get(target)?.has(field) === true,
+ };
}
/** Lines setting `dest` (i1) to whether the record `obj` of a view target
@@ -229,7 +311,12 @@ export function recordViewTestLines(sizeType: "i32" | "i64", obj: string, dest:
* `obj` of view-target shape `struct`: the slot itself, or the viewed
* record's slot when `obj` is a view. Optimized builds test the mark
* inline and look the slot up out of line; unoptimized builds, which give
- * every value a frame slot, make the address one call. */
+ * every value a frame slot, make the address one call.
+ *
+ * `partial` names a shape a PARTIAL view may present: its viewed record can
+ * lack the field, and the slot lookup answers NULL there (scr_record_view_slot),
+ * so the address stops with `scr_trap` and `cstr(PARTIAL_VIEW_ABSENT)`. The
+ * message is the program's own constant; the runtime carries none. */
export function recordViewFieldPtr(
B: BlockBuilder,
sizeType: "i32" | "i64",
@@ -239,13 +326,25 @@ export function recordViewFieldPtr(
position: number,
declare: (decl: string) => void,
outOfLine: boolean,
+ cstr: (text: string) => string,
+ partial = false,
): string {
if (outOfLine) {
- declare(`declare ptr @scr_record_view_field(ptr, ${sizeType}, ${sizeType})`);
+ const field = "scr_record_view_field";
+ declare(`declare ptr @${field}(ptr, ${sizeType}, ${sizeType})`);
const ptr = B.tmp();
B.line(
- `${ptr} = call ptr @scr_record_view_field(ptr ${obj}, ${sizeType} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${slotIndex}) to ${sizeType}), ${sizeType} ${position})`,
+ `${ptr} = call ptr @${field}(ptr ${obj}, ${sizeType} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${slotIndex}) to ${sizeType}), ${sizeType} ${position})`,
);
+ if (!partial) return ptr;
+ const absent = B.tmp();
+ B.line(`${absent} = icmp eq ptr ${ptr}, null`);
+ const trap = B.newLabel("rv.absent");
+ const join = B.newLabel("rv.present");
+ B.condBr(absent, trap, join, `, !prof !{!"branch_weights", i32 1, i32 1000}`);
+ B.startBlock(trap);
+ absentTrapLines(B, cstr, declare);
+ B.startBlock(join);
return ptr;
}
const own = B.tmp();
@@ -261,9 +360,34 @@ export function recordViewFieldPtr(
declare(`declare ptr @scr_record_view_slot(ptr, ${sizeType})`);
const viewed = B.tmp();
B.line(`${viewed} = call ptr @scr_record_view_slot(ptr ${obj}, ${sizeType} ${position})`);
+ let viewFrom = view;
+ if (partial) {
+ const absent = B.tmp();
+ B.line(`${absent} = icmp eq ptr ${viewed}, null`);
+ const trap = B.newLabel("rv.absent");
+ const present = B.newLabel("rv.present");
+ B.condBr(absent, trap, present);
+ B.startBlock(trap);
+ absentTrapLines(B, cstr, declare);
+ viewFrom = present;
+ B.startBlock(present);
+ }
B.br(join);
B.startBlock(join);
const ptr = B.tmp();
- B.line(`${ptr} = phi ptr [ ${own}, %${from} ], [ ${viewed}, %${view} ]`);
+ B.line(`${ptr} = phi ptr [ ${own}, %${from} ], [ ${viewed}, %${viewFrom} ]`);
return ptr;
}
+
+/** The block of an address lookup of a field a partial view lacks: the
+ * program stops with its own message (PARTIAL_VIEW_ABSENT) and does not
+ * return. */
+export function absentTrapLines(
+ B: BlockBuilder,
+ cstr: (text: string) => string,
+ declare: (decl: string) => void,
+): void {
+ declare(`declare void @scr_trap(ptr)`);
+ B.line(`call void @scr_trap(ptr ${cstr(PARTIAL_VIEW_ABSENT)})`);
+ B.terminate("unreachable");
+}
diff --git a/packages/compiler/src/backend/llvm/shapes.test.ts b/packages/compiler/src/backend/llvm/shapes.test.ts
index 4503b5437..3b8e50dba 100644
--- a/packages/compiler/src/backend/llvm/shapes.test.ts
+++ b/packages/compiler/src/backend/llvm/shapes.test.ts
@@ -28,6 +28,10 @@ function declarationHost(
declare(decl) {
declarations.push(decl);
},
+ cstr(text) {
+ declarations.push(`@cstr ${text}`);
+ return "@sc_cstr";
+ },
needOom() {},
mapOps: (operands) => (operands.every((op) => op === "null") ? "null" : "@sc_map_ops"),
sizeType: pointerBits === 32 ? "i32" : "i64",
diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts
index 3963e0409..cb1f4d800 100644
--- a/packages/compiler/src/backend/llvm/shapes.ts
+++ b/packages/compiler/src/backend/llvm/shapes.ts
@@ -73,7 +73,7 @@ import {
disposeFieldLines,
plainRefType,
} from "./arenas.js";
-import { recordViewTestLines, type RecordViews } from "./record-views.js";
+import { PARTIAL_VIEW_ABSENT, recordViewTestLines, type RecordViews } from "./record-views.js";
import { recordMayKeepSource } from "./record-sources.js";
import { LlvmUnsupportedError } from "./unsupported.js";
@@ -109,6 +109,9 @@ export interface ShapeHost {
/** Byte offset from an object pointer back to ScrCycHdr.buf. */
readonly cycleBufOffset: number;
declare(decl: string): void;
+ /** The module constant for a string (LlEmitter.cstr), for a trap message
+ * an emitted accessor passes to the runtime. */
+ cstr(text: string): string;
/** Inline the small-object allocator's fast paths (llvm/alloc.ts). */
readonly inlineAlloc?: boolean;
/** Record and class fields that store a scalar union's tag and bits
@@ -2451,15 +2454,29 @@ export function emitRecordShapes(
const inline = host.inlineUnions.record(field.type);
const fieldTy = llFieldType(field.type, inline);
if (host.recordViews?.targets.has(shape.id) === true) {
- // A view's fields live in the viewed record (record-views.ts).
+ // A view's fields live in the viewed record (record-views.ts); a
+ // shape a partial view may present answers NULL for a field the
+ // viewed record lacks, so the copy stops with the program's own
+ // message (a source-level read threw first).
+ const partial = host.recordViews.partialTargets.has(shape.id);
host.declare(`declare ptr @scr_record_view_slot(ptr, ${host.sizeType})`);
+ const fromView = partial
+ ? [
+ ` %sn${i} = icmp eq ptr %sw${i}, null`,
+ ` br i1 %sn${i}, label %sv${i}.absent, label %sv${i}.join`,
+ `sv${i}.absent:`,
+ ` call void @scr_trap(ptr ${host.cstr(PARTIAL_VIEW_ABSENT)})`,
+ ` unreachable`,
+ ]
+ : [` br label %sv${i}.join`];
+ if (partial) host.declare(`declare void @scr_trap(ptr)`);
clone.push(
` %so${i} = getelementptr inbounds %${struct}, ptr %src, i64 0, i32 ${index}`,
...recordViewTestLines(host.sizeType, "%src", `%sr${i}v`),
` br i1 %sr${i}v, label %sv${i}.view, label %sv${i}.join`,
`sv${i}.view:`,
` %sw${i} = call ptr @scr_record_view_slot(ptr %src, ${host.sizeType} ${i})`,
- ` br label %sv${i}.join`,
+ ...fromView,
`sv${i}.join:`,
` %sp${i} = phi ptr [ %so${i}, %${i === 0 ? "entry" : `sv${i - 1}.join`} ], [ %sw${i}, %sv${i}.view ]`,
` %sv${i} = load ${fieldTy}, ptr %sp${i}`,
diff --git a/packages/compiler/src/backend/llvm/unwind-abi.ts b/packages/compiler/src/backend/llvm/unwind-abi.ts
index 1e067cb5d..91132cbe2 100644
--- a/packages/compiler/src/backend/llvm/unwind-abi.ts
+++ b/packages/compiler/src/backend/llvm/unwind-abi.ts
@@ -72,9 +72,9 @@ function recursiveFunctions(edges: ReadonlyMap): Set<
* `call` from emitted code that can lower it as an `invoke`: it is not
* async or a generator, not a constructor, not a method a virtual call can
* reach or one a vtable holds (an overridden method), not a closure
- * target, not the entry or a library export, not named by any node other
- * than a `call`'s callee, and not called from an async or generator body
- * (coroutine bodies keep plain calls).
+ * target or a closure's jsval body, not the entry or a library export,
+ * not named by any node other than a `call`'s callee, and not called from
+ * an async or generator body (coroutine bodies keep plain calls).
*
* A native unwind costs about 0.05 µs a raise and 10 to 25 ns a frame on
* x86-64 (scr_unwind.c's cached plans) and about 2 µs a raise and 0.1 µs a
@@ -103,6 +103,9 @@ export function unwindAbiFunctions(
blocked.add("%error.name.read");
blocked.add("%error.message.read");
for (const entry of mod.lib?.exports ?? []) blocked.add(entry.fnName);
+ // A closure's jsval body is called through the function value's jsval
+ // entry (IrFunction.jsEntry), which runtime calls reach.
+ for (const fn of mod.functions) if (fn.jsEntry !== undefined) blocked.add(fn.jsEntry);
// Methods a virtual call can reach, and the overridden methods of class
// hierarchies (whose vtables list them), are called through pointers; the
// others are called directly, like functions. A virtual call reaches its listed
diff --git a/packages/compiler/src/backend/llvm/walkers.ts b/packages/compiler/src/backend/llvm/walkers.ts
index f475f073b..3f79e6541 100644
--- a/packages/compiler/src/backend/llvm/walkers.ts
+++ b/packages/compiler/src/backend/llvm/walkers.ts
@@ -293,6 +293,8 @@ export class LlWalkers {
index - base,
(decl) => this.host.declare(decl),
this.host.rcMode === undefined,
+ (text) => this.host.cstr(text),
+ this.host.recordViews?.partialTargets.has(shapeId) === true,
);
} else {
p = B.tmp();
diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts
index 28d03088a..1b078eecc 100644
--- a/packages/compiler/src/backend/native-link-info-core.ts
+++ b/packages/compiler/src/backend/native-link-info-core.ts
@@ -31,6 +31,10 @@ export interface NativeLinkFeatures {
/** The program assigns process.emitWarning: links the
* SCR_WARNING_REPLACE variant of the dyn-async unit. */
warningReplace?: boolean;
+ /** The program asserts a record literal to a wider record type (a partial
+ * record view): links the SCR_RECORD_VIEW_PARTIAL variant of scr_json.c.
+ * Absent for every program without one, so their objects stay identical. */
+ recordViewPartial?: boolean;
/** The program uses process.stdin.setEncoding, string 'data' listeners
* or stdio 'resize' events: links the SCR_STDIO_EXTENDED events unit. */
stdioExtended?: boolean;
diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts
index d3ed79793..16bd108c9 100644
--- a/packages/compiler/src/backend/native/contracts.ts
+++ b/packages/compiler/src/backend/native/contracts.ts
@@ -170,6 +170,9 @@ export interface CcOptions {
wasm?: boolean;
/** SCR_WARNING_REPLACE: process.emitWarning replacement support. */
warningReplace?: boolean;
+ /** SCR_RECORD_VIEW_PARTIAL: the scr_json.c build whose view slot answers
+ * NULL for a field a partial record view does not have. */
+ recordViewPartial?: boolean;
/** SCR_STDIO_EXTENDED: stdin encoding, string data listeners, resize. */
stdioExtended?: boolean;
/** SCR_STREAM_WRITE_ERRORS: the stream unit publishes the error each
diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts
index 2f6387875..82a5022c6 100644
--- a/packages/compiler/src/backend/native/executable.ts
+++ b/packages/compiler/src/backend/native/executable.ts
@@ -445,6 +445,9 @@ const RUNTIME_DEFINES: readonly {
// programs with dynamic-tier code select it.
{ define: "SCR_JS_TIER", when: (opts) => opts.dynTier === true },
{ define: "SCR_WARNING_REPLACE", when: (opts) => opts.warningReplace },
+ // scr_json.c's view slot answers NULL for a field a partial record view
+ // does not have; only a program with a partial assertion selects it.
+ { define: "SCR_RECORD_VIEW_PARTIAL", when: (opts) => opts.recordViewPartial },
{ define: "SCR_STDIO_EXTENDED", when: (opts) => opts.stdioExtended },
// scr_stream.c's write-callback error publication, which the compiler's
// error-first write adapter reads.
diff --git a/packages/compiler/src/backend/runtime-abi.ts b/packages/compiler/src/backend/runtime-abi.ts
index 985ba6203..63cab76f2 100644
--- a/packages/compiler/src/backend/runtime-abi.ts
+++ b/packages/compiler/src/backend/runtime-abi.ts
@@ -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 = 47 as const;
-export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v47" as const;
+export const RUNTIME_ABI_VERSION = 48 as const;
+export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v48" as const;
/** Object consumption is useful today, but the complete scr_* surface may
* still change before 1.0. The versioned marker prevents accidental mixing;
diff --git a/packages/compiler/src/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts
index 3bafda8c9..41bfb1236 100644
--- a/packages/compiler/src/backend/runtime-pack-core.ts
+++ b/packages/compiler/src/backend/runtime-pack-core.ts
@@ -109,6 +109,7 @@ export interface RuntimeFeatureSet extends NativeLinkFeatures {
builtinValues: boolean;
computedRequire: boolean;
warningReplace: boolean;
+ recordViewPartial: boolean;
stdioExtended: boolean;
/** The program's write completion callbacks take the error argument:
* the SCR_STREAM_WRITE_ERRORS build of the stream unit. */
@@ -331,6 +332,7 @@ export function effectiveRuntimeFeatures(
builtinValues: features.builtinValues === true,
computedRequire: features.computedRequire === true,
warningReplace: features.warningReplace === true,
+ recordViewPartial: features.recordViewPartial === true,
stdioExtended: features.stdioExtended === true,
streamWriteErrors: features.streamWriteErrors === true,
stdioWriteSlot: features.stdioWriteSlot === true,
diff --git a/packages/compiler/src/backend/runtime-pack-targets.test.ts b/packages/compiler/src/backend/runtime-pack-targets.test.ts
index 510e046b6..c19846b8c 100644
--- a/packages/compiler/src/backend/runtime-pack-targets.test.ts
+++ b/packages/compiler/src/backend/runtime-pack-targets.test.ts
@@ -52,7 +52,7 @@ async function fixture(target: NativeTargetSpec, workers = false) {
object_format: target.objectFormat,
minimum_os: target.minimumOs,
},
- runtime_abi: { version: 47, marker: "scr_runtime_abi_v47" },
+ runtime_abi: { version: 48, marker: "scr_runtime_abi_v48" },
compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple },
macros: { executable: [], excluded: ["SCR_LIB"], sanitizer: "external-toolchain-required" },
flavors: {
diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts
index ee45ef84f..1fac50d17 100644
--- a/packages/compiler/src/backend/runtime-pack.test.ts
+++ b/packages/compiler/src/backend/runtime-pack.test.ts
@@ -136,7 +136,7 @@ async function fixture(target: NativeTargetSpec = MACOS_ARM64_TARGET) {
object_format: target.objectFormat,
minimum_os: target.minimumOs,
},
- runtime_abi: { version: 47, marker: "scr_runtime_abi_v47" },
+ runtime_abi: { version: 48, marker: "scr_runtime_abi_v48" },
compiler: {
command: "clang",
identity: "fixture clang",
diff --git a/packages/compiler/src/executable/executable-cache.test.ts b/packages/compiler/src/executable/executable-cache.test.ts
index 9c81ccc19..87103d9a3 100644
--- a/packages/compiler/src/executable/executable-cache.test.ts
+++ b/packages/compiler/src/executable/executable-cache.test.ts
@@ -221,7 +221,7 @@ test("early executable cache misses on source and resolution changes", async ()
expect(await readEarlyExecutableCache(f.root, f.options)).toBeNull();
});
-test("early executable keys isolate compile modes, paths, implementation, checker, function layouts, and FFI manifests", async () => {
+test("early executable keys isolate compile modes, paths, implementation, checker, function layouts, shape inference, and FFI manifests", async () => {
const f = await fixture();
const tracker = new FrontendInputTracker();
tracker.run(() => trackedReadFile(f.source));
@@ -246,6 +246,8 @@ test("early executable keys isolate compile modes, paths, implementation, checke
{ ...f.options, profile: "profile-use:digest" },
{ ...f.options, functionLayout: "layout-digest" },
{ ...f.options, checker: "tsc-ts-executable-identity" },
+ { ...f.options, shapeOverlay: true },
+ { ...f.options, jsInference: true },
{ ...f.options, npmStatic: "auto" },
{ ...f.options, npmStatic: ["commander"] },
{ ...f.options, outPath: join(f.options.outDir, "other") },
diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts
index 604af05b9..c4ed94fa5 100644
--- a/packages/compiler/src/executable/executable-cache.ts
+++ b/packages/compiler/src/executable/executable-cache.ts
@@ -64,6 +64,7 @@ export interface EarlyExecutableNativeFeatures {
eventTarget?: boolean;
wasm?: boolean;
warningReplace?: boolean;
+ recordViewPartial?: boolean;
stdioExtended?: boolean;
/** Error-taking write completion callbacks: SCR_STREAM_WRITE_ERRORS. */
streamWriteErrors?: boolean;
@@ -206,6 +207,9 @@ export interface EarlyExecutableCacheOptions {
/** Omitted unless shape inference's overlay compiles the packages
* (frontend/shape-inference). */
shapeOverlay?: true;
+ /** Omitted unless shape inference types the program's own JavaScript
+ * (SCRIPTC_INFER_JS=1, frontend/js-inference.ts). */
+ jsInference?: true;
/** Raw manifest identity: path and bytes. Native archives remain under the
* stricter native cache's independent dependency validation. */
ffiProfile: { path: string; bytes: Uint8Array } | null;
@@ -364,6 +368,8 @@ function cacheKey(options: EarlyExecutableCacheOptions): string {
...(options.youngNursery ? ["young-nursery"] : []),
...(options.nodeIslands !== undefined ? [`node-islands:${options.nodeIslands}`] : []),
...(options.heapCensus ? ["heap-census"] : []),
+ ...(options.shapeOverlay ? ["shape-overlay"] : []),
+ ...(options.jsInference ? ["js-inference"] : []),
options.npmStatic === null
? ""
: options.npmStatic === "auto"
@@ -415,6 +421,8 @@ function routeKey(options: Omit();
private readonly dispatches = new Map();
+ /** Per receiver local of a call rewritten for a getter-only name, the
+ * classes whose instances it already reads the getter on. A later wave
+ * that reaches another class with the getter rewrites the fallback call
+ * for that class. */
+ private readonly getterReceivers = new Map>();
private readonly properties = new Map();
private readonly computed = new Map<
string,
@@ -1897,6 +1905,108 @@ export class ClassDynamicDispatch {
byMethod.set(method, matching);
return matching;
};
+ // A name some class declares as a getter, and no class as a method: on
+ // an instance of such a class the call reads the member (running the
+ // getter) before it evaluates the arguments, as JavaScript reads the
+ // callee first, and then calls what it read. Any other receiver keeps
+ // the runtime's method dispatch, which answers builtin methods of the
+ // same name (`n.toFixed(2)` while a class declares `get toFixed()`).
+ // The runtime reads fields and other own properties of class instances
+ // that early itself (scr_dyn_prepare_method).
+ const getterCapsules = (method: string): string[] => {
+ const keys = new Set();
+ for (const { info, capsule } of this.propertyReceivers(lowerer, true))
+ if (findMethodOn(lowerer, info, `get:${method}`))
+ keys.add(typeKey({ kind: "object", className: capsule.def.name }));
+ return [...keys];
+ };
+ const getterCall = (
+ fn: IrFunction,
+ invoke: IrExpr & { kind: "dynInvoke" },
+ capsules: readonly string[],
+ covered: ReadonlySet | undefined,
+ ): IrExpr => {
+ const loc = invoke.loc;
+ const receiverLocal = {
+ id: `${GETTER_RECEIVER}${this.getterReceivers.size}`,
+ name: "receiver",
+ type: DYN,
+ mutable: false,
+ };
+ fn.locals.push(receiverLocal);
+ const keys = new Set(capsules);
+ if (covered) for (const key of covered) keys.add(key);
+ this.getterReceivers.set(receiverLocal.id, keys);
+ const calleeLocal = {
+ id: `%dispatch.callee.${fn.locals.length}`,
+ name: "callee",
+ type: DYN,
+ mutable: false,
+ };
+ fn.locals.push(calleeLocal);
+ const receiver = varRef(receiverLocal.id, DYN, loc);
+ const isCapsule = (key: string): IrExpr => ({
+ kind: "libCall",
+ fn: "dyn.typedRefIs",
+ args: [receiver, { kind: "strLit", value: key, type: STRING, loc }],
+ type: BOOL,
+ loc,
+ });
+ const cond = capsules.slice(1).reduce(
+ (rest, key) => ({
+ kind: "ternary",
+ cond: isCapsule(key),
+ then: { kind: "boolLit", value: true, type: BOOL, loc },
+ else_: rest,
+ type: BOOL,
+ loc,
+ }),
+ isCapsule(capsules[0]!),
+ );
+ const read: IrExpr = {
+ kind: "seqExpr",
+ stmts: [
+ {
+ kind: "varDecl",
+ localId: calleeLocal.id,
+ init: {
+ kind: "dynKeyGet",
+ key: { kind: "strLit", value: invoke.method, type: STRING, loc },
+ value: receiver,
+ type: DYN,
+ loc,
+ },
+ loc,
+ },
+ ],
+ result: {
+ kind: "dynCall",
+ callee: varRef(calleeLocal.id, DYN, loc),
+ receiver,
+ calleeName: invoke.calleeName,
+ ...(invoke.calleeNameValue ? { calleeNameValue: invoke.calleeNameValue } : {}),
+ args: invoke.args,
+ type: DYN,
+ loc,
+ },
+ type: DYN,
+ loc,
+ };
+ return {
+ kind: "seqExpr",
+ stmts: [{ kind: "varDecl", localId: receiverLocal.id, init: invoke.recv, loc }],
+ result: {
+ kind: "ternary",
+ cond,
+ then: read,
+ else_: { ...invoke, recv: receiver },
+ type: DYN,
+ loc,
+ },
+ type: DYN,
+ loc,
+ };
+ };
const candidateSets = new Map>();
const candidateSet = (method: string): ReadonlySet => {
let set = candidateSets.get(method);
@@ -2964,6 +3074,19 @@ export class ClassDynamicDispatch {
const info = lowerer.classes.get(name);
return info && findStaticOn(lowerer, info, method)?.method;
});
+ if (
+ invoke &&
+ expr.kind === "dynInvoke" &&
+ candidates(invoke.method).length === 0 &&
+ !staticCandidates(invoke.method)
+ ) {
+ // The other receivers' call a rewrite left in place tests only
+ // the classes that rewrite did not.
+ const covered =
+ expr.recv.kind === "varRef" ? this.getterReceivers.get(expr.recv.localId) : undefined;
+ const capsules = getterCapsules(invoke.method).filter((key) => !covered?.has(key));
+ if (capsules.length > 0) return getterCall(fn, expr, capsules, covered);
+ }
if (
!invoke ||
(candidates(invoke.method).length === 0 &&
diff --git a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts
index 6a6a25292..04f1d0d07 100644
--- a/packages/compiler/src/frontend/lowering/expressions/narrowing.ts
+++ b/packages/compiler/src/frontend/lowering/expressions/narrowing.ts
@@ -28,6 +28,7 @@ import {
typeEquals,
} from "../../../ir/ir.js";
import { isJsSourceFile, locOf } from "../../program.js";
+import { lowerPartialAssertion, partialAssertionSite } from "../partial-assertions.js";
import { hasRuntimeStatics } from "../class-runtime-statics.js";
import { jsBindingHasOpenWrites } from "../lower-stmts.js";
import { documentedParameter, prototypeAssignedFunction } from "../call-signatures.js";
@@ -1385,6 +1386,14 @@ export function lowerAsExpression(
loc: locOf(expr),
};
}
+ // A RECORD LITERAL asserted to a wider record type (`{ truncated: true }
+ // as TruncationResult`): keep the literal's own layout and let the
+ // asserted type view it (partial-assertions.ts). The record-to-record
+ // width copy below cannot build a value the literal does not have.
+ {
+ const partial = partialAssertionSite(lowerer, expr);
+ if (partial) return lowerPartialAssertion(lowerer, expr, partial);
+ }
// `e as C` on a CATCH BINDING (`(err as Error).message`): the checked
// extraction — an instanceof match extracts the payload, anything else
// throws the catchable TypeError (dynCheck's trust-but-verify stance,
diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts
index 720d1f9e9..189a0e79c 100644
--- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts
+++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts
@@ -206,6 +206,7 @@ import { InternalCompilerError } from "../../../errors.js";
import { lowerUnionFieldRead } from "./union-fields.js";
import { errorPropertyRead } from "../error-methods.js";
import { isSafeToDiscard, isSafeToRepeat } from "./evaluation-safety.js";
+import { partialAssertionFields, partialFieldRead } from "../partial-assertions.js";
import { isZlibStreamMember, lowerZlibStreamBytesWritten } from "../builtins/compression.js";
import { isWebCryptoReceiver } from "../builtins/crypto.js";
import { isFsStreamMember, lowerFsStreamProperty } from "../builtins/fs-streams.js";
@@ -4904,6 +4905,23 @@ export function fieldGetExpr(
}
return read;
}
+ // A field some partial record assertion leaves unwritten (a partial
+ // record view, partial-assertions.ts): a read of an absent field throws
+ // the catchable TypeError instead of reading a slot the view lacks.
+ if (partialAssertionFields(lowerer, target.shapeId).has(target.field)) {
+ const obj = target.obj;
+ if (isSafeToRepeat(obj))
+ return partialFieldRead(lowerer, obj, target.shapeId, target.field, target.fieldType, loc)!;
+ const stable = lowerer.declareHiddenLocal("%partialRecv", obj.type);
+ const ref = varRef(stable.id, obj.type, loc);
+ return {
+ kind: "seqExpr",
+ stmts: [{ kind: "varDecl", localId: stable.id, init: obj, loc }],
+ result: partialFieldRead(lowerer, ref, target.shapeId, target.field, target.fieldType, loc)!,
+ type: target.fieldType,
+ loc,
+ };
+ }
return {
kind: "recordGet",
obj: target.obj,
diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts
index 5c41aad52..3f8a5f498 100644
--- a/packages/compiler/src/frontend/lowering/expressions/relational.ts
+++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts
@@ -47,6 +47,7 @@ import { typedArrayHasMember } from "../builtins/typed-array-members.js";
import { fenceEarlyNsMemberRef, nsMemberIdentOf } from "../lower-namespaces.js";
import { maybePromiseInstanceOf } from "../maybe-promise.js";
import { isSafeToDiscard, isSafeToRepeat } from "./evaluation-safety.js";
+import { partialFieldPresence } from "../partial-assertions.js";
import { type ClassInfo, storedClassValueType } from "../lower-classes.js";
import { classInstanceOf } from "../class-dynamic-dispatch.js";
import { InternalCompilerError } from "../../../errors.js";
@@ -2034,6 +2035,13 @@ export function lowerInExpression(
if (!shape) throw new InternalCompilerError(`lowerer bug: unknown shape ${recv.type.shapeId}`);
const field = shape.fields.find((f) => f.name === key);
if (field) {
+ // A field some partial record assertion leaves unwritten (a partial
+ // record view, partial-assertions.ts): the answer is the view's, not
+ // the declared type's.
+ {
+ const viewPresence = partialFieldPresence(lowerer, recv, recv.type.shapeId, key, loc);
+ if (viewPresence) return viewPresence;
+ }
if (
!OBJECT_PROTOTYPE_KEYS.has(key) &&
field.type.kind === "union" &&
diff --git a/packages/compiler/src/frontend/lowering/expressions/updates.ts b/packages/compiler/src/frontend/lowering/expressions/updates.ts
index 4911a88d7..b73455e26 100644
--- a/packages/compiler/src/frontend/lowering/expressions/updates.ts
+++ b/packages/compiler/src/frontend/lowering/expressions/updates.ts
@@ -51,6 +51,7 @@ import { expandoWritableTarget } from "../lower-expando.js";
import { nsWritableTarget } from "../lower-namespaces.js";
import { arrayNumberRead, arrayValueRead, arrayValueStore } from "../array-values.js";
import { isSafeToRepeat } from "./evaluation-safety.js";
+import { partialFieldWriteError } from "../partial-assertions.js";
import { errorPropertyWrite } from "../error-methods.js";
import type { CompoundOp } from "../surfaces.js";
import { tryLowerExpression } from "./try-lower-expression.js";
@@ -903,24 +904,75 @@ export function fieldSetStmt(
loc,
};
}
- return target.container === "class"
- ? {
- kind: "fieldSet",
- obj: target.obj,
- className: target.className,
- field: target.field,
- value,
- ...(isStrictCode(blame) ? {} : { sloppy: true as const }),
- loc,
- }
- : {
- kind: "recordSet",
- obj: target.obj,
- shapeId: target.shapeId,
- field: target.field,
- value,
- loc,
- };
+ if (target.container === "class")
+ return {
+ kind: "fieldSet",
+ obj: target.obj,
+ className: target.className,
+ field: target.field,
+ value,
+ ...(isStrictCode(blame) ? {} : { sloppy: true as const }),
+ loc,
+ };
+ // A field some partial record assertion leaves unwritten (a partial
+ // record view, partial-assertions.ts): a view cannot grow a slot the
+ // asserted literal never wrote, so the write throws the catchable
+ // TypeError (Node would add the property — a documented divergence). The
+ // receiver and the value are evaluated first, as JavaScript does, and the
+ // presence test runs where the write would.
+ const refused = partialFieldWriteError(lowerer, target.shapeId, target.field, loc);
+ if (refused !== null) {
+ const obj = target.obj;
+ const saved = isSafeToRepeat(value)
+ ? null
+ : lowerer.declareHiddenLocal("%partialWriteValue", value.type);
+ // The receiver is saved whenever the value is: a right side can
+ // reassign the variable the receiver reads.
+ const stable =
+ isSafeToRepeat(obj) && saved === null
+ ? null
+ : lowerer.declareHiddenLocal("%partialWrite", obj.type);
+ const ref = stable ? varRef(stable.id, obj.type, loc) : obj;
+ const written = saved ? varRef(saved.id, value.type, loc) : value;
+ return {
+ kind: "block",
+ body: [
+ ...(stable ? [{ kind: "varDecl" as const, localId: stable.id, init: obj, loc }] : []),
+ ...(saved ? [{ kind: "varDecl" as const, localId: saved.id, init: value, loc }] : []),
+ {
+ kind: "if",
+ cond: {
+ kind: "recordHas",
+ obj: ref,
+ shapeId: target.shapeId,
+ field: target.field,
+ type: BOOL,
+ loc,
+ },
+ then: [],
+ else_: [{ kind: "exprStmt", expr: refused, loc }],
+ loc,
+ },
+ {
+ kind: "recordSet",
+ obj: ref,
+ shapeId: target.shapeId,
+ field: target.field,
+ value: written,
+ loc,
+ },
+ ],
+ loc,
+ };
+ }
+ return {
+ kind: "recordSet",
+ obj: target.obj,
+ shapeId: target.shapeId,
+ field: target.field,
+ value,
+ loc,
+ };
}
export function lowerFieldCompoundValue(
diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts
index 8231365b1..ee0ff6d02 100644
--- a/packages/compiler/src/frontend/lowering/lower-stmts.ts
+++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts
@@ -1,6 +1,7 @@
import { lowerStaticPrivateAssignment } from "./private-statics.js";
import type { DiagnosticCode } from "../../diagnostics/codes.js";
import { keepsInitializerLayout, notePreservedLayout } from "./structural-instances.js";
+import { partialFieldRead } from "./partial-assertions.js";
import { jsGlobalValueByName } from "./global-values.js";
import { lowerTupleAliasDecl, stripTypeWrappers } from "./typed-arguments.js";
import {
@@ -3858,6 +3859,10 @@ export function lowerBindingPattern(
type: fieldType,
loc,
};
+ // A field some partial record assertion leaves unwritten (a partial
+ // record view, partial-assertions.ts): the element read throws the
+ // catchable TypeError instead of reading a slot the view lacks.
+ value = partialFieldRead(lowerer, srcRef(), srcType.shapeId, propName, fieldType, loc) ?? value;
// `{ tld = "localhost" }`: the element default applies exactly when
// the field holds the undefined arm — the same undefined test and
// narrow/retag as a defaulted parameter's prologue (declareParams).
@@ -7329,13 +7334,18 @@ export function jsBindingHasOpenWrites(lowerer: Lowerer, decl: ts.VariableDeclar
)
)
return true;
- // A `+=` of a string turns a number or boolean binding into a string
- // (`n += "px"`); the checker keeps the declared type.
+ // A `+=` of a string, or of a value that may be one, turns a number or
+ // boolean binding into a string (`n += "px"`); the checker keeps the
+ // declared type.
+ const mayBeString = (type: ts.Type): boolean =>
+ type.flags & ts.TypeFlags.Union
+ ? constituentTypes(type).some(mayBeString)
+ : (type.flags & ts.TypeFlags.StringLike) !== 0;
if (
candidates.some(
(node) =>
node.operatorToken.kind === ts.SyntaxKind.PlusEqualsToken &&
- (lowerer.typeOf(node.right).flags & ts.TypeFlags.StringLike) !== 0,
+ mayBeString(lowerer.typeOf(node.right)),
)
) {
const declared = lowerer.mapTypeOf(lowerer.typeOf(decl.name));
@@ -7372,6 +7382,13 @@ export function jsBindingHasOpenWrites(lowerer: Lowerer, decl: ts.VariableDeclar
if (declared === null || declared.kind === "dyn" || declared.kind === "union") return false;
return differing.some((node) => {
const written = lowerer.mapTypeOf(lowerer.typeOf(node.right));
+ // A union a primitive binding cannot hold (`x = c ? 1 : "a"` into
+ // `var x = 0`).
+ if (
+ written?.kind === "union" &&
+ (declared.kind === "f64" || declared.kind === "bool" || declared.kind === "string")
+ )
+ return true;
return (
written !== null &&
written.kind !== "dyn" &&
diff --git a/packages/compiler/src/frontend/lowering/lowerer.test.ts b/packages/compiler/src/frontend/lowering/lowerer.test.ts
index 6f1c1c555..da2f7da7f 100644
--- a/packages/compiler/src/frontend/lowering/lowerer.test.ts
+++ b/packages/compiler/src/frontend/lowering/lowerer.test.ts
@@ -17,7 +17,11 @@ import { classMethodValue } from "./class-method-values.js";
const loc = { file: "island.ts", start: 0, end: 1 };
const record = (shapeId: string): IrType => ({ kind: "record", shapeId });
const context = () =>
- new Lowerer({ getTypeChecker: () => ({}) } as Program, { fileName: loc.file } as SourceFile, []);
+ new Lowerer(
+ { getTypeChecker: () => ({}), getImplementationSourceFiles: () => [] } as unknown as Program,
+ { fileName: loc.file } as SourceFile,
+ [],
+ );
test("reuses complete copy routes without repeating discriminator planning", () => {
const lowerer = context();
diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts
index ef9d5c31c..7700b7406 100644
--- a/packages/compiler/src/frontend/lowering/lowerer.ts
+++ b/packages/compiler/src/frontend/lowering/lowerer.ts
@@ -780,6 +780,7 @@ import type { ExpandoMembers } from "./lower-expando.js";
import { lowerRecordFieldCall, lowerObjectMethodCall } from "./lower-calls.js";
import { fenceCrossBlockNsRef, nsPathPrefix } from "./lower-namespaces.js";
import { isSafeToRepeat } from "./expressions/evaluation-safety.js";
+import { partialAssertionFields } from "./partial-assertions.js";
import { lowerTernary } from "./lower-exprs.js";
import {
maybePromiseReturnStmt,
@@ -3352,6 +3353,10 @@ export class Lowerer {
/** The collection types that uses prove for untyped JavaScript bindings,
* by every identifier naming one (js-collection-types.ts). */
inferredCollectionTypes: Map | undefined = undefined;
+ /** Partial record assertions (`partial-assertions.ts`): per target shape,
+ * the required fields some asserted literal leaves unwritten. */
+ partialAssertions: Map; text: string }> | undefined =
+ undefined;
/** overload-choice's answers per call, binding and function. */
readonly dynamicOverloadAnswers = new Map();
/** Error parameters of an error-first callback with a known value in
@@ -4510,6 +4515,7 @@ export class Lowerer {
"priorRetriedSymbols",
"wholeSlotDegradation",
"inferredCollectionTypes",
+ "partialAssertions",
"foreignElementKeys",
// caches made when first needed
"nanCodedReads",
@@ -14516,6 +14522,10 @@ export class Lowerer {
* undefined-armed union fields, which can be absent, and `true` for
* every other declared field. */
recordFieldPresent(obj: IrExpr, shapeId: string, field: string, loc: SrcLoc): IrExpr {
+ // A partial record view (partial-assertions.ts) can lack a field its
+ // asserted literal never wrote, whatever the field's required type.
+ if (partialAssertionFields(this, shapeId).has(field))
+ return { kind: "recordHas", obj, shapeId, field, type: BOOL, loc };
const f = this.shapes.get(shapeId)?.fields.find((x) => x.name === field);
if (f && f.type.kind === "union" && this.armTag(f.type.unionId, UNDEFINED_T) >= 0) {
return { kind: "recordHas", obj, shapeId, field, type: BOOL, loc };
diff --git a/packages/compiler/src/frontend/lowering/partial-assertions.ts b/packages/compiler/src/frontend/lowering/partial-assertions.ts
new file mode 100644
index 000000000..545f4b26b
--- /dev/null
+++ b/packages/compiler/src/frontend/lowering/partial-assertions.ts
@@ -0,0 +1,253 @@
+/* Partial record assertions (`{ truncated: true } as TruncationResult`).
+ *
+ * A record literal carries exactly the fields it writes, but its asserted
+ * type may require more. Node erases the assertion: the value keeps the
+ * literal's properties, and reading a field it never had yields undefined.
+ * A record type has a fixed layout, so scriptc keeps the LITERAL's layout
+ * (shape A — the written fields and the asserted type's optional fields,
+ * these in their absent state, with the asserted type's field types) and
+ * hands the asserted type B a PARTIAL record view of it (record-views.ts):
+ * the value is a B whose record-source entry names A, and the required B
+ * fields A does not declare are absent. Reflection through the view is
+ * exact.
+ *
+ * This module finds those sites and answers, per target shape, which
+ * required fields some asserted literal leaves unwritten. The lowering
+ * consults it for member reads and writes, destructuring and `in`, so a
+ * read of such a field throws the catchable TypeError (Node yields
+ * undefined — a documented divergence) instead of reaching a slot the view
+ * does not have. Computed once for the whole program, on first use, like
+ * the untyped-collection inference (js-collection-types.ts). */
+
+import * as ts from "../ts7/adapter.js";
+import type { Lowerer } from "./lowerer.js";
+import { isJsSourceFile, locOf } from "../program.js";
+import { nodeThrowExpr } from "../../ir/build.js";
+import {
+ BOOL,
+ DYN,
+ type IrExpr,
+ type IrType,
+ type SrcLoc,
+ UNDEFINED_T,
+ VOID,
+} from "../../ir/ir.js";
+
+/** One site's facts: the asserted record type, the literal's keys in
+ * source order, and the B fields the literal does not write. */
+export interface PartialAssertionSite {
+ readonly target: IrType & { kind: "record" };
+ readonly keys: readonly string[];
+ /** The optional B fields the literal does not write, which the literal's
+ * layout keeps in their absent state. */
+ readonly absent: readonly string[];
+ readonly missing: ReadonlySet;
+ /** The asserted type's source spelling, for the throw messages. */
+ readonly text: string;
+}
+
+interface PartialTarget {
+ readonly missing: ReadonlySet;
+ readonly text: string;
+}
+
+const NO_FIELDS: ReadonlySet = new Set();
+
+/** The required fields some asserted literal leaves unwritten on shapes of
+ * `shapeId`; empty when no partial assertion produces that shape. */
+export function partialAssertionFields(lowerer: Lowerer, shapeId: string): ReadonlySet {
+ return partialTargetOf(lowerer, shapeId)?.missing ?? NO_FIELDS;
+}
+
+function partialTargetOf(lowerer: Lowerer, shapeId: string): PartialTarget | null {
+ if (lowerer.partialAssertions === undefined) {
+ const found = new Map();
+ lowerer.partialAssertions = found;
+ for (const file of lowerer.program.getImplementationSourceFiles()) {
+ if (isJsSourceFile(file)) continue;
+ ts.walkPreorder(file, (node) => {
+ if (!ts.isAsExpression(node) && !ts.isTypeAssertion(node)) return;
+ const site = partialAssertionSite(lowerer, node);
+ if (site === null) return;
+ const known = found.get(site.target.shapeId);
+ const missing = new Set();
+ if (known !== undefined) {
+ for (const name of known.missing) missing.add(name);
+ }
+ for (const name of site.missing) missing.add(name);
+ found.set(site.target.shapeId, { missing, text: site.text });
+ });
+ }
+ }
+ return lowerer.partialAssertions.get(shapeId) ?? null;
+}
+
+/** The partial-assertion facts of `expr`, or null when it asserts a record
+ * literal to a type that needs no partial view. */
+export function partialAssertionSite(
+ lowerer: Lowerer,
+ expr: ts.AsExpression | ts.TypeAssertion,
+): PartialAssertionSite | null {
+ let operand: ts.Expression = expr.expression;
+ while (ts.isParenthesizedExpression(operand)) operand = operand.expression;
+ if (!ts.isObjectLiteralExpression(operand)) return null;
+ const literal: ts.ObjectLiteralExpression = operand;
+ const keys: string[] = [];
+ for (const prop of literal.properties.nodes) {
+ if (!ts.isShorthandPropertyAssignment(prop) && !ts.isPropertyAssignment(prop)) return null;
+ const name = prop.name;
+ if (!ts.isIdentifier(name) && !ts.isStringLiteral(name)) return null;
+ // A repeated key is the last write's; the literal alone would not
+ // decide the shape. Keys ride the literal's own inferred shape.
+ if (keys.includes(name.text)) return null;
+ keys.push(name.text);
+ }
+ const targetTs = lowerer.checker.getTypeFromTypeNode(expr.type);
+ const target = lowerer.mapTypeOf(targetTs);
+ if (target?.kind !== "record") return null;
+ const shape = lowerer.shapes.get(target.shapeId);
+ if (!shape || shape.tuple || shape.indexValue !== undefined) return null;
+ const missing = new Set();
+ for (const key of keys) {
+ // A key the asserted type does not declare would ride the overflow
+ // map, which a view has no slots for.
+ if (!shape.fields.some((f) => f.name === key)) return null;
+ }
+ const absent: string[] = [];
+ for (const field of shape.fields) {
+ if (keys.includes(field.name)) continue;
+ // An omitted optional field keeps a slot in the literal's layout, in its
+ // absent state (lowerPartialAssertion), as an ordinary record of the
+ // asserted type does: reads yield undefined, `in` answers false and a
+ // write adds the property. Only a required field can be missing.
+ if (field.type.kind === "union" && lowerer.armTag(field.type.unionId, UNDEFINED_T) >= 0) {
+ absent.push(field.name);
+ continue;
+ }
+ missing.add(field.name);
+ }
+ if (missing.size === 0) return null;
+ return { target, keys, absent, missing, text: lowerer.checker.typeToString(targetTs) };
+}
+
+/** The checked read of `field` through a value of `shapeId` that a partial
+ * assertion may have produced, or null when the field is always present.
+ * `obj` is read twice (the presence test and the read), so it must be safe
+ * to repeat. */
+export function partialFieldRead(
+ lowerer: Lowerer,
+ obj: IrExpr,
+ shapeId: string,
+ field: string,
+ fieldType: IrType,
+ loc: SrcLoc,
+): IrExpr | null {
+ const target = partialTargetOf(lowerer, shapeId);
+ if (target === null || !target.missing.has(field)) return null;
+ return {
+ kind: "ternary",
+ cond: { kind: "recordHas", obj, shapeId, field, type: BOOL, loc },
+ then: { kind: "recordGet", obj, shapeId, field, type: fieldType, loc },
+ else_: nodeThrowExpr(
+ 1,
+ "",
+ `Cannot read property '${field}': the object asserted as '${target.text}' has no such property`,
+ fieldType,
+ loc,
+ ),
+ type: fieldType,
+ loc,
+ };
+}
+
+/** The TypeError a write of a field the asserted literal never wrote
+ * throws, or null when the field is always present. */
+export function partialFieldWriteError(
+ lowerer: Lowerer,
+ shapeId: string,
+ field: string,
+ loc: SrcLoc,
+): IrExpr | null {
+ const target = partialTargetOf(lowerer, shapeId);
+ if (target === null || !target.missing.has(field)) return null;
+ return nodeThrowExpr(
+ 1,
+ "",
+ `Cannot set property '${field}': the object asserted as '${target.text}' has no such property`,
+ VOID,
+ loc,
+ );
+}
+
+/** The own-property presence test for `field` on a receiver of `shapeId`
+ * (`"f" in value`), or null when no partial assertion can leave it absent.
+ * `obj` must be safe to repeat. */
+export function partialFieldPresence(
+ lowerer: Lowerer,
+ obj: IrExpr,
+ shapeId: string,
+ field: string,
+ loc: SrcLoc,
+): IrExpr | null {
+ const target = partialTargetOf(lowerer, shapeId);
+ if (target === null || !target.missing.has(field)) return null;
+ return { kind: "recordHas", obj, shapeId, field, type: BOOL, loc };
+}
+
+/** The checked conversion a partial assertion lowers to: the literal built
+ * at its OWN layout A (the asserted type's field types, values checked
+ * against them), boxed live, and asserted to B as a partial view. */
+export function lowerPartialAssertion(
+ lowerer: Lowerer,
+ expr: ts.AsExpression | ts.TypeAssertion,
+ site: PartialAssertionSite,
+): IrExpr {
+ const loc = locOf(expr);
+ let literal: ts.Expression = expr.expression;
+ while (ts.isParenthesizedExpression(literal)) literal = literal.expression;
+ const shape = lowerer.shapes.get(site.target.shapeId)!;
+ // The site check accepted exactly the literal's property assignments and
+ // shorthands, in source order.
+ const props = (literal as ts.ObjectLiteralExpression).properties.nodes;
+ const fields = site.keys.map((key, i) => {
+ const prop = props[i]!;
+ const initializer = ts.isPropertyAssignment(prop)
+ ? prop.initializer
+ : (prop.name as unknown as ts.Expression);
+ const type = shape.fields.find((f) => f.name === key)!.type;
+ const value = lowerer.coerceInto(prop, lowerer.lowerExprExpecting(initializer, type), type);
+ return { name: key, type, value };
+ });
+ for (const name of site.absent) {
+ const type = shape.fields.find((f) => f.name === name)!.type as IrType & { kind: "union" };
+ fields.push({
+ name,
+ type,
+ value: { kind: "fieldAbsent", unionId: type.unionId, type, loc },
+ });
+ }
+ // A's identity is its name-sorted field list; its DECLARED order is the
+ // literal's, which JSON and Object.keys read through the view's capsule,
+ // then the absent optional fields, which a later write appends.
+ const shapeId = lowerer.shapes.intern(
+ [...fields]
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
+ .map((f) => ({ name: f.name, type: f.type })),
+ false,
+ undefined,
+ [...site.keys, ...site.absent],
+ );
+ const built: IrExpr = {
+ kind: "recordLit",
+ fields: fields.map((f) => ({ name: f.name, value: f.value })),
+ type: { kind: "record", shapeId },
+ loc,
+ };
+ return {
+ kind: "dynCheck",
+ partialView: true,
+ value: { kind: "dynFrom", value: built, liveRef: true, type: DYN, loc },
+ type: site.target,
+ loc,
+ };
+}
diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts
index bcd5e44dc..ae600d483 100644
--- a/packages/compiler/src/frontend/program.ts
+++ b/packages/compiler/src/frontend/program.ts
@@ -161,6 +161,7 @@ import {
captureShapeOverlayState,
type ShapeOverlayLoadState,
} from "./shape-inference/index.js";
+import { jsInferenceEnabled } from "./js-inference.js";
import { inferredJsDiagnosticSuppressed } from "./inferred-js-diagnostics.js";
import {
containsJsx,
@@ -1401,7 +1402,10 @@ export function loadProgram(
externalTypes,
npmStaticPackages.length > 0,
projector,
- npmStaticPackages.length > 0 && shapeOverlayEnabled() ? shapeOverlayer : undefined,
+ (npmStaticPackages.length > 0 && shapeOverlayEnabled()) ||
+ (jsInferenceEnabled() && process.env["SCRIPTC_SHAPE_OVERLAY"] !== "0")
+ ? shapeOverlayer
+ : undefined,
);
// Shape inference, stage S1: a report only (SCRIPTC_DEBUG_SHAPES).
const shapes = process.env["SCRIPTC_DEBUG_SHAPES"];
diff --git a/packages/compiler/src/frontend/shape-inference/analyze.ts b/packages/compiler/src/frontend/shape-inference/analyze.ts
index 670d4a2f8..b8d456af7 100644
--- a/packages/compiler/src/frontend/shape-inference/analyze.ts
+++ b/packages/compiler/src/frontend/shape-inference/analyze.ts
@@ -149,6 +149,11 @@ export interface ShapeAnalysis {
declValues(decl: Node): Set;
/** The classes a class declaration's chain contains, base last. */
classChain(decl: Node): Node[];
+ /** Whether every instance of a class has the own field `name` before
+ * any code can observe the instance. */
+ fieldSurelyPresent(decl: Node, name: string): boolean;
+ /** The atom of a primitive type (`undefined`, `number`, …). */
+ prim(name: string): Atom;
/** `if` statements in walked code whose condition is known: the branch
* whose condition values never allowed it is absent from `walkedBranches`. */
readonly branchIfs: ReadonlySet;
@@ -338,6 +343,16 @@ function propertyNameText(name: Node): string | null {
return null;
}
+/** Whether an object literal has a spread or a key not known statically. */
+function hasUnknownKeys(node: Node): boolean {
+ if (!ts.isObjectLiteralExpression(node)) return false;
+ return list(node.properties).some((p) => {
+ if (ts.isSpreadAssignment(p)) return true;
+ const name = (p as { name?: Node }).name;
+ return name !== undefined && propertyNameText(name) === null;
+ });
+}
+
const shortFiles = new Map();
function shortFile(fileName: string): string {
@@ -615,6 +630,8 @@ class Analyzer {
fnOfInstance: this.fnOfInstance,
declValues: (decl) => this.declValues(decl),
classChain: (decl) => this.classChain(decl),
+ fieldSurelyPresent: (decl, name) => this.fieldSurelyPresent(decl, name),
+ prim: (name) => this.prim(name),
branchIfs: this.branchIfs,
walkedBranches: this.walkedBranches,
};
@@ -943,7 +960,11 @@ class Analyzer {
this.escapedFunctionCalls.add(info);
this.activate(info);
for (const p of info.params) this.add(p, this.top);
- if (!info.isArrow && info.ownerClass === null) this.add(info.thisNode, this.top);
+ // Unknown code can call a class's method with any receiver too (through
+ // `Reflect.get` with a proxy as the receiver); in the program's own
+ // files its receiver is ⊤.
+ if (!info.isArrow && (info.ownerClass === null || !isPackageJsFile(info.node.getSourceFile())))
+ this.add(info.thisNode, this.top);
this.sink(info.ret, reason);
}
@@ -1065,6 +1086,53 @@ class Analyzer {
return out;
}
+ /** Whether every instance of a class has the own field `name` before any
+ * code can observe the instance. Construction runs from the base class
+ * down, each class's field initializers and then its constructor body:
+ * the field counts when a declaration or a `this.name = …` statement of
+ * the constructor body defines it ahead of anything that reads `this` or
+ * passes it on (a call with `this`, a method call on it, a closure, a
+ * field initializer or right-hand side that reads it) and ahead of an
+ * early `return`. */
+ fieldSurelyPresent(decl: Node, name: string): boolean {
+ for (const c of this.classChain(decl).reverse()) {
+ const members = list((c as ts.ClassLikeDeclaration).members);
+ for (const m of members) {
+ if (isStatic(m) || !ts.isPropertyDeclaration(m)) continue;
+ if (m.initializer !== undefined && observesThis(m.initializer)) return false;
+ if (propertyNameText(m.name) === name) return true;
+ }
+ const ctor = members.find(
+ (m): m is ts.ConstructorDeclaration =>
+ ts.isConstructorDeclaration(m) && m.body !== undefined,
+ );
+ for (const st of ctor === undefined ? [] : list(ctor.body!.statements)) {
+ const e = ts.isExpressionStatement(st) ? st.expression : undefined;
+ if (
+ e !== undefined &&
+ ts.isBinaryExpression(e) &&
+ e.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
+ ts.isPropertyAccessExpression(e.left) &&
+ e.left.expression.kind === ts.SyntaxKind.ThisKeyword
+ ) {
+ if (observesThis(e.right)) return false;
+ if (e.left.name.text === name) return true;
+ continue;
+ }
+ // `super(…)` runs the base constructor, walked above.
+ if (
+ e !== undefined &&
+ ts.isCallExpression(e) &&
+ e.expression.kind === ts.SyntaxKind.SuperKeyword &&
+ !list(e.arguments).some(observesThis)
+ )
+ continue;
+ if (observesThis(st) || returnsEarly(st)) return false;
+ }
+ }
+ return false;
+ }
+
lookupMember(
decl: Node,
name: string,
@@ -1719,7 +1787,12 @@ class Analyzer {
const src = this.expr(s.expression);
const target = this.newNode();
if (ts.isForInStatement(s)) this.add(target, this.prim("string"));
- else this.iterateInto(src, target, s);
+ else if (s.awaitModifier !== undefined) {
+ // `for await` awaits each value a sync iterable yields.
+ const yielded = this.newNode();
+ this.iterateInto(src, yielded, s);
+ this.awaitInto(yielded, target);
+ } else this.iterateInto(src, target, s);
const init = s.initializer;
if (ts.isVariableDeclarationList(init)) {
for (const d of list(init.declarations)) {
@@ -1836,9 +1909,13 @@ class Analyzer {
this.on(src, (a) => {
if (a.kind === "array" || a.kind === "collection") this.edge(this.element(a), dst);
else if (a.kind === "prim" && a.label === "string") this.add(dst, this.prim("string"));
- else if (a.kind === "prim" || a.kind === "object" || a.kind === "dictionary") {
- /* not iterable (a TypeError): no value. Literals with a
- Symbol.iterator member have a computed key; they are G sites. */
+ else if (a.kind === "object" && a.node !== undefined && hasUnknownKeys(a.node)) {
+ // An object literal with a computed key or a spread may have a
+ // Symbol.iterator or Symbol.asyncIterator member.
+ this.add(dst, this.top);
+ this.escape(a, `iterated at ${this.loc(site)}`);
+ } else if (a.kind === "prim" || a.kind === "object" || a.kind === "dictionary") {
+ /* not iterable (a TypeError): no value. */
} else {
this.add(dst, this.top);
if (a.kind !== "top") this.escape(a, `iterated at ${this.loc(site)}`);
@@ -2712,6 +2789,15 @@ class Analyzer {
}
if (this.classChain(decl).some((c) => this.baseClass(c) === undefined))
this.add(dst, this.top);
+ // A field the instance may lack reads undefined (`Late` sets `x`
+ // only on one branch). In the program's own files only.
+ if (
+ member === null &&
+ !OBJECT_PROTOTYPE_MEMBERS.has(name) &&
+ !isPackageJsFile(decl.getSourceFile()) &&
+ !this.fieldSurelyPresent(decl, name)
+ )
+ this.add(dst, this.prim("undefined"));
this.edge(this.field(a, name), dst);
this.unknownKeysInto(a, dst);
return;
@@ -3467,8 +3553,12 @@ class Analyzer {
this.add(this.element(out), this.prim("undefined"));
this.add(dst, out);
this.add(dst, this.prim("null"));
- } else if (r !== undefined) prim(r);
- else if (method === "replace" || method === "replaceAll") prim("string");
+ } else if (method === "valueOf") prim(path.slice(0, path.indexOf(".")));
+ else if (r !== undefined) {
+ prim(r);
+ // An index out of range reads undefined.
+ if (method === "at" || method === "codePointAt") prim("undefined");
+ } else if (method === "replace" || method === "replaceAll") prim("string");
else escapeAll();
if (method === "replace" || method === "replaceAll")
callback(1, [this.constNode(this.prim("string"))], null);
@@ -4161,3 +4251,18 @@ function mayBeFalsy(a: Atom): boolean {
if (a.kind !== "prim") return false;
return a.literal !== true;
}
+
+/** Whether code reads `this` or `super`, at any depth. */
+function observesThis(node: Node): boolean {
+ if (node.kind === ts.SyntaxKind.ThisKeyword || node.kind === ts.SyntaxKind.SuperKeyword)
+ return true;
+ return ts.forEachChild(node, (child) => (observesThis(child) ? true : undefined)) === true;
+}
+
+/** Whether a constructor statement can end the constructor: a `return`
+ * outside the functions it declares. */
+function returnsEarly(node: Node): boolean {
+ if (ts.isReturnStatement(node)) return true;
+ if (ts.isFunctionLike(node) || ts.isClassLikeDeclaration(node)) return false;
+ return ts.forEachChild(node, (child) => (returnsEarly(child) ? true : undefined)) === true;
+}
diff --git a/packages/compiler/src/frontend/shape-inference/domain.ts b/packages/compiler/src/frontend/shape-inference/domain.ts
index 21e52346c..69555aef5 100644
--- a/packages/compiler/src/frontend/shape-inference/domain.ts
+++ b/packages/compiler/src/frontend/shape-inference/domain.ts
@@ -127,7 +127,6 @@ export const STRING_METHOD_RESULTS: ReadonlyMap = new Map<
["lastIndexOf", "number"],
["localeCompare", "number"],
["match", "array"],
- ["matchAll", "array"],
["normalize", "string"],
["padEnd", "string"],
["padStart", "string"],
diff --git a/packages/compiler/src/frontend/shape-inference/index.ts b/packages/compiler/src/frontend/shape-inference/index.ts
index 5f5547e39..522e01732 100644
--- a/packages/compiler/src/frontend/shape-inference/index.ts
+++ b/packages/compiler/src/frontend/shape-inference/index.ts
@@ -366,10 +366,13 @@ function overlaySiteClones(): number {
function overlayJsFiles(analysis: ShapeAnalysis): ts.SourceFile[] {
// Package JavaScript of packages that keep shapes on, and the program's
- // own JavaScript files.
+ // own JavaScript files. Inference for the program's JavaScript alone
+ // (SCRIPTC_INFER_JS=1 without SCRIPTC_SHAPE_OVERLAY=1) leaves packages as
+ // they are.
return analysis.files.filter((sf) => {
if (sf.isDeclarationFile || !isJsSourceFile(sf)) return false;
if (!isPackageJsFile(sf)) return !sf.fileName.includes("/node_modules/");
+ if (!shapeOverlayEnabled()) return false;
const pkg = npmStaticPackageOfPath(sf.fileName);
return pkg === null || !isShapelessPackage(pkg);
});
diff --git a/packages/compiler/src/frontend/shape-inference/memo.ts b/packages/compiler/src/frontend/shape-inference/memo.ts
index 8f5632a42..c917e1f5f 100644
--- a/packages/compiler/src/frontend/shape-inference/memo.ts
+++ b/packages/compiler/src/frontend/shape-inference/memo.ts
@@ -60,11 +60,16 @@ export function textHash(text: string): string {
return createHash("sha256").update(text).digest("hex").slice(0, 32);
}
-/** The memo key of a program's JavaScript files (name and text). */
+/** The memo key of a program's JavaScript files (name and text) and the
+ * shape and inference settings. */
export function overlayMemoKey(files: readonly (readonly [string, string])[]): string {
const hash = createHash("sha256");
const settings = Object.keys(process.env)
- .filter((k) => k.startsWith("SCRIPTC_SHAPE_") && !k.startsWith("SCRIPTC_SHAPE_CHECK"))
+ .filter(
+ (k) =>
+ (k.startsWith("SCRIPTC_SHAPE_") && !k.startsWith("SCRIPTC_SHAPE_CHECK")) ||
+ k === "SCRIPTC_INFER_JS",
+ )
.sort();
for (const k of settings)
hash
diff --git a/packages/compiler/src/frontend/shape-inference/overlay.ts b/packages/compiler/src/frontend/shape-inference/overlay.ts
index bbc547745..9014a737e 100644
--- a/packages/compiler/src/frontend/shape-inference/overlay.ts
+++ b/packages/compiler/src/frontend/shape-inference/overlay.ts
@@ -20,7 +20,7 @@
* keep their lines. */
import * as ts from "../ts7/adapter.js";
import type { Atom } from "./domain.js";
-import type { ShapeAnalysis } from "./analyze.js";
+import { isPackageJsFile, type Op, type ShapeAnalysis } from "./analyze.js";
import type { SiteVerdict } from "./report.js";
export interface ShapeOverlay {
@@ -112,6 +112,15 @@ const MAX_RECORD_ARMS = Number(process.env.SCRIPTC_SHAPE_RECORD_ARMS ?? 1);
/** The most class or record arms an inferred union may have. */
const MAX_ARMS = 6;
+/** Operations whose receivers' class instances keep the dynamic representation. */
+const REFLECTIVE_OPS: ReadonlySet = new Set([
+ "computedWrite",
+ "delete",
+ "defineProperty",
+ "setPrototypeOf",
+ "proxy",
+]);
+
function list(arr: { readonly nodes: readonly T[] } | undefined): readonly T[] {
return arr?.nodes ?? [];
}
@@ -153,6 +162,17 @@ export function planShapeOverlay(
}
const pSites = new Map();
const siteVerdicts = new Set(verdicts.map((v) => v.site.atom));
+ // Class instances that a computed write, `delete`, a property
+ // definition, a prototype change or a proxy reaches keep the dynamic
+ // representation in the program's own files: the typed lowering of a
+ // class instance does not support those operations.
+ const reflectedInstances = new Set();
+ for (const o of analysis.ops) {
+ if (!REFLECTIVE_OPS.has(o.kind)) continue;
+ for (const a of analysis.nodes[o.recv]!.atoms)
+ if (a.kind === "instance" && a.node !== undefined && !isPackageJsFile(a.node.getSourceFile()))
+ reflectedInstances.add(a);
+ }
const dynHeld = dynHeldSites(analysis, verdicts);
// Records and arrays print only for sites whose objects never escape: a
// native record that reached untyped code would be seen as a copy.
@@ -208,13 +228,32 @@ export function planShapeOverlay(
// annotation's id (its position), so a refused lowering can name it and
// the next load drop it. `close`: the text that closes a type cast.
const patchedSpans = protectedSpansOf(sf.fileName);
+ // The checker reads a declaration's last JSDoc comment only, so an
+ // annotation placed after the program's own comment repeats that
+ // comment's text.
+ const ownDocBefore = (pos: number): string => {
+ if (isPackageJsFile(sf)) return "";
+ let end = pos;
+ while (end > 0 && /\s/.test(sf.text[end - 1]!)) end--;
+ if (end < 5 || sf.text.slice(end - 2, end) !== "*/") return "";
+ const start = sf.text.lastIndexOf("/*", end - 3);
+ if (start < 0 || sf.text[start + 2] !== "*") return "";
+ const comment = sf.text.slice(start, end);
+ if (comment.includes(MARK)) return "";
+ return comment
+ .slice(3, -2)
+ .replace(/^\s*\*/gm, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+ };
const annotate = (pos: number, tags: string, close?: number): void => {
// A declaration a project type patch edited keeps the patch alone.
if (declarationPatched(sf, pos, patchedSpans)) return;
+ const own = close === undefined ? ownDocBefore(pos) : "";
edits.push({
pos,
id: pos,
- text: `/** ${tags} ${MARK} #${pos} */ ${close !== undefined ? "(" : ""}`,
+ text: `/** ${own === "" ? "" : `${own} `}${tags} ${MARK} #${pos} */ ${close !== undefined ? "(" : ""}`,
});
if (close !== undefined) edits.push({ pos: close, id: pos, text: ")" });
};
@@ -277,7 +316,7 @@ export function planShapeOverlay(
: null;
break;
case "instance":
- t = a.node !== undefined ? className(a.node) : null;
+ t = a.node !== undefined && !reflectedInstances.has(a) ? className(a.node) : null;
break;
case "object": {
const v = pSites.get(a);
@@ -620,12 +659,42 @@ export function planShapeOverlay(
return out;
};
+ // A constructor parameter the constructor stores in a field (`this.value
+ // = value`) types the field: the checker types a JavaScript field from
+ // its constructor assignments alone. When other writes give the field
+ // more values than the parameter's, the parameter stays untyped in the
+ // program's own files, so the field keeps the dynamic representation.
+ const storedIntoWiderField = (
+ fn: ts.FunctionLikeDeclaration,
+ param: string,
+ t: string,
+ ): boolean => {
+ if (!ts.isConstructorDeclaration(fn) || fn.body === undefined || isPackageJsFile(sf))
+ return false;
+ const cls = fn.parent;
+ if (cls === undefined) return false;
+ return list(fn.body.statements).some((st) => {
+ if (!ts.isExpressionStatement(st) || !ts.isBinaryExpression(st.expression)) return false;
+ const b = st.expression;
+ return (
+ b.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
+ ts.isPropertyAccessExpression(b.left) &&
+ b.left.expression.kind === ts.SyntaxKind.ThisKeyword &&
+ ts.isIdentifier(b.left.name) &&
+ ts.isIdentifier(b.right) &&
+ b.right.text === param &&
+ fieldType(cls, b.left.name.text) !== t
+ );
+ });
+ };
+
const paramTags = (fn: ts.FunctionLikeDeclaration): string | null => {
const tags: string[] = [];
// The package's JSDoc declares types: the overlay adds none where it
// has one, and none at all for a signature it types as a whole.
const doc = packageDocOf(fn);
if (doc.whole) return null;
+ let opaqueResult = false;
if (!ts.isConstructorDeclaration(fn) && !constructed.has(fn) && !doc.returns) {
const ret = returnValues(fn);
const t = ret !== undefined && ret.size > 0 ? typeOf(ret, false, 0) : null;
@@ -642,7 +711,17 @@ export function planShapeOverlay(
`[shape-overlay-why] ${fn.name.text}: ${[...ret].map((a) => `${a.kind}:${a.label}${analysis.escaped.has(a) ? "(escaped)" : ""}`).join(" | ")}\n`,
);
if (t !== null) tags.push(`@returns {${t}}`);
+ // The checker types an unannotated method's result from its own
+ // body, which an override in the program's own files may not match
+ // (`method(value = 1)` overridden by one returning a string): a
+ // family whose results print as no type returns `any`.
+ else if (familyOf(fn).length > 1 && !isPackageJsFile(sf)) tags.push("@returns {any}");
+ else
+ opaqueResult =
+ ret !== undefined &&
+ [...ret].some((a) => a.kind === "object" || a.kind === "array" || a.kind === "top");
}
+ const typedParams = tags.length;
list(fn.parameters).forEach((p, index) => {
if (
!ts.isIdentifier(p.name) ||
@@ -658,13 +737,44 @@ export function planShapeOverlay(
// lowering refuses: such a parameter stays untyped.
if (t !== null && typeofTestsOutside(fn, p.name.text, values)) return;
if (doc.params.has(p.name.text)) return;
+ if (t !== null && storedIntoWiderField(fn, p.name.text, t)) return;
if (t !== null) tags.push(`@param {${t}} ${p.name.text}`);
});
+ // With typed parameters the checker infers the result from them: an
+ // object or array the analysis does not type (a `__proto__` literal,
+ // a prototype change) would become a record the lowering checks, so
+ // in the program's own files the result stays `any`.
+ if (opaqueResult && tags.length > typedParams && !isPackageJsFile(sf))
+ tags.push("@returns {any}");
return tags.length > 0 ? tags.join(" ") : null;
};
+ // Getters of one name in several classes of the program's own file: an
+ // override can return what the base's body does not (`get value()`
+ // returning a string over one returning a number), and the checker
+ // types a read from the getter it resolves, so they return `any`.
+ const getterClasses = new Map();
+ if (!isPackageJsFile(sf)) {
+ const countGetters = (node: ts.Node): void => {
+ if (ts.isGetAccessorDeclaration(node) && ts.isIdentifier(node.name))
+ getterClasses.set(node.name.text, (getterClasses.get(node.name.text) ?? 0) + 1);
+ ts.forEachChild(node, (c) => {
+ countGetters(c);
+ return undefined;
+ });
+ };
+ countGetters(sf);
+ }
+
const visit = (node: ts.Node): void => {
if (ts.isMethodDeclaration(node)) receiverCasts(node);
+ if (
+ ts.isGetAccessorDeclaration(node) &&
+ ts.isIdentifier(node.name) &&
+ (getterClasses.get(node.name.text) ?? 0) > 1 &&
+ node.type === undefined
+ )
+ annotate(ts.tokenStartOfNode(node), "@returns {any}");
if (
ts.isMethodDeclaration(node) ||
ts.isConstructorDeclaration(node) ||
@@ -744,6 +854,10 @@ export function planShapeOverlay(
const id = analysis.fields.get(a)?.get(name);
if (id !== undefined) for (const x of analysis.nodes[id]!.atoms) atoms.add(x);
}
+ // In the program's own files, code that sees an instance before the
+ // constructor stores the field reads undefined from it.
+ if (atoms.size > 0 && !isPackageJsFile(sf) && !analysis.fieldSurelyPresent(cls, name))
+ atoms.add(analysis.prim("undefined"));
return typeOf(atoms, false, 0);
};
diff --git a/packages/compiler/src/frontend/shape-inference/static-objects.ts b/packages/compiler/src/frontend/shape-inference/static-objects.ts
index 2b41c2f9d..31477e7b1 100644
--- a/packages/compiler/src/frontend/shape-inference/static-objects.ts
+++ b/packages/compiler/src/frontend/shape-inference/static-objects.ts
@@ -377,10 +377,17 @@ function writesBinding(id: ts.Identifier): boolean {
}
function enclosingFunctionLike(n: Node): Node | null {
- for (let p = n.parent; p !== undefined; p = p.parent) if (ts.isFunctionLike(p)) return p;
+ for (let c = n, p = n.parent; p !== undefined; c = p, p = p.parent)
+ if (runsWithFunction(p, c)) return p;
return null;
}
+/** Whether `child` of `p` runs when the function `p` runs: a method's or
+ * accessor's computed name runs with its object literal or class. */
+function runsWithFunction(p: Node, child: Node): boolean {
+ return ts.isFunctionLike(p) && (p as { name?: Node }).name !== child;
+}
+
function primitiveLiteralText(e: ts.Expression): string | null {
if (
ts.isNumericLiteral(e) ||
@@ -1306,8 +1313,8 @@ export function planStaticObjectRewrites(
/** Whether `node` sits inside a function of its top-level statement. */
const insideFunction = (node: Node): boolean => {
const top = topStatement(node);
- for (let p: Node | undefined = node.parent; p !== undefined && p !== top; p = p.parent)
- if (ts.isFunctionLike(p)) return true;
+ for (let c = node, p = node.parent; p !== undefined && p !== top; c = p, p = p.parent)
+ if (runsWithFunction(p, c)) return true;
return false;
};
/** The nodes whose statements must have run for an effect-free receiver
@@ -1359,8 +1366,8 @@ export function planStaticObjectRewrites(
const top = topStatement(read)!;
if (!insideFunction(read)) return stmts.indexOf(top) > last;
let fn: Node | undefined;
- for (let p: Node | undefined = read.parent; p !== undefined && p !== top; p = p.parent)
- if (ts.isFunctionLike(p)) fn = p;
+ for (let c = read, p = read.parent; p !== undefined && p !== top; c = p, p = p.parent)
+ if (runsWithFunction(p, c)) fn = p;
const from = fn !== undefined && ts.isFunctionDeclaration(fn) ? 0 : stmts.indexOf(top);
return stmts.slice(from, last + 1).every(definitionOnly);
};
diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts
index 34ea20b28..0e609116d 100644
--- a/packages/compiler/src/index.ts
+++ b/packages/compiler/src/index.ts
@@ -1,5 +1,6 @@
import { createRequire } from "node:module";
import { setShapeOverlayMemoDir, shapeOverlayEnabled } from "./frontend/shape-inference/index.js";
+import { jsInferenceEnabled } from "./frontend/js-inference.js";
import { nodeFrontendServices } from "./frontend/services-node.js";
import {
runTypePatchCommand,
@@ -762,6 +763,7 @@ async function compileExecutableNative(
...(features.eventTarget ? { eventTarget: true } : {}),
...(features.wasm ? { wasm: true } : {}),
...(features.warningReplace ? { warningReplace: true } : {}),
+ ...(features.recordViewPartial ? { recordViewPartial: true } : {}),
...(features.stdioExtended ? { stdioExtended: true } : {}),
...(features.streamWriteErrors ? { streamWriteErrors: true } : {}),
...(features.stdioWriteSlot ? { stdioWriteSlot: true } : {}),
@@ -1941,6 +1943,9 @@ async function compileTracked(
...(heapCensusRequested() ? { heapCensus: true as const } : {}),
npmStatic: opts.npmStatic ?? null,
...(shapeOverlayEnabled() ? { shapeOverlay: true as const } : {}),
+ ...(jsInferenceEnabled() && process.env["SCRIPTC_SHAPE_OVERLAY"] !== "0"
+ ? { jsInference: true as const }
+ : {}),
ffiProfile:
opts.ffiProfilePath === undefined || ffiProfileBytes === null
? null
diff --git a/packages/compiler/src/ir/codec.generated.ts b/packages/compiler/src/ir/codec.generated.ts
index e576b30e2..b3d15dfae 100644
--- a/packages/compiler/src/ir/codec.generated.ts
+++ b/packages/compiler/src/ir/codec.generated.ts
@@ -5,7 +5,7 @@ import type { IrReader, IrWriter } from "./codec.js";
/** A digest of the coders below: an encoding written with other coders is
* refused instead of misread, also after a change that keeps IR_VERSION. */
-export const IR_CODEC_LAYOUT = "5940048d92e96642";
+export const IR_CODEC_LAYOUT = "c8e9ba1a465defc2";
/** Writes `mod` field by field (codec.ts describes the bytes). */
export function encodeIrModule(w: IrWriter, mod: IrModule): void {
@@ -25,7 +25,7 @@ export function decodeIrFunction(r: IrReader): IrFunction {
}
const KEYS0: readonly string[] = ["irVersion","sourceFile","functions","classes","globals","records","unions","entry","workers","declBoundary","inferredFactFiles","declOrigins","lifetimeRegions","foreignElementKeys","threadSlotKeys","freezableArrays","sloppyDynWrites","untypedIntegrity","commonJsEntry","nodeIsland","ffiImports","lib","contracts","int32ArrayCandidates","wasmModules","wasmUntranslated","wasmCustomSections"];
-const KEYS1: readonly string[] = ["name","sourceName","ownsPrototype","params","returnType","locals","captures","classCaptures","body","loc","tier","speculativeDispatch","fastRead","plainLoops","async","generator","asyncCacheGlobal","asyncCycleCacheGlobal"];
+const KEYS1: readonly string[] = ["name","sourceName","ownsPrototype","params","returnType","locals","captures","classCaptures","body","loc","jsEntry","tier","speculativeDispatch","fastRead","plainLoops","async","generator","asyncCacheGlobal","asyncCycleCacheGlobal"];
const KEYS2: readonly string[] = ["libnode","nodeVersion","modules"];
const KEYS3: readonly string[] = ["profileName","prefix","initSymbol","sinkRegisterSymbol","collectSymbol","resultResetSymbol","threadInstances","callbackRegisterSymbol","callbacks","exports","trapOverlays","identity"];
const KEYS4: readonly string[] = ["int32Fields","int32Params","int32Returns","int32Locals","int32Elements","noThrow","acyclic","immortalGlobals","programLifetime","inlineArrays"];
@@ -150,7 +150,7 @@ const KEYS122: readonly string[] = ["kind","fnName","captures","adapts","type","
const KEYS123: readonly string[] = ["kind","value","type","loc"];
const KEYS124: readonly string[] = ["kind","elems","type","loc"];
const KEYS125: readonly string[] = ["kind","callee","receiver","calleeName","calleeNameValue","args","spreads","type","loc"];
-const KEYS126: readonly string[] = ["kind","value","preserveRefs","adopt","exact","inferredSite","origin","type","loc"];
+const KEYS126: readonly string[] = ["kind","value","preserveRefs","adopt","exact","partialView","inferredSite","origin","type","loc"];
const KEYS127: readonly string[] = ["kind","value","spelling","firstProp","type","loc"];
const KEYS128: readonly string[] = ["kind","value","type","loc","liveRef","fnName","fnLength"];
const KEYS129: readonly string[] = ["kind","key","negated","value","type","loc"];
@@ -424,6 +424,11 @@ function encIrFunction(w: IrWriter, v: IrFunction): void {
}
encT19(w, v.body);
encSrcLoc(w, v.loc);
+ if (v.jsEntry === undefined) w.byte(0);
+ else {
+ w.byte(1);
+ w.str(v.jsEntry);
+ }
if (v.tier === undefined) w.byte(0);
else {
w.byte(1);
@@ -476,15 +481,16 @@ function decIrFunction(r: IrReader): IrFunction {
const f7 = r.tag(2, "IrFunction.classCaptures") === 0 ? undefined : decT18(r);
const f8 = decT19(r);
const f9 = decSrcLoc(r);
- const f10 = r.tag(2, "IrFunction.tier") === 0 ? undefined : (r.str() as NonNullable);
- const f11 = r.tag(2, "IrFunction.speculativeDispatch") === 0 ? undefined : r.bool();
- const f12 = r.tag(2, "IrFunction.fastRead") === 0 ? undefined : (r.str() as NonNullable);
- const f13 = r.tag(2, "IrFunction.plainLoops") === 0 ? undefined : (r.bool() as NonNullable);
- const f14 = r.tag(2, "IrFunction.async") === 0 ? undefined : (r.bool() as NonNullable);
- const f15 = r.tag(2, "IrFunction.generator") === 0 ? undefined : decT21(r);
- const f16 = r.tag(2, "IrFunction.asyncCacheGlobal") === 0 ? undefined : r.str();
- const f17 = r.tag(2, "IrFunction.asyncCycleCacheGlobal") === 0 ? undefined : r.str();
- const o: IrFunction = { name: f0, ...(f1 !== undefined ? { sourceName: f1 } : {}), ...(f2 !== undefined ? { ownsPrototype: f2 } : {}), params: f3, returnType: f4, locals: f5, ...(f6 !== undefined ? { captures: f6 } : {}), ...(f7 !== undefined ? { classCaptures: f7 } : {}), body: f8, loc: f9, ...(f10 !== undefined ? { tier: f10 } : {}), ...(f11 !== undefined ? { speculativeDispatch: f11 } : {}), ...(f12 !== undefined ? { fastRead: f12 } : {}), ...(f13 !== undefined ? { plainLoops: f13 } : {}), ...(f14 !== undefined ? { async: f14 } : {}), ...(f15 !== undefined ? { generator: f15 } : {}), ...(f16 !== undefined ? { asyncCacheGlobal: f16 } : {}), ...(f17 !== undefined ? { asyncCycleCacheGlobal: f17 } : {}) };
+ const f10 = r.tag(2, "IrFunction.jsEntry") === 0 ? undefined : r.str();
+ const f11 = r.tag(2, "IrFunction.tier") === 0 ? undefined : (r.str() as NonNullable);
+ const f12 = r.tag(2, "IrFunction.speculativeDispatch") === 0 ? undefined : r.bool();
+ const f13 = r.tag(2, "IrFunction.fastRead") === 0 ? undefined : (r.str() as NonNullable);
+ const f14 = r.tag(2, "IrFunction.plainLoops") === 0 ? undefined : (r.bool() as NonNullable);
+ const f15 = r.tag(2, "IrFunction.async") === 0 ? undefined : (r.bool() as NonNullable);
+ const f16 = r.tag(2, "IrFunction.generator") === 0 ? undefined : decT21(r);
+ const f17 = r.tag(2, "IrFunction.asyncCacheGlobal") === 0 ? undefined : r.str();
+ const f18 = r.tag(2, "IrFunction.asyncCycleCacheGlobal") === 0 ? undefined : r.str();
+ const o: IrFunction = { name: f0, ...(f1 !== undefined ? { sourceName: f1 } : {}), ...(f2 !== undefined ? { ownsPrototype: f2 } : {}), params: f3, returnType: f4, locals: f5, ...(f6 !== undefined ? { captures: f6 } : {}), ...(f7 !== undefined ? { classCaptures: f7 } : {}), body: f8, loc: f9, ...(f10 !== undefined ? { jsEntry: f10 } : {}), ...(f11 !== undefined ? { tier: f11 } : {}), ...(f12 !== undefined ? { speculativeDispatch: f12 } : {}), ...(f13 !== undefined ? { fastRead: f13 } : {}), ...(f14 !== undefined ? { plainLoops: f14 } : {}), ...(f15 !== undefined ? { async: f15 } : {}), ...(f16 !== undefined ? { generator: f16 } : {}), ...(f17 !== undefined ? { asyncCacheGlobal: f17 } : {}), ...(f18 !== undefined ? { asyncCycleCacheGlobal: f18 } : {}) };
return o;
}
@@ -2923,6 +2929,11 @@ function encIrExpr(w: IrWriter, v: IrExpr): void {
w.byte(1);
w.bool(v.exact);
}
+ if (v.partialView === undefined) w.byte(0);
+ else {
+ w.byte(1);
+ w.bool(v.partialView);
+ }
if (v.inferredSite === undefined) w.byte(0);
else {
w.byte(1);
@@ -3953,11 +3964,12 @@ function decIrExpr(r: IrReader): IrExpr {
const f1 = r.tag(2, "IrFunction.body[].arr.preserveRefs") === 0 ? undefined : (r.bool() as NonNullable["preserveRefs"]>);
const f2 = r.tag(2, "IrFunction.body[].arr.adopt") === 0 ? undefined : (r.bool() as NonNullable["adopt"]>);
const f3 = r.tag(2, "IrFunction.body[].arr.exact") === 0 ? undefined : (r.bool() as NonNullable["exact"]>);
- const f4 = r.tag(2, "IrFunction.body[].arr.inferredSite") === 0 ? undefined : r.str();
- const f5 = r.tag(2, "IrFunction.body[].arr.origin") === 0 ? undefined : r.num();
- const f6 = decIrType(r);
- const f7 = decSrcLoc(r);
- const o: Extract = { kind: "dynCheck", value: f0, ...(f1 !== undefined ? { preserveRefs: f1 } : {}), ...(f2 !== undefined ? { adopt: f2 } : {}), ...(f3 !== undefined ? { exact: f3 } : {}), ...(f4 !== undefined ? { inferredSite: f4 } : {}), ...(f5 !== undefined ? { origin: f5 } : {}), type: f6, loc: f7 };
+ const f4 = r.tag(2, "IrFunction.body[].arr.partialView") === 0 ? undefined : (r.bool() as NonNullable["partialView"]>);
+ const f5 = r.tag(2, "IrFunction.body[].arr.inferredSite") === 0 ? undefined : r.str();
+ const f6 = r.tag(2, "IrFunction.body[].arr.origin") === 0 ? undefined : r.num();
+ const f7 = decIrType(r);
+ const f8 = decSrcLoc(r);
+ const o: Extract = { kind: "dynCheck", value: f0, ...(f1 !== undefined ? { preserveRefs: f1 } : {}), ...(f2 !== undefined ? { adopt: f2 } : {}), ...(f3 !== undefined ? { exact: f3 } : {}), ...(f4 !== undefined ? { partialView: f4 } : {}), ...(f5 !== undefined ? { inferredSite: f5 } : {}), ...(f6 !== undefined ? { origin: f6 } : {}), type: f7, loc: f8 };
return o;
}
case 26: {
diff --git a/packages/compiler/src/ir/codec.test.ts b/packages/compiler/src/ir/codec.test.ts
index ce443af52..0ce314b25 100644
--- a/packages/compiler/src/ir/codec.test.ts
+++ b/packages/compiler/src/ir/codec.test.ts
@@ -17,7 +17,8 @@ import { F64, STRING, VOID, type IrFunction, type IrModule } from "./ir.js";
import { deserializeModuleBytes } from "./serialize.js";
// Programs with many node kinds: classes, closures, async, generators,
-// dynamic values, streams, records, regexes, lone surrogates and BigIntStats.
+// dynamic values, closures with jsval entries, streams, records, regexes,
+// lone surrogates and BigIntStats.
const PROGRAMS = [
"1003-json-parse-unions.ts",
"114-live-record-delete.ts",
@@ -29,6 +30,7 @@ const PROGRAMS = [
"7018-bigint-stats.ts",
"7110-als-completion-callbacks.ts",
"7321-speculative-loop-bail-sites.ts",
+ "8601-dynamic-function-value-numbers.js",
];
/** Whether `a` and `b` hold the same data: the same keys with defined
@@ -137,6 +139,21 @@ test("absent optional fields stay absent", () => {
expect(Object.keys(back.functions[0]!).sort()).toEqual(Object.keys(source.functions[0]!).sort());
});
+test("a closure body keeps the name of its jsval entry", () => {
+ const source: IrFunction = {
+ name: "%fn.add",
+ params: [],
+ returnType: VOID,
+ locals: [],
+ body: [{ kind: "return", value: null, loc }],
+ loc,
+ jsEntry: "%js.%fn.add",
+ };
+ expect(decodeFunctionBinary(encodeFunctionBinary(source)).jsEntry).toBe("%js.%fn.add");
+ const back = decodeFunctionBinary(encodeFunctionInOrder(source)!);
+ expect(JSON.stringify(back)).toBe(JSON.stringify(source));
+});
+
test("a function whose objects follow the declared field order reads back with its key order", () => {
// As lowering builds functions: the source name after the name, and the
// async flag set after the literal.
diff --git a/packages/compiler/src/ir/fields.generated.ts b/packages/compiler/src/ir/fields.generated.ts
index 124d4a483..757c17362 100644
--- a/packages/compiler/src/ir/fields.generated.ts
+++ b/packages/compiler/src/ir/fields.generated.ts
@@ -2119,10 +2119,11 @@ export type IrFieldsCurrent = [
Unread["spreads"]>, readonly unknown[]>[number]>, object>, "arg" | "what">>,
Unread["spreads"]>, readonly unknown[]>[number]>, object>["arg"]>>,
Unread, "kind" | "callee" | "receiver" | "calleeName" | "calleeNameValue" | "args" | "spreads" | "type" | "loc">>,
- Unread, "kind" | "value" | "preserveRefs" | "adopt" | "exact" | "inferredSite" | "origin" | "type" | "loc">>,
+ Unread, "kind" | "value" | "preserveRefs" | "adopt" | "exact" | "partialView" | "inferredSite" | "origin" | "type" | "loc">>,
Unread["preserveRefs"]>>,
Unread["adopt"]>>,
Unread["exact"]>>,
+ Unread["partialView"]>>,
Unread["origin"]>>,
Unread, "kind" | "value" | "spelling" | "firstProp" | "type" | "loc">>,
Unread, "kind" | "value" | "type" | "loc" | "liveRef" | "fnName" | "fnLength">>,
diff --git a/packages/compiler/src/ir/function-references.test.ts b/packages/compiler/src/ir/function-references.test.ts
index 68e6e379e..b5f2cadae 100644
--- a/packages/compiler/src/ir/function-references.test.ts
+++ b/packages/compiler/src/ir/function-references.test.ts
@@ -134,6 +134,7 @@ const TYPE_FIELDS: Classified = {
const FUNCTION_FIELDS: Record>, Role> = {
name: "other",
sourceName: "other",
+ jsEntry: "reference",
asyncCacheGlobal: "other",
asyncCycleCacheGlobal: "other",
"params[].name": "other",
@@ -292,6 +293,11 @@ const REFERENCE_FIXTURES: Record IrModule> = {
}),
]),
entry: () => ({ ...module(), entry: TARGET }),
+ "functions[].jsEntry": () => {
+ const mod = module();
+ mod.functions.push({ ...entry(), name: "%m0.closure", captures: [], jsEntry: TARGET });
+ return mod;
+ },
"classes[].prototypeDataHelper": () => ({
...module(),
classes: [{ name: "%m0.Cell", fields: [], prototypeDataHelper: TARGET, loc }],
diff --git a/packages/compiler/src/ir/function-references.ts b/packages/compiler/src/ir/function-references.ts
index 61e6e2797..b4e6b06dc 100644
--- a/packages/compiler/src/ir/function-references.ts
+++ b/packages/compiler/src/ir/function-references.ts
@@ -14,13 +14,15 @@ export function exprFunctionReference(expr: IrExpr): string | undefined {
/** The expression kinds exprFunctionReference answers for. */
export const FUNCTION_REFERENCE_KINDS = ["closure", "orDefault"] as const;
-/** Functions the module's metadata names: the entry, library exports and
- * the class prototype helpers the runtime calls. Class methods and
- * constructors follow the `%.` convention instead of being
- * named here; callers that need them derive them from `mod.classes`. */
+/** Functions the module's metadata names: the entry, library exports,
+ * closures' jsval entries and the class prototype helpers the runtime
+ * calls. Class methods and constructors follow the `%.`
+ * convention instead of being named here; callers that need them derive
+ * them from `mod.classes`. */
export function moduleFunctionReferences(mod: IrModule): string[] {
const names = [mod.entry];
for (const entry of mod.lib?.exports ?? []) names.push(entry.fnName);
+ for (const fn of mod.functions) if (fn.jsEntry !== undefined) names.push(fn.jsEntry);
for (const cls of mod.classes ?? []) {
if (cls.prototypeDataHelper !== undefined) names.push(cls.prototypeDataHelper);
if (cls.instancePrototypeHelper !== undefined) names.push(cls.instancePrototypeHelper);
diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts
index a49ef234e..dfa266c80 100644
--- a/packages/compiler/src/ir/ir.ts
+++ b/packages/compiler/src/ir/ir.ts
@@ -1984,6 +1984,10 @@ export interface IrFunction {
classCaptures?: (IrParam & { slot: number })[];
body: IrStmt[];
loc: SrcLoc;
+ /** A closure body's jsval entry (ir/jsval.ts): a function with the same
+ * captures that takes jsval parameters. The backend gives each function
+ * value boxed from this closure a jsval call entry into it. */
+ jsEntry?: string;
/** The function's execution tier (ir/tiers.ts). Absent means static: the
* function may use dynamic values only through boundary operations
* (dynFrom, dynCheck, kind tests) and moves, which the validator enforces
@@ -3538,7 +3542,10 @@ export type IrExpr =
* message exactly). A function callee calls through the boxed thunk:
* per-arg validation against the boxed signature (mismatches throw the
* path-annotated TypeError), result converted back to dyn. Callee and
- * args are borrowed; the result is owned (+1). MAY THROW.
+ * args are borrowed; the result is owned (+1). MAY THROW. In a
+ * dynamic-tier function the jsval pass may make an argument of the
+ * fixed-arity form a jsval (ir/jsval.ts): a callee with a jsval entry
+ * takes it as is, any other callee gets it boxed.
*
* `spreads` (the runtime-arity form — `f(...args)`, the rest-forwarding
* idiom): entries name indices into `args` whose dyn values FLATTEN into
@@ -3570,7 +3577,8 @@ export type IrExpr =
* the kind's prototype lacks throws Node's catchable " is
* not a function"; OBJ receivers call the own member (own properties
* shadow prototypes in JS too); undefined/null receivers throw Node's
- * "Cannot read properties of ...". Arguments are already dyn.
+ * "Cannot read properties of ...". Arguments are already dyn, or jsvals
+ * as for dynCall.
* `calleeName` is the source spelling for the error texts. Receiver and
* args are borrowed; the result is owned (+1). MAY THROW. */
| {
@@ -4114,6 +4122,13 @@ export type IrExpr =
/** `exact`: the value is known to be a capsule this program boxed from
* exactly the class `type` names (a `dyn.typedRefKeyIs` test guards the
* expression), so the check is one load of the capsule's instance. */
+ /** `partialView`: `value` is `dynFrom{liveRef}` of a record whose fields
+ * are a SUBSET of `type`'s, with equal types — a partial record literal
+ * asserted to a wider record type (`{ truncated: true } as
+ * TruncationResult`). The conversion answers a partial record view
+ * (record-views.ts): a `type` value whose fields the source lacks are
+ * absent, so presence tests answer false and reads of them throw the
+ * catchable TypeError. */
/** `adopt`: the declaration-typed boundary's cast (decl-projection.ts).
* The conversion preserves identity: a source object or array that
* something else still references (reference count above one) is
@@ -4132,6 +4147,7 @@ export type IrExpr =
preserveRefs?: true;
adopt?: true;
exact?: true;
+ partialView?: true;
/** Shape inference's overlay: this conversion checks an
* analysis-proven fact (" :"); a failure is a
* compiler analysis bug and reports itself as one. */
diff --git a/packages/compiler/src/ir/js-ic.test.ts b/packages/compiler/src/ir/js-ic.test.ts
index 35de18085..eb471da7c 100644
--- a/packages/compiler/src/ir/js-ic.test.ts
+++ b/packages/compiler/src/ir/js-ic.test.ts
@@ -305,10 +305,18 @@ test("a function holds a site when it calls a classified helper or dispatches a
expect(hasJsIcSite(caller, sites)).toBe(true);
expect(hasJsIcSite(invoker, sites)).toBe(true);
expect(hasJsIcSite(plain, sites)).toBe(false);
- // Module initializers keep plain method dispatch (top-level handle
- // wiring), while their property sites still cache.
+ // Module initializers keep plain method dispatch at top level (handle
+ // wiring), while their property sites still cache, and so do method
+ // sites in their loops.
expect(hasJsIcSite({ ...invoker, name: "%init.0" }, sites)).toBe(false);
expect(hasJsIcSite({ ...caller, name: "%init.0" }, sites)).toBe(true);
+ const looped: IrStmt = {
+ kind: "while",
+ cond: { kind: "boolLit", value: false, type: BOOL, loc },
+ body: invoker.body,
+ loc,
+ };
+ expect(hasJsIcSite({ ...invoker, name: "%init.0", body: [looped] }, sites)).toBe(true);
});
test("a function holds a site when an object literal has a constant-key member", () => {
diff --git a/packages/compiler/src/ir/js-ic.ts b/packages/compiler/src/ir/js-ic.ts
index 5a483c585..f4e583cf1 100644
--- a/packages/compiler/src/ir/js-ic.ts
+++ b/packages/compiler/src/ir/js-ic.ts
@@ -18,13 +18,13 @@
* operation's semantics. This module recognizes the helpers by structure,
* never by name alone: a helper whose body is not exactly capsule branches
* plus the generic tail is not a site. `dynInvoke` is a site too: its
- * method read on an ordinary object is a [[Get]] (outside module
- * initializers, see jsIcMethodSites).
+ * method read on an ordinary object is a [[Get]] (in module initializers,
+ * only inside loops; see jsIcMethodSites).
*
* Static functions never contain sites (invariant I1): the backend only
* consults this module for calls in dynamic-tier functions. */
import type { IrExpr, IrFunction, IrModule, IrStmt } from "./ir.js";
-import { everyStmtList } from "./traverse.js";
+import { everyExprChild, everyStmtChild } from "./traverse.js";
export type JsIcSite =
| { kind: "get"; key: string }
@@ -299,18 +299,33 @@ function unwrapFromDyn(e: IrExpr | null | undefined): IrExpr | null {
/** Whether a function holds a cache site: a call to a classified helper,
* a method dispatch, or an object literal member with a constant key. */
export function hasJsIcSite(fn: IrFunction, sites: JsIcSites): boolean {
- const methods = jsIcMethodSites(fn.name);
- return !everyStmtList(fn.body, {
- expr: (e) =>
- !(
- (methods && e.kind === "dynInvoke") ||
- (methods &&
- e.kind === "dynObjLit" &&
- (e.fields ?? []).some((f) => jsIcLiteralKey(f.key) !== null)) ||
- (e.kind === "call" && sites.site(e.callee) !== null)
- ),
- stmt: () => true,
- });
+ const site = (e: IrExpr, inLoop: boolean): boolean =>
+ (jsIcMethodSites(fn.name, inLoop) &&
+ (e.kind === "dynInvoke" ||
+ (e.kind === "dynObjLit" &&
+ (e.fields ?? []).some((f) => jsIcLiteralKey(f.key) !== null)))) ||
+ (e.kind === "call" && sites.site(e.callee) !== null);
+ const expr = (e: IrExpr, inLoop: boolean): boolean =>
+ !site(e, inLoop) &&
+ everyExprChild(
+ e,
+ (child) => expr(child, inLoop),
+ (child) => stmt(child, inLoop),
+ );
+ const stmt = (s: IrStmt, inLoop: boolean): boolean => {
+ const inner =
+ inLoop ||
+ s.kind === "while" ||
+ s.kind === "doWhile" ||
+ s.kind === "for" ||
+ s.kind === "forOf";
+ return everyStmtChild(
+ s,
+ (child) => expr(child, inner),
+ (child) => stmt(child, inner),
+ );
+ };
+ return !fn.body.every((s) => stmt(s, false));
}
/** The key of an object literal member that its site adds through the
@@ -318,18 +333,19 @@ export function hasJsIcSite(fn: IrFunction, sites: JsIcSites): boolean {
* literal starts as an empty object with the default prototype, so each
* such member appends one data property, and the site caches the shape
* transition and shares the key bytes the shape holds. Module
- * initializers have no literal sites (jsIcMethodSites). */
+ * initializers have literal sites only in loops (jsIcMethodSites). */
export function jsIcLiteralKey(key: IrExpr): string | null {
return key.kind === "strLit" && key.value !== "__proto__" ? key.value : null;
}
-/** Whether method dispatch in a function goes through caches. Module
- * initializers (`%init.N`) run once, and the methods they call at top level
- * are overwhelmingly registrations on native handles (`worker.on(...)`,
- * `server.listen(...)`), which a cache never answers: they keep the plain
- * dispatch, so a typed program whose only dynamic code is such top-level
- * wiring links no dynamic-tier runtime. Property sites still cache there
- * (top-level loops over objects). */
-export function jsIcMethodSites(fnName: string): boolean {
- return !fnName.startsWith("%init.");
+/** Whether method dispatch at a site of a function goes through caches.
+ * Module initializers (`%init.N`) run once, and the methods they call at
+ * top level are overwhelmingly registrations on native handles
+ * (`worker.on(...)`, `server.listen(...)`), which a cache never answers:
+ * they keep the plain dispatch, so a typed program whose only dynamic code
+ * is such top-level wiring links no dynamic-tier runtime. A site inside a
+ * loop of an initializer runs once per iteration, so it caches. Property
+ * sites cache everywhere. */
+export function jsIcMethodSites(fnName: string, inLoop: boolean): boolean {
+ return inLoop || !fnName.startsWith("%init.");
}
diff --git a/packages/compiler/src/ir/jsval.test.ts b/packages/compiler/src/ir/jsval.test.ts
index d2eadf18d..9435e6436 100644
--- a/packages/compiler/src/ir/jsval.test.ts
+++ b/packages/compiler/src/ir/jsval.test.ts
@@ -316,8 +316,12 @@ test("a parameter that callers pass numbers and operator results takes a jsval",
});
test("a parameter read by dyn consumers or passed cells keeps the dyn ABI", () => {
- // keyed(o) { return o.x }: the read needs a cell, so a jsval would box.
- const keyed = fn("keyed", [{ kind: "return", value: keyGet(ref("o"), "x"), loc }]);
+ // keyed(o) { o.y; return o.x }: both reads need a cell, so a jsval would
+ // box twice where the caller boxes once.
+ const keyed = fn("keyed", [
+ { kind: "exprStmt", expr: keyGet(ref("o"), "y"), loc },
+ { kind: "return", value: keyGet(ref("o"), "x"), loc },
+ ]);
// half(o) { return o / 2 }, called with property reads only.
const half = fn("half", [
{ kind: "return", value: op("dyn.arithmetic", ref("o"), num(2), "/"), loc },
@@ -407,3 +411,93 @@ test("a module global a static function reads stays dyn", () => {
]);
expect(validateModule(mod)).toEqual([]);
});
+
+test("a class member that dynamic code calls directly gets a jsval entry", () => {
+ // %Counter.scale(o) { return o * 2 }: vtables and the runtime reach the
+ // member by name, so it keeps the dyn ABI as a wrapper.
+ const member = fn("%Counter.scale", [
+ { kind: "return", value: op("dyn.arithmetic", ref("o"), num(2), "*"), loc },
+ ]);
+ member.sourceName = "scale";
+ const caller = fn("caller", [
+ { kind: "return", value: op("dyn.add", call("%Counter.scale", num(1.5)), num(1)), loc },
+ ]);
+ const staticCaller = fn("staticCaller", [
+ { kind: "exprStmt", expr: call("%Counter.scale", ref("o")), loc },
+ { kind: "return", value: ref("o"), loc },
+ ]);
+ const mod = moduleOf(member, caller, staticCaller);
+ promoteJsvals(mod);
+ const entry = mod.functions.find((f) => f.name === "%js.%Counter.scale")!;
+ expect(entry.params[0]!.type).toEqual(JSVAL);
+ expect(entry.returnType).toEqual(JSVAL);
+ expect(entry.sourceName).toBe("scale");
+ expect(jsCalls(entry.body)).toEqual(["js.arithmetic", "js.fromF64"]);
+ // The member forwards its cell and boxes the entry's result.
+ expect(member.params[0]!.type).toEqual(DYN);
+ expect(member.returnType).toEqual(DYN);
+ expect(member.sourceName).toBeUndefined();
+ expect(member.body).toHaveLength(1);
+ expect(jsCalls(member.body)).toEqual(["js.toDyn", "js.fromDyn"]);
+ // Dynamic callers call the entry; static callers keep the member.
+ expect(JSON.stringify(caller.body)).toContain('"callee":"%js.%Counter.scale"');
+ expect(JSON.stringify(staticCaller.body)).toContain('"callee":"%Counter.scale"');
+ expect(validateModule(mod)).toEqual([]);
+});
+
+test("a closure boxed into a function value gets a jsval body, and dynamic calls pass jsvals", () => {
+ // %fn.add(o) { return o + 1 }, boxed by a dynamic function and called
+ // through dynInvoke with an operator result.
+ const closure = fn("%fn.add", [{ kind: "return", value: op("dyn.add", ref("o"), num(1)), loc }]);
+ closure.captures = [];
+ // %fn.read(o) { return o.x }: a property read needs the cell.
+ const reader = fn("%fn.read", [{ kind: "return", value: keyGet(ref("o"), "x"), loc }]);
+ reader.captures = [];
+ const funcType = { kind: "func" as const, params: [DYN], ret: DYN };
+ const box = (name: string): IrExpr => ({
+ kind: "dynFrom",
+ value: { kind: "closure", fnName: name, captures: [], type: funcType, loc },
+ type: DYN,
+ loc,
+ });
+ const user = fn("user", [
+ { kind: "exprStmt", expr: box("%fn.add"), loc },
+ { kind: "exprStmt", expr: box("%fn.read"), loc },
+ {
+ kind: "return",
+ value: {
+ kind: "dynInvoke",
+ recv: ref("o"),
+ method: "add",
+ calleeName: "o.add",
+ args: [op("dyn.add", keyGet(ref("o"), "n"), num(1)), ref("o")],
+ type: DYN,
+ loc,
+ },
+ loc,
+ },
+ ]);
+ const mod = moduleOf(closure, reader, user);
+ promoteJsvals(mod);
+ expect(closure.jsEntry).toBe("%js.%fn.add");
+ expect(reader.jsEntry).toBeUndefined();
+ const entry = mod.functions.find((f) => f.name === "%js.%fn.add")!;
+ expect(entry.captures).toEqual([]);
+ expect(entry.params[0]!.type).toEqual(JSVAL);
+ expect(entry.returnType).toEqual(JSVAL);
+ // The closure keeps its dyn body for every other caller.
+ expect(closure.params[0]!.type).toEqual(DYN);
+ expect(closure.returnType).toEqual(DYN);
+ // The operator result crosses unboxed; the cell argument stays dyn.
+ let invoke: Extract | undefined;
+ const find = (value: unknown): void => {
+ if (Array.isArray(value)) value.forEach(find);
+ else if (value !== null && typeof value === "object") {
+ if ((value as IrExpr).kind === "dynInvoke") invoke = value as typeof invoke;
+ Object.values(value).forEach(find);
+ }
+ };
+ find(user.body);
+ expect(invoke!.args.map((a) => a.type.kind)).toEqual(["jsval", "dyn"]);
+ expect(validateModule(mod)).toEqual([]);
+});
diff --git a/packages/compiler/src/ir/jsval.ts b/packages/compiler/src/ir/jsval.ts
index fad4a55c7..cea14e446 100644
--- a/packages/compiler/src/ir/jsval.ts
+++ b/packages/compiler/src/ir/jsval.ts
@@ -33,6 +33,15 @@
* - a dyn local becomes a jsval local when the boxing it saves on writes
* of operator results outweighs the boxing its reads by dyn consumers add
* (loop depth weights both);
+ * - a function that dynamic-tier functions call directly but that is also
+ * reached otherwise (a class member, a function value, a static caller)
+ * gets a jsval entry `%js.` holding its body when one of its
+ * parameters qualifies the same way: the direct calls of dynamic-tier
+ * functions call the entry, and the function itself becomes a wrapper
+ * that keeps the dyn ABI for everyone else;
+ * - a closure whose body uses a parameter only as an operand gets a jsval
+ * copy of its body (IrFunction.jsEntry), which the function values boxed
+ * from it enter with jsval arguments from dynCall and dynInvoke sites;
* - a dyn module global becomes a jsval global by the same measure, summed
* over every function, when only dynamic-tier functions reference it.
*
@@ -47,14 +56,25 @@
* Async functions and generators are rewritten like other functions (their
* suspensions keep jsvals like any pointer) but keep the dyn result ABI. */
import { exprFunctionReference, moduleFunctionReferences } from "./function-references.js";
-import { DYN, JSVAL, type IrExpr, type IrFunction, type IrModule, type IrStmt } from "./ir.js";
+import {
+ DYN,
+ JSVAL,
+ type IrExpr,
+ type IrFunction,
+ type IrLocal,
+ type IrModule,
+ type IrParam,
+ type IrStmt,
+} from "./ir.js";
import { JsIcSites } from "./js-ic.js";
+import { reparseStmt } from "./serialize.js";
import {
everyExprChild,
everyStmtChild,
everyStmtList,
mapExprChildren,
mapStmtChildren,
+ transformStmtList,
} from "./traverse.js";
/** dyn operators whose result is a fresh dynamic value, with their jsval
@@ -117,11 +137,18 @@ export function promoteJsvals(mod: IrModule): number {
const sites = new JsIcSites(mod);
const storesJs = (e: IrExpr): boolean =>
e.kind === "call" && sites.site(e.callee)?.kind === "set";
- const params = jsvalParams(mod, direct, results, storesJs);
- const globals = promotableGlobals(mod, results, params, storesJs);
+ const candidates = entryCandidates(mod, direct, sites);
+ const params = jsvalParams(mod, direct, candidates, results, sites, storesJs);
+ splitEntries(mod, candidates, params, results);
+ const jsDynArguments = closureEntries(mod, params, results, storesJs);
+ const globals = promotableGlobals(mod, results, params, storesJs, jsDynArguments);
let count = 0;
for (const fn of mod.functions)
- if (jsvalEligible(fn) && promoteFunction(fn, results, params, storesJs, globals)) count++;
+ if (
+ jsvalEligible(fn) &&
+ promoteFunction(fn, results, params, storesJs, globals, jsDynArguments)
+ )
+ count++;
for (const fn of mod.functions) if (results.has(fn.name)) fn.returnType = JSVAL;
for (const global of mod.globals ?? []) if (globals.has(global.id)) global.type = JSVAL;
return count;
@@ -162,60 +189,279 @@ function directCallees(mod: IrModule): Set {
return results;
}
-/** The parameters of direct callees that take a jsval, by position. A dyn
- * parameter of a direct callee other than a library helper (`%dyn.*`,
- * whose calls the inline caches emit) is a candidate. Its credit is the
- * share of its arguments, weighted by the callers' loop depth, that the
- * callers compute as jsvals (operator results, jsval results and static
- * numbers): the boxing a call saves. promotableLocals weighs it against
- * the callee's reads of the parameter by dyn consumers. */
+/** Functions that dynamic-tier functions call directly but that are also
+ * reached otherwise, so they keep the dyn ABI: candidates for a jsval
+ * entry (splitEntries). Library helpers (`%dyn.*`), inline-cache sites, the
+ * entry, closures (`captures`, `selfRef`) and suspending functions are
+ * left out. */
+function entryCandidates(
+ mod: IrModule,
+ direct: ReadonlySet,
+ sites: JsIcSites,
+): Set {
+ const called = new Set();
+ for (const fn of mod.functions) {
+ if (fn.tier !== "dynamic") continue;
+ everyStmtList(fn.body, {
+ expr: (e) => {
+ if (e.kind === "call") called.add(e.callee);
+ return true;
+ },
+ stmt: () => true,
+ });
+ }
+ const candidates = new Set();
+ for (const fn of mod.functions) {
+ if (!called.has(fn.name) || direct.has(fn.name) || fn.name === mod.entry) continue;
+ if (!jsvalEligible(fn) || fn.async !== undefined || fn.generator !== undefined) continue;
+ if (fn.captures !== undefined || fn.classCaptures !== undefined) continue;
+ if (fn.speculativeDispatch !== undefined || fn.fastRead !== undefined) continue;
+ if (fn.name.startsWith("%dyn.") || sites.site(fn.name) !== null) continue;
+ if (!fn.params.some((p) => p.type.kind === "dyn")) continue;
+ const plain = everyStmtList(fn.body, { expr: (e) => e.kind !== "selfRef", stmt: () => true });
+ if (plain) candidates.add(fn.name);
+ }
+ return candidates;
+}
+
+/** The parameters that take a jsval, by function and position: parameters
+ * of direct callees and entry candidates, other than inline-cache sites.
+ * A parameter's credit is the share of its arguments, weighted by the
+ * callers' loop depth, that the callers compute as jsvals (operator
+ * results, jsval results, static numbers and the callers' own jsval
+ * parameters): the boxing a call saves. promotableLocals weighs it against
+ * the callee's reads of the parameter by dyn consumers.
+ *
+ * The decision starts from every candidate parameter and drops the ones
+ * whose credit no longer pays, until nothing changes, so a parameter that
+ * a dispatch helper forwards to a method can qualify in both. */
function jsvalParams(
mod: IrModule,
direct: ReadonlySet,
+ candidates: ReadonlySet,
results: ReadonlySet,
+ sites: JsIcSites,
storesJs: (e: IrExpr) => boolean,
): Map {
const byName = new Map();
- const passed = new Map();
+ const params = new Map();
for (const fn of mod.functions) {
byName.set(fn.name, fn);
- if (!direct.has(fn.name) || fn.name.startsWith("%dyn.")) continue;
- if (!fn.params.some((p) => p.type.kind === "dyn")) continue;
- passed.set(fn.name, { total: fn.params.map(() => 0), js: fn.params.map(() => 0) });
- }
- const params = new Map();
- if (passed.size === 0) return params;
- const jsArgument = (e: IrExpr): boolean =>
- isProducerCall(e) || isImmediateSource(e) || (e.kind === "call" && results.has(e.callee));
- for (const fn of mod.functions) {
- if (fn.tier !== "dynamic") continue;
- everyWeightedExpr(fn.body, (e, weight) => {
- const counts = e.kind === "call" ? passed.get(e.callee) : undefined;
- if (counts === undefined || e.kind !== "call") return;
- e.args.forEach((arg, i) => {
- if (i >= counts.total.length || arg.type.kind !== "dyn") return;
- counts.total[i]! += weight;
- if (jsArgument(arg)) counts.js[i]! += weight;
- });
- });
+ if (!direct.has(fn.name) && !candidates.has(fn.name)) continue;
+ if (sites.site(fn.name) !== null) continue;
+ const js = fn.params.map((p) => p.type.kind === "dyn");
+ if (js.includes(true)) params.set(fn.name, js);
}
+ const returnsJs = (callee: string): boolean =>
+ results.has(callee) ||
+ (candidates.has(callee) && byName.get(callee)!.returnType.kind === "dyn");
const producer = (e: IrExpr): boolean =>
- isProducerCall(e) || (e.kind === "call" && results.has(e.callee));
- for (const [name, counts] of passed) {
- const fn = byName.get(name)!;
- const credits = new Map();
- fn.params.forEach((p, i) => {
- if (p.type.kind === "dyn" && counts.js[i]! > 0)
- credits.set(p.localId, counts.js[i]! / counts.total[i]!);
- });
- if (credits.size === 0) continue;
- const promoted = promotableLocals(fn, producer, results.has(name), storesJs, credits);
- const js = fn.params.map((p) => credits.has(p.localId) && promoted.has(p.localId));
- if (js.includes(true)) params.set(name, js);
+ isProducerCall(e) || (e.kind === "call" && returnsJs(e.callee));
+ for (let changed = params.size > 0; changed;) {
+ changed = false;
+ const passed = new Map();
+ for (const [name, js] of params)
+ passed.set(name, { total: js.map(() => 0), js: js.map(() => 0) });
+ for (const fn of mod.functions) {
+ if (fn.tier !== "dynamic") continue;
+ const own = params.get(fn.name);
+ const jsLocals = new Set(fn.params.filter((_, i) => own?.[i] === true).map((p) => p.localId));
+ const jsArgument = (e: IrExpr): boolean =>
+ producer(e) || isImmediateSource(e) || (e.kind === "varRef" && jsLocals.has(e.localId));
+ everyWeightedExpr(fn.body, (e, weight) => {
+ const counts = e.kind === "call" ? passed.get(e.callee) : undefined;
+ if (counts === undefined || e.kind !== "call") return;
+ e.args.forEach((arg, i) => {
+ if (i >= counts.total.length || arg.type.kind !== "dyn") return;
+ counts.total[i]! += weight;
+ if (jsArgument(arg)) counts.js[i]! += weight;
+ });
+ });
+ }
+ for (const [name, js] of params) {
+ const fn = byName.get(name)!;
+ const counts = passed.get(name)!;
+ const credits = new Map();
+ fn.params.forEach((p, i) => {
+ if (js[i] === true && counts.js[i]! > 0)
+ credits.set(p.localId, counts.js[i]! / counts.total[i]!);
+ });
+ const promoted =
+ credits.size === 0
+ ? new Set()
+ : promotableLocals(fn, producer, returnsJs(name), storesJs, credits, params);
+ const next = fn.params.map((p, i) => js[i] === true && promoted.has(p.localId));
+ if (next.every((v, i) => v === js[i])) continue;
+ changed = true;
+ if (next.includes(true)) params.set(name, next);
+ else params.delete(name);
+ }
}
return params;
}
+/** Give each entry candidate with a jsval parameter its jsval entry: a new
+ * function `%js.` takes the body (its stack frame included), the
+ * function keeps its name and dyn ABI as a wrapper that calls the entry,
+ * and the direct calls of dynamic-tier functions call the entry instead.
+ * Static callers, vtables, function values and the runtime keep reaching
+ * the wrapper. The decisions in `params` and `results` move to the
+ * entries. */
+function splitEntries(
+ mod: IrModule,
+ candidates: ReadonlySet,
+ params: Map,
+ results: Set,
+): void {
+ const entries = new Map();
+ for (const name of candidates) {
+ const js = params.get(name);
+ if (js === undefined) continue;
+ const entry = `%js.${name}`;
+ entries.set(name, entry);
+ params.delete(name);
+ params.set(entry, js);
+ }
+ if (entries.size === 0) return;
+ const functions: IrFunction[] = [];
+ for (const fn of mod.functions) {
+ const entryName = entries.get(fn.name);
+ if (entryName === undefined) {
+ functions.push(fn);
+ continue;
+ }
+ const { ownsPrototype: _ownsPrototype, ...rest } = fn;
+ const entry: IrFunction = {
+ ...rest,
+ name: entryName,
+ params: fn.params.map((p) => ({ ...p })),
+ };
+ if (fn.returnType.kind === "dyn") results.add(entryName);
+ const call: IrExpr = {
+ kind: "call",
+ callee: entryName,
+ args: fn.params.map((p) => ({
+ kind: "varRef",
+ localId: p.localId,
+ type: p.type,
+ loc: fn.loc,
+ })),
+ type: fn.returnType,
+ loc: fn.loc,
+ };
+ fn.locals = fn.params.map((p) => ({
+ id: p.localId,
+ name: p.name,
+ type: p.type,
+ mutable: false,
+ }));
+ fn.body =
+ fn.returnType.kind === "void"
+ ? [{ kind: "exprStmt", expr: call, loc: fn.loc }]
+ : [{ kind: "return", value: call, loc: fn.loc }];
+ delete fn.sourceName;
+ functions.push(fn, entry);
+ }
+ mod.functions = functions;
+ const wrappers = new Set(entries.keys());
+ const retarget = (e: IrExpr): IrExpr => {
+ const entry = e.kind === "call" ? entries.get(e.callee) : undefined;
+ return entry === undefined ? e : { ...(e as Extract), callee: entry };
+ };
+ for (const fn of mod.functions) {
+ if (fn.tier !== "dynamic" || wrappers.has(fn.name)) continue;
+ fn.body = transformStmtList(fn.body, { expr: retarget, stmt: (s) => s });
+ }
+}
+
+/** Give closures a jsval entry for the function values boxed from them:
+ * `%js.`, a copy of the body with the same captures whose
+ * parameters that the body only uses as operands take jsvals, named by
+ * the closure's `jsEntry`. The backend's box builders register the entry
+ * on every function value of the closure, and the dynamic tier's dynCall
+ * and dynInvoke sites call it with jsval arguments. A closure keeps one
+ * body when no parameter qualifies, when a parameter is typed, when its
+ * body is not dynamic-tier,
+ * when a closure of it reads arguments past its parameters (a
+ * rest-marked type), or when it suspends. Returns whether any closure got
+ * an entry. */
+function closureEntries(
+ mod: IrModule,
+ params: Map,
+ results: Set,
+ storesJs: (e: IrExpr) => boolean,
+): boolean {
+ const boxed = new Set();
+ const variadic = new Set();
+ for (const fn of mod.functions) {
+ everyStmtList(fn.body, {
+ expr: (e) => {
+ if (e.kind === "closure") {
+ if (e.type.kind === "func" && e.type.rest !== true) boxed.add(e.fnName);
+ else variadic.add(e.fnName);
+ }
+ return true;
+ },
+ stmt: () => true,
+ });
+ }
+ for (const name of variadic) boxed.delete(name);
+ if (boxed.size === 0) return false;
+ const producer = (e: IrExpr): boolean =>
+ isProducerCall(e) || (e.kind === "call" && results.has(e.callee));
+ const functions: IrFunction[] = [];
+ let any = false;
+ for (const fn of mod.functions) {
+ functions.push(fn);
+ if (!boxed.has(fn.name) || fn.captures === undefined || fn.classCaptures !== undefined)
+ continue;
+ if (!jsvalEligible(fn) || fn.async !== undefined || fn.generator !== undefined) continue;
+ // The jsval entry passes words: a typed parameter would need the dyn
+ // entry's argument checks.
+ if (fn.params.some((p) => p.type.kind !== "dyn")) continue;
+ // A caller of a function value passes no argument the body could not
+ // take as a cell, so a parameter takes a jsval only when no dyn
+ // consumer reads it: the half credit loses to any such read.
+ const credits = new Map();
+ for (const p of fn.params) if (p.type.kind === "dyn") credits.set(p.localId, 0.5);
+ if (credits.size === 0) continue;
+ const jsResult = fn.returnType.kind === "dyn";
+ const promoted = promotableLocals(fn, producer, jsResult, storesJs, credits, params, true);
+ const js = fn.params.map((p) => credits.has(p.localId) && promoted.has(p.localId));
+ if (!js.includes(true)) continue;
+ const name = `%js.${fn.name}`;
+ const { ownsPrototype: _ownsPrototype, ...rest } = fn;
+ functions.push({
+ ...rest,
+ name,
+ params: fn.params.map((p: IrParam): IrParam => ({ ...p })),
+ locals: fn.locals.map(copyLocal),
+ captures: fn.captures.map((c: IrParam): IrParam => ({ ...c })),
+ body: fn.body.map(reparseStmt),
+ });
+ fn.jsEntry = name;
+ params.set(name, js);
+ if (jsResult) results.add(name);
+ any = true;
+ }
+ mod.functions = functions;
+ return any;
+}
+
+/** A local's own copy, for a body copy whose rewrite retypes locals. */
+function copyLocal(local: IrLocal): IrLocal {
+ const copy: IrLocal = {
+ id: local.id,
+ name: local.name,
+ type: local.type,
+ mutable: local.mutable,
+ };
+ if (local.source !== undefined) copy.source = local.source;
+ if (local.boxed !== undefined) copy.boxed = local.boxed;
+ if (local.tdz !== undefined) copy.tdz = local.tdz;
+ return copy;
+}
+
/** Visit every expression of a body with its loop weight. */
function everyWeightedExpr(body: IrStmt[], visit: (e: IrExpr, weight: number) => void): void {
const expr = (e: IrExpr, weight: number): void => {
@@ -268,7 +514,9 @@ interface LocalCost {
/** The dyn locals of a function that become jsval locals. `producer`
* tells which dyn expressions the rewrite computes as jsvals. Parameters
* are candidates only with a credit (jsvalParams); `jsParams` lists the
- * arguments that calls pass as jsvals. */
+ * arguments that calls pass as jsvals, `jsDynArguments` whether dynCall
+ * and dynInvoke take jsval arguments (closureEntries), and `jsBindings`
+ * the module globals that hold jsvals. */
function promotableLocals(
fn: IrFunction,
producer: (e: IrExpr) => boolean,
@@ -276,14 +524,41 @@ function promotableLocals(
storesJs: (e: IrExpr) => boolean,
credits: ReadonlyMap = new Map(),
jsParams: ReadonlyMap = new Map(),
+ jsDynArguments = false,
+ jsBindings: ReadonlySet = new Set(),
): Set {
- const candidates = localCandidates(fn, credits);
- if (candidates.size === 0) return new Set();
- const blocked = new Set();
- scanBindingCosts(fn, candidates, blocked, producer, jsResult, storesJs, jsParams);
- const promoted = new Set();
- for (const [id, cost] of candidates)
- if (!blocked.has(id) && cost.saved > cost.added) promoted.add(id);
+ /* One round: `sources` are the bindings taken as jsvals, so a copy of
+ * one is a jsval write, and the copy's read of it a jsval use. */
+ const round = (sources: ReadonlySet): Set => {
+ const candidates = localCandidates(fn, credits);
+ if (candidates.size === 0) return new Set();
+ const blocked = new Set();
+ scanBindingCosts(fn, candidates, blocked, producer, jsResult, storesJs, jsParams, {
+ sources,
+ jsDynArguments,
+ });
+ const promoted = new Set();
+ // A caller boxes on every call, the callee only on the paths that read
+ // the parameter by a dyn consumer: a parameter takes a jsval on a tie.
+ for (const [id, cost] of candidates)
+ if (
+ !blocked.has(id) &&
+ (cost.saved > cost.added || (credits.has(id) && cost.saved >= cost.added))
+ )
+ promoted.add(id);
+ return promoted;
+ };
+ // Each round takes the previous round's promotions as sources, so a copy
+ // chain from a promoted local, a credited parameter or a jsval global
+ // follows it.
+ let sources = new Set([...credits.keys(), ...jsBindings]);
+ let promoted = round(sources);
+ for (let i = 0; i < 4; i++) {
+ const next = new Set([...sources, ...promoted]);
+ if (next.size === sources.size) break;
+ sources = next;
+ promoted = round(sources);
+ }
return promoted;
}
@@ -319,6 +594,7 @@ function promotableGlobals(
results: ReadonlySet,
jsParams: ReadonlyMap,
storesJs: (e: IrExpr) => boolean,
+ jsDynArguments: boolean,
): Set {
const candidates = new Map();
if (mod.lib !== undefined) return new Set();
@@ -349,7 +625,10 @@ function promotableGlobals(
});
continue;
}
- scanBindingCosts(fn, candidates, blocked, producer, results.has(fn.name), storesJs, jsParams);
+ scanBindingCosts(fn, candidates, blocked, producer, results.has(fn.name), storesJs, jsParams, {
+ sources: new Set(),
+ jsDynArguments,
+ });
}
const promoted = new Set();
for (const [id, cost] of candidates)
@@ -359,7 +638,9 @@ function promotableGlobals(
/** Adds to `candidates` the boxing a jsval binding saves and adds in `fn`,
* and to `blocked` the candidates `fn` references other than by reads and
- * plain writes. */
+ * plain writes. `sources` are bindings taken as jsvals: writing one to a
+ * candidate saves a box, and the copy reads it as a jsval. With
+ * `jsDynArguments`, dynCall and dynInvoke take jsval arguments. */
function scanBindingCosts(
fn: IrFunction,
candidates: ReadonlyMap,
@@ -368,6 +649,7 @@ function scanBindingCosts(
jsResult: boolean,
storesJs: (e: IrExpr) => boolean,
jsParams: ReadonlyMap,
+ { sources, jsDynArguments }: { sources: ReadonlySet; jsDynArguments: boolean },
): void {
const write = (id: string, value: IrExpr | null, weight: number): void => {
const cost = candidates.get(id);
@@ -377,7 +659,12 @@ function scanBindingCosts(
blocked.add(id);
return;
}
- if (producer(value) || isImmediateSource(value)) cost.saved += weight;
+ if (
+ producer(value) ||
+ isImmediateSource(value) ||
+ (value.kind === "varRef" && sources.has(value.localId))
+ )
+ cost.saved += weight;
};
/* `js`: the expression is an operand the rewrite turns into a jsval. */
const expr = (e: IrExpr, js: boolean, weight: number): void => {
@@ -389,7 +676,7 @@ function scanBindingCosts(
}
case "assignExpr":
write(e.localId, e.value, weight);
- expr(e.value, false, weight);
+ expr(e.value, sources.has(e.localId), weight);
// The assignment's own value is read where it stands.
if (!js && candidates.has(e.localId)) candidates.get(e.localId)!.added += weight;
return;
@@ -425,6 +712,18 @@ function scanBindingCosts(
}
break;
}
+ case "dynCall":
+ case "dynInvoke": {
+ const jsArgs = jsDynArguments && (e.kind === "dynInvoke" || e.spreads === undefined);
+ for (const arg of e.args) expr(arg, jsArgs, weight);
+ if (e.kind === "dynInvoke") expr(e.recv, false, weight);
+ else {
+ expr(e.callee, false, weight);
+ if (e.receiver !== undefined) expr(e.receiver, false, weight);
+ }
+ if (e.calleeNameValue !== undefined) expr(e.calleeNameValue, false, weight);
+ return;
+ }
default:
break;
}
@@ -470,12 +769,12 @@ function scanBindingCosts(
return;
case "varDecl":
write(s.localId, s.init, weight);
- if (s.init !== null) expr(s.init, false, weight);
+ if (s.init !== null) expr(s.init, sources.has(s.localId), weight);
return;
case "assign":
if (s.initializes !== undefined) blocked.add(s.localId);
write(s.localId, s.value, weight);
- expr(s.value, false, weight);
+ expr(s.value, sources.has(s.localId), weight);
return;
case "while":
case "doWhile":
@@ -527,19 +826,28 @@ function promoteFunction(
params: ReadonlyMap,
storesJs: (e: IrExpr) => boolean,
globals: ReadonlySet,
+ jsDynArguments: boolean,
): boolean {
const resultCall = (e: IrExpr): boolean => e.kind === "call" && results.has(e.callee);
const operatorCall = (e: IrExpr): boolean => isProducerCall(e);
+ const ownParams = params.get(fn.name);
+ // The function's own jsval parameters are sources for the copies the
+ // rewrite makes jsval locals.
+ const own = new Map();
+ fn.params.forEach((p, i) => {
+ if (ownParams?.[i] === true) own.set(p.localId, 1);
+ });
const promoted = promotableLocals(
fn,
(e) => operatorCall(e) || resultCall(e),
results.has(fn.name),
storesJs,
- new Map(),
+ own,
params,
+ jsDynArguments,
+ globals,
);
for (const id of globals) promoted.add(id);
- const ownParams = params.get(fn.name);
fn.params.forEach((p, i) => {
if (ownParams?.[i] === true) promoted.add(p.localId);
});
@@ -606,6 +914,25 @@ function promoteFunction(
changed = true;
return { ...e, args: callArgs(e) };
}
+ // A callee with a jsval entry takes the arguments the rewrite computes
+ // as jsvals unboxed; the call boxes them for any other callee.
+ if (
+ jsDynArguments &&
+ (e.kind === "dynInvoke" || (e.kind === "dynCall" && e.spreads === undefined))
+ ) {
+ const args = e.args.map((arg) =>
+ producesJs(arg) || isImmediateSource(arg) ? toJs(arg) : rewrite(arg),
+ );
+ if (e.kind === "dynInvoke") {
+ const invoke = { ...e, recv: rewrite(e.recv), args };
+ if (e.calleeNameValue !== undefined) invoke.calleeNameValue = rewrite(e.calleeNameValue);
+ return invoke;
+ }
+ const call = { ...e, callee: rewrite(e.callee), args };
+ if (e.receiver !== undefined) call.receiver = rewrite(e.receiver);
+ if (e.calleeNameValue !== undefined) call.calleeNameValue = rewrite(e.calleeNameValue);
+ return call;
+ }
return mapExprChildren(e, rewrite, rewriteStmt);
};
const rewriteStmt = (s: IrStmt): IrStmt => {
diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts
index cf237747b..75befdad2 100644
--- a/packages/compiler/src/ir/runtime-features.ts
+++ b/packages/compiler/src/ir/runtime-features.ts
@@ -1249,6 +1249,12 @@ const RUNTIME_FEATURE_RULES = {
utilPromisify: rule({ fns: ["util.promisifyDyn"] }),
/** process.emitWarning is assigned (process.setEmitWarning). */
warningReplace: rule({ fns: ["process.setEmitWarning"] }),
+ /** A record literal asserted to a wider record type answers a partial
+ * record view (record-views.ts): the SIZE_MAX sentinel and its trap live in
+ * the SCR_RECORD_VIEW_PARTIAL build of scr_json.c. */
+ recordViewPartial: rule({
+ expr: (node) => node.kind === "dynCheck" && node.partialView === true,
+ }),
/** Module namespace objects as checked-dynamic values
* (scr_module_namespace.c). */
moduleNamespace: rule({ fns: ["module.namespaceValue"] }),
@@ -1377,6 +1383,7 @@ function noRuntimeFeatures(): RuntimeFeatures {
tlsCa: false,
utilPromisify: false,
warningReplace: false,
+ recordViewPartial: false,
wasm: false,
webcrypto: false,
zlib: false,
@@ -1468,6 +1475,13 @@ function scanRuntimeFeatures(
const sites = new JsIcSites(mod);
features.dynTier = mod.functions.some((fn) => fn.tier === "dynamic" && hasJsIcSite(fn, sites));
}
+ // A jsval parameter or result (ir/jsval.ts) is retained and released
+ // through the tier's word reference counting, with or without a js.*
+ // operation in the body.
+ if (!features.dynTier)
+ features.dynTier = mod.functions.some(
+ (fn) => fn.returnType.kind === "jsval" || fn.params.some((p) => p.type.kind === "jsval"),
+ );
return features;
}
diff --git a/packages/compiler/src/ir/self-captures.test.ts b/packages/compiler/src/ir/self-captures.test.ts
index bd03725aa..237012b77 100644
--- a/packages/compiler/src/ir/self-captures.test.ts
+++ b/packages/compiler/src/ir/self-captures.test.ts
@@ -204,3 +204,26 @@ test("mutually recursive closures only drop their own capture", () => {
ret(call("a.0")),
]);
});
+
+test("a closure's jsval body drops the same capture as the closure", () => {
+ const n = local("n.0", { type: F64 });
+ const walk = lifted("walk", [use(ref("n.0", F64)), ret(call("f.0"))], [n, local("f.0")]);
+ walk.jsEntry = "%js.walk";
+ const fns = run(
+ outer(
+ [
+ decl("n.0", { kind: "numLit", value: 1, type: F64, loc }),
+ decl("f.0", closure("walk", ["n.0", "f.0"])),
+ ret(call("f.0")),
+ ],
+ [n, local("f.0")],
+ ),
+ walk,
+ lifted("%js.walk", [use(ref("n.0", F64)), ret(call("f.0"))], [n, local("f.0")]),
+ );
+ for (const name of ["walk", "%js.walk"]) {
+ const fn = fns.get(name)!;
+ expect(fn.captures!.map((c) => c.localId)).toEqual(["n.0"]);
+ expect(fn.body[1]).toEqual(ret({ kind: "callValue", callee: self, args: [], type: F64, loc }));
+ }
+});
diff --git a/packages/compiler/src/ir/self-captures.ts b/packages/compiler/src/ir/self-captures.ts
index 70242cb8b..771253d7f 100644
--- a/packages/compiler/src/ir/self-captures.ts
+++ b/packages/compiler/src/ir/self-captures.ts
@@ -118,11 +118,18 @@ export function resolveSelfCaptures(mod: IrModule): IrModule {
}
}
if (resolved.size === 0) return mod;
+ // A closure's jsval body (IrFunction.jsEntry) runs in the closure's own
+ // environment, so it drops the same capture.
+ for (const fn of mod.functions) {
+ const index = resolved.get(fn.name);
+ if (index !== undefined && fn.jsEntry !== undefined) resolved.set(fn.jsEntry, index);
+ }
// Only the resolved bodies and the functions that create their closures
// change.
const touched = new Set(resolved.keys());
- for (const name of resolved.keys()) for (const site of sites.get(name)!) touched.add(site.owner);
+ for (const name of resolved.keys())
+ for (const site of sites.get(name) ?? []) touched.add(site.owner);
const rewritten = mod.functions.map((fn) => {
if (!touched.has(fn.name)) return fn;
const own = resolved.get(fn.name);
diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts
index 8d3a735ee..4111a46b2 100644
--- a/packages/compiler/src/ir/validate.ts
+++ b/packages/compiler/src/ir/validate.ts
@@ -3528,7 +3528,7 @@ function validateFunction(
if (e.type.kind !== "dyn") err(`dynCall must be dyn-typed, got ${e.type.kind}`, e.loc);
for (const a of e.args) {
checkExpr(a);
- if (a.type.kind !== "dyn")
+ if (a.type.kind !== "dyn" && !(a.type.kind === "jsval" && e.spreads === undefined))
err(`dynCall argument of kind ${a.type.kind} (must be dyn)`, e.loc);
}
// The runtime-arity form: spread entries point into args (their
@@ -3556,7 +3556,7 @@ function validateFunction(
if (e.type.kind !== "dyn") err(`dynInvoke must be dyn-typed, got ${e.type.kind}`, e.loc);
for (const a of e.args) {
checkExpr(a);
- if (a.type.kind !== "dyn")
+ if (a.type.kind !== "dyn" && a.type.kind !== "jsval")
err(`dynInvoke argument of kind ${a.type.kind} (must be dyn)`, e.loc);
}
break;
@@ -6311,6 +6311,37 @@ function validateFunction(
) {
err(`dynCheck against non-JSON-representable type ${e.type.kind}`, e.loc);
}
+ if (e.partialView === true) {
+ // A partial record assertion (record-views.ts): the operand is a
+ // live record whose fields are a SUBSET of the target's, with
+ // equal types, so the view lacks only properties the record
+ // never had.
+ if (
+ e.type.kind !== "record" ||
+ e.value.kind !== "dynFrom" ||
+ e.value.liveRef !== true ||
+ e.value.value.type.kind !== "record"
+ ) {
+ err(`partialView dynCheck needs a live-record operand and a record target`, e.loc);
+ break;
+ }
+ const target = records.get(e.type.shapeId);
+ const source = records.get(e.value.value.type.shapeId);
+ if (!target || !source) {
+ err(`partialView dynCheck names an unknown record shape`, e.loc);
+ break;
+ }
+ for (const field of source.fields) {
+ const found = target.fields.find((f) => f.name === field.name);
+ if (!found || typeKey(found.type) !== typeKey(field.type)) {
+ err(
+ `partialView source field "${field.name}" is not a target field with an equal type`,
+ e.loc,
+ );
+ break;
+ }
+ }
+ }
break;
}
default: {
diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts
index 74244f31d..751683054 100644
--- a/packages/compiler/src/native/sanitizer.test.ts
+++ b/packages/compiler/src/native/sanitizer.test.ts
@@ -31,6 +31,7 @@ const features = {
builtinValues: false,
computedRequire: false,
warningReplace: false,
+ recordViewPartial: false,
stdioExtended: false,
streamWriteErrors: false,
stdioWriteSlot: false,
diff --git a/packages/compiler/test/counted-loop-emission.test.ts b/packages/compiler/test/counted-loop-emission.test.ts
index cec0a205b..f9a102dd7 100644
--- a/packages/compiler/test/counted-loop-emission.test.ts
+++ b/packages/compiler/test/counted-loop-emission.test.ts
@@ -79,7 +79,10 @@ test("worker programs poll counted loops over typed arrays between blocks of ite
"console.log(b[7], mix(8), c[0], shrink(new Uint8Array(8).fill(1)));",
workers ? "if (!isMainThread) new Worker(new URL(import.meta.url));" : "",
].join("\n");
- const emit = async (workers: boolean): Promise => {
+ const emit = async (
+ workers: boolean,
+ options: Parameters[1] = {},
+ ): Promise => {
const entry = join(dir, `${workers ? "worker" : "plain"}.ts`);
await writeFile(entry, source(workers));
const outPath = join(dir, `${workers ? "worker" : "plain"}.ir.json`);
@@ -89,14 +92,16 @@ test("worker programs poll counted loops over typed arrays between blocks of ite
// other targets read the process-wide alert word.
return emitLlvmModule(deserializeModule(await readFile(outPath, "utf8")), {
targetTriple: "x86_64-unknown-linux-gnu",
+ ...options,
});
};
- // Every emitted version of a function (the number and int32 entries).
+ // Every emitted version of a function (the number and int32 entries, and
+ // the copies of a dispatching build).
const bodies = (ll: string, name: string): string =>
[
...ll.matchAll(
new RegExp(
- `define internal [^\\n]+ @sc_b?f_${name}(?:\\.i32)?\\([^\\n]*\\n([\\s\\S]*?)\\n\\}`,
+ `define internal [^\\n]+ @sc_b?f_${name}(?:\\.i32)?(?:\\.base|\\.v3)?\\([^\\n]*\\n([\\s\\S]*?)\\n\\}`,
"g",
),
),
@@ -125,6 +130,20 @@ test("worker programs poll counted loops over typed arrays between blocks of ite
expect(codes).toMatch(/@llvm\.uadd\.sat\.i64\(i64 %\w+, i64 64\)/);
expect(codes).toMatch(/@llvm\.umin\.i64\(i64 %\w+, i64 %\w+\)/);
expect(codes).toMatch(/^loop\.poll\d+:$/m);
+ // Code for x86-64-v3 takes blocks of 128 iterations, one step of its
+ // vectorized loop over bytes.
+ const x86 = { targetTriple: "x86_64-unknown-linux-gnu" };
+ for (const options of [
+ { ...x86, targetCpu: "x86-64-v3" as const },
+ { ...x86, targetCpu: "x86-64-v4" as const },
+ { ...x86, cpuDispatch: true },
+ ])
+ expect(bodies(await emit(true, options), "codes"), JSON.stringify(options)).toMatch(
+ /@llvm\.uadd\.sat\.i64\(i64 %\w+, i64 128\)/,
+ );
+ expect(bodies(await emit(true, { ...x86, targetCpu: "x86-64-v2" }), "codes")).toMatch(
+ /@llvm\.uadd\.sat\.i64\(i64 %\w+, i64 64\)/,
+ );
// A loop that assigns its array reads the new length on the next
// iteration: it keeps the budget.
const shrink = bodies(workerModule, "shrink");
diff --git a/packages/compiler/test/cpu-dispatch-emission.test.ts b/packages/compiler/test/cpu-dispatch-emission.test.ts
index 43d80b335..e538e405d 100644
--- a/packages/compiler/test/cpu-dispatch-emission.test.ts
+++ b/packages/compiler/test/cpu-dispatch-emission.test.ts
@@ -119,10 +119,12 @@ test("a dispatching x86-64 build gives each program function a baseline and an x
for (const [name] of coroutines) expect(defs.has(`${name}.v3`), name).toBe(false);
// Functions used as values are referenced by their dispatching name.
expect(ll).not.toMatch(/ptr @[\w.$-]+\.(v3|base)(?!\()/);
- expect(ll).toMatch(/^attributes #3 = \{ sanitize_address uwtable "target-cpu"="x86-64-v3" \}$/m);
+ expect(ll).toMatch(
+ /^attributes #3 = \{ sanitize_address uwtable "target-cpu"="x86-64-v3" "scriptc-cpu-copy" \}$/m,
+ );
// Unsanitized builds leave sanitize_address out of the copies' groups too.
expect(emit({ ...x86, cpuDispatch: true, sanitizeAttributes: false })).toMatch(
- /^attributes #3 = \{ uwtable "target-cpu"="x86-64-v3" \}$/m,
+ /^attributes #3 = \{ uwtable "target-cpu"="x86-64-v3" "scriptc-cpu-copy" \}$/m,
);
// main sets the level before anything else runs.
expect(defs.get("main")).toMatch(
diff --git a/packages/compiler/test/native-codegen-integration.test.ts b/packages/compiler/test/native-codegen-integration.test.ts
index 2ac6143b3..d3462cc82 100644
--- a/packages/compiler/test/native-codegen-integration.test.ts
+++ b/packages/compiler/test/native-codegen-integration.test.ts
@@ -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_v47");
+ expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v48");
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_v47");
+ expect(undefinedSymbols).toContain("_scr_runtime_abi_v48");
});
test("helper and clang object paths retain the same outbound FFI C ABI", async () => {
diff --git a/packages/compiler/test/native-partitions.test.ts b/packages/compiler/test/native-partitions.test.ts
index 836d1202b..947004d33 100644
--- a/packages/compiler/test/native-partitions.test.ts
+++ b/packages/compiler/test/native-partitions.test.ts
@@ -93,6 +93,7 @@ async function emit(
optLevel: "0" | "2";
cache?: string;
split?: boolean;
+ profileGenerate?: string;
name: string;
},
): Promise {
@@ -120,6 +121,9 @@ async function emit(
"/src/program.ts",
...(options.cache === undefined ? [] : ["--cache-dir", options.cache]),
...(options.split === true ? ["--split", "codegen"] : []),
+ ...(options.profileGenerate === undefined
+ ? []
+ : ["--profile-generate", options.profileGenerate]),
]);
return outputs;
}
@@ -593,6 +597,148 @@ describe.runIf(helper !== null)("LLVM program partitions", () => {
expect(fewGrouped.get("main")).toBe(fewPlain.get("main"));
});
+ test.runIf(target?.llvmTriple.startsWith("x86_64") === true)(
+ "an x86-64-v3 copy shares its baseline copy's partition and takes its code unless it has wider vectors",
+ async () => {
+ const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-cpu-copies-"));
+ dirs.push(dir);
+ // Copies as --cpu-dispatch emits them: `.base` in #0 and
+ // `.v3` in #1. Two callers each, so that every copy is placed by
+ // its own name. Each f calls h in its own tier; f and h have 128-bit
+ // vectors, g a 256-bit vector, and s only scalar code.
+ const pairs = 4;
+ const copies = (tier: "base" | "v3", group: number): string[] => [
+ `define internal void @h.${tier}(ptr %p) #${group} {`,
+ " %v = load <2 x double>, ptr %p",
+ " %w = fmul <2 x double> %v, splat (double 3.0)",
+ " store <2 x double> %w, ptr %p",
+ " ret void",
+ "}",
+ ...Array.from({ length: pairs }, (_, index) => [
+ `define internal void @f${index}.${tier}(ptr %p) #${group} {`,
+ " %v = load <2 x double>, ptr %p",
+ ` %w = fadd <2 x double> %v, splat (double ${index + 1}.0)`,
+ " store <2 x double> %w, ptr %p",
+ ` call void @h.${tier}(ptr %p)`,
+ " ret void",
+ "}",
+ ]).flat(),
+ `define internal void @g.${tier}(ptr %p) #${group} {`,
+ " %v = load <4 x double>, ptr %p",
+ " %w = fadd <4 x double> %v, %v",
+ " store <4 x double> %w, ptr %p",
+ " ret void",
+ "}",
+ `define internal double @s.${tier}(double %x) #${group} {`,
+ " %y = fadd double %x, 1.0",
+ " ret double %y",
+ "}",
+ ];
+ const calls = (result: string): string[] =>
+ (["base", "v3"] as const).flatMap((tier) => [
+ ...Array.from(
+ { length: pairs },
+ (_, index) => ` call void @f${index}.${tier}(ptr @sink)`,
+ ),
+ ` call void @g.${tier}(ptr @sink)`,
+ ` %${result}.${tier} = call double @s.${tier}(double 1.0)`,
+ ]);
+ const source = [
+ "@sink = global [4 x double] zeroinitializer",
+ ...copies("base", 0),
+ ...copies("v3", 1),
+ "define void @other() {",
+ ...calls("o"),
+ " ret void",
+ "}",
+ "define i32 @main() {",
+ ...calls("m"),
+ " ret i32 0",
+ "}",
+ "attributes #0 = { noinline uwtable }",
+ 'attributes #1 = { noinline uwtable "target-cpu"="x86-64-v3" "scriptc-cpu-copy" }',
+ ].join("\n");
+ // Each function's object and disassembly, by its name without the
+ // thin link's `.llvm.` suffix.
+ const disassemble = async (objects: string[]) => {
+ const functions = new Map();
+ for (const [index, object] of objects.entries()) {
+ const { stdout } = await execFileAsync("objdump", ["-dr", "--no-show-raw-insn", object]);
+ for (const part of stdout.split(/\n(?=[0-9a-f]+ <)/)) {
+ const name = /^[0-9a-f]+ <([^>]+)>:/.exec(part)?.[1]?.replace(/\.llvm\.\d+$/, "");
+ if (name !== undefined) functions.set(name, { object: index, code: part });
+ }
+ }
+ return (name: string) => {
+ const found = functions.get(name);
+ expect(found, name).toBeDefined();
+ return found!;
+ };
+ };
+ const at = await disassemble(
+ await emit(dir, source + "\n", { outputs: 8, optLevel: "2", name: "copies" }),
+ );
+ for (const stem of [
+ "h",
+ "g",
+ "s",
+ ...Array.from({ length: pairs }, (_, index) => `f${index}`),
+ ])
+ expect(at(`${stem}.v3`).object, stem).toBe(at(`${stem}.base`).object);
+ // 128-bit vectors: the v3 copies run the baseline's code (SSE2, without
+ // VEX encodings), and f's still calls h's v3 copy.
+ for (let index = 0; index < pairs; index++) {
+ const code = at(`f${index}.v3`).code;
+ expect(code).toMatch(/\saddpd\s/);
+ expect(code).not.toMatch(/\svaddpd\s/);
+ expect(code).toMatch(/[\s<]h\.v3(?:\.llvm\.\d+)?[-+>]/);
+ }
+ expect(at("h.v3").code).toMatch(/\smulpd\s/);
+ // A 256-bit vector keeps the v3 code, and so does scalar code.
+ expect(at("g.v3").code).toContain("%ymm");
+ expect(at("s.v3").code).toMatch(/\svaddsd\s/);
+ expect(at("g.base").code).not.toContain("%ymm");
+ // An instrumented build keeps every copy's code: a baseline body would
+ // count the v3 copy's executions against the baseline copy.
+ const counted = await disassemble(
+ await emit(dir, source + "\n", {
+ outputs: 8,
+ optLevel: "2",
+ profileGenerate: join(dir, "copies-%p.profraw"),
+ name: "counted",
+ }),
+ );
+ for (let index = 0; index < pairs; index++)
+ expect(counted(`f${index}.v3`).code).toMatch(/\svaddpd\s/);
+ expect(counted("h.v3").code).toMatch(/\svmulpd\s/);
+ // A profile that gives f0's baseline copy fewer runs than its v3 copy
+ // (collected on an x86-64-v3 machine) keeps f0's v3 code: the baseline
+ // body is optimized as colder code. f1 has no profile counts.
+ const profiled = source
+ .replace(
+ "define internal void @f0.v3(ptr %p) #1 {",
+ "define internal void @f0.v3(ptr %p) #1 !prof !90 {",
+ )
+ .replace(
+ "define internal void @f0.base(ptr %p) #0 {",
+ "define internal void @f0.base(ptr %p) #0 !prof !91 {",
+ );
+ const runs = await disassemble(
+ await emit(
+ dir,
+ [
+ profiled,
+ '!90 = !{!"function_entry_count", i64 1000}',
+ '!91 = !{!"function_entry_count", i64 0}',
+ ].join("\n") + "\n",
+ { outputs: 8, optLevel: "2", name: "runs" },
+ ),
+ );
+ expect(runs("f0.v3").code).toMatch(/\svaddpd\s/);
+ expect(runs("f1.v3").code).not.toMatch(/\svaddpd\s/);
+ },
+ );
+
test("internal definitions nothing reaches are left out of every partition", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-unreachable-"));
dirs.push(dir);
diff --git a/packages/compiler/test/record-partial-assertion-lowering.test.ts b/packages/compiler/test/record-partial-assertion-lowering.test.ts
new file mode 100644
index 000000000..3668031f0
--- /dev/null
+++ b/packages/compiler/test/record-partial-assertion-lowering.test.ts
@@ -0,0 +1,215 @@
+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 "../src/index.js";
+import type { IrExpr } from "../src/ir/ir.js";
+
+/** Every IR node in the module, in traversal order. */
+async function moduleNodes(source: string): Promise {
+ const dir = await mkdtemp(join(tmpdir(), "scriptc-record-partial-"));
+ try {
+ const entry = join(dir, "main.ts");
+ await writeFile(entry, source);
+ const outPath = join(dir, "main.ir.json");
+ const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
+ if (!result.ok)
+ throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
+ const mod = deserializeModule(await readFile(outPath, "utf8"));
+ const nodes: T[] = [];
+ const visit = (value: unknown): void => {
+ if (Array.isArray(value)) {
+ for (const item of value) visit(item);
+ return;
+ }
+ if (value === null || typeof value !== "object") return;
+ const node = value as { kind?: unknown };
+ if (typeof node.kind === "string") nodes.push(node as T);
+ for (const child of Object.values(value)) visit(child);
+ };
+ visit(mod);
+ return nodes;
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+}
+
+const TR = `interface TR { content: string; truncated: boolean; truncatedBy: "lines" | "bytes" | null }
+`;
+
+test("a read of a field a partial assertion never wrote is presence-checked", async () => {
+ const nodes = await moduleNodes<{
+ kind: string;
+ cond?: { kind: string; field?: string };
+ then?: { kind: string };
+ else_?: { kind: string; fn?: string; args?: IrExpr[] };
+ }>(
+ `${TR}function read(value: TR): string { return value.content; }
+ read({ truncated: true } as TR);
+ export {};
+ `,
+ );
+ const guards = nodes.filter(
+ (n) => n.kind === "ternary" && n.cond?.kind === "recordHas" && n.cond.field === "content",
+ );
+ expect(guards).toHaveLength(1);
+ const guard = guards[0]!;
+ expect(guard.then).toMatchObject({ kind: "recordGet" });
+ // Node yields undefined; the view throws the catchable TypeError instead.
+ expect(guard.else_).toMatchObject({
+ kind: "libCall",
+ fn: "error.nodeThrow",
+ args: [
+ { kind: "numLit", value: 1 },
+ { kind: "strLit" },
+ {
+ kind: "strLit",
+ value: "Cannot read property 'content': the object asserted as 'TR' has no such property",
+ },
+ ],
+ });
+});
+
+test("a field the literal writes reads without a presence check", async () => {
+ const nodes = await moduleNodes<{ kind: string; cond?: { kind: string } }>(
+ `${TR}function read(value: TR): boolean { return value.truncated; }
+ read({ truncated: true, content: "c" } as TR);
+ export {};
+ `,
+ );
+ expect(nodes.some((n) => n.kind === "ternary" && n.cond?.kind === "recordHas")).toBe(false);
+});
+
+test("a write of a field a partial assertion never wrote throws before storing", async () => {
+ const nodes = await moduleNodes<{
+ kind: string;
+ body?: { kind: string; cond?: { kind: string; field?: string }; else_?: unknown[] }[];
+ }>(
+ `${TR}function write(value: TR): void { value.content = "x"; }
+ write({ truncated: true } as TR);
+ export {};
+ `,
+ );
+ const guard = nodes
+ .filter((n) => n.kind === "block")
+ .flatMap((n) => n.body ?? [])
+ .find((s) => s.kind === "if" && s.cond?.kind === "recordHas" && s.cond.field === "content");
+ expect(guard).toBeDefined();
+ expect(guard!.else_).toMatchObject([
+ {
+ kind: "exprStmt",
+ expr: {
+ kind: "libCall",
+ fn: "error.nodeThrow",
+ args: [
+ { kind: "numLit", value: 1 },
+ { kind: "strLit" },
+ {
+ kind: "strLit",
+ value:
+ "Cannot set property 'content': the object asserted as 'TR' has no such property",
+ },
+ ],
+ },
+ },
+ ]);
+});
+
+test("an optional field a partial assertion omits keeps an absent slot, read without a check", async () => {
+ const nodes = await moduleNodes<{
+ kind: string;
+ cond?: { kind: string; field?: string };
+ fields?: { name: string; value: { kind: string } }[];
+ }>(
+ `interface N { content: string; note?: string }
+ function read(value: N): string | undefined { return value.note; }
+ read({ content: "c" } as N);
+ read({} as N);
+ export {};
+ `,
+ );
+ // The literal's layout holds the omitted optional field in its absent
+ // state, as an ordinary record of the asserted type does.
+ const literal = nodes.find(
+ (n) => n.kind === "recordLit" && n.fields?.some((f) => f.name === "note") === true,
+ );
+ expect(literal?.fields?.find((f) => f.name === "note")?.value.kind).toBe("fieldAbsent");
+ expect(
+ nodes.some(
+ (n) => n.kind === "ternary" && n.cond?.kind === "recordHas" && n.cond.field === "note",
+ ),
+ ).toBe(false);
+});
+
+test("a write of a field a partial assertion never wrote evaluates the value before the check", async () => {
+ const nodes = await moduleNodes<{
+ kind: string;
+ localId?: string;
+ body?: { kind: string; localId?: string; cond?: { kind: string; field?: string } }[];
+ }>(
+ `${TR}function value(): string { return "x"; }
+ function write(target: TR): void { target.content = value(); }
+ write({ truncated: true } as TR);
+ export {};
+ `,
+ );
+ const body = nodes
+ .filter((n) => n.kind === "block")
+ .map((n) => n.body ?? [])
+ .find((stmts) =>
+ stmts.some(
+ (s) => s.kind === "if" && s.cond?.kind === "recordHas" && s.cond.field === "content",
+ ),
+ );
+ expect(body).toBeDefined();
+ const saved = body!.findIndex(
+ (s) => s.kind === "varDecl" && s.localId?.includes("partialWriteValue") === true,
+ );
+ const check = body!.findIndex((s) => s.kind === "if");
+ expect(saved).toBeGreaterThanOrEqual(0);
+ expect(saved).toBeLessThan(check);
+});
+
+test("a write of an optional field a partial assertion omits needs no guard", async () => {
+ const nodes = await moduleNodes<{ kind: string; cond?: { kind: string; field?: string } }>(
+ `interface N { content: string; note?: string }
+ function write(target: N): void { target.note = "x"; }
+ write({} as N);
+ write({ content: "c" });
+ export {};
+ `,
+ );
+ expect(
+ nodes.some((n) => n.kind === "if" && n.cond?.kind === "recordHas" && n.cond.field === "note"),
+ ).toBe(false);
+});
+
+test("a partial-field write saves its receiver when the right side may reassign it", async () => {
+ const nodes = await moduleNodes<{
+ kind: string;
+ body?: { kind: string; localId?: string; cond?: { kind: string; field?: string } }[];
+ }>(
+ `${TR}function value(): string { return "x"; }
+ let target: TR = { truncated: true } as TR;
+ target.content = value();
+ export {};
+ `,
+ );
+ const body = nodes
+ .filter((n) => n.kind === "block")
+ .map((n) => n.body ?? [])
+ .find((stmts) =>
+ stmts.some(
+ (s) => s.kind === "if" && s.cond?.kind === "recordHas" && s.cond.field === "content",
+ ),
+ );
+ expect(body).toBeDefined();
+ const receiver = body!.findIndex(
+ (s) => s.kind === "varDecl" && /%partialWrite(?!Value)/.test(s.localId ?? ""),
+ );
+ const value = body!.findIndex(
+ (s) => s.kind === "varDecl" && s.localId?.includes("partialWriteValue") === true,
+ );
+ expect(receiver).toBeGreaterThanOrEqual(0);
+ expect(receiver).toBeLessThan(value);
+});
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8600-dynamic-class-method-numbers.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8600-dynamic-class-method-numbers.js.json
new file mode 100644
index 000000000..02bc96de1
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8600-dynamic-class-method-numbers.js.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/8600-dynamic-class-method-numbers.js"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8601-dynamic-function-value-numbers.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8601-dynamic-function-value-numbers.js.json
new file mode 100644
index 000000000..b51915642
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8601-dynamic-function-value-numbers.js.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/8601-dynamic-function-value-numbers.js"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-call-callee-order.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-call-callee-order.mjs.json
new file mode 100644
index 000000000..1e72aaf98
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-call-callee-order.mjs.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/dynamic-call-callee-order.mjs"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/inferred-builtin-method-results.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/inferred-builtin-method-results.js.json
new file mode 100644
index 000000000..f64dbe6f4
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/inferred-builtin-method-results.js.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/inferred-builtin-method-results.js"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/inferred-field-before-assignment.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/inferred-field-before-assignment.js.json
new file mode 100644
index 000000000..88052ebb2
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/inferred-field-before-assignment.js.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/inferred-field-before-assignment.js"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-binding-union-writes.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-binding-union-writes.js.json
new file mode 100644
index 000000000..8dcf96a98
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-binding-union-writes.js.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/js-binding-union-writes.js"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-partial-assertion.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-partial-assertion.ts.json
new file mode 100644
index 000000000..b0464bd69
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-partial-assertion.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/record-partial-assertion.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/runtime-darwin-arm64/scripts/build.mjs b/packages/runtime-darwin-arm64/scripts/build.mjs
index 3e7c8930a..cc164c8d4 100644
--- a/packages/runtime-darwin-arm64/scripts/build.mjs
+++ b/packages/runtime-darwin-arm64/scripts/build.mjs
@@ -364,7 +364,7 @@ async function build() {
package: packageManifest.name,
version: packageManifest.version,
target: RUNTIME_PACK_MATRIX.target,
- runtime_abi: { version: 47, marker: "scr_runtime_abi_v47" },
+ runtime_abi: { version: 48, marker: "scr_runtime_abi_v48" },
compiler: {
command: compiler,
identity: compilerVersion,
diff --git a/packages/runtime-pack-common/profiles/linux-x64-gnu.profdata.gz b/packages/runtime-pack-common/profiles/linux-x64-gnu.profdata.gz
index 5d3fe0f89..49e3030ee 100644
Binary files a/packages/runtime-pack-common/profiles/linux-x64-gnu.profdata.gz and b/packages/runtime-pack-common/profiles/linux-x64-gnu.profdata.gz differ
diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs
index 40652e644..82bf652d3 100644
--- a/packages/runtime-pack-common/runtime-pack-matrix.mjs
+++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs
@@ -263,6 +263,21 @@ export function variantsFor(source) {
when: { dynTier: true, webStreams: true, nodeIsland: true },
defines: ["SCR_JS_TIER", "SCR_HANDLE_CLASSES", "SCR_NODE_ISLANDS"],
});
+ // A program with a partial record assertion (record-views.ts) links the
+ // build whose view slot answers NULL for a field the viewed record does
+ // not declare, so the emitted guard, clone helper and presence test can
+ // stop with the program's message instead of reading a bogus slot. The
+ // selector takes the variant with the most conditions, so each of the
+ // builds above needs its counterpart; every other program keeps the
+ // default object, byte for byte.
+ for (const { id, when, defines } of variants.slice()) {
+ const suffix = id === "default" ? "" : `-${id}`;
+ variants.push({
+ id: `record-view-partial${suffix}`,
+ when: { ...when, recordViewPartial: true },
+ defines: [...defines, "SCR_RECORD_VIEW_PARTIAL"],
+ });
+ }
}
// util.inspect and instanceof of Node island handles; Node's report for
// an uncaught island error.
diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs
index 355f30e9a..c4191fec6 100644
--- a/packages/runtime-pack-common/scripts/build.mjs
+++ b/packages/runtime-pack-common/scripts/build.mjs
@@ -771,7 +771,7 @@ async function build() {
package: packageManifest.name,
version: packageManifest.version,
target: matrix.target,
- runtime_abi: { version: 47, marker: "scr_runtime_abi_v47" },
+ runtime_abi: { version: 48, marker: "scr_runtime_abi_v48" },
compiler: {
command: compiler,
identity: compilerVersion,
diff --git a/packages/runtime/src/scr_console.c b/packages/runtime/src/scr_console.c
index 0941db370..c7e0fc80a 100644
--- a/packages/runtime/src/scr_console.c
+++ b/packages/runtime/src/scr_console.c
@@ -151,7 +151,7 @@ static void scr_enable_utf8_console_output(void) {
}
#endif
-void scr_runtime_abi_v47(void) {}
+void scr_runtime_abi_v48(void) {}
void scr_init(void) {
#ifdef SCR_WORKERS
diff --git a/packages/runtime/src/scr_core.h b/packages/runtime/src/scr_core.h
index f3c85e740..254cc884f 100644
--- a/packages/runtime/src/scr_core.h
+++ b/packages/runtime/src/scr_core.h
@@ -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_v47(void);
+void scr_runtime_abi_v48(void);
void scr_runtime_workers_v8(void);
/* ── the trap funnel (scr_console.c; scr_library.c under -DSCR_LIB) ──────
diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h
index 71f56f47e..2bc1d9a31 100644
--- a/packages/runtime/src/scr_dyn.h
+++ b/packages/runtime/src/scr_dyn.h
@@ -329,6 +329,12 @@ 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 closure's jsval entry (scr_js.h words): calls the closure's
+ * jsval body with `args` (BORROWED words; a missing argument is undefined)
+ * and returns the result as an owned word. With an exception pending the
+ * word is undefined; callers test the exception cell, not the word. */
+typedef void *(*ScrDynJsEntry)(ScrClosure *clo, void *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
@@ -577,7 +583,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; ScrDynMessageListener message; } fn;
+ struct { ScrClosure *clo; ScrDynThunk thunk; const char *sig; const char *name; uint32_t arity; ScrClassObj *class_obj; ScrDynMessageListener message; ScrDynJsEntry js; } 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
@@ -1363,6 +1369,9 @@ typedef struct {
size_t key_len;
const size_t *offsets;
} ScrRecordViewSource;
+/* The slot of field `position` of a view. The SCR_RECORD_VIEW_PARTIAL build
+ * of scr_json.c, which only a program with a partial record assertion links,
+ * answers NULL for a field the viewed record does not declare (record-views.ts). */
void *scr_record_view_slot(void *view, size_t position);
void *scr_record_view_field(void *value, size_t offset, size_t position);
void *scr_dyn_record_view(ScrDyn *d, const ScrRecordViewSource *sources, size_t count,
@@ -1432,6 +1441,12 @@ void scr_dyn_typed_ref_bind_bag_class(ScrDyn *d, const ScrDynTypedBagClassOps *o
* exception pending, when the prototype hook threw. Never runs user
* code. */
ScrDyn *scr_dyn_typed_ref_bag_method(const ScrDyn *d, const char *key, size_t key_len);
+/* The own data property `key` of a class instance's capsule, +1, as its
+ * view holds it; NULL when the instance has no such own data property, or
+ * when `key` names a member of its class's prototype or of
+ * Object.prototype. NULL with an exception pending when building the view
+ * threw. */
+ScrDyn *scr_dyn_typed_ref_own_member(const ScrDyn *d, const char *key, size_t key_len);
/* The constructor name of a class instance capsule's own class, for
* util.inspect; NULL when the capsule is not a bound class instance, its
* class is anonymous, or its prototype was replaced. */
@@ -1817,6 +1832,9 @@ ScrDyn *scr_dyn_new_func(ScrClosure *clo, ScrDynThunk thunk, uint32_t arity, con
/* 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);
+/* Gives a function box made with thunk its jsval entry. Emitted call sites
+ * of the dynamic tier load v.fn.js directly (at byte 72 on 64-bit targets). */
+void scr_dyn_func_set_js(ScrDyn *fn, ScrDynThunk thunk, ScrDynJsEntry entry);
/* Sets a freshly boxed function's `length` (a builtin function value whose
* length is not its adapter's parameter count). */
void scr_dyn_function_set_length(ScrDyn *fn, uint32_t length);
diff --git a/packages/runtime/src/scr_dyn_invoke.c b/packages/runtime/src/scr_dyn_invoke.c
index 7a9b1bc35..0279ccab1 100644
--- a/packages/runtime/src/scr_dyn_invoke.c
+++ b/packages/runtime/src/scr_dyn_invoke.c
@@ -787,6 +787,14 @@ ScrDyn *scr_dyn_prepare_method(ScrDyn *recv, const char *method) {
scr_str_release(key);
return result;
}
+ /* A class instance's own function-valued property is read before the
+ * arguments are evaluated, as JavaScript reads the callee first; its
+ * methods keep the dispatch scr_dyn_invoke makes. */
+ if (recv->kind == SCR_DYN_TYPED_REF) {
+ ScrDyn *own = scr_dyn_typed_ref_own_member(recv, method, strlen(method));
+ if (own && scr_dyn_is_callable(own)) return own;
+ scr_dyn_release(own);
+ }
return NULL;
}
diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c
index bb4cae8da..090bfbfc8 100644
--- a/packages/runtime/src/scr_json.c
+++ b/packages/runtime/src/scr_json.c
@@ -4405,10 +4405,23 @@ void *scr_dyn_record_view(ScrDyn *d, const ScrRecordViewSource *sources, size_t
return view;
}
-/* The slot of field `position` of a view (emitted view guards, cold). */
+/* The slot of field `position` of a view (emitted view guards, cold). A
+ * PARTIAL view (a record literal asserted to a wider record type) holds
+ * SIZE_MAX for every field its viewed record does not declare: the
+ * SCR_RECORD_VIEW_PARTIAL build of this unit answers NULL for it, the guard
+ * that gets NULL stops with scr_trap and the program's own message, and a
+ * presence test asks for the same slot and answers by it. Only a program
+ * with such an assertion links that build (runtime-pack-matrix.mjs); every
+ * other program links this default object, whose bytes are unchanged. */
void *scr_record_view_slot(void *view, size_t position) {
ScrRecordSource *entry = scr_record_source_slot(view);
+#ifdef SCR_RECORD_VIEW_PARTIAL
+ size_t offset = entry->offsets[position];
+ if (offset == SIZE_MAX) return NULL;
+ return (char *)entry->viewed + offset;
+#else
return (char *)entry->viewed + entry->offsets[position];
+#endif
}
/* The address of field `position` of a value of a view target type, whose
@@ -4762,6 +4775,52 @@ bool scr_dyn_typed_ref_class_has(const ScrDyn *d, const ScrStr *key) {
return ops && scr_dyn_name_in_strs(key, ops->names, ops->count);
}
+ScrDyn *scr_dyn_typed_ref_own_member(const ScrDyn *d, const char *key, size_t key_len) {
+ if (d->kind != SCR_DYN_TYPED_REF || d->prototype) return NULL;
+ if (!d->v.typed_ref.bag_class) {
+ /* A class instance's view holds its own fields and the properties added
+ * to it, and its class's prototype members stay off the view. A name
+ * that is neither one of those members nor one of Object.prototype's
+ * can only be an own property, so the view is built (refreshed from the
+ * instance) for it. Other capsules answer through scr_dyn_invoke. */
+ const ScrDynTypedClassOps *klass = scr_typed_ref_class_ops(d);
+ if (!klass) return NULL;
+ ScrStr *name = scr_str_new(key, key_len);
+ bool member = scr_dyn_name_in_strs(name, klass->names, klass->count) || scr_dyn_object_proto_name(name);
+ scr_str_release(name);
+ if (member) return NULL;
+ ScrDyn *view = scr_dyn_typed_ref_materialize(d);
+ if (scr_exc_pending()) {
+ scr_dyn_release(view);
+ return NULL;
+ }
+ ScrDyn *found = NULL;
+ if (view && view->kind == SCR_DYN_OBJ) {
+ if (scr_dyn_is_layout(view)) {
+ ScrDyn **slot = scr_dyn_layout_slot(view, key, key_len);
+ if (slot) found = scr_dyn_retain(*slot);
+ } else {
+ const ScrDynEntry *own = scr_dyn_find_entry(view, key, key_len);
+ if (own && !own->accessor) found = scr_dyn_retain(own->value);
+ }
+ }
+ scr_dyn_release(view);
+ return found;
+ }
+ const ScrDynTypedBagClassOps *ops = (const ScrDynTypedBagClassOps *)(const void *)d->v.typed_ref.array;
+ void *instance = d->v.typed_ref.ptr;
+ const ScrDyn *bag = ops->bag(instance);
+ if (!bag || bag->kind != SCR_DYN_OBJ) return NULL;
+ const ScrDynEntry *own = scr_dyn_find_entry(bag, key, key_len);
+ if (!own || own->accessor) return NULL;
+ for (size_t i = 0; i < ops->count; i++) {
+ const ScrStr *name = ops->names[i];
+ if (name->len != key_len || memcmp(name->data, key, key_len) != 0) continue;
+ return own->writable ? ops->field(instance, i) : NULL;
+ }
+ return scr_dyn_retain(own->value);
+}
+
static const ScrDynTypedRecordOps *scr_typed_ref_record_ops(const ScrDyn *d) {
if (!d->v.typed_ref.record || d->v.typed_ref.materialized) return NULL;
const ScrDynTypedRecordOps *ops = (const ScrDynTypedRecordOps *)(const void *)d->v.typed_ref.array;
@@ -6410,9 +6469,20 @@ ScrDyn *scr_dyn_new_func(ScrClosure *clo, ScrDynThunk thunk, uint32_t arity, con
d->v.fn.arity = clo->identity != NULL ? clo->identity_length : arity;
d->v.fn.class_obj = NULL;
d->v.fn.message = NULL;
+ d->v.fn.js = NULL;
return d;
}
+/* The emitted fast path loads the entry at this offset (64-bit targets). */
+_Static_assert(sizeof(void *) != 8 || offsetof(ScrDyn, v.fn.js) == 72,
+ "ScrDyn.v.fn.js moved: update the dynamic tier's call sites");
+
+void scr_dyn_func_set_js(ScrDyn *fn, ScrDynThunk thunk, ScrDynJsEntry entry) {
+ /* As scr_dyn_func_set_message: an adapted function's original box keeps
+ * its own entries. */
+ if (fn && fn->kind == SCR_DYN_FUNC && fn->v.fn.thunk == thunk) fn->v.fn.js = entry;
+}
+
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. */
diff --git a/scripts/sandbox-platform.mjs b/scripts/sandbox-platform.mjs
index c3e4a34b6..7bb061ac6 100644
--- a/scripts/sandbox-platform.mjs
+++ b/scripts/sandbox-platform.mjs
@@ -21,12 +21,18 @@ export function sandboxHostSchedule(platform, invariantFiles) {
* Sandbox image already defines these variables. Empty values restore the
* unsharded, platform-native defaults; intentional remote and local shards
* override them at their call sites.
+ *
+ * An explicitly configured SCRIPTC_LIBNODE is forwarded as is. It names a pack
+ * inside the Sandbox, never on the host, and the Node island tests fail rather
+ * than skip in a CI lane without one, so a lane started from a snapshot that
+ * carries a pack must be told where it is.
*/
-export function sandboxLaneEnv(lane) {
+export function sandboxLaneEnv(lane, env = process.env) {
return {
CI: "1",
SCRIPTC_PORTABLE_ONLY: "",
SCRIPTC_SAN: lane === "san" ? "1" : "",
SCRIPTC_TEST_SHARD: "",
+ SCRIPTC_LIBNODE: env.SCRIPTC_LIBNODE ?? "",
};
}
diff --git a/scripts/sandbox-test.mjs b/scripts/sandbox-test.mjs
index f2e58cb41..caf0c3a16 100644
--- a/scripts/sandbox-test.mjs
+++ b/scripts/sandbox-test.mjs
@@ -234,7 +234,10 @@ Environment:
SCRIPTC_SANDBOX_TIMEOUT sandbox and command timeout (default: 45m)
SCRIPTC_TEST_WORKERS Vitest workers per sandbox (default: 4)
SCRIPTC_LOCAL_TEST_WORKERS Vitest workers per local lane (default: 2)
- SCRIPTC_LOCAL_CASE_SHARDS local shards per external suite lane (default: 2)`);
+ SCRIPTC_LOCAL_CASE_SHARDS local shards per external suite lane (default: 2)
+ SCRIPTC_LIBNODE Optional libnode pack inside the Sandbox (for example
+ /vercel/sandbox/libnode-pack) that the lane exports, so the
+ Node island tests run there as they do in CI`);
process.exit(0);
}
diff --git a/tests/corpus/8600-dynamic-class-method-numbers.js b/tests/corpus/8600-dynamic-class-method-numbers.js
new file mode 100644
index 000000000..e78ca0f88
--- /dev/null
+++ b/tests/corpus/8600-dynamic-class-method-numbers.js
@@ -0,0 +1,66 @@
+// Untyped code calls methods of compiled classes with numbers: overrides,
+// `super` calls, static methods, missing and extra arguments, `arguments`,
+// methods taken as values, numeric results and throws.
+class Cursor {
+ constructor(x, y) {
+ this.x = x;
+ this.y = y;
+ }
+ step(dx, dy) {
+ this.x += dx;
+ this.y += dy;
+ return this;
+ }
+ scaled(k) {
+ return this.x * k + this.y * k;
+ }
+ shift(dx, dy = dx * 2) {
+ return this.x + dx + dy;
+ }
+ count() {
+ return arguments.length + ":" + (arguments[0] ?? "none");
+ }
+ checked(v) {
+ if (v > 50) throw new RangeError("step too large: " + v);
+ return v / 2;
+ }
+ static mid(a, b) {
+ return (a + b) / 2;
+ }
+}
+class Fast extends Cursor {
+ step(dx, dy) {
+ super.step(dx * 2, dy * 2);
+ return this;
+ }
+ scaled(k) {
+ return super.scaled(k) + 0.25;
+ }
+}
+const cursors = [new Cursor(1.5, -2.5), new Fast(0.5, 0.25), new Cursor(1e10, -1e10)];
+let total = 0;
+for (let r = 0; r < 300; r++) {
+ const c = cursors[r % 3];
+ c.step((r % 7) * 0.5 - 1, (r % 5) * 1.25 - 2);
+ total += c.scaled(r % 4 === 0 ? 1.5 : 0.75);
+ total += c.shift(r * 0.1);
+ if (Math.abs(c.x) > 1e6 && c.x < 1e9) c.x = 0;
+}
+console.log(total.toFixed(4));
+for (const c of cursors) console.log(c.constructor.name, c.x, c.y);
+console.log(cursors[0].shift(1.5), cursors[0].shift(1.5, 0.5), cursors[1].shift(-0));
+console.log(cursors[0].count(), cursors[0].count(2.5, 3), cursors[1].count(undefined));
+console.log(Cursor.mid(1.5, 2), Cursor.mid(-0, 0), Fast.mid(2 ** 53, 1));
+const fast = new Fast(0.5, 0.25);
+const step = fast.step;
+console.log(step.call(new Cursor(10.5, 20.5), 1, 2).x, typeof step);
+let caught = 0;
+for (let i = 45; i < 55; i++) {
+ try {
+ total += cursors[0].checked(i * 1.25);
+ } catch (e) {
+ if (caught++ === 0) console.log(e instanceof RangeError, e.message);
+ }
+}
+console.log(caught, total.toFixed(4));
+console.log(cursors[0].scaled(NaN), cursors[1].scaled(Infinity), Object.is(cursors[0].scaled(-0) * 0, 0));
diff --git a/tests/corpus/8601-dynamic-function-value-numbers.js b/tests/corpus/8601-dynamic-function-value-numbers.js
new file mode 100644
index 000000000..81aec82c7
--- /dev/null
+++ b/tests/corpus/8601-dynamic-function-value-numbers.js
@@ -0,0 +1,92 @@
+// Untyped code calls function values with numbers: object literal
+// methods, closures held in arrays and objects, `this`, missing and extra
+// arguments, `arguments`, captured bindings, recursion by name, throws,
+// callbacks of builtins, bound functions, proxies and non-functions.
+import assert from "node:assert";
+
+const tally = {
+ total: 0,
+ add(value) {
+ this.total += value;
+ return this.total;
+ },
+ scaled(value, k) {
+ return value * (k ?? 2);
+ },
+ count() {
+ return arguments.length;
+ },
+};
+let sum = 0;
+for (let i = 0; i < 500; i++) sum += tally.add((i % 7) * 0.5) % 11;
+console.log(sum, tally.total);
+console.log(tally.scaled(1.5), tally.scaled(1.5, 3), tally.scaled(1.5, 3, 99), tally.count(1, 2.5, 3));
+
+let captured = 0.25;
+const step = (dx) => {
+ captured += dx;
+ return captured;
+};
+const fns = [step, (v) => v * v, (a, b) => a - b];
+let acc = 0;
+for (let i = 0; i < 300; i++) acc += fns[i % 3](i * 0.5, 1.5);
+console.log(acc, captured);
+
+const ops = {
+ inc: (v) => v + 1,
+ fact: function fact(n) {
+ return n <= 1 ? 1 : n * fact(n - 1);
+ },
+};
+console.log(ops.inc(41.5), ops.fact(10), ops.fact(1.5));
+
+const holder = { k: 3, read(v) { return this.k * v; } };
+const read = holder.read;
+console.log(holder.read(2.5), read.call({ k: 10 }, 2.5), read.bind({ k: 4 })(2.5));
+
+const checked = {
+ limit(v) {
+ if (v > 10) throw new RangeError("too large: " + v);
+ return v;
+ },
+};
+let caught = 0;
+for (let i = 0; i < 20; i++) {
+ try {
+ acc += checked.limit(i * 0.75);
+ } catch (e) {
+ if (caught++ === 0) console.log(e instanceof RangeError, e.message);
+ }
+}
+console.log(caught, acc);
+
+console.log([1.5, 2.5, 3.5].map((v) => ops.inc(v)).join(","), [1, 2, 3].reduce((a, b) => a + b * 0.5, 0));
+const proxied = new Proxy(ops.inc, { apply: (target, self, args) => target(args[0] * 10) });
+console.log(proxied(2.5));
+class Point {}
+const values = { notFn: 5, cls: Point, inc: ops.inc };
+for (const key of ["notFn", "cls", "inc"]) {
+ try {
+ console.log(key, values[key](1.5));
+ } catch (e) {
+ console.log(key, e.constructor.name, e.message);
+ }
+}
+const big = { mul: (v) => v * 1e308 };
+console.log(big.mul(10), big.mul(-10), Object.is(big.mul(-0), -0), big.mul(NaN));
+
+
+// A library call that throws inside a function value's body reaches the
+// call through the value.
+const succeeded = (fn) =>
+ function (err, v) {
+ assert.ifError(err);
+ return fn(v * 2);
+ };
+for (const err of [null, new TypeError("boom"), 0]) {
+ try {
+ console.log(succeeded((v) => v + 0.5)(err, 1.25));
+ } catch (e) {
+ console.log(e.message);
+ }
+}
diff --git a/tests/corpus/dynamic-call-callee-order.mjs b/tests/corpus/dynamic-call-callee-order.mjs
new file mode 100644
index 000000000..e2bc4bf97
--- /dev/null
+++ b/tests/corpus/dynamic-call-callee-order.mjs
@@ -0,0 +1,156 @@
+// A call through an untyped receiver reads its callee before it evaluates
+// the arguments: an argument that replaces the member does not change which
+// function the call runs. Class instance fields, properties added to an
+// instance later, plain object properties, prototype methods, getters, own
+// accessors, a field that does not hold a function, and array methods named
+// like a class field or getter.
+const log = [];
+class Holder {
+ constructor() {
+ this.run = () => "field-init";
+ }
+ get viaGetter() {
+ log.push("getter");
+ return () => "from-getter";
+ }
+}
+function callRun(target, replace) {
+ return target.run(replace());
+}
+function callMethod(target, replace) {
+ return target.method(replace());
+}
+function callGetter(target, replace) {
+ return target.viaGetter(replace());
+}
+const holder = new Holder();
+holder.run = () => "field-old";
+console.log(
+ callRun(holder, () => {
+ holder.run = () => "field-new";
+ return 0;
+ }),
+ holder.run(),
+);
+function callExtra(target, replace) {
+ return target.extra(replace());
+}
+const later = new Holder();
+later.extra = () => "added-old";
+console.log(
+ callExtra(later, () => {
+ later.extra = () => "added-new";
+ return 0;
+ }),
+ later.extra(),
+);
+const plain = { run: () => "plain-old" };
+console.log(
+ callRun(plain, () => {
+ plain.run = () => "plain-new";
+ return 0;
+ }),
+ plain.run(),
+);
+class Swapped {
+ method() {
+ return "method-old";
+ }
+}
+console.log(
+ callMethod(new Swapped(), () => {
+ Swapped.prototype.method = () => "method-new";
+ return 0;
+ }),
+ new Swapped().method(),
+);
+console.log(
+ callGetter(new Holder(), () => {
+ log.push("argument");
+ return 0;
+ }),
+ log.join(","),
+);
+log.length = 0;
+const accessorTarget = { run: () => "plain" };
+Object.defineProperty(accessorTarget, "run", {
+ get() {
+ log.push("own-getter");
+ return () => "own-accessor";
+ },
+ configurable: true,
+});
+console.log(
+ callRun(accessorTarget, () => {
+ log.push("argument");
+ return 0;
+ }),
+ log.join(","),
+);
+class Box {
+ constructor(value) {
+ this.run = value;
+ }
+}
+try {
+ callRun(new Box(5), () => 0);
+} catch (e) {
+ console.log(e instanceof TypeError, e.message);
+}
+// Fields and getters named like builtin methods: calls of those methods on
+// arrays and numbers through untyped receivers keep the builtin, and the
+// getters still answer for class instances.
+class Material {
+ constructor() {
+ this.map = null;
+ }
+ get filter() {
+ return () => "material-filter";
+ }
+ get toFixed() {
+ return (digits) => `material-${digits}`;
+ }
+}
+function doubled(items) {
+ return items.map((x) => x * 2).filter((x) => x > 2);
+}
+function fixed(value, digits) {
+ return value.toFixed(digits);
+}
+function filtered(value) {
+ return value.filter(() => true);
+}
+console.log(new Material().map, doubled([1, 2, 3]).join(","), filtered([4, 5]).join(","));
+console.log(fixed(3.14159, 2), fixed(new Material(), 3), filtered(new Material()));
+// A second class with the same getter, constructed only in a method that
+// an untyped call reaches: its instances also read the getter before the
+// arguments.
+const order = [];
+class EarlyRunner {
+ get step() {
+ order.push("early-get");
+ return (value) => `early-${value}`;
+ }
+}
+class LateRunner {
+ get step() {
+ order.push("late-get");
+ return (value) => `late-${value}`;
+ }
+}
+class RunnerFactory {
+ make() {
+ return new LateRunner();
+ }
+}
+function noted(value) {
+ order.push(`arg-${value}`);
+ return value;
+}
+function step(target, value) {
+ return target.step(noted(value));
+}
+function made(factory) {
+ return factory.make();
+}
+console.log(step(new EarlyRunner(), 1), step(made(new RunnerFactory()), 2), order.join(","));
diff --git a/tests/corpus/inferred-builtin-method-results.js b/tests/corpus/inferred-builtin-method-results.js
new file mode 100644
index 000000000..60a6a1486
--- /dev/null
+++ b/tests/corpus/inferred-builtin-method-results.js
@@ -0,0 +1,21 @@
+// Results of string and number methods in untyped code: `at` and
+// `codePointAt` past the end read undefined, a number's `valueOf` is a
+// number, and `matchAll` yields match arrays with their index.
+function charAt(text, i) {
+ return text.at(i);
+}
+function codeAt(text, i) {
+ return text.codePointAt(i);
+}
+function unwrap(n) {
+ return n.valueOf();
+}
+function pairs(text) {
+ const out = [];
+ for (const m of text.matchAll(/(\w)=(\d)/g)) out.push(`${m[1]}${m[2]}@${m.index}`);
+ return out;
+}
+console.log(charAt("abc", 1), charAt("abc", 5), typeof charAt("abc", -9));
+console.log(codeAt("ab", 0), codeAt("ab", 9));
+console.log(unwrap(4) + 1, typeof unwrap(4));
+console.log(pairs("a=1 b=2").join(","), pairs("none").length);
diff --git a/tests/corpus/inferred-field-before-assignment.js b/tests/corpus/inferred-field-before-assignment.js
new file mode 100644
index 000000000..93f4498a0
--- /dev/null
+++ b/tests/corpus/inferred-field-before-assignment.js
@@ -0,0 +1,42 @@
+// Code that sees an instance before its constructor stores a field (a
+// helper called with `this`, a local holding `this`, a base constructor
+// calling an overridden method) reads undefined from the field.
+const seen = [];
+function inspect(point) {
+ seen.push(point.x + 1);
+}
+class Point {
+ constructor(x) {
+ inspect(this);
+ this.x = x;
+ }
+}
+class Later {
+ constructor(x) {
+ const self = this;
+ report(self);
+ this.x = x;
+ }
+}
+function report(value) {
+ seen.push(value.x === undefined ? "absent" : value.x * 2);
+}
+class Base {
+ constructor() {
+ this.describe();
+ }
+ describe() {}
+}
+class Derived extends Base {
+ constructor(x) {
+ super();
+ this.x = x;
+ }
+ describe() {
+ seen.push(typeof this.x);
+ }
+}
+const a = new Point(1);
+const b = new Later(2);
+const c = new Derived(3);
+console.log(seen.join(","), a.x + b.x + c.x);
diff --git a/tests/corpus/js-binding-union-writes.js b/tests/corpus/js-binding-union-writes.js
new file mode 100644
index 000000000..b5ceb1e3b
--- /dev/null
+++ b/tests/corpus/js-binding-union-writes.js
@@ -0,0 +1,15 @@
+// JavaScript bindings initialized with a number and later written with a
+// value that may be a string: a conditional of a number and a string, and
+// `+=` of an element that can be either. Node keeps each value as written.
+function pick(c) {
+ var x = 0;
+ x = c > 1 ? 1 : "a";
+ return x;
+}
+function total(parts) {
+ let n = 0;
+ for (let i = 0; i < parts.length; i++) n += parts[i];
+ return n;
+}
+console.log(pick(0), pick(2), typeof pick(0), typeof pick(2));
+console.log(total([1, 2]), total([1, "a", 2]), typeof total([1, "a"]));
diff --git a/tests/corpus/record-partial-assertion.ts b/tests/corpus/record-partial-assertion.ts
new file mode 100644
index 000000000..8acc1b344
--- /dev/null
+++ b/tests/corpus/record-partial-assertion.ts
@@ -0,0 +1,65 @@
+// A partial `as` assertion keeps the literal's own layout and answers the
+// asserted type through a record view: fields the literal never wrote are
+// absent, and reflection sees exactly what Node sees.
+interface TruncationResult { content: string; truncated: boolean; truncatedBy: "lines" | "bytes" | null; totalLines: number; firstLineExceedsLimit: boolean }
+class Panel {
+ private truncation?: TruncationResult;
+ setComplete(truncation?: TruncationResult): void { this.truncation = truncation; }
+ summary(): string { return `${this.truncation?.truncated ?? false}`; }
+ dump(): string { return JSON.stringify(this.truncation ?? null); }
+ keys(): string { return this.truncation ? Object.keys(this.truncation).join(",") : "-"; }
+ hasContent(): boolean { return this.truncation !== undefined && "content" in this.truncation; }
+}
+function render(truncated: boolean, output: string): string {
+ const a = new Panel();
+ a.setComplete(truncated ? ({ truncated: true } as TruncationResult) : undefined);
+ const b = new Panel();
+ b.setComplete(truncated ? ({ truncated: true, content: output } as TruncationResult) : undefined);
+ return [a.summary(), a.dump(), a.keys(), a.hasContent(), b.summary(), b.dump(), b.keys(), b.hasContent()].join(" ");
+}
+console.log(render(true, "out"));
+console.log(render(false, "out"));
+const full: TruncationResult = { content: "c", truncated: false, truncatedBy: null, totalLines: 3, firstLineExceedsLimit: false };
+const p = new Panel(); p.setComplete(full);
+console.log(p.summary(), p.dump(), p.keys(), p.hasContent(), full.totalLines + 1);
+
+// An optional field the literal omits reads as undefined, and `in` answers
+// false for it.
+interface Note { content: string; truncated: boolean; note?: string; count?: number }
+function note(flag: boolean): Note {
+ return flag ? ({ truncated: true } as Note) : { content: "c", truncated: false, note: "n" };
+}
+const omitted = note(true);
+console.log(omitted.note, omitted.note ?? "none", omitted.count === undefined, typeof omitted.note);
+console.log("note" in omitted, "truncated" in omitted, "content" in omitted);
+const written = note(false);
+console.log(written.note, "note" in written, "count" in written);
+
+// A write of a field the literal omits evaluates its right side first.
+const order: string[] = [];
+function rhs(): string {
+ order.push("rhs");
+ return "v";
+}
+const target = note(true);
+try {
+ target.content = rhs();
+} catch {
+ order.push("caught");
+}
+console.log(order[0]);
+
+// An ordinary record of the asserted type keeps a slot for its absent
+// optional field, so a write adds the property.
+const plainNote: Note = { content: "c", truncated: false };
+plainNote.note = "added";
+console.log(plainNote.note, "note" in plainNote, JSON.stringify(plainNote));
+
+// A right side that reassigns the receiver's variable: the original
+// receiver takes the write.
+let reassigned: Note = { content: "first", truncated: false, note: "n" };
+const firstNote = reassigned;
+const otherNote: Note = { content: "other", truncated: true, note: "o" };
+reassigned.content = ((reassigned = otherNote), "changed");
+console.log(firstNote.content, otherNote.content, reassigned === otherNote);
+export {};
diff --git a/tests/harness/corpus-costs/8600-dynamic-class-method-numbers.js.json b/tests/harness/corpus-costs/8600-dynamic-class-method-numbers.js.json
new file mode 100644
index 000000000..d048a3c1d
--- /dev/null
+++ b/tests/harness/corpus-costs/8600-dynamic-class-method-numbers.js.json
@@ -0,0 +1 @@
+{ "plain": 4300, "san": 54700 }
diff --git a/tests/harness/corpus-costs/8601-dynamic-function-value-numbers.js.json b/tests/harness/corpus-costs/8601-dynamic-function-value-numbers.js.json
new file mode 100644
index 000000000..af9dd75c4
--- /dev/null
+++ b/tests/harness/corpus-costs/8601-dynamic-function-value-numbers.js.json
@@ -0,0 +1 @@
+{ "plain": 3300, "san": 10100 }
diff --git a/tests/harness/corpus-costs/inferred-field-before-assignment.js.json b/tests/harness/corpus-costs/inferred-field-before-assignment.js.json
new file mode 100644
index 000000000..d5d1c5f65
--- /dev/null
+++ b/tests/harness/corpus-costs/inferred-field-before-assignment.js.json
@@ -0,0 +1 @@
+{ "plain": 2500, "san": 3500 }
diff --git a/tests/harness/corpus-costs/record-partial-assertion.ts.json b/tests/harness/corpus-costs/record-partial-assertion.ts.json
new file mode 100644
index 000000000..618ba4e43
--- /dev/null
+++ b/tests/harness/corpus-costs/record-partial-assertion.ts.json
@@ -0,0 +1 @@
+{ "plain": 1400, "san": 24500 }
diff --git a/tests/harness/js-inference.test.ts b/tests/harness/js-inference.test.ts
new file mode 100644
index 000000000..a3ae8fb5e
--- /dev/null
+++ b/tests/harness/js-inference.test.ts
@@ -0,0 +1,97 @@
+/* Shape inference for the program's own JavaScript (SCRIPTC_INFER_JS=1,
+ * frontend/js-inference.ts): each corpus program below compiles with the
+ * overlay on its own files and prints what Node prints, with Node's exit
+ * code. Each covers a case where an inferred type used to be narrower than
+ * the values the program holds. */
+import { execFile } from "node:child_process";
+import { mkdirSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { promisify } from "node:util";
+import { expect, test } from "vitest";
+import { compile } from "@scriptc/compiler";
+
+const execFileAsync = promisify(execFile);
+const repoRoot = join(import.meta.dirname, "../..");
+const corpus = join(repoRoot, "tests/corpus");
+const outDir = join(repoRoot, "node_modules/.cache/scriptc-tests/js-inference");
+const sanitize = process.env["SCRIPTC_SAN"] === "1";
+
+const programs = [
+ // `+=` of a value that may be a string, and a union stored into a number binding.
+ "7038-js-var-later-writes.mjs",
+ "7054-let-bindings-with-null-writes.mjs",
+ // `for await` over a sync iterable, and an iterable object literal with a computed key.
+ "8380-untyped-generator-loops.mjs",
+ // `matchAll` yields match arrays.
+ "6011-match-metadata-js.js",
+ // `at`, `codePointAt` and a number's `valueOf`.
+ "inferred-builtin-method-results.js",
+ "webassembly-wpt-jsapi.mjs",
+ // Class instances reached by computed writes and property definitions.
+ "7620-js-class-view-descriptors.js",
+ // Overridden methods and getters whose results differ.
+ "js-class-return-shapes.js",
+ // The program's own JSDoc next to the overlay's.
+ "class-instance-constructor.js",
+ // A method called with a proxy as its receiver.
+ "js-callable-proxy.js",
+ // A constructor parameter stored into a field that later holds strings.
+ "js-loose-dynamic-primitives.js",
+ // A `__proto__: null` literal among a function's results.
+ "object-literal-members.mjs",
+ // A computed key read before the binding it names is initialized.
+ "4090-computed-key-tdz.js",
+ // Fields an instance may lack.
+ "8410-dynamic-class-instance-reads.js",
+ // Fields read before the constructor stores them.
+ "inferred-field-before-assignment.js",
+];
+
+// Programs where the overlay types nothing: the proxy receiver makes the
+// class's methods ⊤, and the computed key keeps its read.
+const untyped = new Set(["js-callable-proxy.js", "4090-computed-key-tdz.js"]);
+
+async function run(
+ file: string,
+ args: string[],
+ cwd: string,
+): Promise<{ stdout: string; code: number }> {
+ try {
+ const child = execFileAsync(file, args, { cwd, encoding: "utf8" });
+ child.child.stdin?.end();
+ const { stdout } = await child;
+ return { stdout, code: 0 };
+ } catch (error) {
+ const failed = error as { stdout?: string; code?: number };
+ return { stdout: failed.stdout ?? "", code: failed.code ?? -1 };
+ }
+}
+
+async function withInference(build: () => Promise): Promise {
+ const saved = process.env["SCRIPTC_INFER_JS"];
+ process.env["SCRIPTC_INFER_JS"] = "1";
+ try {
+ return await build();
+ } finally {
+ if (saved === undefined) delete process.env["SCRIPTC_INFER_JS"];
+ else process.env["SCRIPTC_INFER_JS"] = saved;
+ }
+}
+
+test.each(programs)("%s matches Node with inference on", { timeout: 900_000 }, async (name) => {
+ mkdirSync(outDir, { recursive: true });
+ const entry = join(corpus, name);
+ const out = join(outDir, name.replace(/\.m?js$/, ""));
+ const result = await withInference(() => compile(entry, { outPath: out, outDir, sanitize }));
+ if (!result.ok)
+ throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
+ const [built, node] = await Promise.all([
+ run(result.binaryPath, [], dirname(entry)),
+ run(process.execPath, [entry], dirname(entry)),
+ ]);
+ // The overlay typed the program's own files, except where every value
+ // involved stays untyped.
+ expect((result.warnings ?? []).some((w) => w.code === "SC6103")).toBe(!untyped.has(name));
+ expect(built.stdout).toBe(node.stdout);
+ expect(built.code).toBe(node.code);
+});
diff --git a/tests/harness/sandbox-platform.test.ts b/tests/harness/sandbox-platform.test.ts
index 31f45865c..999c74614 100644
--- a/tests/harness/sandbox-platform.test.ts
+++ b/tests/harness/sandbox-platform.test.ts
@@ -34,16 +34,26 @@ test("other hosts retain native-file coverage in Linux Sandboxes", () => {
});
test("lane environments override inherited sanitizer and scheduler settings", () => {
- expect(sandboxLaneEnv("plain")).toEqual({
+ expect(sandboxLaneEnv("plain", {})).toEqual({
CI: "1",
SCRIPTC_PORTABLE_ONLY: "",
SCRIPTC_SAN: "",
SCRIPTC_TEST_SHARD: "",
+ SCRIPTC_LIBNODE: "",
});
- expect(sandboxLaneEnv("san")).toEqual({
+ expect(sandboxLaneEnv("san", {})).toEqual({
CI: "1",
SCRIPTC_PORTABLE_ONLY: "",
SCRIPTC_SAN: "1",
SCRIPTC_TEST_SHARD: "",
+ SCRIPTC_LIBNODE: "",
});
});
+
+test("a lane forwards the libnode pack it is given", () => {
+ // The path names a pack inside the Sandbox; the island tests require one in
+ // a CI lane, and a snapshot-provided pack is only found through this.
+ const pack = "/vercel/sandbox/libnode-pack";
+ expect(sandboxLaneEnv("plain", { SCRIPTC_LIBNODE: pack }).SCRIPTC_LIBNODE).toBe(pack);
+ expect(sandboxLaneEnv("san", { SCRIPTC_LIBNODE: pack }).SCRIPTC_LIBNODE).toBe(pack);
+});