From f1c17603bb3464b41fc8af1335c31440acc9f106 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 10:43:05 -0800 Subject: [PATCH] Scripts: #targetengine runs a script in a named engine that keeps its globals A script whose first lines name an engine with `#targetengine name` (or `//@targetengine name`) runs in that engine, and a later script that names it sees the globals and functions earlier runs left, until the engine is dropped or the session ends. A script without the line, and `#targetengine main`, still get a new engine for each run. - A session keeps at most 8 named engines, each with a 64 MiB heap. A run that goes past the heap ends with the heap limit, and the engine stays usable. Names are 1 to 64 bytes. The line comes once, in the script itself, before its code and its #include lines. - An uncaught error, a limit or script.stop puts the documents back and keeps the globals the run set. An internal error drops the engine. A run stopped at a dialog or left paused ends where it was before the engine is kept. An error in a function an earlier run defined is reported without a line and column. - `vc`, `$`, `alert`, `confirm` and `prompt` are made once for each engine and act for the run that calls them, including copies an earlier run kept in a global. `vc.args` gives a read-only copy of the calling run's `args`, and what a run sets on `$` lasts for that run only. - `script.engines` lists the named engines with their heap, and `script.engine.drop {name}` drops one. Scripts cannot run either. - The engines stay on the thread that made them. A session used on another thread starts there with no named engines, and the old ones are dropped on their own thread. - The web build has no named engines: a run that names one other than `main` ends with an error before any of it runs. - `Realm::end` ends a run where it is, `Realm::error_source` counts from the realm's last script, and `HostItem::FrozenGetter` gives a frozen value each time it is read. - The script checker lists `#targetengine` as a supported directive. Docs: docs/mcp.md, docs/target-app-parity.md, docs/gaps.md. --- crates/engine/src/cmd/scripts.rs | 36 +- crates/engine/src/lib.rs | 2 + crates/engine/src/script/dialogs.rs | 8 +- crates/engine/src/script/dollar.rs | 79 +++-- crates/engine/src/script/include.rs | 120 +++++-- crates/engine/src/script/mod.rs | 393 ++++++++++++++++++++-- crates/engine/src/session/documents.rs | 1 + crates/engine/src/tests.rs | 1 + crates/engine/src/tests/script_engines.rs | 391 +++++++++++++++++++++ crates/engine/src/tests/script_include.rs | 2 +- crates/engine/src/tests/script_run.rs | 2 +- crates/js/src/host.rs | 14 +- crates/js/src/host_tests.rs | 48 +++ crates/js/src/realm.rs | 31 +- crates/scriptcheck/src/catalog.rs | 2 +- crates/scriptcheck/src/tests.rs | 2 +- docs/gaps.md | 5 +- docs/mcp.md | 50 ++- docs/target-app-parity.md | 3 +- 19 files changed, 1082 insertions(+), 108 deletions(-) create mode 100644 crates/engine/src/tests/script_engines.rs diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 493ba68db..a9a5d0fe6 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Run Script", [], None, - "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text) and $.includePath the #includepath list; alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine and #engine are refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script), fuelLeft, fuelUsed, hostCalls and the fields above", + "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text), $.includePath the #includepath list and $.engineName the engine's name (main without #targetengine); alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine name runs the script in the named engine, whose globals and functions stay for the next run that names it until script.engine.drop drops it or the session ends (script.engines lists them; a name of letters, digits and _ not starting with a digit, or a quoted one of 1 to 64 bytes without control characters, matched as written; only in the script itself, once, before its code and #include lines; main, and a script without the line, get a new engine each run; a session keeps 8 named engines, a ninth name is refused, each has 64 MiB of heap, and an uncaught error, a limit or script.stop keeps the globals the run set while the documents go back; host functions kept in a global act for the run that calls them; not on the web); #engine is refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script), fuelLeft, fuelUsed, hostCalls and the fields above", always, run ), @@ -45,6 +45,24 @@ pub fn specs() -> Vec { always, stop ), + cmd!( + query "script.engines", + "List Script Engines", + [], + None, + "{} the named engines (#targetengine) this session keeps, in the order they were made: a named engine keeps its globals between the runs that name it until it is dropped or the session ends; main, the engine of a script without #targetengine, is new for each run and is not listed; a session keeps at most 8, each with at most 64 MiB of heap (VectorCraft's own limits; none on the web) → {engines: [{name, heapBytes: its heap after its last run, running: a run has it now}], limit: 8, heapLimit: 67108864}", + always, + engines + ), + cmd!( + query "script.engine.drop", + "Drop Script Engine", + [], + None, + "{name: a named engine script.engines lists} drop the engine and the globals its runs left; the next run that names it gets a new one; main, an engine no run made and the engine of the running script are refused → {dropped: true, name}", + always, + engine_drop + ), cmd!( query "script.console", "Script Console", @@ -175,6 +193,22 @@ fn stop(s: &mut Session, _: &Value) -> Result { Ok(json!({"stopped": s.script_request_stop()})) } +fn engines(s: &mut Session, _: &Value) -> Result { + let list: Vec = + s.script_engines().into_iter().map(|(name, heap, running)| json!({"name": name, "heapBytes": heap, "running": running})).collect(); + Ok(json!({"engines": list, "limit": script::MAX_ENGINES, "heapLimit": script::ENGINE_HEAP_BYTES})) +} + +fn engine_drop(s: &mut Session, p: &Value) -> Result { + const C: &str = "script.engine.drop"; + let name = match p.get("name") { + Some(Value::String(name)) if !name.is_empty() => name.clone(), + _ => return Err(bad(C, "`name` must be the name of a script engine")), + }; + s.drop_script_engine(&name)?; + Ok(json!({"dropped": true, "name": name})) +} + fn console_lines(s: &mut Session, _: &Value) -> Result { Ok(s.script_console().to_json()) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 091348a6b..7d98e6537 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -258,6 +258,8 @@ pub struct Session { pub recovery: cmd::recovery::Recovery, /// The running script's job, while there is one: the mutation lease ([`script`]). pub(crate) script_key: Option, + /// The named script engines this session keeps ([`script`]). + pub(crate) script_engines: script::EnginesKey, /// Stops the running script at its next instruction; the same flag for every run. pub(crate) script_stop: vectorcraft_js::Interrupt, /// The report of the run that ended last. diff --git a/crates/engine/src/script/dialogs.rs b/crates/engine/src/script/dialogs.rs index 1faf624f1..a674396f4 100644 --- a/crates/engine/src/script/dialogs.rs +++ b/crates/engine/src/script/dialogs.rs @@ -290,12 +290,12 @@ impl Dialogs { } } -/// Adds `alert`, `confirm` and `prompt` to the realm. -pub(super) fn install(realm: &mut Realm, host: &Rc) -> Result<(), RunError> { +/// Adds `alert`, `confirm` and `prompt` to the realm, for the run `link` holds. +pub(super) fn install(realm: &mut Realm, link: &super::Link) -> Result<(), RunError> { use Arg::{Bool, Text, TextOrUndefined}; let call = |kind: DialogKind| -> HostFunction { - let h = host.clone(); - Rc::new(move |args| open(&h, kind, &args)) + let l = link.clone(); + Rc::new(move |args| open(&*super::current(&l)?, kind, &args)) }; realm.define_host_function("alert", vec![Text, TextOrUndefined, Bool], call(DialogKind::Alert))?; realm.define_host_function("confirm", vec![Text, Bool, TextOrUndefined], call(DialogKind::Confirm))?; diff --git a/crates/engine/src/script/dollar.rs b/crates/engine/src/script/dollar.rs index 3e6b00584..fcf6dbf54 100644 --- a/crates/engine/src/script/dollar.rs +++ b/crates/engine/src/script/dollar.rs @@ -4,14 +4,15 @@ //! the locale the global `localize` reads //! (). //! -//! Every value here belongs to one run: a run starts with the locale of the interface, the -//! environment values of [`ENV_NAMES`] read from the process, localization off and a timer at zero, -//! and what a script changes ends with its run. `$.setenv` never changes the process's environment. +//! Every value here belongs to one run, in a named engine too ([`RunState`]): a run starts with the +//! locale of the interface, the environment values of [`ENV_NAMES`] read from the process, +//! localization off and a timer at zero, and what a script changes ends with its run. `$.setenv` +//! never changes the process's environment. //! //! These are VectorCraft's own values and rules: `$.appEncoding` is always UTF-8; `$.build` and -//! `$.version` give VectorCraft's version, `$.engineName` is `VectorCraft` and `$.buildDate` is -//! `null`; `$.flags` takes only 512, which turns localization on; `$.level` and `$.memCache` are 0 -//! and `$.screens` is empty; `$.os` names the system without its version; `$.strict` is whether the +//! `$.version` give VectorCraft's version, `$.engineName` is the name `#targetengine` gave, or +//! `VectorCraft` without one, and `$.buildDate` is `null`; `$.flags` takes only 512, which turns +//! localization on; `$.level` and `$.memCache` are 0 and `$.screens` is empty; `$.os` names the system without its version; `$.strict` is whether the //! realm reads scripts as strict mode code; `$.bp` and `$.colorPicker` throw, since scripts have no //! debugger and cannot show a dialog; `$.gc` does nothing, since the interpreter collects its own //! garbage; `$.getenv` and `$.setenv` know only [`ENV_NAMES`], with values of at most @@ -25,7 +26,7 @@ use std::time::Duration; use vectorcraft_js::{Data, HostError, HostItem, Localization, ScriptItem}; -use super::{Host, function}; +use super::{Link, current, function}; /// The longest `$.sleep`, in milliseconds. pub const MAX_SLEEP_MS: f64 = 1_000.0; @@ -36,12 +37,34 @@ pub const MAX_ENV_BYTES: usize = 64; /// The value of `$.flags` that turns localization on, the only one a script may set. pub const LOCALIZE_FLAG: f64 = 512.0; -/// What `$` knows of a run. +/// What `$` knows of its realm. pub(crate) struct RunInfo { - /// The locale the run starts with, such as `de_DE`. - pub locale: String, /// The realm reads scripts as strict mode code. pub strict: bool, + /// The name `#targetengine` gave the realm's engine. + pub engine: Option, +} + +/// The values of `$` that belong to one run, which the run's host holds. +pub(crate) struct RunState { + /// The locale the run starts with, such as `de_DE`. + locale: String, + env: RefCell)>>, + /// The time since the run started at which `$.hiresTimer` was last read. + timer: Cell, +} + +impl RunState { + pub(crate) fn new(locale: &str) -> RunState { + RunState { locale: locale.to_string(), env: RefCell::new(process_env()), timer: Cell::new(Duration::ZERO) } + } +} + +/// Sets `localization` as a run starts: `locale`, this system's platform and localization off. +pub(super) fn start_run(localization: &Localization, locale: &str) { + localization.set_enabled(false); + localization.set_locale(locale); + localization.set_platform(platform()); } /// The locale for an interface language code such as `de`, `pt-br` or `zh-hant`: the language, @@ -148,14 +171,10 @@ fn text(s: impl Into) -> Data { Data::String(s.into()) } -/// The members `$` has besides `write`, `writeln`, `evalFile`, `fileName` and `includePath`. -pub(super) fn members(host: &Rc, info: RunInfo, localization: &Rc) -> Vec<(String, HostItem)> { +/// The members `$` has besides `write`, `writeln`, `evalFile`, `fileName` and `includePath`. They +/// act for the run that `link` holds when they are called. +pub(super) fn members(link: &Link, info: RunInfo, localization: &Rc) -> Vec<(String, HostItem)> { let version = env!("CARGO_PKG_VERSION"); - let host_locale = info.locale.clone(); - localization.set_locale(&info.locale); - localization.set_platform(platform()); - let env = Rc::new(RefCell::new(process_env())); - let timer = Rc::new(Cell::new(Duration::ZERO)); let getter = |f: Rc Data>| HostItem::Getter(function(move |_| Ok(f()))); let property = |get: Rc Data>, set: Rc Result<(), HostError>>| HostItem::Property { get: function(move |_| Ok(get())), @@ -167,13 +186,13 @@ pub(super) fn members(host: &Rc, info: RunInfo, localization: &Rc host_locale.clone(), - Data::String(t) if t.is_empty() => host_locale.clone(), + Data::Null | Data::Undefined => current(&run)?.dollar.locale.clone(), + Data::String(t) if t.is_empty() => current(&run)?.dollar.locale.clone(), Data::String(t) => script_locale(&t).ok_or_else(|| HostError::Range(BAD_LOCALE.to_string()))?, _ => return Err(HostError::Type(BAD_LOCALE.to_string())), }; @@ -224,31 +243,34 @@ pub(super) fn members(host: &Rc, info: RunInfo, localization: &Rc, info: RunInfo, localization: &Rc *n, Some(Data::Number(_)) => return Err(HostError::Range(format!("$.sleep: give a number of milliseconds from 0 to {MAX_SLEEP_MS}"))), @@ -282,7 +305,7 @@ pub(super) fn members(host: &Rc, info: RunInfo, localization: &Rc, pub strict: Option, + pub engine: Option<(String, u32, u32)>, +} + +/// What the preprocessor lines of a file have named so far: its `#script` name and its engine, and +/// whether code or an `#include` line has come before. +#[derive(Default)] +struct Named { + name: Option, + engine: Option<(String, u32, u32)>, + code_seen: bool, +} + +/// Are `units` only blanks and comments? +fn only_blanks_and_comments(units: &[u16]) -> bool { + let mut i = 0; + while let Some(&u) = units.get(i) { + match u { + 0x2F if units.get(i + 1) == Some(&0x2F) => { + while units.get(i).is_some_and(|&c| !matches!(c, 0x0A | 0x0D | 0x2028 | 0x2029)) { + i += 1; + } + } + 0x2F if units.get(i + 1) == Some(&0x2A) => { + let Some(end) = units.get(i + 2..).and_then(|rest| rest.windows(2).position(|w| w == [0x2A, 0x2F])) else { return false }; + i += end + 4; + } + 0xFEFF => i += 1, + u if char::from_u32(u32::from(u)).is_some_and(char::is_whitespace) => i += 1, + _ => return false, + } + } + true +} + +/// The engine name of a `#targetengine` line's value `value` (as written, quotes included), or what +/// is wrong with it. +fn engine_name(value: &str) -> Result { + let name = match value.chars().next() { + None => return Err("needs a name"), + Some(q @ ('"' | '\'')) => { + let inner = value.get(1..).and_then(|v| v.strip_suffix(q)).ok_or("needs a closing quote after the name")?; + if inner.contains(q) { + return Err("takes one name"); + } + if inner.chars().any(char::is_control) { + return Err("needs a name without control characters"); + } + inner + } + _ => { + let mut chars = value.chars(); + let first_ok = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_'); + if !first_ok || !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Err("needs a name of letters, digits and _, or a quoted name"); + } + value + } + }; + if name.is_empty() { + return Err("needs a name"); + } + if name.len() > MAX_ENGINE_NAME_BYTES { + return Err("needs a name of at most 64 bytes"); + } + Ok(name.to_string()) } /// A file being run: its name for people and the folder its relative paths start from (none for a @@ -342,8 +415,8 @@ impl Files { /// its `#script` line names the run. fn expand(&mut self, shown: Arc, dir: Option, text: &str, script: bool) -> Result { let mut out = Out { units: Vec::with_capacity(text.len()), line: 1, column: 0, after_cr: false, map: SourceMap::default(), strict: None }; - let mut name = None; - self.file(&mut out, shown, dir.as_deref(), text, script, &mut name)?; + let mut named = Named::default(); + self.file(&mut out, shown, dir.as_deref(), text, script, &mut named)?; let source = String::from_utf16(&out.units).map_err(|_| Failure { kind: FailKind::Syntax, message: "the script is not valid text".into(), @@ -352,20 +425,12 @@ impl Files { line: 0, column: 0, })?; - Ok(Expanded { source, map: out.map, name, strict: out.strict }) + Ok(Expanded { source, map: out.map, name: named.name, strict: out.strict, engine: named.engine }) } /// Adds file `shown` (in folder `dir`, with text `text`) to `out`, its included files in place /// of their lines. - fn file( - &mut self, - out: &mut Out, - shown: Arc, - dir: Option<&Path>, - text: &str, - script: bool, - name: &mut Option, - ) -> Result<(), Failure> { + fn file(&mut self, out: &mut Out, shown: Arc, dir: Option<&Path>, text: &str, script: bool, named: &mut Named) -> Result<(), Failure> { let index = out.map.files.len(); out.map.files.push(shown.clone()); let units: Vec = text.encode_utf16().collect(); @@ -385,7 +450,11 @@ impl Files { units.get(..start).and_then(|u| u.iter().rposition(|&c| matches!(c, 0x0A | 0x0D | 0x2028 | 0x2029))).map_or(0, |i| i + 1); let column = u32::try_from(start - line_start + 1).unwrap_or(u32::MAX); let at = |kind, message: String| fail(kind, message, d.line, column); - out.emit(units.get(cursor..start).unwrap_or_default()); + let before = units.get(cursor..start).unwrap_or_default(); + if !named.code_seen && !only_blanks_and_comments(before) { + named.code_seen = true; + } + out.emit(before); cursor = end; let label = format!("{}{}", d.form, d.name); if d.value.len() > MAX_VALUE_BYTES { @@ -397,11 +466,12 @@ impl Files { if value.is_empty() { return Err(at(FailKind::Syntax, format!("{label} needs a file name"))); } + named.code_seen = true; let (real, bytes) = self.open(value, dir, true).map_err(|(kind, message)| at(kind, message))?; let included = self.admit(&real, bytes, value).map_err(|(kind, message)| at(kind, message))?; self.chain.push(real.clone()); self.levels += 1; - let r = self.file(out, display(&real), folder_of(&real).as_deref(), &included, false, name); + let r = self.file(out, display(&real), folder_of(&real).as_deref(), &included, false, named); self.chain.pop(); self.levels -= 1; r?; @@ -416,7 +486,7 @@ impl Files { return Err(at(FailKind::Syntax, format!("{label} needs a name"))); } if script && index == 0 { - *name = Some(value.to_string()); + named.name = Some(value.to_string()); } } Some(DirectiveName::Target) => { @@ -429,7 +499,19 @@ impl Files { "on" => out.strict = Some(true), _ => return Err(at(FailKind::Syntax, format!("{label} takes on or off"))), }, - Some(DirectiveName::TargetEngine) => return Err(at(FailKind::Syntax, format!("unsupported script directive: {label}"))), + Some(DirectiveName::TargetEngine) => { + if !script || index > 0 { + return Err(at(FailKind::Syntax, format!("{label} may only be in the script itself, not in a file it runs"))); + } + if named.engine.is_some() { + return Err(at(FailKind::Syntax, format!("the script has more than one {label} line"))); + } + if named.code_seen { + return Err(at(FailKind::Syntax, format!("{label} must come before the script's code and #include lines"))); + } + let engine = engine_name(&d.value).map_err(|why| at(FailKind::Syntax, format!("{label} {why}")))?; + named.engine = Some((engine, d.line, column)); + } None if d.name.eq_ignore_ascii_case("engine") => { return Err(at(FailKind::Syntax, format!("unsupported script directive: {label}"))); } diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 213cff53f..b3067b2ca 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -36,6 +36,13 @@ //! //! `alert`, `confirm` and `prompt` open dialogs ([`dialogs`]): in the app the run waits for the //! person's answer, and a run with nobody to ask takes its answers from `script.run`'s `answers`. +//! +//! A script with `#targetengine name` runs in that named engine, which the session keeps with the +//! globals its runs leave, at most [`MAX_ENGINES`] of them with [`ENGINE_HEAP_BYTES`] of heap each +//! (VectorCraft's own limits). A script without the line, or with `main`, gets a new realm for each +//! run. An engine keeps no document and no run: its host functions reach the run through a link +//! that holds the run's host only while the run goes on. An error, a limit or a stop puts the +//! documents back and keeps the engine's globals; an internal error drops the engine. pub mod console; pub mod dialogs; @@ -193,6 +200,150 @@ fn with_job(key: &JobKey, f: impl FnOnce(&mut Job) -> T) -> Option { JOBS.with(|jobs| jobs.borrow_mut().get_mut(&key.id).map(f)) } +/// The most named engines (`#targetengine`) a session keeps. +pub const MAX_ENGINES: usize = 8; +/// The heap a named engine may use, with what it keeps between runs. +pub const ENGINE_HEAP_BYTES: usize = 64 << 20; +/// The engine of a script without `#targetengine`, which `#targetengine main` also names: a new +/// one for each run, never kept. +pub const MAIN_ENGINE: &str = "main"; + +/// The run a script's host functions act for. A named engine keeps its functions between runs, so +/// they find the run through this link, which holds the run's [`Host`] only while the run goes on. +type Link = Rc>>>; + +/// The host of the run `link` holds, or an error when no run holds it. +fn current(link: &Link) -> std::result::Result, HostError> { + link.borrow().clone().ok_or_else(|| HostError::Error(NO_RUN.to_string())) +} + +/// A named engine: its interpreter, with the globals its runs left (`None` while a run has it), +/// the link its host functions use, and its heap after its last run. +struct Engine { + name: String, + realm: Option, + link: Link, + heap: usize, +} + +/// The named engines of one thread's sessions, by their key, each in the order they were made. +struct ThreadEngines { + thread: std::thread::ThreadId, + by_key: HashMap>, +} + +impl ThreadEngines { + fn new() -> Self { + let thread = std::thread::current().id(); + released().insert(thread, Vec::new()); + Self { thread, by_key: HashMap::new() } + } +} + +impl Drop for ThreadEngines { + fn drop(&mut self) { + released().remove(&self.thread); + } +} + +thread_local! { + /// The named engines of this thread's sessions. They are held here, and not in their session, + /// because they hold interpreters, which stay on this thread. + static ENGINES: RefCell = RefCell::new(ThreadEngines::new()); +} + +static NEXT_ENGINES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +/// For each thread that holds engines, the keys of the ones sessions let go of on another thread. +/// The thread drops them the next time it uses its engines; a thread that has ended has no entry, +/// since its engines ended with it. +static RELEASED_ENGINES: std::sync::LazyLock>>> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); + +fn released() -> std::sync::MutexGuard<'static, HashMap>> { + RELEASED_ENGINES.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Lets the engines with key `id` go, for `thread`, which holds them, to drop. +fn release_engines(thread: std::thread::ThreadId, id: u64) { + if let Some(ids) = released().get_mut(&thread) { + ids.push(id); + } +} + +/// A session's hold on its named engines: their key, and the thread that holds them once a run +/// made one. Dropping it drops them. The engines stay on that thread, so a session used on another +/// thread lets them go and starts there with none. +#[derive(Debug)] +pub struct EnginesKey(std::sync::Mutex<(u64, Option)>); + +impl EnginesKey { + pub(crate) fn new() -> Self { + Self(std::sync::Mutex::new((NEXT_ENGINES.fetch_add(1, std::sync::atomic::Ordering::Relaxed), None))) + } + + /// The key of the session's engines on this thread. + fn here(&self) -> u64 { + let mut key = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let thread = std::thread::current().id(); + match key.1 { + Some(t) if t != thread => { + release_engines(t, key.0); + *key = (NEXT_ENGINES.fetch_add(1, std::sync::atomic::Ordering::Relaxed), Some(thread)); + } + Some(_) => {} + None => key.1 = Some(thread), + } + key.0 + } +} + +impl Drop for EnginesKey { + fn drop(&mut self) { + let (id, thread) = *self.0.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(t) = thread.filter(|t| *t != std::thread::current().id()) { + release_engines(t, id); + return; + } + let gone = ENGINES.try_with(|engines| engines.try_borrow_mut().ok().and_then(|mut e| e.by_key.remove(&id))); + drop(gone); + } +} + +fn with_engines(key: &EnginesKey, f: impl FnOnce(&mut Vec) -> T) -> T { + let id = key.here(); + ENGINES.with(|engines| { + let gone: Vec> = { + let mut here = engines.borrow_mut(); + let ids = released().get_mut(&here.thread).map(std::mem::take).unwrap_or_default(); + ids.iter().filter_map(|id| here.by_key.remove(id)).collect() + }; + drop(gone); + f(engines.borrow_mut().by_key.entry(id).or_default()) + }) +} + +/// Tests: the named engines this thread holds, for every session. +#[cfg(test)] +pub(crate) fn engines_on_this_thread() -> usize { + ENGINES.with(|engines| engines.borrow().by_key.values().map(Vec::len).sum()) +} + +/// Tests: whether engines can still be let go for `thread`, which holds an entry while it lives. +#[cfg(test)] +pub(crate) fn releases_kept_for(thread: std::thread::ThreadId) -> bool { + released().contains_key(&thread) +} + +/// The heap of a named engine. +fn engine_heap() -> usize { + #[cfg(test)] + if let Some(n) = TEST_ENGINE_HEAP.with(Cell::get) { + return n; + } + ENGINE_HEAP_BYTES +} + /// The state a script changes that a rollback puts back. struct World { docs: Vec, @@ -517,6 +668,10 @@ struct Host { can_wait: Rc>, /// What the last `#strict` line of the file `$.evalFile` last gave set (`None` without one). eval_strict: Cell>, + /// The values of `$` that belong to the run. + dollar: dollar::RunState, + /// The run's `args`, which `vc.args` gives, and their size as `vc.args` charges it. + args: RefCell<(Data, u64)>, } /// A run's host calls by kind, as its report gives them in `hostCallCounts`. @@ -693,6 +848,10 @@ impl Host { struct Job { realm: Realm, host: Rc, + /// The link the realm's host functions find `host` through. + link: Link, + /// The named engine the realm belongs to, which gets it back when the run ends. + engine: Option, /// Between slices the working copy; during the last slice's commit the state before the run. world: World, /// The documents open when the run began, by [`DocState::uid`]. @@ -1005,10 +1164,11 @@ fn each_line(text: &str, mut done: impl FnMut(String, bool)) { /// `$.evalFile(path[, timeout])` runs a file and gives its last value, `$.fileName` is the file /// being run and `$.includePath` the `#includepath` list. Its other members are /// [`dollar::members`]. -fn dollar_object(host: &Rc, info: dollar::RunInfo, localization: &Rc) -> Vec<(String, HostItem)> { +fn dollar_object(link: &Link, info: dollar::RunInfo, localization: &Rc) -> Vec<(String, HostItem)> { let writer = |end: bool| { - let h = host.clone(); + let l = link.clone(); HostItem::TextFunction(function(move |a| { + let h = current(&l)?; let text: String = a.iter().map(|d| if let Data::String(s) = d { s.as_str() } else { "" }).collect(); h.charge_call(text.len() as u64 + 2)?; bump(&h.counts.console_writes); @@ -1017,21 +1177,24 @@ fn dollar_object(host: &Rc, info: dollar::RunInfo, localization: &Rc String| { - let h = host.clone(); + let l = link.clone(); HostItem::Getter(function(move |_| { + let h = current(&l)?; let text = read(&h.files.borrow()); h.charge_call(text.len() as u64 + 2)?; Ok(Data::String(text)) })) }; let eval_file = { - let h = host.clone(); - let end = host.clone(); - let strict = host.clone(); + let (l, end, strict) = (link.clone(), link.clone(), link.clone()); HostItem::Evaluator { - source: function(move |a| eval_file(&h, a)), - end: Rc::new(move || end.files.borrow_mut().end_eval()), - strict_writes: Rc::new(move || strict.eval_strict.get()), + source: function(move |a| eval_file(&*current(&l)?, a)), + end: Rc::new(move || { + if let Ok(h) = current(&end) { + h.files.borrow_mut().end_eval(); + } + }), + strict_writes: Rc::new(move || current(&strict).ok().and_then(|h| h.eval_strict.get())), } }; let mut items = vec![ @@ -1041,7 +1204,7 @@ fn dollar_object(host: &Rc, info: dollar::RunInfo, localization: &Rc) -> std::result::Result + 'sta Rc::new(f) } -fn vc_object(host: &Rc, args: Data) -> Vec<(String, HostItem)> { +/// The `vc` object, made once for each realm: its members act for the run `link` holds when they +/// are called. +fn vc_object(link: &Link) -> Vec<(String, HostItem)> { + let args = { + let l = link.clone(); + function(move |_| { + let h = current(&l)?; + let (args, size) = &*h.args.borrow(); + h.charge_call(*size)?; + Ok(args.clone()) + }) + }; let run = { - let h = host.clone(); - function(move |a| vc_run(&h, a)) + let l = link.clone(); + function(move |a| vc_run(¤t(&l)?, a)) }; let document = { - let h = host.clone(); + let l = link.clone(); function(move |_| { + let h = current(&l)?; h.charge_call(64)?; bump(&h.counts.document_reads); vc_document(&h) }) }; let commands = { - let h = host.clone(); + let l = link.clone(); let list = Rc::new(vc_commands()); let size = data_size(&list); function(move |_| { + let h = current(&l)?; h.charge_call(size)?; bump(&h.counts.command_list_reads); Ok((*list).clone()) }) }; let log = { - let h = host.clone(); + let l = link.clone(); function(move |a| { + let h = current(&l)?; h.charge_call(data_size(a.first().unwrap_or(&Data::Undefined)))?; bump(&h.counts.vc_log); vc_log(&h, a) @@ -1125,7 +1302,7 @@ fn vc_object(host: &Rc, args: Data) -> Vec<(String, HostItem)> { ("document".into(), HostItem::Getter(document)), ("commands".into(), HostItem::Getter(commands)), ("log".into(), HostItem::Function(log)), - ("args".into(), HostItem::Value(args)), + ("args".into(), HostItem::FrozenGetter(args)), ( "api".into(), HostItem::Object(vec![("version".into(), HostItem::Value(Data::String(API_VERSION.into()))), ("has".into(), HostItem::Function(has))]), @@ -1188,13 +1365,16 @@ fn located(host: &Host, tag: u32, mark: Option, e: &RunError) -> (RunError, if e.line == 0 { return (e.clone(), None); } + // An error in code from another source, such as a function an earlier run of a named engine + // defined, has no place in this run's files, so it is given without one. + let unplaced = || (RunError { kind: e.kind.clone(), message: e.message.clone(), line: 0, column: 0 }, None); match tag { 0 => map(&host.script_map.borrow(), true), n if n != vectorcraft_js::UNKNOWN_SOURCE => match host.eval_maps.borrow().get(n as usize - 1) { Some(m) => map(m, false), - None => (e.clone(), None), + None => unplaced(), }, - _ => (e.clone(), None), + _ => unplaced(), } } @@ -1214,6 +1394,95 @@ impl Session { self.script_key.is_some() } + /// The named engine of the running script. + fn running_engine(&self) -> Option { + self.script_key.as_ref().and_then(|k| with_job(k, |j| j.engine.clone())).flatten() + } + + /// The named engines (`#targetengine`) this session keeps, in the order they were made: each + /// one's name, its heap after its last run in bytes, and whether a run has it now. + pub fn script_engines(&self) -> Vec<(String, usize, bool)> { + with_engines(&self.script_engines, |engines| engines.iter().map(|e| (e.name.clone(), e.heap, e.realm.is_none())).collect()) + } + + /// Drops the named engine `name` and the globals its runs left; a later run that names it gets a + /// new one. + pub fn drop_script_engine(&mut self, name: &str) -> Result<()> { + if name == MAIN_ENGINE { + return Err(EngineError::Other(DROP_MAIN.to_string())); + } + if self.running_engine().as_deref() == Some(name) { + return Err(EngineError::Busy); + } + let gone = with_engines(&self.script_engines, |engines| { + let at = engines.iter().position(|e| e.name == name)?; + Some(engines.remove(at)) + }); + match gone { + Some(engine) => { + drop(engine); + Ok(()) + } + None => Err(EngineError::Other(format!("there is no script engine named {name:?}"))), + } + } + + /// The realm of the named engine `name` for a run, with the link its host functions use: the + /// realm its last run left, or `None` for an engine made now. → the kind and text of the + /// failure when the engine cannot be had. + fn take_engine(&mut self, name: &str) -> std::result::Result<(Option, Link), (include::FailKind, String)> { + if cfg!(target_arch = "wasm32") { + return Err((include::FailKind::Error, NO_ENGINES_ON_WEB.to_string())); + } + with_engines(&self.script_engines, |engines| { + if let Some(e) = engines.iter_mut().find(|e| e.name == name) { + let realm = e.realm.take().ok_or((include::FailKind::Error, ENGINE_BUSY.to_string()))?; + return Ok((Some(realm), e.link.clone())); + } + if engines.len() >= MAX_ENGINES { + return Err(( + include::FailKind::Limit, + format!("a session keeps at most {MAX_ENGINES} script engines; drop one with script.engine.drop"), + )); + } + let link: Link = Rc::new(RefCell::new(None)); + engines.push(Engine { name: name.to_string(), realm: None, link: link.clone(), heap: 0 }); + Ok((None, link)) + }) + } + + /// The end of a run in engine `engine` (`None` for a realm that is not kept): the link no longer + /// holds the run, and the engine keeps `realm`, or is dropped without one. + fn return_engine(&self, engine: Option<&str>, link: &Link, realm: Option) { + let host = link.borrow_mut().take(); + drop(host); + let Some(name) = engine else { return }; + let mut realm = realm; + let heap = realm.as_mut().map(Realm::collect_garbage); + let gone = with_engines(&self.script_engines, |engines| { + let at = engines.iter().position(|e| e.name == name)?; + match (realm.take(), engines.get_mut(at)) { + (Some(r), Some(e)) => { + e.realm = Some(r); + e.heap = heap.unwrap_or(0); + None + } + _ => Some(engines.remove(at)), + } + }); + drop(gone); + drop(realm); + } + + /// The end of `job`: its engine gets its realm back, with a run that was stopped where it was + /// ended, unless the run ended with an internal error, which drops the engine. + fn end_job(&self, job: Job, internal: bool) { + let Job { mut realm, link, engine, host, .. } = job; + drop(host); + realm.end(); + self.return_engine(engine.as_deref(), &link, (!internal).then_some(realm)); + } + /// The flag that stops the script running in this session from any thread, at its next /// instruction. pub fn script_interrupt(&self) -> Interrupt { @@ -1326,6 +1595,13 @@ impl Session { let line = console::ConsoleLine { run: key.run, name: key.name.clone(), entry: console::Entry::End(ending) }; self.script_console.push(line, false); drop(key); + // The realm of the job's named engine went with the job, so the engine is dropped. + let lost: Vec = with_engines(&self.script_engines, |engines| { + let (lost, kept) = std::mem::take(engines).into_iter().partition(|e| e.realm.is_none()); + *engines = kept; + lost + }); + drop(lost); raise_ids(self); self.script_stop.clear(); self.reset_tool(); @@ -1363,10 +1639,40 @@ impl Session { Ok(include::Expanded { name: Some(name), .. }) if !request.name_given => name.clone(), _ => request.name, }; - let limits = Limits { fuel: request.fuel, heap_bytes: HEAP_BYTES, ..Limits::default() }; + // The engine named by `#targetengine`, other than `main`: its realm, kept from its last run, + // or none for an engine this run makes. + let named = match &expanded { + Ok(include::Expanded { engine: Some((engine, line, column)), .. }) if engine != MAIN_ENGINE => Some((engine.clone(), *line, *column)), + _ => None, + }; + let (expanded, kept) = match named { + None => (expanded, None), + Some((engine, line, column)) => match self.take_engine(&engine) { + Ok(kept) => (expanded, Some((engine, kept))), + Err((kind, message)) => { + let file = Arc::from(files.file_name()); + (Err(include::Failure { kind, message, index: 0, file, line, column }), None) + } + }, + }; + let heap_bytes = if kept.is_some() { engine_heap() } else { HEAP_BYTES }; + let limits = Limits { fuel: request.fuel, heap_bytes, ..Limits::default() }; + let (engine, realm, link) = match kept { + None => (None, None, Rc::new(RefCell::new(None))), + Some((engine, (realm, link))) => (Some(engine), realm, link), + }; + // A realm made here gets the script extensions, `$` and the dialogs; a kept one has them + // from its first run, and they act for the run that calls them. + let fresh = realm.is_none(); let options = Options { max_source_units: include::MAX_TOTAL_BYTES.saturating_mul(2), ..Options::default() }; let strict = options.strict; - let mut realm = Realm::with_options(options, limits); + let mut realm = match realm { + Some(mut realm) => { + realm.set_limits(limits); + realm + } + None => Realm::with_options(options, limits), + }; realm.share_interrupt(&self.script_stop); realm.set_data_limits(DataLimits::default()); self.script_runs += 1; @@ -1402,28 +1708,46 @@ impl Session { wake_at: Cell::new(None), can_wait: realm.wait_flag(), eval_strict: Cell::new(None), + dollar: dollar::RunState::new(&request.locale), + args: RefCell::new((Data::Undefined, 0)), }); - 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") })?; + *link.borrow_mut() = Some(host.clone()); + let args = match to_data(&Value::Object(request.args), &DataLimits::default(), 0, &mut (0, 0)) { + Ok(args) => args, + Err(place) => { + self.return_engine(engine.as_deref(), &link, (!fresh).then_some(realm)); + return Err(EngineError::BadParams { cmd: "script.run".into(), msg: format!("`args{place}` is nested more than 64 levels deep") }); + } + }; + let size = data_size(&args); + *host.args.borrow_mut() = (args, size); let line = console::ConsoleLine { run: host.run, name: host.name.clone(), entry: console::Entry::Start }; self.script_console.push(line, false); - let info = dollar::RunInfo { locale: request.locale, strict }; + let engine_name = match &expanded { + Ok(include::Expanded { engine: Some((name, ..)), .. }) => Some(name.clone()), + _ => None, + }; + let info = dollar::RunInfo { strict, engine: engine_name }; let localization = realm.localization(); + dollar::start_run(&localization, &request.locale); let started = match expanded { Err(f) => Err(preprocessor_error(f)), Ok(expanded) => { *host.script_map.borrow_mut() = expanded.map; realm.set_strict_writes(expanded.strict == Some(true)); - realm - .add_script_extensions() - .and_then(|()| realm.define_host_object("vc", vc_object(&host, args))) - .and_then(|()| realm.define_host_object("$", dollar_object(&host, info, &localization))) - .and_then(|()| dialogs::install(&mut realm, &host)) + (if fresh { realm.add_script_extensions() } else { Ok(()) }) + .and_then(|()| if fresh { realm.define_host_object("vc", vc_object(&link)) } else { Ok(()) }) + .and_then(|()| if fresh { realm.define_host_object("$", dollar_object(&link, info, &localization)) } else { Ok(()) }) + .and_then(|()| if fresh { dialogs::install(&mut realm, &link) } else { Ok(()) }) .and_then(|()| realm.start(&expanded.source)) .map_err(|e| located(&host, realm.error_source(), realm.error_mark(), &e)) } }; if let Err((e, file)) = started { + // An engine this run made is not kept: only a run that starts makes one. An internal + // error drops a kept one. + let keep = !fresh && e.kind != RunErrorKind::Internal; + self.return_engine(engine.as_deref(), &link, keep.then_some(realm)); let mut report = failure_report(&name, &e, file.as_deref(), &host, 0, 0); close_run(self, &host, &mut report, ending(&e.kind)); let message = failure_text(&report); @@ -1432,7 +1756,7 @@ impl Session { } let scope = crate::file_access::ScopeHandle::capture(); let (run, line_name) = (host.run, host.name.clone()); - let job = Job { realm, host, world: World::working(self), start_uids: open, name: name.clone(), scope }; + let job = Job { realm, host, link, engine, world: World::working(self), start_uids: open, name: name.clone(), scope }; let id = NEXT_JOB.fetch_add(1, std::sync::atomic::Ordering::Relaxed); JOBS.with(|jobs| jobs.borrow_mut().insert(id, job)); self.script_key = Some(JobKey { id, run, name: line_name }); @@ -1497,7 +1821,7 @@ impl Session { let (e, file) = located(&job.host, job.realm.error_source(), job.realm.error_mark(), e); let mut report = failure_report(&job.name, &e, file.as_deref(), &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left)); close_run(self, &job.host, &mut report, ending(&e.kind)); - drop(job); + self.end_job(job, e.kind == RunErrorKind::Internal); raise_ids(self); self.script_stop.clear(); self.reset_tool(); @@ -1579,6 +1903,7 @@ impl Session { "hostCalls": host.calls.get(), }); close_run(self, host, &mut report, console::Ending::Finished); + self.end_job(job, false); self.script_report = Some(report.clone()); Ok(report) } @@ -1617,6 +1942,10 @@ const NO_RECOVERY_COPY: &str = "a recovery copy could not be written, so the scr const EVAL_NO_PATH: &str = "$.evalFile requires a file path"; const EVAL_TIMEOUT: &str = "$.evalFile: the timeout must be a finite number of milliseconds, 0 or more"; const EVAL_ARGUMENTS: &str = "$.evalFile: takes a file path and a timeout"; +const NO_RUN: &str = "this function's script run has ended"; +const DROP_MAIN: &str = "the main script engine is new for each run and is never kept, so it cannot be dropped"; +const NO_ENGINES_ON_WEB: &str = "named script engines (#targetengine) are not available on the web"; +const ENGINE_BUSY: &str = "the script engine is in use by another run"; /// The report of a run that failed; `file` is the included file the error is in. fn failure_report(name: &str, e: &RunError, file: Option<&str>, host: &Host, fuel_left: u64, fuel_used: u64) -> Value { @@ -1782,6 +2111,8 @@ thread_local! { pub(crate) static PANIC_IN_SLICE: Cell = const { Cell::new(false) }; /// Tests: the objects a run may add in place of [`GROWTH_OBJECTS`]. pub(crate) static TEST_GROWTH: Cell> = const { Cell::new(None) }; + /// Tests: the heap of a named engine in place of [`ENGINE_HEAP_BYTES`]. + pub(crate) static TEST_ENGINE_HEAP: Cell> = const { Cell::new(None) }; /// Tests: the `JSINCLUDE` folders in place of the environment's. pub(crate) static TEST_JSINCLUDE: RefCell>> = const { RefCell::new(None) }; /// Tests: the time since a run started, in place of its clock's. diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index e34eb711b..5fee850a2 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -134,6 +134,7 @@ impl Session { global_edit_options: Default::default(), keyboard_text: false, script_key: None, + script_engines: crate::script::EnginesKey::new(), script_stop: vectorcraft_js::Interrupt::new(), script_report: None, script_console: Default::default(), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9c6de6a89..c5b40a438 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -153,6 +153,7 @@ mod scalestrokes; mod script_console; mod script_dialogs; mod script_dollar; +mod script_engines; mod script_folder; mod script_include; mod script_run; diff --git a/crates/engine/src/tests/script_engines.rs b/crates/engine/src/tests/script_engines.rs new file mode 100644 index 000000000..56fe4bc26 --- /dev/null +++ b/crates/engine/src/tests/script_engines.rs @@ -0,0 +1,391 @@ +//! `#targetengine`: named engines whose globals last between the runs that name them, their names +//! and places, their limits, `script.engines` and `script.engine.drop`. + +use std::path::PathBuf; +use std::sync::Arc; + +use serde_json::{Value, json}; + +use crate::script::{PANIC_IN_SLICE, TEST_ENGINE_HEAP, engines_on_this_thread, releases_kept_for}; +use crate::*; + +fn run(s: &mut Session, source: &str) -> Result { + s.execute("script.run", &json!({"source": source, "name": "t.js"})) +} + +fn log(s: &mut Session, source: &str) -> Value { + run(s, source).unwrap()["log"].clone() +} + +/// A session with one document holding one rectangle. +fn one_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + s +} + +fn objects(s: &Session) -> usize { + s.documents()[0].doc.layers.iter().map(|l| l.children().map_or(0, Vec::len)).sum() +} + +fn names(s: &mut Session) -> Vec { + let r = s.execute("script.engines", &json!({})).unwrap(); + r["engines"].as_array().unwrap().iter().map(|e| e["name"].as_str().unwrap().to_string()).collect() +} + +const RECT: &str = "{x: 100, y: 0, width: 5, height: 5}"; + +#[test] +fn a_named_engine_keeps_its_globals_and_functions_between_runs() { + let mut s = Session::new(); + assert_eq!(log(&mut s, "#targetengine \"session\"\nvar n = 41;\nfunction f() { return n + 1; }\nvc.log($.engineName);"), json!(["session"])); + assert_eq!(log(&mut s, "//@targetengine session\nvc.log(f()); n = 1;"), json!([42])); + assert_eq!(log(&mut s, "#targetengine 'session'\nvc.log(n);"), json!([1]), "single quotes name the same engine"); + assert_eq!(log(&mut s, "#targetengine Session\nvc.log(typeof n);"), json!(["undefined"]), "names are matched as written"); + assert_eq!(log(&mut s, "#targetengine other\nvc.log(typeof f);"), json!(["undefined"])); + assert_eq!(log(&mut s, "vc.log(typeof n, $.engineName);\nvc.log($.engineName);"), json!(["undefined", "VectorCraft"])); + // `main` is new for each run, as a script without the line is. + log(&mut s, "#targetengine main\nvar m = 1;"); + assert_eq!(log(&mut s, "#targetengine main\nvc.log(typeof m, $.engineName);\nvc.log($.engineName);"), json!(["undefined", "main"])); + assert_eq!(names(&mut s), ["session", "Session", "other"]); + assert_eq!(log(&mut s, "#targetengine \"Ünï cödé 1\"\nvc.log($.engineName);"), json!(["Ünï cödé 1"])); + let r = s.execute("script.run", &json!({"source": "#targetengine session\n$.engineName = 'x'; vc.log($.engineName);"})).unwrap(); + assert_eq!(r["log"], json!(["session"]), "$.engineName is read-only"); +} + +#[test] +fn engine_names_and_places_are_checked_before_anything_runs() { + let mut s = Session::new(); + let long = "a".repeat(65); + let control = "#targetengine \"a\u{1}b\""; + let cases: Vec<(String, &str, u64)> = vec![ + ("#targetengine".into(), "#targetengine needs a name", 1), + ("#targetengine \"\"".into(), "#targetengine needs a name", 1), + ("#targetengine a b".into(), "#targetengine needs a name of letters, digits and _", 1), + ("#targetengine 9a".into(), "#targetengine needs a name of letters, digits and _", 1), + ("#targetengine \"a\" \"b\"".into(), "#targetengine takes one name", 1), + (format!("#targetengine \"{long}\""), "#targetengine needs a name of at most 64 bytes", 1), + (format!("#targetengine {long}"), "#targetengine needs a name of at most 64 bytes", 1), + (control.into(), "#targetengine needs a name without control characters", 1), + ("#targetengine a\n//@targetengine a".into(), "the script has more than one //@targetengine line", 2), + ("var x;\n#targetengine a".into(), "#targetengine must come before the script's code and #include lines", 2), + ("/* a */ 1;\n#targetengine a".into(), "#targetengine must come before the script's code and #include lines", 2), + ]; + for (source, message, line) in &cases { + let e = run(&mut s, &format!("{source}\nvc.log(1);")).unwrap_err().to_string(); + assert!(e.contains(message), "{source}: {e}"); + assert_eq!(s.script_report().unwrap()["line"], *line, "{source}"); + assert_eq!(s.script_report().unwrap()["log"], json!([]), "{source}"); + } + assert!(names(&mut s).is_empty(), "no failed line made an engine"); + let ok = format!( + "// a comment\n/* and\n another */\n#target vectorcraft\n#script \"Named\"\n #targetengine \"{}\"\nvc.log($.engineName.length);", + "b".repeat(64) + ); + assert_eq!(log(&mut s, &ok), json!([64])); + // A script that does not start: an engine it would make is not kept. + assert!(run(&mut s, "#targetengine broken\nvar = 1;").is_err()); + assert_eq!(names(&mut s), ["b".repeat(64)]); +} + +#[test] +fn only_the_script_itself_names_its_engine() { + let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + std::fs::write(base.join("lib.jsxinc"), "#targetengine lib\nvar a = 1;").unwrap(); + let main = base.join("main.jsx"); + let mut s = Session::new(); + for text in ["#include \"lib.jsxinc\"\n", "$.evalFile('lib.jsxinc');\n"] { + std::fs::write(&main, text).unwrap(); + let e = s.execute("script.run", &json!({"path": main.to_string_lossy()})).unwrap_err().to_string(); + assert!(e.contains("#targetengine may only be in the script itself, not in a file it runs"), "{text}: {e}"); + assert!(s.script_report().unwrap()["file"].as_str().unwrap().ends_with("lib.jsxinc"), "{text}"); + } + std::fs::write(base.join("ok.jsxinc"), "var a = 1;").unwrap(); + std::fs::write(&main, "#include \"ok.jsxinc\"\n#targetengine e\n").unwrap(); + let e = s.execute("script.run", &json!({"path": main.to_string_lossy()})).unwrap_err().to_string(); + assert!(e.contains("#targetengine must come before the script's code and #include lines"), "{e}"); + assert_eq!(s.script_report().unwrap()["line"], 2); + // An error in a file $.evalFile runs is at that file's place in a kept engine's later runs too. + std::fs::write(base.join("bad.jsxinc"), "var b;\nthrow new Error('here');").unwrap(); + std::fs::write(&main, "#targetengine e\n$.evalFile('ok.jsxinc');").unwrap(); + s.execute("script.run", &json!({"path": main.to_string_lossy()})).unwrap(); + std::fs::write(&main, "#targetengine e\n$.evalFile('bad.jsxinc');").unwrap(); + assert!(s.execute("script.run", &json!({"path": main.to_string_lossy()})).is_err()); + let report = s.script_report().unwrap().clone(); + assert!(report["file"].as_str().is_some_and(|f| f.ends_with("bad.jsxinc")), "{report}"); + assert_eq!(report["line"], 2, "{report}"); + assert_eq!(names(&mut s), ["e"]); + let _ = std::fs::remove_dir_all(base); + // An error in a function an earlier run defined has no place in the run that calls it. + run(&mut s, "#targetengine e\n\n\n\nfunction bad() { throw new Error('kept'); }").unwrap(); + assert!(run(&mut s, "#targetengine e\nbad();").is_err()); + let report = s.script_report().unwrap().clone(); + assert!(report["line"].as_u64().is_none_or(|l| l == 0), "{report}"); +} + +/// A `$.evalFile` an earlier run kept reads files the way the run that calls it does: relative to +/// that run's script, and as `$.evalFile` does in a run given as text. +#[test] +fn a_kept_eval_file_reads_in_the_calling_run_scope() { + let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-scope-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for dir in ["a", "b"] { + std::fs::create_dir_all(base.join(dir)).unwrap(); + std::fs::write(base.join(dir).join("lib.jsxinc"), format!("'{dir}'")).unwrap(); + } + let mut s = Session::new(); + let a = base.join("a").join("main.jsx"); + std::fs::write(&a, "#targetengine e\nvar ev = $.evalFile;\nvc.log(ev('lib.jsxinc'));").unwrap(); + assert_eq!(s.execute("script.run", &json!({"path": a.to_string_lossy()})).unwrap()["log"], json!(["a"])); + let b = base.join("b").join("main.jsx"); + std::fs::write(&b, "#targetengine e\nvc.log(ev('lib.jsxinc'));").unwrap(); + assert_eq!(s.execute("script.run", &json!({"path": b.to_string_lossy()})).unwrap()["log"], json!(["b"]), "the kept function reads beside b"); + let kept = run(&mut s, "#targetengine e\nvc.log(ev('lib.jsxinc'));").map_err(|e| e.to_string()); + let direct = run(&mut s, "vc.log($.evalFile('lib.jsxinc'));").map_err(|e| e.to_string()); + let _ = std::fs::remove_dir_all(base); + assert!(direct.is_err(), "{direct:?}"); + assert_eq!(kept.unwrap_err().split(": ").last(), direct.unwrap_err().split(": ").last(), "a run given as text reads no file beside a"); +} + +#[test] +fn a_failed_or_stopped_run_keeps_the_globals_it_set_and_puts_the_documents_back() { + let mut s = one_doc(); + let undo = s.documents()[0].history.undo.len(); + let e = run(&mut s, &format!("#targetengine e\nvar n = 1;\nvc.run('shape.rectangle', {RECT});\nthrow new Error('boom');")); + assert!(e.is_err()); + assert_eq!((objects(&s), s.documents()[0].history.undo.len()), (1, undo), "the documents went back"); + assert_eq!(log(&mut s, "#targetengine e\nvc.log(n);"), json!([1])); + // A stop. + s.script_frames = true; + assert_eq!(run(&mut s, &format!("#targetengine e\nn = 10;\nvc.run('shape.rectangle', {RECT});\nwhile (true) {{}}")).unwrap()["running"], true); + assert!(s.script_pump().is_none()); + assert_eq!(s.execute("script.stop", &json!({})).unwrap()["stopped"], true); + assert!(s.script_pump().unwrap().is_err()); + s.script_frames = false; + assert_eq!(objects(&s), 1); + // An error in another engine leaves this one as it was. + assert!(run(&mut s, "#targetengine f\nvar n = 99;\nthrow 1;").is_err()); + assert_eq!(log(&mut s, "#targetengine e\nvc.log(n);"), json!([10])); + // A run that ends well is one undo step. + run(&mut s, &format!("#targetengine e\nvc.run('shape.rectangle', {RECT});")).unwrap(); + assert_eq!((objects(&s), s.documents()[0].history.undo.len()), (2, undo + 1)); + assert_eq!(s.documents()[0].history.undo.last().unwrap().label, "Run Script"); +} + +/// A run stopped at a dialog, or left paused, ends where it was: the engine keeps its globals and +/// the next run starts afresh. +#[test] +fn a_run_stopped_at_a_dialog_or_left_paused_ends_where_it_was() { + let mut s = Session::new(); + s.script_frames = true; + run(&mut s, "#targetengine e\nvar n = 20;\nfunction f() { var big = new Array(4000001).join('x'); alert('x'); n = big.length; }\nf();").unwrap(); + for _ in 0..1_000 { + if s.script_dialog().is_some() { + break; + } + assert!(s.script_pump().is_none()); + } + assert!(s.script_dialog().is_some()); + s.execute("script.stop", &json!({})).unwrap(); + assert!(s.script_pump().unwrap().is_err()); + let heap = s.execute("script.engines", &json!({})).unwrap()["engines"][0]["heapBytes"].as_u64().unwrap(); + assert!(heap < 4 << 20, "the stopped run's frames, and the string they held, are gone: {heap} bytes"); + run(&mut s, "#targetengine e\nn += 1;\nwhile (true) {}").unwrap(); + assert!(s.script_pump().is_none()); + assert!(s.script_abandon()); + s.script_frames = false; + for _ in 0..3 { + assert_eq!(log(&mut s, "#targetengine e\nvc.log(n);"), json!([21])); + } +} + +#[test] +fn a_kept_host_function_acts_for_the_run_that_calls_it() { + let mut s = one_doc(); + run(&mut s, "#targetengine e\nvar oldRun = vc.run, oldVc = vc, oldWrite = $.writeln, oldAlert = alert;").unwrap(); + // The functions an earlier run kept work in this run's transaction, which goes back. + let e = run(&mut s, &format!("#targetengine e\noldRun('shape.rectangle', {RECT});\noldVc.log('kept');\nthrow new Error('x');")); + assert!(e.is_err()); + assert_eq!(objects(&s), 1, "the kept vc.run's change went back with the run"); + assert_eq!(s.script_report().unwrap()["log"], json!(["kept"])); + let r = s + .execute( + "script.run", + &json!({"source": format!("#targetengine e\noldRun('shape.rectangle', {RECT});\noldWrite('out');\noldAlert('hi');"), "answers": [{"kind": "alert", "value": "ok"}]}), + ) + .unwrap(); + assert_eq!((objects(&s), r["dialogCount"].as_u64()), (2, Some(1))); + assert!(r["console"]["lines"].as_array().unwrap().iter().any(|l| l == "out"), "{r}"); + assert_eq!(s.documents()[0].history.undo.last().unwrap().label, "Run Script"); + let r = s + .execute( + "script.run", + &json!({"source": "#targetengine e\nvc.log([oldVc.args.n, vc === oldVc, Object.isFrozen(vc.args)]);", "args": {"n": 2}}), + ) + .unwrap(); + assert_eq!(r["log"], json!([[2, true, true]]), "a kept vc gives the calling run's args, frozen"); +} + +/// The values a run sets on `$` in a named engine end with that run, as in a run without one, and +/// a `$` an earlier run kept gives the values of the run that reads it. +#[test] +fn the_values_of_the_dollar_object_belong_to_each_run_of_a_named_engine() { + let mut s = Session::new(); + let read = "vc.log([$.getenv('OS'), $.getenv('LANG'), $.localize, $.flags, $.locale]);"; + let fresh = log(&mut s, read); + let set = "$.setenv('OS', 'set_here');\n$.setenv('LANG', 'set_here');\n$.localize = true;\n$.locale = 'fr_FR';"; + run(&mut s, &format!("#targetengine e\nvar d = $;\n{set}\n{read}")).unwrap(); + assert_eq!(log(&mut s, &format!("#targetengine e\n{read}")), fresh); + let os = fresh[0][0].clone(); + assert_eq!(log(&mut s, "#targetengine e\nvc.log([d === $, d.engineName, d.getenv('OS'), d.localize]);"), json!([[true, "e", os, false]])); + assert_eq!(log(&mut s, "#targetengine e\nd.setenv('OS', 'again');\nvc.log(d.getenv('OS'));"), json!(["again"])); + assert_eq!(log(&mut s, &format!("#targetengine e\n{read}")), fresh); +} + +#[test] +fn an_engine_does_not_keep_a_closed_document() { + let mut s = one_doc(); + let name = s.documents()[0].title(); + run(&mut s, "#targetengine e\nvar snap = vc.document;\nvar read = function () { return vc.document; };").unwrap(); + let weak = Arc::downgrade(&s.documents()[0].doc); + s.execute("file.close", &json!({})).unwrap(); + assert!(weak.upgrade().is_none(), "nothing holds the closed document"); + assert_eq!(log(&mut s, "#targetengine e\nvc.log(read());\nvc.log(snap.name);"), json!([null, name])); +} + +#[test] +fn a_session_keeps_eight_engines_and_drops_them_on_request() { + let mut s = Session::new(); + for i in 0..8 { + run(&mut s, &format!("#targetengine e{i}\nvar v = {i};")).unwrap(); + } + let e = run(&mut s, "#targetengine e8\nvc.log(1);").unwrap_err().to_string(); + assert!(e.contains("a session keeps at most 8 script engines"), "{e}"); + assert_eq!((s.script_report().unwrap()["kind"].as_str(), s.script_report().unwrap()["log"].clone()), (Some("limit"), json!([]))); + assert_eq!(log(&mut s, "#targetengine e3\nvc.log(v);"), json!([3]), "the eight still run"); + let list = s.execute("script.engines", &json!({})).unwrap(); + assert_eq!((list["limit"].as_u64(), list["heapLimit"].as_u64()), (Some(8), Some(64 << 20))); + for (i, e) in list["engines"].as_array().unwrap().iter().enumerate() { + assert_eq!(e["name"], format!("e{i}")); + assert_eq!(e["running"], false); + assert!(e["heapBytes"].as_u64().unwrap() > 0, "{e}"); + } + assert_eq!(s.execute("script.engine.drop", &json!({"name": "e2"})).unwrap(), json!({"dropped": true, "name": "e2"})); + assert_eq!(names(&mut s), ["e0", "e1", "e3", "e4", "e5", "e6", "e7"]); + run(&mut s, "#targetengine e8\nvar v = 8;").unwrap(); + assert!(run(&mut s, "#targetengine e2\n1").unwrap_err().to_string().contains("at most 8")); + s.execute("script.engine.drop", &json!({"name": "e8"})).unwrap(); + assert_eq!(log(&mut s, "#targetengine e2\nvc.log(typeof v);"), json!(["undefined"]), "a dropped engine comes back new"); + for (params, message) in [ + (json!({"name": "main"}), "the main script engine is new for each run"), + (json!({"name": "nope"}), "there is no script engine named \"nope\""), + (json!({}), "`name` must be the name of a script engine"), + (json!({"name": 3}), "`name` must be the name of a script engine"), + ] { + let e = s.execute("script.engine.drop", ¶ms).unwrap_err().to_string(); + assert!(e.contains(message), "{params}: {e}"); + } + // The running script's engine cannot be dropped; another one can. + s.script_frames = true; + assert_eq!(run(&mut s, "#targetengine e0\nwhile (true) {}").unwrap()["running"], true); + assert!(s.script_pump().is_none()); + assert!(matches!(s.execute("script.engine.drop", &json!({"name": "e0"})), Err(EngineError::Busy))); + let list = s.execute("script.engines", &json!({})).unwrap(); + assert_eq!(list["engines"][0]["running"], true); + s.execute("script.engine.drop", &json!({"name": "e1"})).unwrap(); + s.execute("script.stop", &json!({})).unwrap(); + assert!(s.script_pump().unwrap().is_err()); + assert_eq!(names(&mut s), ["e0", "e3", "e4", "e5", "e6", "e7", "e2"]); + // A script cannot drop or list engines itself. + s.script_frames = false; + let e = run(&mut s, "vc.run('script.engine.drop', {name: 'e0'});").unwrap_err().to_string(); + assert!(e.contains("a script cannot run `script.engine.drop`"), "{e}"); +} + +#[test] +fn a_named_engine_has_its_own_heap_limit_and_stays_usable() { + TEST_ENGINE_HEAP.with(|h| h.set(Some(8 << 20))); + let mut s = Session::new(); + let big = run(&mut s, "#targetengine big\nvar marker = 'set', keep = [];\nfor (var i = 0; i < 10000000; i++) keep.push('item ' + i);"); + // The next run starts on the nearly full heap even with large `args`, and can free memory. + let big_args = json!({"big": "x".repeat(1 << 19)}); + let free = json!({"source": "#targetengine big\nkeep = null;\nvc.log(marker);", "name": "t.js", "args": big_args}); + let kept = s.execute("script.run", &free).unwrap()["log"].clone(); + let after = s.execute("script.engines", &json!({})).unwrap(); + TEST_ENGINE_HEAP.with(|h| h.set(None)); + assert!(big.unwrap_err().to_string().contains("Heap")); + assert_eq!(kept, json!(["set"]), "the globals set before the limit stay, and the engine still runs"); + assert!(after["engines"][0]["heapBytes"].as_u64().unwrap() < 8 << 20, "{after}"); + // A run without #targetengine keeps the larger heap of one run. + assert_eq!(log(&mut s, "var keep = [];\nfor (var i = 0; i < 300000; i++) keep.push('item ' + i);\nvc.log(keep.length);"), json!([300000])); +} + +/// The engines stay on the thread that made them: a session used on another thread starts there +/// with none, and the engines it left, or that a session dropped elsewhere held, are dropped on +/// their own thread when it next uses its engines. +#[test] +fn a_session_moved_to_another_thread_starts_with_no_engines_and_lets_the_old_ones_go() { + let mut s = Session::new(); + run(&mut s, "#targetengine e\nvar kept = 1;").unwrap(); + assert_eq!(engines_on_this_thread(), 1); + let (mut s, there, listed, thread) = std::thread::spawn(move || { + let there = log(&mut s, "#targetengine e\nvc.log(typeof kept);"); + let listed = names(&mut s); + (s, there, listed, std::thread::current().id()) + }) + .join() + .unwrap(); + assert_eq!((there, listed), (json!(["undefined"]), vec!["e".to_string()])); + assert!(!releases_kept_for(thread), "a thread that ended keeps no list of engines to let go"); + // Back here, the first engine has been let go: the run gets a new one. + assert_eq!(log(&mut s, "#targetengine e\nvc.log(typeof kept);"), json!(["undefined"])); + assert_eq!(engines_on_this_thread(), 1); + assert!(!releases_kept_for(thread), "letting go of the ended thread's engines adds nothing for it"); + std::thread::spawn(move || drop(s)).join().unwrap(); + let mut other = Session::new(); + assert!(names(&mut other).is_empty()); + assert_eq!(engines_on_this_thread(), 0, "the engine of the session dropped on another thread is dropped here"); +} + +#[test] +fn an_internal_error_drops_the_engine() { + let mut s = Session::new(); + assert_eq!(log(&mut s, "#targetengine p\nvar a = 1;\nvc.log(a);"), json!([1])); + assert_eq!(log(&mut s, "#targetengine p\nvc.log(a);"), json!([1])); + s.script_frames = true; + run(&mut s, "#targetengine p\nwhile (true) {}").unwrap(); + PANIC_IN_SLICE.with(|p| p.set(true)); + let r = s.script_pump().unwrap(); + PANIC_IN_SLICE.with(|p| p.set(false)); + assert!(r.is_err()); + assert!(names(&mut s).is_empty()); + s.script_frames = false; + assert_eq!(log(&mut s, "#targetengine p\nvc.log(typeof a);"), json!(["undefined"])); +} + +/// A run whose job is gone took its engine's realm with it: the engine is dropped, so the next run +/// that names it starts it new and other engines stay. +#[test] +fn a_run_whose_job_is_gone_drops_its_engine() { + for abandon in [true, false] { + let mut s = Session::new(); + run(&mut s, "#targetengine kept\nvar k = 1;").unwrap(); + run(&mut s, "#targetengine lost\nvar a = 1;").unwrap(); + s.script_frames = true; + run(&mut s, "#targetengine lost\nwhile (true) {}").unwrap(); + assert!(s.script_pump().is_none()); + assert!(crate::script::forget_job(&s)); + if abandon { + assert!(s.script_abandon()); + } else { + assert!(s.script_pump().unwrap().is_err()); + } + s.script_frames = false; + assert_eq!(names(&mut s), ["kept"], "abandon: {abandon}"); + assert_eq!(log(&mut s, "#targetengine lost\nvc.log(typeof a);"), json!(["undefined"]), "abandon: {abandon}"); + assert_eq!(log(&mut s, "#targetengine kept\nvc.log(k);"), json!([1]), "abandon: {abandon}"); + } +} diff --git a/crates/engine/src/tests/script_include.rs b/crates/engine/src/tests/script_include.rs index 0c9ab45b2..0d1932ed5 100644 --- a/crates/engine/src/tests/script_include.rs +++ b/crates/engine/src/tests/script_include.rs @@ -192,7 +192,7 @@ fn script_target_other_app_does_not_route() { assert_eq!(r["log"], json!([1])); for (source, message) in [ ("#target\nvc.log(1);", "#target needs a value"), - ("#targetengine \"session\"", "unsupported script directive: #targetengine"), + ("#targetengine \"session\"", "#targetengine must come before the script's code and #include lines"), ("#engine \"main\"", "unsupported script directive: #engine"), ("#strict maybe", "#strict takes on or off"), ("#frobnicate 1", "unknown script directive: #frobnicate"), diff --git a/crates/engine/src/tests/script_run.rs b/crates/engine/src/tests/script_run.rs index 8294aa638..3359f272f 100644 --- a/crates/engine/src/tests/script_run.rs +++ b/crates/engine/src/tests/script_run.rs @@ -166,7 +166,7 @@ fn an_error_names_the_script_its_place_and_kind() { let e = run(&mut s, "var = 1;").unwrap_err().to_string(); assert!(e.starts_with("t.js: line 1,") && e.ends_with("(syntax)"), "{e}"); assert!(run(&mut s, "app.activeDocument;").unwrap_err().to_string().contains("`vc`")); - assert!(run(&mut s, "#targetengine x\n1").unwrap_err().to_string().contains("unsupported script directive: #targetengine")); + assert!(run(&mut s, "#targetengine 9x\n1").unwrap_err().to_string().contains("#targetengine needs a name of letters, digits and _")); assert_eq!(s.script_report().unwrap()["line"], 1); } diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 4ec8f2c72..edb46f596 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -85,11 +85,13 @@ pub enum Arg { Bool, } -/// How the arguments of a host function are read. +/// How the arguments of a host function are read, and its result given. #[derive(Clone)] pub(crate) enum ArgRead { /// Each as [`Data`]. Data, + /// Each as [`Data`], with the result given frozen, as [`HostItem::Value`] gives its value. + Frozen, /// Each as the string `String(value)` gives. Text, /// One by one as the list says; those past its end are not read. @@ -109,6 +111,9 @@ pub enum HostItem { Converted(Vec, HostFunction), /// A property whose value the function gives each time it is read. Getter(HostFunction), + /// A property whose value the function gives each time it is read, frozen as + /// [`HostItem::Value`] gives its value. + FrozenGetter(HostFunction), /// A property whose value `get` gives each time it is read, and that `set` is given each value /// assigned to it, as its one argument; an error from `set` is thrown to the script. Property { @@ -359,7 +364,7 @@ impl Interp { let result = f(data); self.can_wait.set(false); match result { - Ok(d) => self.write_data(d, 0, false), + Ok(d) => self.write_data(d, 0, matches!(how, ArgRead::Frozen)), Err(HostError::Wait) if can_wait => { self.waiting = true; self.pause.set(true); @@ -412,14 +417,14 @@ impl Interp { let mut data = Vec::with_capacity(args.len()); // A conversion can run the script's own `toString`, which may collect garbage. let mark = self.temp_roots.len(); - if !matches!(how, ArgRead::Data) { + if !matches!(how, ArgRead::Data | ArgRead::Frozen) { self.temp_roots.extend(args.iter().cloned()); } let mut read = Ok(()); for (i, a) in args.iter().enumerate() { reading.path.push(Step::Index(u32::try_from(i).unwrap_or(u32::MAX))); let d = match how { - ArgRead::Data => self.read_data(a, &mut reading), + ArgRead::Data | ArgRead::Frozen => self.read_data(a, &mut reading), ArgRead::Text => self.read_text(a, &mut reading), ArgRead::Each(list) => match list.get(i) { Some(Arg::TextOrUndefined) if matches!(a, Value::Undefined) => Ok(Data::Undefined), @@ -507,6 +512,7 @@ impl Interp { let prop = match item { HostItem::Value(d) => Prop::Data { value: self.write_data(d, 0, true)?, flags: ENUMERABLE }, HostItem::Getter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, ArgRead::Data)), set: None, flags: ENUMERABLE }, + HostItem::FrozenGetter(f) => Prop::Accessor { get: Some(self.host_function(f, &full, ArgRead::Frozen)), set: None, flags: ENUMERABLE }, HostItem::Property { get, set } => Prop::Accessor { get: Some(self.host_function(get, &full, ArgRead::Data)), set: Some(self.host_function(set, &full, ArgRead::Data)), diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs index 48aaa9610..44aea36ad 100644 --- a/crates/js/src/host_tests.rs +++ b/crates/js/src/host_tests.rs @@ -58,6 +58,23 @@ fn state_survives_a_pause_and_a_collection_between_slices() { assert_eq!(finish(&mut r, 50).0.unwrap(), JsValue::Number(1500.0)); } +/// A paused run that is ended leaves no frames behind: the realm starts the next run with its +/// globals, as many times as it is ended. +#[test] +fn an_ended_run_leaves_the_realm_ready_for_the_next() { + let mut r = realm(u64::MAX); + for _ in 0..3 { + r.start("var kept = 'yes'; function deep(n) { return n ? deep(n - 1) : (function () { while (true) {} })(); } deep(50);").unwrap(); + assert_eq!(r.advance(500).unwrap(), Progress::Paused); + r.end(); + assert!(!r.is_running()); + } + assert!(r.interp().frames.is_empty() && r.interp().stack.is_empty()); + r.start("kept").unwrap(); + assert_eq!(finish(&mut r, 1_000).0.unwrap(), JsValue::String("yes".into())); + r.end(); +} + #[test] fn the_fuel_limit_covers_every_slice_together() { let mut r = realm(5_000); @@ -223,6 +240,18 @@ fn a_getter_is_read_each_time() { assert_eq!(r.run("host.n + host.n").unwrap(), JsValue::Number(3.0)); } +/// A frozen getter gives a new value each time it is read, frozen to its leaves. +#[test] +fn a_frozen_getter_gives_a_frozen_value_each_time() { + let mut r = realm(1_000_000); + let f: HostFunction = Rc::new(|_| Ok(Data::Object(vec![("a".into(), Data::Array(vec![Data::Number(1.0)]))]))); + let g = f.clone(); + r.define_host_object("host", vec![("v".into(), HostItem::FrozenGetter(f)), ("open".into(), HostItem::Getter(g))]).unwrap(); + let source = "var v = host.v; v.b = 2; try { v.a.push(2); } catch (e) {} \ + [Object.isFrozen(v), Object.isFrozen(v.a), v.b === undefined, v.a.length, host.v === host.v, Object.isFrozen(host.open)].join()"; + assert_eq!(r.run(source).unwrap(), JsValue::String("true,true,true,1,false,false".into())); +} + #[test] fn a_text_function_takes_each_argument_as_string_gives_it() { let mut r = realm(1_000_000); @@ -408,6 +437,25 @@ fn a_converted_function_reads_each_argument_as_its_list_says() { assert_eq!(out, JsValue::String("function 1".into()), "a script may replace the function"); } +#[test] +fn error_sources_count_from_the_last_script_and_code_from_an_earlier_one_is_unknown() { + let mut r = realm(10_000_000); + let source: HostFunction = Rc::new(|_| Ok(Data::String("\nthrow new Error('in eval')".into()))); + let load = HostItem::Evaluator { source, end: Rc::new(|| {}), strict_writes: Rc::new(|| None) }; + r.define_host_object("host", vec![("load".into(), load)]).unwrap(); + r.run("function late() { throw new Error('late'); }").unwrap(); + assert!(r.run("host.load()").is_err()); + assert_eq!(r.error_source(), 1, "the first source the evaluator gave in this run"); + assert!(r.run("\nlate()").is_err()); + assert_eq!(r.error_source(), UNKNOWN_SOURCE, "an earlier script defined late"); + assert!(r.run("\nthrow 1").is_err()); + assert_eq!(r.error_source(), 0); + assert!(r.run("host.load()").is_err()); + assert_eq!(r.error_source(), 1, "counted again from this run's script"); + assert!(r.run("var = 1").is_err()); + assert_eq!(r.error_source(), 0, "a syntax error is in the script"); +} + proptest::proptest! { /// A slice size does not change what a script computes. #[test] diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index 1c2bb6ecf..23fec090d 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -148,6 +148,10 @@ impl From for RunError { pub struct Realm { interp: Interp, unbroken_fuel: u64, + /// The source tag of the script the last [`Realm::run`] or [`Realm::start`] compiled. Each + /// script gets a new one, and the sources an evaluator gives during its run get the ones after + /// it, so code that an earlier script defined keeps a tag of its own. + script_tag: u32, } impl Default for Realm { @@ -165,7 +169,7 @@ impl Realm { /// `options`; [`Options::max_source_units`] caps the source parsed in one run, all of it /// counted together. pub fn with_options(options: Options, limits: Limits) -> Self { - Self { interp: Interp::new(options, limits), unbroken_fuel: MAX_UNBROKEN_FUEL } + Self { interp: Interp::new(options, limits), unbroken_fuel: MAX_UNBROKEN_FUEL, script_tag: 0 } } /// Sets the fuel [`Realm::advance`] lets a nested run spend past its slice (by default @@ -237,13 +241,15 @@ impl Realm { interp.finally_marks.clear(); interp.error_mark = None; let strict = interp.options.strict; - interp.source_tag = 0; + interp.evaluated = interp.evaluated.saturating_add(1).min(UNKNOWN_SOURCE - 1); + self.script_tag = interp.evaluated; + interp.source_tag = self.script_tag; let code = interp.encode_source(source).and_then(|units| interp.compile_source(&units, strict, CodeKind::Global)); interp.source_tag = UNKNOWN_SOURCE; match code { Ok(code) => Ok(code), Err(CompileError::Syntax(e)) => { - interp.error_tag = 0; + interp.error_tag = self.script_tag; Err(e.into()) } Err(CompileError::Internal(m)) => Err(RunError { kind: RunErrorKind::Internal, message: m, line: 0, column: 0 }), @@ -325,6 +331,15 @@ impl Realm { !self.interp.frames.is_empty() } + /// Ends a run begun with [`Realm::start`] where it is, paused or waiting for a host function's + /// result, as an interrupt would: its stacks are emptied, so the realm can start another run. + /// The globals the run set stay. + pub fn end(&mut self) { + if self.is_running() || self.interp.waiting { + let _ = self.outcome(Err(Abrupt::Limit(LimitKind::Interrupted))); + } + } + /// The flag a host function sets to end the current slice at the next point it can pause. pub fn pause_flag(&self) -> std::rc::Rc> { self.interp.pause.clone() @@ -367,11 +382,15 @@ impl Realm { self.interp.interrupt = interrupt.0.clone(); } - /// Which source the last error's `line` and `column` are in: 0 for the script given to + /// Which source the last error's `line` and `column` are in: 0 for the script last given to /// [`Realm::run`] or [`Realm::start`], `n` for the `n`th source a - /// [`crate::HostItem::Evaluator`] gave in this realm, and [`UNKNOWN_SOURCE`] otherwise. + /// [`crate::HostItem::Evaluator`] gave since then, and [`UNKNOWN_SOURCE`] otherwise, which + /// includes code from an earlier script and from the sources given during its run. pub fn error_source(&self) -> u32 { - self.interp.error_tag + match self.interp.error_tag { + UNKNOWN_SOURCE => UNKNOWN_SOURCE, + tag => tag.checked_sub(self.script_tag).unwrap_or(UNKNOWN_SOURCE), + } } /// The mark of the host error ([`crate::HostError::Marked`]) the last run ended with, when no diff --git a/crates/scriptcheck/src/catalog.rs b/crates/scriptcheck/src/catalog.rs index 7ee71c9ee..8954736d2 100644 --- a/crates/scriptcheck/src/catalog.rs +++ b/crates/scriptcheck/src/catalog.rs @@ -152,7 +152,7 @@ features! { DirectiveInclude "directive.include" Language, DirectiveOther "directive.other" Language, DirectiveTarget "directive.target" Language, - DirectiveTargetengine "directive.targetengine" HostOnly, + DirectiveTargetengine "directive.targetengine" Language, Dollar "dollar" Language, DollarEnv "dollar.env" Files, DollarEvalFile "dollar.evalFile" Files, diff --git a/crates/scriptcheck/src/tests.rs b/crates/scriptcheck/src/tests.rs index e7ead63a4..00d3b8727 100644 --- a/crates/scriptcheck/src/tests.rs +++ b/crates/scriptcheck/src/tests.rs @@ -209,7 +209,7 @@ fn directives_are_read_at_the_start_of_a_line_in_both_forms() { let includes: Vec<_> = r.includes.iter().map(|i| (i.value.as_str(), i.line, i.status)).collect(); assert_eq!(includes, [("lib/a.jsxinc", 3, IncludeStatus::NotRead)]); assert_eq!(problems(&r), [("unknownDirective", Some(8), Some("#pragma")), ("unknownDirective", Some(9), Some("#!"))]); - assert_eq!(r.needs, [Need::Language, Need::HostOnly]); + assert_eq!(r.needs, [Need::Language], "VectorCraft runs #targetengine"); } #[test] diff --git a/docs/gaps.md b/docs/gaps.md index 19376c4bf..b10f0f0a9 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -687,8 +687,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi ## G6 Automation and scripting -- **Missing:** the document object model for scripts and the `#targetengine` and `#engine` script directives (#1183 is - open). File › Scripts › Other Script… and `script.run` run JavaScript files over the command registry, +- **Missing:** the document object model for scripts and the `#engine` script directive (#1183 is + open). File › Scripts › Other Script… and `script.run` run JavaScript files over the command registry, `#targetengine` + runs a script in a named engine whose globals last between runs, File › Scripts lists the scripts of the Scripts folder, and Window › Script Console shows their output, errors and run reports. Actions, Batch, Variables (text, visibility, image and graph), data set CSV import and export, and full agent control over MCP are done. diff --git a/docs/mcp.md b/docs/mcp.md index 998828620..f592404bf 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1492,10 +1492,10 @@ top-level `import` or `export`, such as a React `.jsx` file), `notText` (a NUL b control character, in the first 8 KiB) or `tooLarge` (over 8 MiB). For a script, the reply holds these lists: - `needs` lists what the script needs from the app that runs it: `language` (the script language's own objects - and directives, such as `$`, `alert`, `UnitValue` and `#target`), `dom` (the reference app's document model), - `files`, `menuCommands`, `dialogs`, and `hostOnly` (features only the reference app has: palettes, - `#targetengine`, BridgeTalk, sockets, native code, XMP, the XML object and E4X, operator overloading, - reflection, actions and `selectTool`). + and directives, such as `$`, `alert`, `UnitValue`, `#target` and `#targetengine`), `dom` (the reference app's + document model), `files`, `menuCommands`, `dialogs`, and `hostOnly` (features only the reference app has: + palettes, BridgeTalk, sockets, native code, XMP, the XML object and E4X, operator overloading, reflection, actions + and `selectTool`). - `directives` lists the `#include`, `#includepath`, `#script`, `#strict`, `#target` and `#targetengine` lines, and `targets` splits each `#target` line's application specifier into `app`, `instance`, `version` and `locale`. A directive is read where it starts a line, written `#name` or `//@name`. @@ -1577,10 +1577,11 @@ app through the global object `vc`, and writes to the Script Console and runs ot is read at once and `timeout` is not waited on. The run's fuel and limits apply to the file. An empty path, a missing file and a refused path throw errors the script can catch. - `$.fileName` is the file being run, which is the file `$.evalFile` is running while it runs, and is empty for a - script given as `source`. `$.includePath` is the current `#includepath` list, separated by `;`. + script given as `source`. `$.includePath` is the current `#includepath` list, separated by `;`. `$.engineName` is + the name `#targetengine` gave the engine the script runs in (`VectorCraft` without the line) and is read-only. - `alert(message[, title, errorIcon])`, `confirm(message[, noAsDflt, title])` and `prompt(message, preset[, title])` open a dialog, described below. `vc.api.has("dialogs")` is true. -- `vc.args` is the `args` object. +- `vc.args` gives a read-only copy of the `args` object each time it is read. - `vc.api.version` is `"1.0"`, and `vc.api.has(feature)` says whether the API has a feature. Before any of the script runs, its preprocessor lines are read. The help page @@ -1610,8 +1611,12 @@ VectorCraft reads them by these rules of its own: and `Function` compile takes the checks of the code that compiles it. A file `$.evalFile` runs takes the checks of its own last `#strict` line, for its code and the functions it defines, or the caller's when it has none; the caller's code keeps its own. Any value other than `on` or `off` ends the run with `#strict takes on or off`. -- `#targetengine` and `#engine` end the run with an unsupported-directive error, and any other `#name` with an - unknown-directive error. A `//@` line with another name is a comment. +- `#targetengine name` and `//@targetengine name` run the script in the named engine. The help page above describes + the line: the engine it names lasts for the application session, and a later script that names it sees the globals + and functions earlier ones left. A script without the line, and `#targetengine main`, get a new engine for each + run, as before. The rest are VectorCraft's own rules, described under Script engines below. +- `#engine` ends the run with an unsupported-directive error, and any other `#name` with an unknown-directive error. + A `//@` line with another name is a comment. - A script run from a file reads its included files and the files `$.evalFile` runs only in that file's folder and the folders below it. A path with a `..` name, a path that leads out of the folder through a link or a junction, a Windows device name or stream, and a computer on the network are refused, and so is every path for a script given as @@ -1689,6 +1694,35 @@ change anything outside the documents: `script.*` except `script.check`, `plugin an error. With Data Recovery off for complex documents, a run in the web build does not start while a complex document 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: + +- 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. +- 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. +- The documents go back after an uncaught error, a limit or `script.stop` as they do for any run, and the engine keeps + the globals the run set before it ended. An internal error drops the engine. An error in a function an earlier run + defined is reported without a line and column. +- `vc`, `$`, `alert`, `confirm` and `prompt` act for the run that calls them, including a copy an earlier run kept in a + global. `vc.args` is the calling run's `args`, through a `vc` object an earlier run kept too. An engine + holds no document: `vc.document` is a copy, read when it is called. What a run sets on `$` (`$.locale`, + `$.localize`, `$.flags`, `$.setenv` values and `$.hiresTimer`) lasts for that run only, as in a run without the line. +- `script.engines {}` lists the named engines in the order they were made: `{engines: [{name, heapBytes, running}], + limit: 8, heapLimit: 67108864}`, where `heapBytes` is the engine's heap after its last run. +- `script.engine.drop {name}` drops a named engine and its globals, and returns `{dropped: true, name}`. The next run + that names it gets a new engine. `main`, a name with no engine, and the engine of the running script are refused; + the last one ends with the busy error. +- A script cannot run `script.engines` or `script.engine.drop`. +- The web build has no named engines: a run that names one other than `main` ends with an error before any of it runs. + VectorCraft sets these limits itself: | Limit | Value | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 07d626e5a..574186b3a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -735,7 +735,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: the `$` object has the other members the Dollar object page lists (https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/), from `$.about()` to `$.version`, and scripts have the global `localize(object, ...values)` (https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/), `toSource(value)` and `Object.prototype.toSource()`. Each run starts with the interface's locale, and what a script changes in `$` lasts only for that run. `crates/engine/src/tests/script_dollar.rs`, `crates/js/src/extension_tests.rs`. - Scripts: the Script Console panel's report line reads as `script.console` gives it, `time unavailable` included on a platform without a clock; counts of host calls, dropped lines and cut lines take the singular for one, in each language's own plural forms; and a run that is no longer available still gets its end line (`crates/engine/src/tests/script_console.rs`, `crates/ui-egui/src/panels/tests_script_console.rs`). - Scripts: an error `$.evalFile` throws for a file it cannot run is reported at that file's line and column only while no `catch` has taken it. Caught and thrown again later, in the script or in another file, it is reported where it was thrown again, and a new error whose message holds its text is reported where that error was thrown. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.rs`. - - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), and `#targetengine` and `#engine` are refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). + - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), `#targetengine` names the engine (below), and `#engine` is refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). + - Scripts: `#targetengine name` (and `//@targetengine name`) runs the script in a named engine that lasts for the session, and a later script that names it sees the globals and functions earlier ones left ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/)); `$.engineName` gives the name ([the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/)). VectorCraft's own rules: a script without the line and `#targetengine main` get a new engine each run, the line comes once in the script itself before its code, a session keeps 8 named engines with 64 MiB of heap each, an error or a stop keeps the globals the run set while the documents go back, kept host functions act for the run that calls them, `script.engines` lists the engines and `script.engine.drop` drops one, and the web build has none. `crates/engine/src/tests/script_engines.rs`. - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens, links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`.