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.
This commit is contained in:
dotCooCoo
2026-11-11 03:18:55 -08:00
parent 0e4010d26b
commit 0bb923fc40
6 changed files with 56 additions and 6 deletions
+1 -1
View File
@@ -268,7 +268,7 @@ pub(super) fn members(host: &Rc<Host>, info: RunInfo, localization: &Rc<Localiza
_ => 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)
})
};
+13 -2
View File
@@ -507,6 +507,8 @@ struct Host {
started: Option<std::time::Instant>,
/// The time since [`Host::started`] at which a `$.sleep` ends.
wake_at: Cell<Option<std::time::Duration>>,
/// Set while a host function runs where the run can stop at the call ([`Realm::wait_flag`]).
can_wait: Rc<Cell<bool>>,
}
/// 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") })?;
+17
View File
@@ -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"]));
}
+12 -2
View File
@@ -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, "\"")
}
+12
View File
@@ -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();
+1 -1
View File
@@ -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