From 18326030fd7f10f5a62c95eaacc9fe32328b95b5 Mon Sep 17 00:00:00 2001 From: Foorack Date: Sun, 24 May 2026 11:45:58 +0200 Subject: [PATCH] feat(ecmascript): add execution_timeout to AgentOptions --- nova_cli/src/lib/globals.rs | 1 + nova_cli/src/lib/lib.rs | 1 + nova_vm/src/ecmascript/execution/agent.rs | 11 ++++ .../ecmascript/scripts_and_modules/script.rs | 51 +++++++++++++++++++ nova_vm/src/engine/bytecode/vm.rs | 14 +++++ 5 files changed, 78 insertions(+) diff --git a/nova_cli/src/lib/globals.rs b/nova_cli/src/lib/globals.rs index 401d14bb7..31b5e96b9 100644 --- a/nova_cli/src/lib/globals.rs +++ b/nova_cli/src/lib/globals.rs @@ -324,6 +324,7 @@ pub fn initialize_global_object_with_internals(agent: &mut Agent, global: Object print_internals: false, // Always allow children to block. no_block: false, + execution_timeout: None, }, child_hooks, ); diff --git a/nova_cli/src/lib/lib.rs b/nova_cli/src/lib/lib.rs index e67fc3ef4..9fa832ef0 100644 --- a/nova_cli/src/lib/lib.rs +++ b/nova_cli/src/lib/lib.rs @@ -120,6 +120,7 @@ impl Instance { disable_gc: !config.enable_gc, print_internals: config.verbose, no_block: !config.block, + execution_timeout: None, }, // SAFETY: We keep the host hooks alive for at least as long as the agent unsafe { extend_lifetime(&*host_hooks) as &'static _ }, diff --git a/nova_vm/src/ecmascript/execution/agent.rs b/nova_vm/src/ecmascript/execution/agent.rs index 1289c6be8..62098969b 100644 --- a/nova_vm/src/ecmascript/execution/agent.rs +++ b/nova_vm/src/ecmascript/execution/agent.rs @@ -67,6 +67,10 @@ pub struct AgentOptions { /// calling `Atomics.wait()` will throw an error to signal that blocking the /// main thread is not allowed. pub no_block: bool, + /// Wall-clock time limit for a single script execution. When set, any + /// script that runs longer than this duration will be terminated with a + /// JavaScript `Error`. + pub execution_timeout: Option, } /// Result of methods that may throw a JavaScript error. @@ -894,6 +898,10 @@ pub struct Agent { /// \[\[AsyncEvaluationOrder]] field of modules that are asynchronous or /// have asynchronous dependencies. module_async_evaluation_count: u32, + /// Per-execution deadline derived from `options.execution_timeout`. + /// Set at the start of `script_evaluation` and cleared when it returns. + /// `None` means no deadline is active. + pub(crate) execution_deadline: Option, } impl Agent { @@ -912,6 +920,7 @@ impl Agent { kept_alive: false, private_names_counter: 0, module_async_evaluation_count: 0, + execution_deadline: None, } } @@ -1625,6 +1634,7 @@ impl HeapMarkAndSweep for Agent { kept_alive: _, private_names_counter: _, module_async_evaluation_count: _, + execution_deadline: _, } = self; execution_context_stack.iter().for_each(|ctx| { @@ -1676,6 +1686,7 @@ impl HeapMarkAndSweep for Agent { kept_alive: _, private_names_counter: _, module_async_evaluation_count: _, + execution_deadline: _, } = self; execution_context_stack diff --git a/nova_vm/src/ecmascript/scripts_and_modules/script.rs b/nova_vm/src/ecmascript/scripts_and_modules/script.rs index 189515383..9d68b51a4 100644 --- a/nova_vm/src/ecmascript/scripts_and_modules/script.rs +++ b/nova_vm/src/ecmascript/scripts_and_modules/script.rs @@ -335,6 +335,16 @@ pub fn script_evaluation<'a>( // 10. Push scriptContext onto the execution context stack; scriptContext is now the running execution context. agent.push_execution_context(script_context); + // NOTE: Nova extension: if a wall-clock execution timeout is configured + // and no deadline is already active (i.e. this is the outermost script + // call), record the absolute deadline now so the bytecode dispatch loop + // can enforce it. Nested re-entrant calls intentionally inherit the + // already-running deadline rather than resetting it. + let set_deadline = agent.execution_deadline.is_none(); + if set_deadline && let Some(timeout) = agent.options.execution_timeout { + agent.execution_deadline = std::time::Instant::now().checked_add(timeout); + } + // 11. Let script be scriptRecord.[[ECMAScriptCode]]. // NOTE: We cannot define the script here due to reference safety. @@ -376,6 +386,12 @@ pub fn script_evaluation<'a>( // 14. Suspend scriptContext and remove it from the execution context stack. _ = agent.pop_execution_context(); + // NOTE: Nova extension: clear the deadline we set so it does not leak + // into future executions. + if set_deadline { + agent.execution_deadline = None; + } + // TODO: 15. Assert: The execution context stack is not empty. // This is not currently true as we do not push an "empty" context stack to the root before running script evaluation. // debug_assert!(!agent.execution_context_stack.is_empty()); @@ -1971,4 +1987,39 @@ mod test { .bind(gc.nogc()); assert_eq!(result, Value::from_static_str(&mut agent, "c", gc.nogc())); } + + /// Verify that an infinite loop is interrupted once the configured + /// `execution_timeout` has elapsed. + #[test] + fn execution_timeout_kills_infinite_loop() { + let (mut gc, mut scope) = unsafe { GcScope::create_root() }; + let mut gc = GcScope::new(&mut gc, &mut scope); + let mut agent = Agent::new( + AgentOptions { + execution_timeout: Some(std::time::Duration::from_millis(100)), + ..AgentOptions::default() + }, + &DefaultHostHooks, + ); + initialize_default_realm(&mut agent, gc.reborrow()); + + let start = std::time::Instant::now(); + let source_text = String::from_static_str(&mut agent, "while(true){}", gc.nogc()); + let result = agent.run_script(source_text.unbind(), gc.reborrow()); + let elapsed = start.elapsed(); + + // The script must have thrown an error. + assert!(result.is_err(), "expected a timeout error"); + let err_value = result.unwrap_err().value(); + let Value::Error(err) = err_value else { + panic!("expected an Error value, got {:?}", err_value); + }; + assert_eq!(err.get(&agent).kind, ExceptionType::Error); + + // Execution should have been cut off well within a few seconds. + assert!( + elapsed < std::time::Duration::from_secs(5), + "execution took too long: {elapsed:?}" + ); + } } diff --git a/nova_vm/src/engine/bytecode/vm.rs b/nova_vm/src/engine/bytecode/vm.rs index c7febba87..3faa8f6e1 100644 --- a/nova_vm/src/engine/bytecode/vm.rs +++ b/nova_vm/src/engine/bytecode/vm.rs @@ -304,6 +304,20 @@ impl Vm { if agent.check_gc() { self.trigger_gc(agent, gc.reborrow()); } + // NOTE: Nova extension: check the wall-clock deadline on every + // instruction. `Instant::now()` is only called when a deadline is + // active; in the common case (`execution_deadline` is `None`) the + // short-circuit prevents the syscall entirely. + if let Some(deadline) = agent.execution_deadline + && std::time::Instant::now() >= deadline + { + let err = agent.throw_exception_with_static_message( + ExceptionType::Error, + "Script execution timed out", + gc.into_nogc(), + ); + return ExecutionResult::Throw(err); + } if agent.options.print_internals { Self::print_executing(instr.kind); }