From f16cf72f07b31bdd04cf84f8d4257d9f89b40a19 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 20:09:09 -0800 Subject: [PATCH] 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. --- crates/js/src/builtins.rs | 35 +++++++++++++++++++++++++------- crates/js/src/builtins/array.rs | 2 +- crates/js/src/builtins/global.rs | 8 ++++---- crates/js/src/builtins/json.rs | 2 +- crates/js/src/builtins_tests.rs | 30 +++++++++++++++++++++++++++ crates/js/src/interp.rs | 13 +++++++++++- 6 files changed, 76 insertions(+), 14 deletions(-) diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index c72382e27..4fc1adc87 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -192,30 +192,49 @@ fn new_string(interp: &mut Interp, units: Vec) -> JsResult { 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, /// 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)) } } diff --git a/crates/js/src/builtins/array.rs b/crates/js/src/builtins/array.rs index e3f2ebe00..2f81bf766 100644 --- a/crates/js/src/builtins/array.rs +++ b/crates/js/src/builtins/array.rs @@ -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`. diff --git a/crates/js/src/builtins/global.rs b/crates/js/src/builtins/global.rs index 14f1b6805..261bfa146 100644 --- a/crates/js/src/builtins/global.rs +++ b/crates/js/src/builtins/global.rs @@ -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 { @@ -294,7 +294,7 @@ fn decode(interp: &mut Interp, args: &[Value], reserved: &str) -> JsResult JsResult { @@ -329,7 +329,7 @@ fn escape(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult JsResult< out.push_units(interp, &[unit])?; k += 1; } - Ok(out.finish()) + Ok(out.finish(interp)) } diff --git a/crates/js/src/builtins/json.rs b/crates/js/src/builtins/json.rs index 4aa5c9ac5..8061379ce 100644 --- a/crates/js/src/builtins/json.rs +++ b/crates/js/src/builtins/json.rs @@ -446,7 +446,7 @@ fn stringify_rooted(interp: &mut Interp, args: &[Value]) -> JsResult { 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). diff --git a/crates/js/src/builtins_tests.rs b/crates/js/src/builtins_tests.rs index db483e652..4a4c2aea4 100644 --- a/crates/js/src/builtins_tests.rs +++ b/crates/js/src/builtins_tests.rs @@ -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. diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 472d21c19..7168f9dad 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -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, + /// 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) }