From e77f319e4813af016fbfc869ae6d1ae42cf67509 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Mon, 19 Oct 2026 18:05:59 +0800 Subject: [PATCH] test(macro-js): strengthen cross-invocation isolation coverage Add regression tests pinning the isolation contract for the QuickJS lifecycle investigation: memory-limit and stack-limit recovery across invocations, interrupt-deadline re-arming after an aborted call, per-invocation console sink rebinding, a 32-iteration consecutive invocation sweep, and builtin-intrinsic reset (Object.prototype, Array.prototype.push, vect, Date.now). Measurement on Bastion (src/main.opy, zh-CN, 8 warm release runs via the throwaway opy-compile-prof harness) shows only one JavaScript invocation per compile: the post-compile hook (~25-50 ms of actual script evaluation inside JS_Eval). The script macros in title/title-cn.opy are defined but never expanded. Per-invocation engine setup is ~0.4-1.6 ms total; of that, JS_NewContext (~54 us) is inherent to a fresh context per invocation, and only JS_NewRuntime + JS_FreeRuntime (~27 us) is shareable. A thread-local shared JSRuntime with fresh JSContext per invocation and a post-teardown JS_RunGC was implemented and verified byte-identical output (sha256 7f53ffa8cf71f600259d329d780f0fc00933bfcb7414b60cb14ea63606546c54); warm median moved from ~288 ms to ~292 ms, inside run-to-run spread. No lifecycle change clears the materiality bar, so production code is unchanged; the profiling attribution (~45 ms) conflated JS_Eval script execution with engine setup. Refs #595 --- crates/opy-rs/src/macro_js/tests/isolation.rs | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/crates/opy-rs/src/macro_js/tests/isolation.rs b/crates/opy-rs/src/macro_js/tests/isolation.rs index a5779bb0..b052c13f 100644 --- a/crates/opy-rs/src/macro_js/tests/isolation.rs +++ b/crates/opy-rs/src/macro_js/tests/isolation.rs @@ -101,3 +101,151 @@ fn concurrent_runtime_instances_are_independent() { } }); } + +#[test] +fn memory_limit_recovery_keeps_each_invocation_bounded() { + // An invocation that exhausts the memory limit must neither leak its + // allocation accounting nor disable the limit for the next invocation. + let limits = Limits { + memory_limit_bytes: 2 * 1024 * 1024, + ..Limits::default() + }; + let rt = MacroRuntime::new(limits); + let allocation_loop = + r#"var a = []; for (var i = 0; i < 1000000; i++) { a.push("x".repeat(1024)); }"#; + + for _ in 0..2 { + let error = rt.run_macro(allocation_loop, &[], "mem.js").unwrap_err(); + match error { + MacroError::Script(ref script) => assert_eq!(script.message, "out of memory"), + ref other => panic!("expected Script error, got {other:?}"), + } + } + let result = rt.run_macro(r#""recovered";"#, &[], "after.js").unwrap(); + assert_eq!(result.text, "recovered"); +} + +#[test] +fn stack_limit_recovery_keeps_each_invocation_bounded() { + let limits = Limits { + max_stack_bytes: 128 * 1024, + ..Limits::default() + }; + let rt = MacroRuntime::new(limits); + + for _ in 0..2 { + let error = rt + .run_macro("function f() { return f(); } f();", &[], "stack.js") + .unwrap_err(); + match error { + MacroError::Script(ref script) => { + assert_eq!(script.message, "Maximum call stack size exceeded") + } + ref other => panic!("expected Script error, got {other:?}"), + } + } + let result = rt.run_macro(r#""recovered";"#, &[], "after.js").unwrap(); + assert_eq!(result.text, "recovered"); +} + +#[test] +fn deadline_state_is_re_armed_per_invocation() { + // After an interrupted invocation the interrupt handler must be reset: + // the next call gets a fresh deadline, not the expired one. + let rt = MacroRuntime::new(Limits { + macro_time_budget: Duration::from_millis(50), + ..Limits::default() + }); + rt.run_macro("while (true) {}", &[], "first.js") + .unwrap_err(); + // Succeeding invocations after the abort get a full fresh budget. + for _ in 0..3 { + let result = rt + .run_macro( + r#"var t = Date.now(); while (Date.now() - t < 1) {} "ok";"#, + &[], + "second.js", + ) + .unwrap(); + assert_eq!(result.text, "ok"); + } +} + +#[test] +fn console_sink_is_rebound_per_invocation() { + // `console.log` writes to the invocation's own buffer: a previous call's + // sink must not receive or retain lines. + let rt = runtime(); + let first = rt + .run_macro(r#"console.log("a"); "ok";"#, &[], "first.js") + .unwrap(); + assert_eq!(first.console_output, vec!["a"]); + + let error = rt + .run_macro(r#"console.log("b"); throw new Error("x");"#, &[], "err.js") + .unwrap_err(); + assert!(matches!(error, MacroError::Script(_))); + + let second = rt + .run_macro(r#"console.log("c", 1 + 1); "ok";"#, &[], "second.js") + .unwrap(); + assert_eq!(second.console_output, vec!["c 2"]); +} + +#[test] +fn many_consecutive_invocations_stay_independent() { + // Sweep: interleave leaking, throwing, and clean invocations on one + // runtime; every result must reflect only its own invocation. + let rt = runtime(); + for i in 0..32 { + // Set marker{i} and verify it is visible in the same invocation; + // a later script then asserts it is gone again. + let leak = + format!(r#"globalThis.marker{i} = {i}; (globalThis.marker{i} === {i}).toString();"#); + let leaked = rt.run_macro(&leak, &[], "leak.js").unwrap(); + assert_eq!(leaked.text, "true"); + let check = rt + .run_macro(&format!(r#"typeof globalThis.marker{i};"#), &[], "check.js") + .unwrap(); + assert_eq!(check.text, "undefined"); + if i > 0 { + let previous = rt + .run_macro( + &format!(r#"typeof globalThis.marker{};"#, i - 1), + &[], + "check-prev.js", + ) + .unwrap(); + assert_eq!(previous.text, "undefined"); + } + if i % 3 == 0 { + rt.run_macro("throw new Error(\"x\");", &[], "err.js") + .unwrap_err(); + } + } +} + +#[test] +fn builtin_intrinsics_are_fully_reset() { + // Mutation of shared-looking intrinsics (Object.prototype, Array methods, + // the builtins script's `vect`) must not reach the next invocation. + let rt = runtime(); + rt.run_macro( + r#"Array.prototype.push = function() { return -1; }; + vect = function() { return "tampered"; }; + Date.now = function() { return 0; }; + "ok";"#, + &[], + "poison.js", + ) + .unwrap(); + let result = rt + .run_macro( + r#"var a = [1]; a.push(2); + (a.length === 2 && typeof vect(1,2,3).x === "number" && Date.now() !== 0).toString();"#, + &[], + "verify.js", + ) + .unwrap(); + assert_eq!(result.text, "true"); +}