JavaScript: declarations check the heap before they are bound

Declaration binding instantiation reserves an upper bound of what a script's,
eval's or function's variables and function declarations add to the heap
before making them, and each declaration and parameter takes a unit of fuel.
A long declaration list now ends with the heap limit without growing the
estimate past it.

Realm::set_limits recomputes when the next collection runs, so a lowered heap
limit applies to the next run instead of after the old threshold.

ToNumber skips the same white space and line terminators as the lexer, so
U+180E is no longer white space in a numeric string.
This commit is contained in:
dotCooCoo
2026-11-07 14:07:07 -08:00
parent e1cc8d5de6
commit 47db0b7a8a
5 changed files with 146 additions and 10 deletions
+4 -2
View File
@@ -283,8 +283,10 @@ enum Cell {
/// The estimated bytes of a property or a binding entry. A collection adds its name's code units
/// separately, counting a buffer that several names or strings share once.
pub(crate) const PROP_BYTES: usize = 64;
const OBJECT_BYTES: usize = 96;
const ENV_BYTES: usize = 64;
/// The estimated bytes of an object without its properties.
pub(crate) const OBJECT_BYTES: usize = 96;
/// The estimated bytes of an environment without its bindings.
pub(crate) const ENV_BYTES: usize = 64;
/// The estimated bytes of a rope node.
pub(crate) const ROPE_BYTES: usize = 48;
+56 -4
View File
@@ -23,8 +23,8 @@ use crate::ast::BinaryOp;
use crate::bytecode::{Code, CodeKind, Op, Source};
use crate::compiler::{CompileFail, compile_program};
use crate::heap::{
ArgMap, Binding, CONFIGURABLE, Class, ENUMERABLE, Env, EnvId, ForIn, Func, HIDDEN, Heap, Key, NativeKind, OPEN, ObjId, Object, PROP_BYTES, Prop,
PropMap, ROPE_BYTES, Roots, WRITABLE, key, string_bytes,
ArgMap, Binding, CONFIGURABLE, Class, ENUMERABLE, ENV_BYTES, Env, EnvId, ForIn, Func, HIDDEN, Heap, Key, NativeKind, OBJECT_BYTES, OPEN, ObjId,
Object, PROP_BYTES, Prop, PropMap, ROPE_BYTES, Roots, WRITABLE, key, string_bytes,
};
use crate::value::{Str, Value, array_index, index_key, number_to_str, string_to_number, to_int32, to_uint32};
use crate::{LimitKind, Limits, Options};
@@ -186,6 +186,30 @@ fn index_keys_bytes(len: usize) -> usize {
total
}
/// The estimated bytes of a binding or property named by `len` code units.
fn binding_bytes(len: usize) -> usize {
PROP_BYTES + string_bytes(len)
}
/// At least the bytes that a function object made from `code` adds to the heap: the function,
/// its prototype object, their properties and the environment holding a function expression's
/// own name.
fn closure_bytes(code: &Code) -> usize {
let props = if code.strict { 5 } else { 3 };
let named = code.self_name.as_ref().map_or(0, |n| ENV_BYTES + binding_bytes(n.len()));
2 * OBJECT_BYTES + props * binding_bytes("constructor".len()) + named
}
/// At least the bytes that declaration binding instantiation (section 10.5) adds to the heap for
/// the function declarations and variables of `code`.
fn declaration_bytes(code: &Code) -> usize {
let funs = code.fun_decls.iter().fold(0usize, |total, (name, fi)| {
let closure = code.functions.get(*fi as usize).map_or(0, |f| closure_bytes(f));
total.saturating_add(binding_bytes(name.len()) + closure)
});
code.vars.iter().fold(funs, |total, v| total.saturating_add(binding_bytes(v.len())))
}
/// A call to a function written in JavaScript, ready to enter.
struct Entry {
callee: ObjId,
@@ -272,6 +296,11 @@ impl Interp {
(self.heap.bytes.saturating_mul(2)).max(MIN_GC_BYTES).min(self.limits.heap_bytes)
}
/// Recomputes when the next collection runs, after the limits change.
pub(crate) fn reset_gc_threshold(&mut self) {
self.gc_threshold = self.next_threshold();
}
// ---- errors ----
pub(crate) fn error_object(&mut self, t: ErrorType, message: &str) -> ObjId {
@@ -1558,6 +1587,20 @@ impl Interp {
if self.frames.len() >= self.limits.frames {
return Err(Abrupt::Limit(LimitKind::Frames));
}
let params = code.params.iter().fold(0usize, |total, p| total.saturating_add(binding_bytes(p.len())));
let arguments =
if code.uses_arguments { OBJECT_BYTES.saturating_add(args.len().saturating_add(4).saturating_mul(binding_bytes(10))) } else { 0 };
let need = declaration_bytes(&code).saturating_add(ENV_BYTES).saturating_add(params).saturating_add(arguments);
if self.heap.bytes.saturating_add(need) > self.limits.heap_bytes {
let mark = self.temp_roots.len();
self.temp_roots.push(Value::Object(callee));
self.temp_roots.push(this.clone());
self.temp_roots.extend(construct.map(Value::Object));
self.temp_roots.extend(args.iter().cloned());
let reserved = self.reserve_heap(need);
self.temp_roots.truncate(mark);
reserved?;
}
let this = if code.strict {
this
} else {
@@ -1585,16 +1628,19 @@ impl Interp {
Ok(())
}
/// Declaration binding instantiation for function code (section 10.5).
/// Declaration binding instantiation for function code (section 10.5). Each parameter and
/// declaration takes a unit of fuel.
fn instantiate_function(&mut self, code: &Rc<Code>, outer: EnvId, args: &[Value], callee: ObjId) -> JsResult<EnvId> {
let mut bindings = PropMap::default();
for (i, p) in code.params.iter().enumerate() {
self.use_fuel()?;
bindings.insert(p.clone(), Binding { value: args.get(i).cloned().unwrap_or(Value::Undefined), mutable: true, deletable: false });
}
let name_bytes = bindings.iter().map(|(k, _)| string_bytes(k.len())).sum::<usize>();
let env = self.heap.alloc_env(Env::Decl { bindings, outer: Some(outer) });
self.heap.bytes += name_bytes;
for (name, fi) in &code.fun_decls {
self.use_fuel()?;
let Some(fcode) = code.functions.get(*fi as usize).cloned() else { return internal("function out of range") };
let f = self.make_closure(fcode, env)?;
self.env_insert(env, name.clone(), Binding { value: Value::Object(f), mutable: true, deletable: false })?;
@@ -1605,6 +1651,7 @@ impl Interp {
self.env_insert(env, key("arguments"), Binding { value: Value::Object(a), mutable: !code.strict, deletable: false })?;
}
for v in &code.vars {
self.use_fuel()?;
if !has(self, v) {
self.env_insert(env, v.clone(), Binding { value: Value::Undefined, mutable: true, deletable: false })?;
}
@@ -1722,6 +1769,7 @@ impl Interp {
self.check_source_units(s.len()).map_err(|e| self.compile_error_to_abrupt(e))?;
let units = s.flat();
let code = self.compile_source(&units, strict, CodeKind::Eval).map_err(|e| self.compile_error_to_abrupt(e))?;
self.reserve_heap(declaration_bytes(&code).saturating_add(ENV_BYTES))?;
let frame = self.frame()?;
let (caller_env, caller_var_env, this) = (frame.env, frame.var_env, frame.this.clone());
let (env, var_env) = if code.strict {
@@ -1741,6 +1789,7 @@ impl Interp {
if self.reentry >= self.limits.reentry {
return Err(Abrupt::Limit(LimitKind::Reentry));
}
self.reserve_heap(declaration_bytes(&code).saturating_add(ENV_BYTES))?;
let (env, global) = (self.global_env, self.global);
let (env, var_env) = if code.strict && is_eval {
let e = self.heap.alloc_env(Env::Decl { bindings: PropMap::default(), outer: Some(env) });
@@ -1777,15 +1826,18 @@ impl Interp {
Ok(())
}
/// Declaration binding instantiation for global and eval code (section 10.5).
/// Declaration binding instantiation for global and eval code (section 10.5). The caller has
/// reserved [`declaration_bytes`] of `code`; each declaration takes a unit of fuel.
fn instantiate_global(&mut self, code: &Rc<Code>, var_env: EnvId, is_eval: bool) -> JsResult<()> {
let configurable = if is_eval { CONFIGURABLE } else { 0 };
for (name, fi) in &code.fun_decls {
self.use_fuel()?;
let Some(fcode) = code.functions.get(*fi as usize).cloned() else { return internal("function out of range") };
let f = self.make_closure(fcode, var_env)?;
self.declare(var_env, name, Some(Value::Object(f)), configurable)?;
}
for v in &code.vars {
self.use_fuel()?;
self.declare(var_env, v, None, configurable)?;
}
Ok(())
+79
View File
@@ -316,6 +316,85 @@ fn strings_from_host_functions_count_toward_the_heap() {
assert_eq!(limit(&mut with_big(limits), "big(1000000).length"), LimitKind::Heap);
}
#[test]
fn declarations_check_the_heap_before_they_are_bound() {
// Each script declares more than fits in the heap. The run ends with the heap limit before
// the bindings are made, so the estimate never goes past the limit.
// Short names keep the source strings well inside the heap while the bindings are not.
let limits = Limits { heap_bytes: 2 << 20, ..Limits::default() };
let short = |mut i: usize| {
let mut name = String::from("_");
loop {
name.push(char::from(b'a' + (i % 26) as u8));
i /= 26;
if i == 0 {
return name;
}
}
};
let vars = (0..40_000).map(short).collect::<Vec<_>>().join(",");
let funs = (0..20_000).map(|i| format!("function f{i}() {{}}")).collect::<String>();
let cases = [
format!("var {vars};"),
funs.clone(),
format!("(function () {{ eval('var {vars};'); }})();"),
format!("(function () {{ eval('{funs}'); }})();"),
format!("(0, eval)('\"use strict\"; var {vars};');"),
format!("Function('var {vars};')();"),
format!("Function('{funs}')();"),
];
let mut wrong = Vec::new();
for (i, src) in cases.iter().enumerate() {
let mut realm = small(limits.clone());
let r = realm.run(src).map_err(|e| e.kind);
let bytes = realm.interp().heap.bytes;
if r != Err(RunErrorKind::Limit(LimitKind::Heap)) || bytes > limits.heap_bytes {
wrong.push((i, r, bytes));
}
}
assert!(wrong.is_empty(), "cases that bound past the limit: {wrong:?}");
let few = (0..1000).map(|i| format!("b{i}")).collect::<Vec<_>>().join(",");
let mut realm = small(limits);
assert_eq!(realm.run(&format!("var {few}; b999 = 1; Function('var {few}; return 2')() + b999")), Ok(JsValue::Number(3.0)));
}
#[test]
fn declarations_take_fuel() {
let vars = (0..10_000).map(|i| format!("a{i}")).collect::<Vec<_>>().join(",");
let mut realm = small(Limits { fuel: 1000, ..Limits::default() });
assert_eq!(limit(&mut realm, &format!("var {vars};")), LimitKind::Fuel);
assert_eq!(limit(&mut realm, &format!("Function('var {vars};')();")), LimitKind::Fuel);
}
#[test]
fn a_lower_heap_limit_applies_to_what_is_already_live() {
// A heap already past the new limit ends the next run.
let mut realm = small(Limits::default());
realm.run("var keep = []; for (var i = 0; i < 100000; i++) keep[i] = {i: i};").unwrap();
realm.set_limits(Limits { heap_bytes: 1 << 20, ..Limits::default() });
assert_eq!(limit(&mut realm, "for (var j = 0; j < 1000; j++) ({j: j});"), LimitKind::Heap);
realm.set_limits(Limits::default());
realm.run("keep = null;").unwrap();
realm.set_limits(Limits { heap_bytes: 1 << 20, ..Limits::default() });
assert_eq!(realm.run("for (var j = 0; j < 1000; j++) ({j: j}); j"), Ok(JsValue::Number(1000.0)));
// A heap below the new limit cannot grow past it: the next collection runs by the new limit,
// not by when it would have run under the old, higher one.
let mut realm = small(Limits::default());
realm.run("var keep = []; for (var i = 0; i < 100000; i++) keep[i] = {i: i};").unwrap();
let live = realm.collect_garbage();
realm.set_limits(Limits { heap_bytes: live + (1 << 20), ..Limits::default() });
assert_eq!(limit(&mut realm, "var more = []; for (var j = 0; j < 20000; j++) more[j] = {j: j};"), LimitKind::Heap);
}
#[test]
fn to_number_skips_the_white_space_the_lexer_skips() {
let mut realm = small(Limits::default());
assert_eq!(realm.run("var n = +'\\u180E'; n !== n"), Ok(JsValue::Bool(true)), "U+180E is not white space");
assert_eq!(realm.run("var m = +'\\u180E1'; m !== m"), Ok(JsValue::Bool(true)));
assert_eq!(realm.run("+' \\t\\n\\u2028\\u3000\\uFEFF 1 \\u00A0'"), Ok(JsValue::Number(1.0)));
assert_eq!(realm.run("+' 1 '"), Ok(JsValue::Number(1.0)));
}
#[test]
fn an_infinite_loop_ends_with_the_fuel_limit() {
let mut realm = small(Limits { fuel: 100_000, ..Limits::default() });
+4 -1
View File
@@ -145,9 +145,12 @@ impl Realm {
&self.interp.limits
}
/// Sets the limits for the next run.
/// Sets the limits for the next run. A heap that is already larger than the new
/// [`Limits::heap_bytes`] ends the next run with the heap limit unless a collection frees
/// enough of it.
pub fn set_limits(&mut self, limits: Limits) {
self.interp.limits = limits;
self.interp.reset_gc_threshold();
}
/// The realm's interrupt flag. Setting it stops a running script, or the next one to start.
+3 -3
View File
@@ -202,10 +202,10 @@ impl Drop for Str {
}
}
/// White space and line terminators as StrWhiteSpaceChar (section 9.3.1) counts them.
/// White space and line terminators as StrWhiteSpaceChar (section 9.3.1) counts them: the same
/// classes the lexer uses between tokens.
pub(crate) fn is_str_space(u: u16) -> bool {
matches!(u, 0x09 | 0x0A | 0x0B | 0x0C | 0x0D | 0x20 | 0xA0 | 0x1680 | 0x180E | 0x2028 | 0x2029 | 0x202F | 0x205F | 0x3000 | 0xFEFF)
|| (0x2000..=0x200A).contains(&u)
crate::chars::is_whitespace(u32::from(u)) || crate::chars::is_line_terminator(u32::from(u))
}
/// ToNumber applied to a string (section 9.3.1): the StringNumericLiteral grammar, NaN for