JavaScript: strings the built-ins build take fuel and stay counted in collections

The buffer that join, JSON.stringify, the URI functions, escape and
unescape write into takes one unit of fuel for every 16 code units
written, so writing a long run of separators or copying a long string
over and over ends with the fuel limit. Its bytes are added back to the
heap total after each collection until the string is finished, and a
native call that ends early gives them back, so a collection while a
string is being built no longer lets the heap estimate drop below what
is in use.
This commit is contained in:
dotCooCoo
2026-11-07 20:09:09 -08:00
parent edfd25e26c
commit f16cf72f07
6 changed files with 76 additions and 14 deletions
+28 -7
View File
@@ -192,30 +192,49 @@ fn new_string(interp: &mut Interp, units: Vec<u16>) -> JsResult<Value> {
Ok(Value::String(Str::from_units(units)))
}
/// A string the built-ins build piece by piece: each piece is checked against the longest string
/// the heap limit allows and its bytes are reserved before it is added. Everything the caller
/// still needs must be reachable from the roots.
/// Code units a built-in may write into a string for one unit of fuel.
const UNITS_PER_FUEL: usize = 16;
/// A string the built-ins build piece by piece: each piece takes fuel for its length, is checked
/// against the longest string the heap limit allows, and has its bytes reserved before it is
/// added. The buffer's bytes count toward the heap until `finish` or until the native call that
/// made the builder returns. Everything the caller still needs must be reachable from the roots.
struct Builder {
units: Vec<u16>,
/// Code units already reserved and charged to the heap.
charged: usize,
/// Code units written since fuel was last taken, fewer than `UNITS_PER_FUEL`.
unpaid: usize,
}
impl Builder {
fn new() -> Self {
Builder { units: Vec::new(), charged: 0 }
Builder { units: Vec::new(), charged: 0, unpaid: 0 }
}
/// Checks that `more` code units fit, reserving heap for them.
/// Checks that `more` code units fit, reserving heap for them, and takes fuel for writing them.
fn make_room(&mut self, interp: &mut Interp, more: usize) -> JsResult<()> {
let want = self.units.len().saturating_add(more);
if want > interp.limits.heap_bytes / 4 {
return Err(Abrupt::Limit(crate::LimitKind::Heap));
}
self.unpaid += more;
let fuel = (self.unpaid / UNITS_PER_FUEL) as u64;
self.unpaid %= UNITS_PER_FUEL;
if fuel > 0 {
if interp.fuel < fuel {
interp.fuel = 0;
return Err(Abrupt::Limit(crate::LimitKind::Fuel));
}
// The last unit goes through `use_fuel`, which also checks for an interrupt.
interp.fuel -= fuel - 1;
interp.use_fuel()?;
}
if want > self.charged {
let grow = (want - self.charged).max(self.charged / 2).max(256).min((interp.limits.heap_bytes / 4).saturating_sub(self.charged));
interp.reserve_heap(2 * grow)?;
interp.heap.bytes = interp.heap.bytes.saturating_add(2 * grow);
interp.held_bytes = interp.held_bytes.saturating_add(2 * grow);
self.charged += grow;
}
Ok(())
@@ -246,8 +265,10 @@ impl Builder {
self.push_units(interp, &units)
}
/// The string built, whose bytes were charged as it grew.
fn finish(self) -> Value {
/// The string built, whose bytes were charged as it grew. From here on the heap counts them
/// as the string's.
fn finish(self, interp: &mut Interp) -> Value {
interp.held_bytes = interp.held_bytes.saturating_sub(2 * self.charged);
Value::String(Str::from_units(self.units))
}
}
+1 -1
View File
@@ -304,7 +304,7 @@ fn join_elements(interp: &mut Interp, o: ObjId, len: u32, sep: &[u16], item: fn(
out.push_units(interp, &s.flat())?;
}
out.push_repeated(interp, sep, u64::from(len - 1).saturating_sub(seps))?;
Ok(out.finish())
Ok(out.finish(interp))
}
/// An element as `join` writes it: nothing for `undefined` and `null`.
+4 -4
View File
@@ -241,7 +241,7 @@ fn encode(interp: &mut Interp, args: &[Value], unescaped: fn(u16) -> bool) -> Js
}
k += 1;
}
Ok(out.finish())
Ok(out.finish(interp))
}
fn hex_byte(s: &[u16], at: usize) -> Option<u8> {
@@ -294,7 +294,7 @@ fn decode(interp: &mut Interp, args: &[Value], reserved: &str) -> JsResult<Value
out.push_units(interp, ch.encode_utf16(&mut units))?;
k += 1;
}
Ok(out.finish())
Ok(out.finish(interp))
}
fn encode_uri(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Value> {
@@ -329,7 +329,7 @@ fn escape(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<Va
out.push_units(interp, &[u16::from(b'%'), u16::from(b'u'), hex(12), hex(8), hex(4), hex(0)])?;
}
}
Ok(out.finish())
Ok(out.finish(interp))
}
/// `unescape` (section B.2.2).
@@ -360,5 +360,5 @@ fn unescape(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult<
out.push_units(interp, &[unit])?;
k += 1;
}
Ok(out.finish())
Ok(out.finish(interp))
}
+1 -1
View File
@@ -446,7 +446,7 @@ fn stringify_rooted(interp: &mut Interp, args: &[Value]) -> JsResult<Value> {
while !w.stack.is_empty() {
step(interp, &settings, &mut w)?;
}
Ok(w.out.finish())
Ok(w.out.finish(interp))
}
/// Reads the replacer and the space argument (section 15.12.3, steps 4 to 8).
+30
View File
@@ -383,6 +383,36 @@ fn number_parsing_counts_its_copy_toward_the_heap_limit() {
assert_eq!(limit(&mut realm, "parseFloat(s)"), LimitKind::Heap);
}
#[test]
fn strings_built_ins_write_take_fuel_and_stay_counted_across_collections() {
// Each join writes 1 Mi code units, so a thousand of them do not fit in 2,000,000 units of
// fuel, whether the units are separators or a string's contents.
let limits = Limits { heap_bytes: 8 << 20, fuel: 2_000_000, ..Limits::default() };
assert_eq!(limit(&mut small(limits.clone()), "var a = new Array((1 << 20) + 1); for (var i = 0; i < 1000; i++) a.join('x'); i"), LimitKind::Fuel);
assert_eq!(
limit(&mut small(limits), "var a = [new Array((1 << 20) + 1).join('x')]; for (var i = 0; i < 1000; i++) a.join(''); i"),
LimitKind::Fuel
);
// `join` holds a 2 MiB buffer for `s` while the second element's `toString` keeps new objects.
// A collection in between still counts the buffer, so the objects and the buffer together do
// not fit in a heap with 1 MiB to spare beyond the objects.
let setup = "var s = new Array((1 << 20) + 1).join('x'); var keep = []; 0";
let fill = "for (var i = 0; i < 30000; i++) keep.push({});";
let mut measure = small(Limits::default());
assert_eq!(measure.run(&format!("{setup}; {fill} 0")), Ok(JsValue::Number(0.0)));
measure.interp().collect_garbage();
let heap_bytes = measure.interp().heap.bytes + (1 << 20);
let mut realm = small(Limits { heap_bytes, ..Limits::default() });
assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0)));
let join = format!("var f = {{toString: function () {{ {fill} return ''; }}}}; [s, f].join('').length");
assert_eq!(limit(&mut realm, &join), LimitKind::Heap);
// A join that throws gives its buffer back, so a hundred of them still fit in 8 MiB.
let mut realm = small(Limits { heap_bytes: 8 << 20, ..Limits::default() });
let src = "var s = new Array((1 << 20) + 1).join('x'); var t = {toString: function () { throw 1; }}; \
for (var i = 0; i < 100; i++) { try { [s, t].join(''); } catch (e) {} } i";
assert_eq!(realm.run(src), Ok(JsValue::Number(100.0)));
}
#[test]
fn values_built_ins_hold_survive_collections() {
// A small heap makes the callbacks' garbage start collections while the results are built.
+12 -1
View File
@@ -166,6 +166,10 @@ pub(crate) struct Interp {
/// The objects `Array.prototype.join` and `toLocaleString` are joining, innermost last: a
/// cycle back to one of them joins as an empty string.
pub(crate) join_stack: Vec<ObjId>,
/// Bytes of the string buffers native functions are building outside the heap. They are
/// charged to `heap.bytes` and added back after each collection, and a native call's
/// buffers are released when it returns.
pub(crate) held_bytes: usize,
}
/// The least heap size a collection waits for.
@@ -302,6 +306,7 @@ impl Interp {
last_thrown: Value::Undefined,
random: random_seed(),
join_stack: Vec::new(),
held_bytes: 0,
};
crate::builtins::install(&mut interp);
interp.gc_threshold = interp.next_threshold();
@@ -455,6 +460,7 @@ impl Interp {
roots.objects.extend(self.intrinsics.all());
roots.envs.push(self.global_env);
self.heap.collect(&roots);
self.heap.bytes = self.heap.bytes.saturating_add(self.held_bytes);
}
/// Runs frames until the boundary frame on top returns, and returns its value.
@@ -1733,7 +1739,9 @@ impl Interp {
let mark = self.temp_roots.len();
self.temp_roots.push(this.clone());
self.temp_roots.extend(args.iter().cloned());
let held = self.held_bytes;
let r = native(self, this, &args, false);
self.held_bytes = held;
self.temp_roots.truncate(mark);
return Ok(Prepared::Done(r?));
}
@@ -1834,7 +1842,10 @@ impl Interp {
self.enter_function(Entry { callee, code, env, this: Value::Object(obj), args }, Some(obj), false)
}
Some(Func::Native { f: native, constructor: true, .. }) => {
let v = native(self, Value::Undefined, &args, true)?;
let held = self.held_bytes;
let r = native(self, Value::Undefined, &args, true);
self.held_bytes = held;
let v = r?;
self.stack.truncate(start);
self.push(v)
}