From 0bb923fc409a788e0a0c33a6b1219be5cd53be57 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 03:18:55 -0800 Subject: [PATCH] Scripts: $.sleep throws where the run cannot wait, and toSource checks its length as it escapes A getter, a conversion, a callback of a built-in function or a file $.evalFile runs is executed on native frames, so the run cannot stop at a $.sleep there. Such a call used to only mark the run to pause, so the code after it ran first and the wait came later. $.sleep with more than 0 milliseconds now throws an Error there, as alert, confirm and prompt do. toSource checked the 16,777,216-unit length limit only after a whole string was written. A long string of characters that are escaped could reach the heap limit first, which ends the run instead of throwing a RangeError. The limit is now checked before each part is added. --- crates/engine/src/script/dollar.rs | 2 +- crates/engine/src/script/mod.rs | 15 +++++++++++++-- crates/engine/src/tests/script_dollar.rs | 17 +++++++++++++++++ crates/js/src/builtins/extensions.rs | 14 ++++++++++++-- crates/js/src/extension_tests.rs | 12 ++++++++++++ docs/mcp.md | 2 +- 6 files changed, 56 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/script/dollar.rs b/crates/engine/src/script/dollar.rs index bbddfb330..3e6b00584 100644 --- a/crates/engine/src/script/dollar.rs +++ b/crates/engine/src/script/dollar.rs @@ -268,7 +268,7 @@ pub(super) fn members(host: &Rc, info: RunInfo, localization: &Rc return Err(HostError::Type("$.sleep: give a number of milliseconds".into())), }; h.charge_call(8)?; - h.sleep(Duration::from_secs_f64(ms / 1000.0)); + h.sleep(Duration::from_secs_f64(ms / 1000.0))?; Ok(Data::Undefined) }) }; diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 18af39c36..3e74f96d8 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -507,6 +507,8 @@ struct Host { started: Option, /// The time since [`Host::started`] at which a `$.sleep` ends. wake_at: Cell>, + /// Set while a host function runs where the run can stop at the call ([`Realm::wait_flag`]). + can_wait: Rc>, } /// An error `$.evalFile` raised, as thrown, and the file, line and column it is at. @@ -595,14 +597,22 @@ impl Host { } /// `$.sleep`: ends the slice, and the run does not go on until `d` has passed. Without a clock - /// it does not wait. - fn sleep(&self, d: std::time::Duration) { + /// it does not wait. A getter, a conversion, a built-in's callback or a file `$.evalFile` runs + /// cannot stop at the call, so a wait there is an error. + fn sleep(&self, d: std::time::Duration) -> std::result::Result<(), HostError> { if let Some(now) = self.elapsed() && !d.is_zero() { + if !self.can_wait.get() { + return Err(HostError::Error( + "$.sleep: a run cannot wait inside a callback of a built-in function, a getter, a conversion or a file $.evalFile runs; call it from the script's own code" + .into(), + )); + } self.wake_at.set(Some(now.saturating_add(d))); self.pause.set(true); } + Ok(()) } /// The time left of a `$.sleep`, or none when the run is not asleep. @@ -1357,6 +1367,7 @@ impl Session { eval_failure: RefCell::default(), started: clock(), wake_at: Cell::new(None), + can_wait: realm.wait_flag(), }); let args = to_data(&Value::Object(request.args), &DataLimits::default(), 0, &mut (0, 0)) .map_err(|place| EngineError::BadParams { cmd: "script.run".into(), msg: format!("`args{place}` is nested more than 64 levels deep") })?; diff --git a/crates/engine/src/tests/script_dollar.rs b/crates/engine/src/tests/script_dollar.rs index 9ec90b5f7..c080b6a14 100644 --- a/crates/engine/src/tests/script_dollar.rs +++ b/crates/engine/src/tests/script_dollar.rs @@ -213,3 +213,20 @@ fn sleep_takes_milliseconds_from_0_to_1000() { assert!(thrown(&mut s, bad).starts_with("TypeError"), "{bad}"); } } + +#[test] +fn sleep_throws_where_the_run_cannot_wait_instead_of_running_on() { + let mut s = Session::new(); + s.script_frames = true; + TEST_ELAPSED.set(Some(Duration::ZERO)); + let source = "var o = {}; Object.defineProperty(o, 'x', { get: function () { $.sleep(250); vc.log('after'); return 1; } }); + try { o.x; } catch (e) { vc.log(e.name + ': ' + e.message); } + try { [1].map(function () { $.sleep(250); vc.log('after'); }); } catch (e) { vc.log(e.name + ': ' + e.message); } + [1].map(function () { $.sleep(0); }); + vc.log('done');"; + s.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); + let r = s.script_pump().expect("the run does not wait").unwrap(); + TEST_ELAPSED.set(None); + let message = "Error: $.sleep: a run cannot wait inside a callback of a built-in function, a getter, a conversion or a file $.evalFile runs; call it from the script's own code"; + assert_eq!(r["log"], json!([message, message, "done"])); +} diff --git a/crates/js/src/builtins/extensions.rs b/crates/js/src/builtins/extensions.rs index 102139c9e..0ac7e9ac6 100644 --- a/crates/js/src/builtins/extensions.rs +++ b/crates/js/src/builtins/extensions.rs @@ -199,7 +199,12 @@ impl Source { } fn check_length(&mut self, interp: &mut Interp) -> JsResult<()> { - if self.out.units.len() > interp.data_limits.bytes { + self.fit(interp, 0) + } + + /// Checks that `more` code units can be added without passing the length limit. + fn fit(&mut self, interp: &mut Interp, more: usize) -> JsResult<()> { + if self.out.units.len().saturating_add(more) > interp.data_limits.bytes { return Err(interp.throw(ErrorType::Range, "toSource: the source is too long")); } Ok(()) @@ -229,8 +234,11 @@ impl Source { } } - /// `s` in double quotes, with the characters a string literal cannot hold escaped. + /// `s` in double quotes, with the characters a string literal cannot hold escaped. The length + /// limit is checked before each addition, so a string that grows as it is escaped throws a + /// `RangeError` before the text takes more heap than the limit allows. fn quoted(&mut self, interp: &mut Interp, s: &[u16]) -> JsResult<()> { + self.fit(interp, 1)?; self.out.push_str(interp, "\"")?; let mut start = 0; for (i, &c) in s.iter().enumerate() { @@ -245,10 +253,12 @@ impl Source { 0..=0x1F | 0x7F | 0x2028 | 0x2029 | 0xD800..=0xDFFF => format!("\\u{c:04X}"), _ => continue, }; + self.fit(interp, (i - start).saturating_add(escape.len()))?; self.out.push_units(interp, s.get(start..i).unwrap_or_default())?; self.out.push_str(interp, &escape)?; start = i + 1; } + self.fit(interp, s.len().saturating_sub(start).saturating_add(1))?; self.out.push_units(interp, s.get(start..).unwrap_or_default())?; self.push(interp, "\"") } diff --git a/crates/js/src/extension_tests.rs b/crates/js/src/extension_tests.rs index 77f40e55a..54d1a1029 100644 --- a/crates/js/src/extension_tests.rs +++ b/crates/js/src/extension_tests.rs @@ -126,6 +126,18 @@ fn to_source_refuses_what_is_not_plain_data() { assert!(thrown(&mut r, "toSource({get g() { throw 'ran'; }})").starts_with("TypeError"), "the getter does not run"); } +#[test] +fn to_source_of_a_string_with_many_escapes_throws_a_range_error_before_the_heap_limit() { + let mut r = realm(); + // A run's heap in the web build. 2^23 control characters, each written as six, pass a quarter + // of it, which is all one string may take. + r.set_limits(Limits { heap_bytes: 128 << 20, ..Limits::default() }); + let source = "var s = '\\x01'; for (var i = 0; i < 23; i++) s = s + s; + try { toSource(s); 'no error'; } catch (e) { e.name + ': ' + e.message; }"; + assert_eq!(text(&mut r, source), "RangeError: toSource: the source is too long"); + assert_eq!(text(&mut r, "toSource('a\\x01a\\x01a\\x01a\\x01').length + ''"), "30", "short strings are written whole"); +} + #[test] fn to_source_stops_at_the_fuel_limit() { let mut r = realm(); diff --git a/docs/mcp.md b/docs/mcp.md index 520c59a99..7bcd5ca3e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1625,7 +1625,7 @@ VectorCraft's own, and `vc.api.has("dollar")` is true where they are available: | `localize` | `false`; while `true`, `String(object)` of a localization object gives its text for `$.locale` | | `os` | `Windows`, `macOS`, `Linux`, `FreeBSD`, `Unix` or `Web`, without a version | | `screens` | an empty array | -| `sleep(ms)` | waits from 0 to 1,000 milliseconds between two frames of the app, without stopping it from drawing; `script.stop` ends the wait; it does not wait on the web | +| `sleep(ms)` | waits from 0 to 1,000 milliseconds between two frames of the app, without stopping it from drawing; `script.stop` ends the wait; it does not wait on the web; called with more than 0 inside a getter, a conversion, a callback of a built-in function or a file `$.evalFile` runs, it throws an `Error` instead of waiting | | `strict` | `false` | Members that are values (`build`, `level`, `os` and the like) cannot be changed. A wrong value for a member that can