expr: an expression cannot rebind a prelude name for the next one (#2133) (#2176)

* expr: an expression cannot rebind a prelude name for the next one (#2133)

One runtime serves every expression on a thread and user code runs as a script
at global scope, so `linear = function () {}` rebound the name for every later
expression, and for every later project in the session. #1387 closed the `__`
globals, the model constructors and the prelude's own prototypes; the rest of
the global surface is here: the prelude's own 104 helpers, the built-ins that an
expression and the prelude both call, and the prototypes those hang off.

Freezing a prototype costs an expression the right to shadow an inherited name
on its own object: once the inherited data property is non-writable, `o.x = …`
is dropped in sloppy mode rather than creating an own property, which is the
override mistake. Every name that was shadowable before is therefore replaced
by an accessor whose setter writes an own property on the receiver, emulating
OrdinarySetWithOwnDescriptor so the substitution does not show through.

The repair covers every own writable, configurable data property of every
prototype the block reaches, not a list of commonly shadowed names: a list
leaves `a.map = …` throwing and `a.push = …` and `a.join = …` silently running
the native method, which is worse, because the expression keeps working and
quietly computes something else.

`name` and `message` on an error's prototype are the one exception, and they
are frozen rather than repaired. The host renders a thrown error by reading
both off the object with no context to call a getter with, and nowhere safe to
call one: the time budget is checked between polls of the evaluation and
formatting runs after that returns, so a getter of the expression's own would
run outside it, bounded only by the loop and recursion limits at some hundreds
of millions of iterations. `e.name = 'MyError'` is dropped in silence as the
price of keeping user code out of the failure path.

The setup code reads every intrinsic it needs before the first prototype is
rewritten, and indexes arrays rather than calling their methods. Repairing
`Array.prototype` turns `concat` and `forEach` into accessors, and an earlier
attempt at this used both while walking the remaining globals, which left
every prototype after `Array.prototype` unfrozen and the prelude failing to
load at all.

Measured on macOS arm64, release, min of 3 x N, against this base: an
inherited-name read costs ~68 ns where it is now an accessor, which is +18%
on a loop doing a hundred of them and +2% to +7% on ordinary expressions.
Extending the repair past `Object.prototype` is what the array-method read
pays for; nothing else moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* expr: the prelude's lock answers delete and defineProperty too (#2133)

Tests for the two routes to a global binding the existing ones did not cover
against a prelude function, asked for in review. `writable: false` answers an
assignment and a `var`; it is `configurable: false` that answers these, and
they fail differently: a `delete` of a non-configurable property evaluates to
false in sloppy mode, while a `defineProperty` over one throws, so that is the
one route whose refusal the user sees.

Also records which mode decides that, because it was asked and the answer is
not the obvious one: an expression cannot reach strict mode at all.
`Runtime::script` wraps the text in `try{…}catch(__e){throw __e}`, so a
`"use strict"` the user writes is an expression statement inside a block
rather than a directive prologue and has no effect. A refused write is
therefore always the silent kind, never a TypeError.

No change to the prelude; these pass as it stands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* expr: freeze Math and JSON plainly instead of repairing them (#2176)

Math and JSON are namespaces, not prototypes: nothing inherits sin or
parse, so the accessor repair keeps no shadowing there and only puts a
getter call on every Math.* read of a per-frame expression. Freeze them
directly and lock the two bindings so Math = {} cannot replace them for
the next expression either. Prototypes keep the repair.

---------

Co-authored-by: Anubhav-Rai <adobearakap@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-11-05 15:21:59 +01:00
1 parent b2eb3f7877
commit 1786c24523
2 files changed
+508

No files matched your search

+155
View File
@@ -1295,3 +1295,158 @@ function nearestKey(t) { return thisProperty.nearestKey(t); }
if ('value' in Object.getOwnPropertyDescriptor(globalThis, k)) Object.defineProperty(globalThis, k, { writable: false, configurable: false });
});
})();
// ---------------------------------------------------------------------------------------------
// One runtime serves every expression on a thread, and user code runs as a script at global
// scope, so `linear = …` or a top-level `var clamp` would otherwise rebind a prelude name for
// every later expression, and for every later project in the session (#2133). The block above
// closes the `__` globals, the model constructors and the prelude's own prototypes; what is left
// is the rest of the global surface: the prelude's own helpers (`linear`, `clamp`, `wiggle`), the
// JavaScript built-ins that an expression and the prelude both call, and the prototypes those
// hang off. A non-writable, non-configurable binding makes such a write a silent no-op in sloppy
// mode, and `var` leaves an existing global property alone. A name the user introduces is not
// frozen, so an expression that declares its own functions still works.
(function () {
var g = globalThis;
// Every intrinsic this block needs, read once before the first prototype is rewritten. The
// block turns `Array.prototype.concat` and `forEach` into accessors as it goes, so reaching for
// them through a prototype part-way down the list would be reaching for something it has just
// changed under itself: the loops below index arrays directly for the same reason.
var describe = Object.getOwnPropertyDescriptor, define = Object.defineProperty;
var getNames = Object.getOwnPropertyNames, getSyms = Object.getOwnPropertySymbols;
var freeze = Object.freeze, isFrozen = Object.isFrozen, isExtensible = Object.isExtensible;
var protoOf = Object.getPrototypeOf;
var lock = function (n) {
var d = describe(g, n);
if (!d || !d.writable) return;
define(g, n, { value: d.value, writable: false, enumerable: d.enumerable, configurable: false });
};
// `name` and `message` on an error's prototype are the two names the repair below has to leave
// alone. The host renders a thrown error by reading both straight off the object, with no
// context to call a getter with, and it has no way to run one safely either: the time budget is
// checked between polls of the evaluation, and formatting happens after that returns, so a
// getter of the expression's own would run outside it — bounded by the loop and recursion limits
// at some hundreds of millions of iterations, which is a hang. Left as data properties they are
// frozen rather than repaired, so `e.name = 'MyError'` on an expression's own error is dropped
// in silence. That is the cost; an unbounded getter in the failure path is not worth paying to
// avoid it.
var HOST_READ = ['name', 'message'];
var hostRead = [];
var errorNames = ['Error', 'EvalError', 'RangeError', 'ReferenceError', 'SyntaxError', 'TypeError',
'URIError', 'AggregateError'];
for (var e = 0; e < errorNames.length; e++) {
var C = g[errorNames[e]];
if (typeof C === 'function') {
var cd = describe(C, 'prototype');
if (cd && 'value' in cd) hostRead.push(cd.value);
}
}
var readByHost = function (p, n) {
for (var i = 0; i < HOST_READ.length; i++) {
if (n !== HOST_READ[i]) continue;
for (var j = 0; j < hostRead.length; j++) if (hostRead[j] === p) return true;
}
return false;
};
// Freezing a prototype costs an expression the right to shadow an inherited name on its own
// object: once the inherited data property is non-writable, `o.toString = …` is dropped in
// sloppy mode rather than creating an own property — the "override mistake". That is ordinary
// expression code: `counts[w]` with `w` of "constructor" or "valueOf" uses an object as a
// dictionary, and `a.map = …` gives one array its own version of a method. So every name that
// was shadowable before the freeze becomes an accessor whose setter writes an own property on
// the instance, which leaves the shadowing that worked before this block working after it.
var tolerate = function (p, n) {
var d = describe(p, n);
// An accessor has no value to stand in for, and a name already non-writable or
// non-configurable could not be shadowed before this ran either: no regression to repair.
if (!d || !d.configurable || !('value' in d) || !d.writable) return;
var v = d.value;
define(p, n, {
get: function () { return v; },
set: function (nv) {
// This setter stands in for the data property it replaced, so it answers a write the way
// that property would: an accessor is handed the receiver and nothing else, while a data
// property has the receiver's own property consulted first
// (OrdinarySetWithOwnDescriptor). Doing that here keeps the repair invisible — an own
// writable property is updated in place and keeps its other attributes, an own
// non-writable one drops the write rather than being overwritten, and a target that
// cannot take a new property is left alone instead of being handed to `defineProperty`,
// which would throw. That last case covers a frozen prototype, an object the expression
// froze itself, and `null` or `undefined`.
if (this === p || this === null || this === undefined) return;
var own = describe(this, n);
if (own) {
if (own.writable) define(this, n, { value: nv });
return;
}
if (!isExtensible(this)) return;
define(this, n, { value: nv, writable: true, enumerable: true, configurable: true });
},
enumerable: d.enumerable,
configurable: false,
});
};
var freezeProto = function (p) {
if (p === null || (typeof p !== 'object' && typeof p !== 'function') || isFrozen(p)) return;
var names = getNames(p);
for (var i = 0; i < names.length; i++) if (!readByHost(p, names[i])) tolerate(p, names[i]);
// Symbol keys go the same way: `a[Symbol.iterator] = …` on an expression's own array is an
// ordinary way to make something iterable, and left frozen it is dropped in silence while
// `for…of` quietly walks the array's own elements instead.
var syms = getSyms(p);
for (var j = 0; j < syms.length; j++) tolerate(p, syms[j]);
freeze(p);
};
// Freezing a function also makes its own `prototype` property non-writable, so `Comp.prototype =
// {}` is a no-op; freezing that prototype object closes the methods hanging off it.
var shut = function (o) {
var d = describe(o, 'prototype');
if (d && 'value' in d) freezeProto(d.value);
freeze(o);
};
var globals = getNames(g);
for (var k = 0; k < globals.length; k++) {
var n = globals[k];
var d = describe(g, n);
if (!d || !('value' in d) || typeof d.value !== 'function') continue;
// The function object as well as the binding: the prelude calls `Array.isArray` and
// `Object.setPrototypeOf` while it answers an expression, so a replacement for one of those
// would not merely shadow a name, it would change what a later expression computes.
shut(d.value);
lock(n);
}
// `Math.sin = …` goes the same way, and the prelude leans on both of these itself. They are the
// `prototype` of no function, so the loop above never reaches them, and they are the prototype
// of no object either: nothing inherits `sin` or `parse`, so there is no shadowing for the
// accessor repair to keep, and a plain freeze closes them. That matters on the hot path, where
// a repaired name costs an accessor call on every read and a per-frame expression reads
// `Math.*` more than anything else. The bindings are locked as well, so `Math = {}` cannot take
// them away from the next expression either.
freeze(Math);
freeze(JSON);
lock('Math');
lock('JSON');
// The iterator intrinsics are the `prototype` of no global function either, and `for…of`,
// destructuring and spread all run through them: replacing `next` on the array iterator left
// every later expression on the thread unable to iterate at all.
var walk = function (o) {
for (var p = o; p !== null && p !== undefined; p = protoOf(p)) freezeProto(p);
};
var probes = [function () { return [][Symbol.iterator](); },
function () { return ''[Symbol.iterator](); },
function () { return new Map()[Symbol.iterator](); },
function () { return new Set()[Symbol.iterator](); },
function () { return (function* () {})(); },
function () { return 'a'.matchAll(/a/g); },
function () { return Int8Array.prototype; }];
for (var m = 0; m < probes.length; m++) {
// Each one is a probe for an intrinsic this engine may not have at all.
try { walk(protoOf(probes[m]())); } catch (e) { /* not present */ }
}
})();
+353
View File
@@ -193,6 +193,315 @@ fn functions_and_let_are_reusable_across_evaluations() {
assert_close!(f.num(e, 2.0), 9.0);
}
#[test]
fn an_expression_cannot_rebind_a_prelude_name_for_the_next_one() {
let f = fx();
// #2133: one runtime serves every expression on a thread and user code runs at global scope, so
// a write to a prelude name used to outlive the expression that made it and poison every later
// one — in this session's other projects too.
assert_close!(f.num("linear = function () { return 777 };\n50", 0.0), 50.0);
assert_close!(f.num("linear(time, 0, 1, 0, 100)", 0.0), 0.0);
assert_close!(f.num("var clamp = 5;\n10", 0.0), 10.0);
assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0);
assert_close!(f.num("Math.sin = function () { return 9 };\n1", 0.0), 1.0);
assert_close!(f.num("Math.sin(Math.PI / 2) * 100", 0.0), 100.0);
assert_close!(f.num("Object.prototype.leak = 9;\n2", 0.0), 2.0);
assert_close!(f.num("({}).leak === undefined ? 1 : 0", 0.0), 1.0);
// The runtime is one thread-local singleton, so a second project on this thread meets the same
// objects: the isolation has to hold between projects, not only between the evaluations of one.
let g = fx();
assert_close!(g.num("linear(time, 0, 1, 0, 100)", 0.0), 0.0);
assert_close!(g.num("clamp(50, 0, 100)", 0.0), 50.0);
assert_close!(g.num("Math.sin(Math.PI / 2) * 100", 0.0), 100.0);
assert_close!(g.num("({}).leak === undefined ? 1 : 0", 0.0), 1.0);
}
/// The four ways a script can reach a global binding, against a prelude *function* rather than one
/// of the unqualified accessors: assignment, `var`, `delete` and `defineProperty`. The lock is
/// `writable: false, configurable: false`, and it is the second half of that pair that answers the
/// last two — a `delete` of a non-configurable property is false, and a `defineProperty` over one
/// is a `TypeError`. Asked for by a reviewer on #2176.
#[test]
fn a_prelude_function_survives_every_way_a_script_can_reach_its_binding() {
let f = fx();
// Assignment and `var`: silent, and the next expression still has the original.
assert_close!(f.num("clamp = function () { return 777 };\n1", 0.0), 1.0);
assert_close!(f.num("var clamp = 5;\n2", 0.0), 2.0);
assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0);
// `delete` of a non-configurable binding answers false in sloppy mode rather than throwing.
assert_close!(f.num("delete clamp ? 1 : 0", 0.0), 0.0);
assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0);
assert_close!(f.num("typeof clamp === 'function' ? 1 : 0", 0.0), 1.0);
// `defineProperty` over one is a `TypeError`, so this is the one route the user does see. The
// expression fails; what it was reaching for is unchanged for the next one.
assert!(f.err("Object.defineProperty(globalThis, 'clamp', { value: 5 })").contains("TypeError"));
assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0);
// Reconfiguring it to writable is refused the same way, so the lock cannot be reopened.
assert!(f.err("Object.defineProperty(globalThis, 'clamp', { writable: true })").contains("TypeError"));
assert_close!(f.num("clamp = 7;\n3", 0.0), 3.0);
assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0);
// A second project on the same thread-local runtime meets the same bindings.
let g = fx();
assert_close!(g.num("clamp(50, 0, 100)", 0.0), 50.0);
assert_close!(g.num("typeof clamp === 'function' ? 1 : 0", 0.0), 1.0);
}
/// Which mode a write is silent in decides what the user sees, and the answer is that an expression
/// cannot reach strict mode at all: `Runtime::script` wraps the text in `try{…}catch(__e){throw __e}`,
/// so a `"use strict"` the user writes is an expression statement inside a block rather than a
/// directive prologue, and has no effect. A failed write is therefore always the silent kind.
/// Asked for by a reviewer on #2176.
#[test]
fn a_use_strict_in_an_expression_is_inert_so_a_refused_write_stays_silent() {
let f = fx();
assert_close!(f.num("\"use strict\";\nlinear = function () { return 777 };\n50", 0.0), 50.0);
assert_close!(f.num("linear(time, 0, 1, 0, 100)", 0.0), 0.0);
// The directive is inert rather than honoured: in strict mode an assignment to an undeclared
// name is a ReferenceError, and here it quietly creates a global instead.
assert_close!(f.num("\"use strict\";\nundeclared_global = 5;\nundeclared_global", 0.0), 5.0);
// Which is also why the refused write above cannot be made to report itself by the user: the
// only mode an expression runs in is the one where it is dropped.
assert_close!(f.num("\"use strict\";\ntypeof linear === 'function' ? 1 : 0", 0.0), 1.0);
}
/// The same leak, reached through the objects instead of the names: a prelude constructor keeps its
/// methods on its `prototype`, the prelude itself calls `Array.isArray` and `Object.setPrototypeOf`
/// while it answers an expression, and `thisProject` is reachable surface that holds no function at
/// all (#2133).
#[test]
fn an_expression_cannot_tamper_with_a_prelude_object_for_the_next_one() {
let f = fx();
// An unqualified name is an accessor, so it takes a `defineProperty` or a `delete` rather than
// an assignment; the setter an assignment goes through writes per-evaluation state.
assert_close!(f.num("try { Object.defineProperty(globalThis, 'width', { value: 777 }) } catch (e) {}\n1", 0.0), 1.0);
assert_close!(f.num("delete width;\n2", 0.0), 2.0);
assert_close!(f.num("width", 0.0), 20.0);
assert_close!(f.num("Comp.prototype = {};\n3", 0.0), 3.0);
assert_close!(f.num("Comp.prototype.leak = 9;\n4", 0.0), 4.0);
assert_close!(f.num("thisComp.width", 0.0), 200.0);
// `add` reads its arguments through `Array.isArray`, and reaching an effect builds a group with
// `Object.setPrototypeOf`, so a replacement for either would change the answers below.
assert_close!(f.num("Array.isArray = function () { return false };\n5", 0.0), 5.0);
assert_close!(f.num("Object.setPrototypeOf = function (o) { return o };\n6", 0.0), 6.0);
assert_close!(f.num("add([1, 2], [3, 4])[0]", 0.0), 4.0);
assert_close!(f.num("effect(\"Slider Control\")(\"Slider\")", 0.0), 42.0);
assert_close!(f.num("thisProject = 5;\nthisProject.leak = 9;\n7", 0.0), 7.0);
assert_close!(f.num("typeof thisProject === 'object' && thisProject.leak === undefined ? 1 : 0", 0.0), 1.0);
}
/// `Math` and `JSON` are namespaces rather than prototypes: nothing inherits from them, so the
/// prelude freezes them plainly instead of turning every method into a shadowable accessor, which
/// would put a getter call on every `Math.sin` of a per-frame expression for a shadowing no object
/// can do. They still cannot be rewritten, through a member or through the binding (#2176).
#[test]
fn math_and_json_are_frozen_and_cannot_be_replaced_for_the_next_expression() {
let f = fx();
assert_close!(f.num("Object.isFrozen(Math) && Object.isFrozen(JSON) ? 1 : 0", 0.0), 1.0);
// Plain data properties, not the accessor repair: the hot path reads the native method directly.
assert_close!(f.num("'value' in Object.getOwnPropertyDescriptor(Math, 'sin') ? 1 : 0", 0.0), 1.0);
assert_close!(f.num("'value' in Object.getOwnPropertyDescriptor(JSON, 'stringify') ? 1 : 0", 0.0), 1.0);
// A member write is dropped in silence, and the next expression still has the original.
assert_close!(f.num("Math.sin = function () { return 9 }; Math.max = function () { return -1 };\n1", 0.0), 1.0);
assert_close!(f.num("Math.sin(Math.PI / 2) * 100 + Math.max(3, 7)", 0.0), 107.0);
assert_close!(f.num("JSON.stringify = function () { return 'x' }; JSON.parse = null;\n2", 0.0), 2.0);
assert_close!(f.num("JSON.stringify(JSON.parse('[1,2]')) === '[1,2]' ? 1 : 0", 0.0), 1.0);
assert_close!(f.num("Math.leak = 5; JSON.leak = 6;\n3", 0.0), 3.0);
assert_close!(f.num("Math.leak === undefined && JSON.leak === undefined ? 1 : 0", 0.0), 1.0);
assert_close!(f.num("delete Math.floor ? 1 : 0", 0.0), 0.0);
assert!(f.err("Object.defineProperty(Math, 'floor', { value: 5 })").contains("TypeError"));
// The bindings are locked too: assignment, `var` and `delete` leave both in place.
assert_close!(f.num("Math = { sin: function () { return 9 } }; JSON = null;\n4", 0.0), 4.0);
assert_close!(f.num("var Math = 5; var JSON = 6;\n5", 0.0), 5.0);
assert_close!(f.num("delete Math || delete JSON ? 1 : 0", 0.0), 0.0);
assert_close!(f.num("Math.floor(2.7) + JSON.parse('3')", 0.0), 5.0);
// A second project on the same thread-local runtime meets the same objects.
let g = fx();
assert_close!(g.num("Math.sin(Math.PI / 2) * 100", 0.0), 100.0);
assert_close!(g.num("JSON.stringify([1]) === '[1]' ? 1 : 0", 0.0), 1.0);
}
/// Closing the prototypes must not cost an expression its own objects: once an inherited data
/// property is non-writable, an assignment meaning to shadow it on an instance is dropped in sloppy
/// mode rather than creating an own property, and using an object as a dictionary runs into exactly
/// those names (#2133).
#[test]
fn an_expression_can_shadow_an_inherited_name_on_its_own_object() {
let f = fx();
assert_close!(f.num("var o = {}; o.toString = function () { return 'x' }; o.toString() === 'x' ? 1 : 0", 0.0), 1.0);
assert_close!(f.num("var d = {}; d.constructor = 3; d.constructor", 0.0), 3.0);
assert_close!(f.num("var c = {}; c['valueOf'] = 2; c['valueOf']", 0.0), 2.0);
// The shared prototype itself still cannot be written through, which is the point of freezing.
assert_close!(f.num("Object.prototype.toString = function () { return 'z' };\n1", 0.0), 1.0);
assert_close!(f.num("({}).toString() === 'z' ? 0 : 1", 0.0), 1.0);
}
/// The names an expression shadows are not only `Object.prototype`'s: giving one array its own
/// `map` or one function its own `call` is ordinary JavaScript, and a prototype frozen without the
/// repair takes it away. The two halves fail differently, which is why both are checked here —
/// `map`, `forEach` and `filter` throw, while `push` and `join` silently run the native method and
/// return its answer, so an expression using them keeps working and quietly computes something
/// else (#2133).
#[test]
fn an_expression_can_shadow_an_inherited_method_on_its_own_array_or_function() {
let f = fx();
assert_close!(f.num("var a = []; a.map = function () { return 42 }; a.map()", 0.0), 42.0);
assert_close!(f.num("var a = [1, 2]; a.forEach = function () { return 5 }; a.forEach()", 0.0), 5.0);
assert_close!(f.num("var a = [1, 2]; a.filter = function () { return 6 }; a.filter()", 0.0), 6.0);
// `push` returned the native length and `join` the native string before the repair.
assert_close!(f.num("var a = [1]; a.push = function () { return 99 }; a.push(2)", 0.0), 99.0);
assert_close!(f.num("var a = [1]; a.push = function () { return 99 }; a.push(2); a.length", 0.0), 1.0);
assert_close!(f.num("var a = [1]; a.join = function () { return 'J' }; a.join() === 'J' ? 1 : 0", 0.0), 1.0);
assert_close!(f.num("var fn = function () {}; fn.call = function () { return 7 }; fn.call()", 0.0), 7.0);
assert_close!(f.num("var fn = function () {}; fn.apply = function () { return 8 }; fn.apply()", 0.0), 8.0);
// A symbol key on a prototype the loop reaches goes the same way.
assert_close!(f.num("var a = [1]; a[Symbol.toPrimitive] = function () { return 11 }; +a", 0.0), 11.0);
// Reading an unshadowed method still answers with the native one, through the repair.
assert_close!(f.num("[1, 2, 3].map(function (x) { return x * 2 })[2]", 0.0), 6.0);
assert_close!(f.num("[1, 2, 3].filter(function (x) { return x > 1 }).length", 0.0), 2.0);
assert_close!(f.num("[3, 1, 2].sort()[0]", 0.0), 1.0);
assert_close!(f.num("[1, 2].concat([3]).length", 0.0), 3.0);
// The shared prototype itself still cannot be written through, which is the point of freezing.
assert_close!(f.num("Array.prototype.map = function () { return 0 };\n1", 0.0), 1.0);
assert_close!(f.num("[1].map(function (x) { return x + 1 })[0]", 0.0), 2.0);
assert_close!(f.num("Function.prototype.call = function () { return 0 };\n1", 0.0), 1.0);
assert_close!(f.num("(function () { return 4 }).call(null)", 0.0), 4.0);
}
/// `name` and `message` on an error's prototype are the one exception to the repair, because the
/// host reads both off a thrown object to render it and has no context to call a getter with, nor
/// anywhere safe to call one: formatting happens after the evaluation's time budget stops being
/// checked. What matters is that the message still names the error and the line, and that the
/// assignment the exception costs is dropped in silence rather than failing the expression (#2133).
#[test]
fn an_error_still_renders_with_its_name_and_the_users_line() {
let f = fx();
// The write the exception costs: a no-op, not a throw, and the next statement still runs.
assert_close!(f.num("var e = new Error('x'); e.name = 'MyError'; 1", 0.0), 1.0);
assert_close!(f.num("var e = new Error('x'); e.name === 'Error' ? 1 : 0", 0.0), 1.0);
// An own `message` is the instance's, so it is untouched by any of this.
assert_close!(f.num("new Error('x').message === 'x' ? 1 : 0", 0.0), 1.0);
// The prototype itself still cannot be rebound for the next expression, which is the point.
assert_close!(f.num("Error.prototype.name = 'Hacked';\n1", 0.0), 1.0);
assert_close!(f.num("new Error('x').name === 'Error' ? 1 : 0", 0.0), 1.0);
// A getter the expression installs on its own error is reached through its own property, so it
// never goes near the host's read of the prototype.
assert_close!(f.num("var e = new Error('x'); Object.defineProperty(e, 'name', { get: function () { return 'Own' } }); e.name === 'Own' ? 1 : 0", 0.0), 1.0);
}
/// The repair must not turn a dropped assignment into a failed expression: in sloppy mode a write
/// to a frozen target is ignored, and the setter runs for every receiver that inherits the name,
/// including the other frozen prototypes and an object the expression froze itself.
#[test]
fn assigning_a_repaired_name_on_a_frozen_object_is_dropped_not_an_error() {
let f = fx();
// `hasOwnProperty` is `Object.prototype`'s, so this runs the repaired setter with a frozen
// `Array.prototype` as the receiver.
assert_close!(f.num("Array.prototype.hasOwnProperty = function () { return true };\n1", 0.0), 1.0);
assert_close!(f.num("[].hasOwnProperty('length') ? 1 : 0", 0.0), 1.0);
// The prelude freezes these, and each inherits the repaired names.
assert_close!(f.num("Math.toString = function () { return 'm' };\n2", 0.0), 2.0);
assert_close!(f.num("JSON.valueOf = 3;\n3", 0.0), 3.0);
assert_close!(f.num("thisProject.toLocaleString = 4;\n4", 0.0), 4.0);
// An object the expression froze itself behaves as it does outside the prelude.
assert_close!(f.num("var o = {}; Object.freeze(o); o.toString = function () { return 'x' };\n5", 0.0), 5.0);
assert_close!(f.num("var s = {}; Object.seal(s); s.constructor = 6;\n6", 0.0), 6.0);
// Still a no-op where it has to be, and still working where it has to work.
assert_close!(f.num("({}).toString() === '[object Object]' ? 1 : 0", 0.0), 1.0);
assert_close!(f.num("var k = {}; k.toString = function () { return 'y' }; k.toString() === 'y' ? 1 : 0", 0.0), 1.0);
// A primitive target boxes into a throwaway wrapper, and `null` is not extensible, which is why
// the receiver check alone is enough.
assert_close!(f.num("'abc'.toString = function () { return 'q' };\n7", 0.0), 7.0);
assert_close!(f.num("(5).constructor = 8;\n8", 0.0), 8.0);
assert_close!(f.num("Object.isExtensible(null) === false && Object.isExtensible(undefined) === false ? 1 : 0", 0.0), 1.0);
}
/// The repaired name is an accessor standing in for a data property, and an accessor is handed the
/// receiver while a data property has the receiver's own property consulted first, so the setter
/// has to do that itself or the substitution shows through. An ordinary assignment finds the own
/// property and never reaches the setter, so these call it with the receiver directly, which is
/// what `Reflect.set(Object.prototype, name, v, o)` would do; `Reflect` is not available to
/// expressions. Every expected value is what that `Reflect.set` answers in node, where the name
/// is still the data property this one stands in for.
#[test]
fn the_repair_answers_a_write_as_the_data_property_it_replaced_would() {
let f = fx();
let set = "var put = Object.getOwnPropertyDescriptor(Object.prototype, 'toString').set;";
// An own writable property is updated in place, even when the receiver takes no new ones.
assert_close!(f.num(&format!("{set} var o = {{ toString: 1 }}; Object.preventExtensions(o); put.call(o, 2); o.toString"), 0.0), 2.0);
assert_close!(f.num(&format!("{set} var o = {{ toString: 1 }}; put.call(o, 2); o.toString"), 0.0), 2.0);
// ... and keeps the attributes it had, rather than being redefined as a fresh enumerable one.
assert_close!(
f.num(
&format!(
"{set} var o = {{}}; Object.defineProperty(o, 'toString', {{ value: 1, writable: true, enumerable: false, configurable: true }});
put.call(o, 2); Object.getOwnPropertyDescriptor(o, 'toString').enumerable ? 1 : 0"
),
0.0
),
0.0
);
// An own non-writable property drops the write instead of being overwritten.
assert_close!(
f.num(
&format!(
"{set} var o = {{}}; Object.defineProperty(o, 'toString', {{ value: 1, writable: false, enumerable: false, configurable: true }});
put.call(o, 2); o.toString"
),
0.0
),
1.0
);
// Writing through the prototype itself changes nothing for anyone.
assert_close!(f.num(&format!("{set} put.call(Object.prototype, 2); ({{}}).toString === undefined ? 0 : 1"), 0.0), 1.0);
// An ordinary assignment finds the own property first and never reaches the setter at all.
assert_close!(f.num("var o = { toString: 1 }; o.toString = 2; o.toString", 0.0), 2.0);
}
/// `for…of`, destructuring and spread all go through the iterator intrinsics, and those are the
/// `prototype` of no global function, so freezing what the globals reach never touched them (#2133).
#[test]
fn an_expression_cannot_poison_iteration_for_the_next_one() {
let f = fx();
assert_close!(f.num("Object.getPrototypeOf([][Symbol.iterator]()).next = function () { return { done: true } };\n1", 0.0), 1.0);
assert_close!(f.num("var s = 0; for (var x of [1, 2, 3]) s += x; s", 0.0), 6.0);
assert_close!(f.num("var [a, b] = [4, 5]; a + b", 0.0), 9.0);
assert_close!(f.num("Math.max(...[1, 7, 3])", 0.0), 7.0);
}
/// Shadowing an inherited name on an instance has to keep working for symbol keys too: making an
/// object iterable by assigning `Symbol.iterator` is ordinary JavaScript, and a frozen
/// `Array.prototype` drops that write in silence, so `for…of` walks the array's own elements and
/// quietly returns a different number rather than failing (#2133).
#[test]
fn an_expression_can_give_its_own_array_an_iterator() {
let f = fx();
assert_close!(
f.num(
"var a = [1, 2];
a[Symbol.iterator] = function () { var d = false; return { next: function () { if (d) return { done: true }; d = true; return { value: 9, done: false } } } };
var s = 0; for (var x of a) s += x; s",
0.0
),
9.0
);
assert_close!(f.num("var a = [1, 2]; a[Symbol.iterator] = 1; a[Symbol.iterator] === 1 ? 1 : 0", 0.0), 1.0);
// `Object.prototype` carries no symbol keys, so a plain object was never affected.
assert_close!(f.num("var o = {}; o[Symbol.iterator] = 1; o[Symbol.iterator] === 1 ? 1 : 0", 0.0), 1.0);
// An inherited name that is already non-writable is left alone, as it is outside the prelude:
// `Date.prototype[Symbol.toPrimitive]` drops the write on `main` and in node as well.
assert_close!(f.num("var d = new Date(0); d[Symbol.toPrimitive] = 1; d[Symbol.toPrimitive] === 1 ? 1 : 0", 0.0), 0.0);
// Repairing the key must not reopen it: the shared prototype still cannot be written through.
assert_close!(
f.num(
"Array.prototype[Symbol.iterator] = function () { return { next: function () { return { done: true } } } };
1",
0.0
),
1.0
);
assert_close!(f.num("var s = 0; for (var x of [1, 2, 3]) s += x; s", 0.0), 6.0);
}
#[test]
fn comments_are_ignored() {
let f = fx();
@@ -1672,3 +1981,47 @@ fn an_expression_cant_change_the_prelude_for_the_next() {
}
mod property_group;
/// Timing harness for the prelude's cost on the evaluation path. Ignored: it is a measurement, not
/// an assertion. `cargo test -p effectcraft-expr --release --lib bench_evaluation -- --ignored
/// --nocapture`.
#[test]
#[ignore = "timing measurement, not a test"]
fn bench_evaluation() {
let f = fx();
// Each case runs on a property whose dimension its answer fits: a scalar handed to `position`
// is rejected after the script has already run, so the case would be timing an error path.
// Ordinary expressions first, then three that read an inherited name in a loop: the repair
// turns each of those reads into an accessor call, and `Array.prototype.map` is the one an
// expression is most likely to reach for in quantity.
let cases: [(&str, &str, &str, u32); 10] = [
("array maths", POS, "value + [10, 0]", 2500),
("scalar maths", ROT, "time * 100 + 5", 3000),
("prelude call", ROT, "linear(time, 0, 1, 0, 100)", 2500),
("cross-layer read", ROT, "thisComp.layer(\"B\").transform.position[0]", 200),
("effect read", ROT, "effect(\"Slider Control\")(\"Slider\")", 150),
("wiggle", POS, "wiggle(2, 10)", 800),
("dictionary", ROT, "var c = {}; var k = 'constructor'; c[k] = 1; c[k] + value", 2500),
("toString x100", ROT, "var o = {}; var n = 0; for (var i = 0; i < 100; i++) { if (o.toString) n++; } n", 250),
("hasOwnProperty x100", ROT, "var o = {}; var n = 0; for (var i = 0; i < 100; i++) { if (o.hasOwnProperty) n++; } n", 250),
("array method x100", ROT, "var a = [1]; var n = 0; for (var i = 0; i < 100; i++) { if (a.map) n++; } n", 250),
];
for (label, path, text, iters) in cases {
let mut best = u128::MAX;
// Three passes, keeping the fastest: a slower pass is scheduling noise, not the prelude.
for _ in 0..3 {
// The warm-up checks as it goes, at every time the measured loop uses: that loop drops
// its answer by design, so a case that had started failing would be timed on its error
// path and still print a number.
for i in 0..50 {
assert!(f.ev(f.a, path, text, f64::from(i % 10) * 0.1).is_ok(), "{label}");
}
let start = std::time::Instant::now();
for i in 0..iters {
let _ = f.ev(f.a, path, text, f64::from(i % 10) * 0.1);
}
best = best.min(start.elapsed().as_nanos() / u128::from(iters));
}
println!("BENCH {best:>9} ns/eval {label} (min of 3 x {iters})");
}
}