Scripts: a kept engine's next run starts after the last uncaught exception is dropped

Resetting Folder.current for the next run of a named engine now drops the
last run's uncaught exception and $.error first, so memory only they held
no longer makes the reset fail with the heap limit on every later run.

A File an earlier run left open now counts as closed in a later run:
setting its encoding stores it for the next open, and reading the encoding
gives the stored one.

docs/mcp.md: engines stay on the thread that made them, and a
#targetengine line in a file $.evalFile runs makes that call throw a
SyntaxError when it runs.
This commit is contained in:
dotCooCoo
2026-11-11 13:50:12 -08:00
parent 3a7c46975c
commit 8e90c833f6
5 changed files with 51 additions and 16 deletions
+5 -3
View File
@@ -151,7 +151,8 @@ fn a_kept_eval_file_reads_in_the_calling_run_scope() {
}
/// `File` and `Folder` objects an earlier run kept read in the folder of the run that calls them,
/// `Folder.current` starts at each run's script, and a file an earlier run left open is not open.
/// `Folder.current` starts at each run's script, and a file an earlier run left open is not open:
/// it takes the encoding it is given, as a closed file does.
#[test]
fn kept_file_objects_read_in_the_calling_run_folder() {
let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-files-{}", std::process::id()));
@@ -165,11 +166,12 @@ fn kept_file_objects_read_in_the_calling_run_folder() {
std::fs::write(&a, "#targetengine e\nvar keep = new File('data.txt');\nkeep.open('r');\nvc.log([Folder.current.name, keep.read(1)]);").unwrap();
assert_eq!(s.execute("script.run", &json!({"path": a.to_string_lossy()})).unwrap()["log"], json!([["a", "A"]]));
let b = base.join("b").join("main.jsx");
let text = "#targetengine e\nvar g = new File('data.txt');\ng.open('r');\nvc.log([Folder.current.name, g.read(), keep.read(), keep.error, keep.exists]);";
let text = "#targetengine e\nvar g = new File('data.txt');\ng.open('r');\nvc.log([Folder.current.name, g.read(), keep.read(), keep.error, keep.exists]);\n\
var before = keep.encoding;\nkeep.encoding = 'BINARY';\nkeep.open('r');\nvc.log([before, keep.encoding]);";
std::fs::write(&b, text).unwrap();
let log = s.execute("script.run", &json!({"path": b.to_string_lossy()})).unwrap()["log"].clone();
let _ = std::fs::remove_dir_all(base);
assert_eq!(log, json!([["b", "B", "", "Script file is not open", false]]));
assert_eq!(log, json!([["b", "B", "", "Script file is not open", false], ["UTF-8", "BINARY"]]));
}
#[test]
+16 -9
View File
@@ -31,6 +31,8 @@ pub(crate) struct Classes {
folder_proto: ObjId,
host: ObjId,
current: Value,
/// The count of [`reset_current`] calls: a file is open only in the run that opened it.
run: f64,
}
impl Classes {
@@ -43,6 +45,7 @@ impl Classes {
const PATH: &str = "__vcPath";
const ERROR: &str = "__vcError";
const HANDLE: &str = "__vcHandle";
const RUN: &str = "__vcRun";
const ENCODING: &str = "__vcEncoding";
const LINE_FEED: &str = "__vcLineFeed";
@@ -132,12 +135,13 @@ fn parsed(i: &mut Interp, this: &Value, what: &str, name: &str) -> JsResult<Valu
fn instance(i: &mut Interp, proto: ObjId, fs: Value) -> JsResult<Value> {
let line_feed = host_value(i, "lineFeed")?;
i.rooted(&[fs.clone(), line_feed.clone()], |i| {
i.reserve_heap(OBJECT_BYTES + 5 * PROP_BYTES)?;
i.reserve_heap(OBJECT_BYTES + 6 * PROP_BYTES)?;
let o = i.heap.alloc_object(Object::new(Class::Ordinary, Some(proto)));
i.rooted(&[Value::Object(o)], |i| {
set_slot(i, o, PATH, fs.clone())?;
set_slot(i, o, ERROR, Value::str(""))?;
set_slot(i, o, HANDLE, Value::Number(-1.0))?;
set_slot(i, o, RUN, Value::Number(-1.0))?;
set_slot(i, o, ENCODING, Value::str(""))?;
set_slot(i, o, LINE_FEED, line_feed.clone())?;
Ok(Value::Object(o))
@@ -189,8 +193,7 @@ fn text_result(i: &mut Interp, o: ObjId, r: Value, failed: Value) -> JsResult<Va
/// The id of the object's open file, or `None` after setting `error` when it has none.
fn opened(i: &mut Interp, o: ObjId) -> JsResult<Option<Value>> {
let h = slot(i, o, HANDLE)?;
if matches!(h, Value::Number(n) if n >= 0.0) {
if let Some(h) = is_open(i, o)? {
return Ok(Some(h));
}
let m = message(i, "notOpen")?;
@@ -198,9 +201,11 @@ fn opened(i: &mut Interp, o: ObjId) -> JsResult<Option<Value>> {
Ok(None)
}
/// The id of the object's open file, when this run opened it.
fn is_open(i: &Interp, o: ObjId) -> JsResult<Option<Value>> {
let h = slot(i, o, HANDLE)?;
Ok(matches!(h, Value::Number(n) if n >= 0.0).then_some(h))
let this_run = matches!(slot(i, o, RUN)?, Value::Number(n) if n == classes(i)?.run);
Ok((this_run && matches!(h, Value::Number(n) if n >= 0.0)).then_some(h))
}
/// Closes the object's open file, if it has one.
@@ -528,6 +533,8 @@ fn open(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<Value>
return Ok(Value::Bool(false));
}
set_slot(i, o, HANDLE, id)?;
let run = classes(i)?.run;
set_slot(i, o, RUN, Value::Number(run))?;
Ok(Value::Bool(true))
}
@@ -759,11 +766,11 @@ fn folder_members(i: &mut Interp, ctor: ObjId, proto: ObjId) -> JsResult<()> {
Ok(())
}
/// Sets `Folder.current` back to the host's `current`, when the realm has `File` and `Folder`.
/// Sets `Folder.current` back to the host's `current`, when the realm has `File` and `Folder`, and
/// begins a new run, in which the files earlier runs opened are not open.
pub(crate) fn reset_current(i: &mut Interp) -> JsResult<()> {
if i.files.is_none() {
return Ok(());
}
let Some(c) = i.files.as_mut() else { return Ok(()) };
c.run += 1.0;
let start = host(i, "current", &[])?;
if let Some(c) = i.files.as_mut() {
c.current = start;
@@ -780,7 +787,7 @@ pub(crate) fn install(i: &mut Interp, items: Vec<(String, HostItem)>) -> JsResul
i.reserve_heap(2 * OBJECT_BYTES)?;
let file_proto = i.heap.alloc_object(Object::new(Class::Ordinary, Some(object_proto)));
let folder_proto = i.heap.alloc_object(Object::new(Class::Ordinary, Some(object_proto)));
i.files = Some(Classes { file_proto, folder_proto, host: host_obj, current: Value::str("") });
i.files = Some(Classes { file_proto, folder_proto, host: host_obj, current: Value::str(""), run: 0.0 });
let start = host(i, "current", &[])?;
if let Some(c) = i.files.as_mut() {
c.current = start;
+19
View File
@@ -75,6 +75,25 @@ fn an_ended_run_leaves_the_realm_ready_for_the_next() {
r.end();
}
/// Setting `Folder.current` back for the next run drops what only the last run's uncaught
/// exception held, so a full heap from that exception does not stop the next run.
#[test]
fn resetting_folder_current_drops_the_last_uncaught_exception() {
let mut r = realm(10_000_000);
let len = Rc::new(Cell::new(4));
let n = len.clone();
let current: HostFunction = Rc::new(move |_| Ok(Data::String("d".repeat(n.get()))));
r.define_file_classes(vec![("current".into(), HostItem::Function(current))]).unwrap();
assert!(r.run("throw (function () { var a = []; for (var i = 0; i < 2000; i++) a.push('item ' + i); return a; })();").is_err());
let interp = r.interp();
interp.collect_garbage();
interp.limits.heap_bytes = interp.heap.bytes;
len.set(200);
assert_eq!(r.reset_folder_current(), Ok(()));
r.set_limits(Limits::default());
assert_eq!(r.run("typeof Folder"), Ok(JsValue::String("function".into())));
}
#[test]
fn the_fuel_limit_covers_every_slice_together() {
let mut r = realm(5_000);
+6 -1
View File
@@ -436,8 +436,13 @@ impl Realm {
}
/// Sets `Folder.current` back to the host's `current`, for the next run of a realm that has
/// `File` and `Folder` ([`Realm::define_file_classes`]); a realm without them is left as it is.
/// `File` and `Folder` ([`Realm::define_file_classes`]); a realm without them keeps its
/// `Folder.current`. The files an earlier run opened are no longer open. The last run's uncaught
/// exception and `$.error` are dropped first, as [`Realm::start`] drops them, so memory that only
/// they held is free for the reset.
pub fn reset_folder_current(&mut self) -> Result<(), RunError> {
self.interp.last_thrown = Value::Undefined;
self.interp.last_error = Value::Null;
match crate::file_classes::reset_current(&mut self.interp) {
Ok(()) => Ok(()),
Err(e) => self.outcome(Err(e)).map(drop),
+5 -3
View File
@@ -1723,14 +1723,16 @@ has unsaved changes.
### Script engines
A named engine (`#targetengine`) keeps its globals and functions between the runs that name it, until it is dropped
or the session ends. These are VectorCraft's own rules:
or the session ends. Engines stay on the thread that made them: a session used on another thread starts there with
no engines, and the engines it left are dropped when their own thread next uses engines. These are VectorCraft's own rules:
- The name is either a name of ASCII letters, digits and `_` that does not start with a digit, or a name in single or
double quotes of 1 to 64 bytes of UTF-8 without control characters. Names are matched as written: `Session` and
`session` are two engines. `main` is the engine of a script without the line, which is new for each run.
- Only the script itself has the line, once, before its code and its `#include` lines; blanks, comments and the other
preprocessor lines may come before it. A second line, a late line, a bad name, or the line in an included file or a
file `$.evalFile` runs ends the run at that line before any of it runs, and makes no engine.
preprocessor lines may come before it. A second line, a late line, a bad name, or the line in an included file ends
the run at that line before any of it runs, and makes no engine. In a file `$.evalFile` runs, the line makes that
call throw a `SyntaxError` when the call runs.
- A session keeps at most 8 named engines. A run that names a ninth ends with a limit error before any of it runs,
and the other eight are kept. Each named engine has a heap of 64 MiB, which holds what it keeps between runs; a run
that goes past it ends with the heap limit, and the engine stays usable.