diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index f7c21a8d8..301b1235a 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -466,6 +466,7 @@ fn function_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsRe .chain([parts.len().saturating_sub(1), HEAD.len() + MID.len() + TAIL.len(), body.len()]) .fold(0usize, usize::saturating_add); interp.check_source_units(total).map_err(|e| interp.compile_error_to_abrupt(e))?; + interp.prepare_source(total)?; let mut units = Vec::with_capacity(total); units.extend(HEAD.encode_utf16()); for (i, p) in parts.iter().enumerate() { @@ -554,6 +555,7 @@ fn eval(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult self.prepare_source(units.len().saturating_sub(floor)), + Err(_) => Ok(()), + }; + rest.map(|()| units.and_then(|units| self.compile_source_writes(&units, false, writes, crate::bytecode::CodeKind::Eval))) + }); + let code = code.and_then(|c| c); self.source_tag = tag; let r = match code { - Ok(code) => self.run_global(code, true), - Err(e) => Err(self.compile_error_to_abrupt(e)), + Ok(Ok(code)) => self.run_global(code, true), + Ok(Err(e)) => Err(self.compile_error_to_abrupt(e)), + Err(limit) => Err(limit), }; end(); r diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs index 6fc6c4e04..fe7ce894f 100644 --- a/crates/js/src/host_tests.rs +++ b/crates/js/src/host_tests.rs @@ -540,6 +540,27 @@ fn error_sources_count_from_the_last_script_and_code_from_an_earlier_one_is_unkn assert_eq!(r.error_source(), 0, "a syntax error is in the script"); } +#[test] +fn an_evaluators_source_takes_fuel_before_it_is_parsed() { + // The evaluator gives 131,072 spaces and a number: far more code units than the fuel left. + let ends = Rc::new(Cell::new(0)); + let ended = ends.clone(); + let source: HostFunction = Rc::new(|_| Ok(Data::String(format!("{}7", " ".repeat(1 << 17))))); + let item = HostItem::Evaluator { source, end: Rc::new(move || ended.set(ended.get() + 1)), strict_writes: Rc::new(|| None) }; + let mut r = realm(10_000); + r.define_host_object("host", vec![("evaluate".into(), item)]).unwrap(); + let e = r.run("host.evaluate()").unwrap_err(); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel)); + assert_eq!(ends.get(), 1, "the evaluator's end runs once"); + // This fuel covers a third of the source's bytes but not all of its code units. + r.set_limits(Limits { fuel: 60_000, ..Limits::default() }); + assert_eq!(r.run("host.evaluate()").unwrap_err().kind, RunErrorKind::Limit(LimitKind::Fuel)); + assert_eq!(ends.get(), 2); + r.set_limits(Limits { fuel: 1_000_000, ..Limits::default() }); + assert_eq!(r.run("host.evaluate()"), Ok(JsValue::Number(7.0))); + assert_eq!(ends.get(), 3); +} + proptest::proptest! { /// A slice size does not change what a script computes. #[test] diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index afb6cde63..a67dd586b 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -9,7 +9,8 @@ //! Before each instruction the interpreter takes one unit of fuel, checks the interrupt flag and, //! when the heap has grown past its threshold, collects garbage. Running out of fuel, the heap //! limit, the frame limit, the re-entry limit and the interrupt flag each end the run with an -//! [`Abrupt::Limit`] that unwinds every frame without running catch or finally blocks. +//! [`Abrupt::Limit`] that unwinds every frame without running catch or finally blocks. Source +//! compiled during a run takes a unit of fuel for each code unit before it is parsed. //! //! Collection runs only between instructions. An operation that may run JavaScript in the middle //! keeps its operands on the operand stack until it is done, so everything it holds is reachable @@ -617,6 +618,22 @@ impl Interp { Ok(()) } + /// Prepares for parsing `units` code units of source at run time: takes a unit of fuel for + /// each code unit and checks that the source text fits in the heap, before any of it is + /// flattened, parsed or compiled. Running out of fuel uses up what was left. + pub(crate) fn prepare_source(&mut self, units: usize) -> JsResult<()> { + let fuel = u64::try_from(units).unwrap_or(u64::MAX); + if fuel > self.fuel { + self.fuel = 0; + return Err(Abrupt::Limit(LimitKind::Fuel)); + } + self.fuel -= fuel; + if self.interrupt.load(Ordering::Relaxed) { + return Err(Abrupt::Limit(LimitKind::Interrupted)); + } + self.reserve_heap(string_bytes(units)) + } + /// Removes the [`Self::finally_marks`] entries of `finally` blocks that `break`, `continue` or /// `return` left: those whose stack slot no longer holds their exception. fn drop_left_finally_marks(&mut self) { @@ -2397,6 +2414,7 @@ impl Interp { }; let strict = self.strict(); self.check_source_units(s.len()).map_err(|e| self.compile_error_to_abrupt(e))?; + self.prepare_source(s.len())?; let units = s.flat(); let code = self.compile_source(&units, strict, CodeKind::Eval).map_err(|e| self.compile_error_to_abrupt(e))?; self.charge_code(&code, declaration_bytes(&code).saturating_add(ENV_BYTES))?; diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index 28157d6ee..93c487b37 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -500,6 +500,19 @@ fn declarations_take_fuel() { assert_eq!(limit(&mut realm, &format!("Function('var {vars};')();")), LimitKind::Fuel); } +#[test] +fn source_compiled_during_a_run_takes_fuel_before_it_is_parsed() { + // Doubling a string 17 times makes 131,072 spaces in about a hundred instructions, so only + // the compiled source can use up the fuel. + let build = "var s = ' '; for (var i = 0; i < 17; i++) s += s;"; + let mut realm = small(Limits { fuel: 10_000, ..Limits::default() }); + for call in ["eval(s)", "(0, eval)(s)", "Function(s)", "new Function('a', s)"] { + assert_eq!(limit(&mut realm, &format!("{build} {call};")), LimitKind::Fuel, "{call}"); + } + let mut realm = small(Limits { fuel: 1_000_000, ..Limits::default() }); + assert_eq!(realm.run(&format!("{build} eval(s + '1') + (0, eval)(s + '2') + Function(s + 'return 3')()")), Ok(JsValue::Number(6.0))); +} + #[test] fn a_lower_heap_limit_applies_to_what_is_already_live() { // A heap already past the new limit ends the next run. @@ -823,13 +836,14 @@ fn long_property_names_count_toward_the_heap_after_their_strings_are_collected() // Each name is a 16,385-unit buffer that only the object's key or the function's binding // holds once the string that made it is garbage, so about 32 of them fill the heap well before // the fuel runs out. In the second script the property lookup flattens a long string each - // time, so collections run. + // time, so collections run. Each eval there also takes fuel for its source, so that run has + // more fuel. let mut realm = small(Limits { heap_bytes: 1 << 20, fuel: 20_000, ..Limits::default() }); let src = "var s = 'x'; while (s.length < 10000) s += s; var o = {}; for (var i = 0; ; i++) o[s + i] = 1;"; assert_eq!(limit(&mut realm, src), LimitKind::Heap); let src = "var s = 'x'; while (s.length < 10000) s += s; \ (function () { for (var i = 0; ; i++) { eval('var ' + s + i + ' = 1'); ({})[s + i]; } })();"; - let mut realm = small(Limits { heap_bytes: 1 << 20, fuel: 20_000, ..Limits::default() }); + let mut realm = small(Limits { heap_bytes: 1 << 20, fuel: 5_000_000, ..Limits::default() }); assert_eq!(limit(&mut realm, src), LimitKind::Heap); } diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index f274c42ea..b17d6a277 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -14,7 +14,8 @@ use crate::{Options, SyntaxError}; /// cannot catch it, and their `finally` blocks do not run. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Limits { - /// Instructions a run may execute. + /// Instructions a run may execute. Source that `eval`, `Function` or a host evaluator + /// compiles during the run also takes one unit for each UTF-16 code unit, before it is parsed. pub fuel: u64, /// Estimated bytes the heap (objects, environments and the strings they hold) may reach /// after a collection. A string longer than a quarter of this many code units cannot be diff --git a/docs/mcp.md b/docs/mcp.md index da260b682..5065a0e62 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1826,7 +1826,7 @@ VectorCraft sets these limits itself: | Included and evaluated files | 64 in all, 16 levels deep, 1,024 paths looked up | | Heap | 256 MiB (128 MiB on the web) | | Host calls | 2,000,000 | -| `maxFuel`, instructions in all | 1 to 100,000,000,000; 1,000,000,000 by default | +| `maxFuel`, instructions in all, plus one for each UTF-16 code unit of source that `eval`, `Function` or an evaluated file compiles during the run | 1 to 100,000,000,000; 1,000,000,000 by default | | Instructions without a pause | 200,000,000 | | Nesting depth | 64 levels | | A value crossing to or from a command | 16 MiB and 1,048,576 entries |