From 8e90c833f65d86246c971645920598ea3f1afbba Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 13:50:12 -0800 Subject: [PATCH] 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. --- crates/engine/src/tests/script_engines.rs | 8 +++++--- crates/js/src/file_classes.rs | 25 +++++++++++++++-------- crates/js/src/host_tests.rs | 19 +++++++++++++++++ crates/js/src/realm.rs | 7 ++++++- docs/mcp.md | 8 +++++--- 5 files changed, 51 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/tests/script_engines.rs b/crates/engine/src/tests/script_engines.rs index 41af47300..5506cb98d 100644 --- a/crates/engine/src/tests/script_engines.rs +++ b/crates/engine/src/tests/script_engines.rs @@ -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] diff --git a/crates/js/src/file_classes.rs b/crates/js/src/file_classes.rs index 4eba59a90..715834fd5 100644 --- a/crates/js/src/file_classes.rs +++ b/crates/js/src/file_classes.rs @@ -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 JsResult { 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 JsResult> { - 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> { Ok(None) } +/// The id of the object's open file, when this run opened it. fn is_open(i: &Interp, o: ObjId) -> JsResult> { 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 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; diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs index d3c8a7a46..6fc6c4e04 100644 --- a/crates/js/src/host_tests.rs +++ b/crates/js/src/host_tests.rs @@ -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); diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index 9d514e5b4..f274c42ea 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -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), diff --git a/docs/mcp.md b/docs/mcp.md index 3a2c24717..c48bcd8eb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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.