fix(audit-2026-06-11/H-C1): engine.on_progress deadline check terminates runaway scripts
tokio::time::timeout over JoinHandle only drops the future — the
spawn_blocking OS thread keeps running until the Rhai script self-
completes or hits the per-op budget. A `loop {}` body with a generous
max_operations could pin a blocking worker for tens of seconds; on Pi-
class hardware one anonymous-callable script call could permanently
subtract a worker from the pool.
Installs an `engine.on_progress` hook that consults a thread-local
deadline; when `Instant::now() >= deadline` the hook returns `Some(_)`,
triggering `ErrorTerminated` inside the Rhai loop. The deadline is set
by `Engine::execute_with_deadline` / `Engine::execute_ast_with_deadline`
via an RAII `DeadlineGuard`, called from the orchestrator client; the
old `execute` / `execute_ast` paths are unchanged so tests, validation,
and the parse-only path keep working.
Invoke re-entry (`sdk/invoke.rs`) inherits the parent's deadline
transparently — the thread-local is set for the entire spawn_blocking
lifetime, and Rhai is single-threaded so the same thread services the
parent + every sub-script.
New tests:
* `deadline_terminates_a_runaway_loop` — `loop {}` body with
`max_operations = u64::MAX` and a 100 ms deadline aborts within 2 s
(typically <200 ms). Maps to `ExecError::Runtime`.
* `no_deadline_set_does_not_abort` — `None` deadline keeps the pre-
audit behavior; the op-budget still bites.
Audit ref: security_audit/05_sandbox_exec.md#f-se-h-01.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
use std::num::NonZeroUsize;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -183,13 +183,12 @@ impl ExecutorClient for LocalExecutorClient {
|
||||
) -> Result<ExecResponse, ExecError> {
|
||||
// Acquire before spending any wall-clock budget. The permit is
|
||||
// held by this future; on `tokio::time::timeout` firing, the
|
||||
// future drops and the permit returns to the pool — but the
|
||||
// detached `spawn_blocking` thread keeps running until the
|
||||
// Rhai script finishes (or panics). So in-use blocking threads
|
||||
// can briefly exceed the gate's permit count after a timeout.
|
||||
// That is intentional: a new admission can be served while the
|
||||
// already-doomed script winds down, which is preferable to
|
||||
// wedging the slot for the worst-case timeout duration.
|
||||
// future drops and the permit returns to the pool. Audit
|
||||
// 2026-06-11 H-C1: the detached `spawn_blocking` thread is now
|
||||
// also bounded by the Rhai `on_progress` deadline hook (installed
|
||||
// via `execute_with_deadline`), so a runaway script terminates
|
||||
// soon after the deadline instead of holding its OS thread until
|
||||
// the per-op budget self-exhausts.
|
||||
let _permit =
|
||||
self.gate
|
||||
.try_acquire()
|
||||
@@ -201,10 +200,13 @@ impl ExecutorClient for LocalExecutorClient {
|
||||
|
||||
let timeout = timeout.min(HARD_TIMEOUT_CAP);
|
||||
let timeout_secs = u32::try_from(timeout.as_secs()).unwrap_or(u32::MAX);
|
||||
let deadline = Instant::now() + timeout;
|
||||
|
||||
let engine = self.engine.clone();
|
||||
let source = source.to_string();
|
||||
let join = tokio::task::spawn_blocking(move || engine.execute(&source, req));
|
||||
let join = tokio::task::spawn_blocking(move || {
|
||||
engine.execute_with_deadline(&source, req, Some(deadline))
|
||||
});
|
||||
|
||||
match tokio::time::timeout(timeout, join).await {
|
||||
Err(_) => Err(ExecError::Timeout(timeout_secs)),
|
||||
@@ -235,9 +237,12 @@ impl ExecutorClient for LocalExecutorClient {
|
||||
|
||||
let timeout = timeout.min(HARD_TIMEOUT_CAP);
|
||||
let timeout_secs = u32::try_from(timeout.as_secs()).unwrap_or(u32::MAX);
|
||||
let deadline = Instant::now() + timeout;
|
||||
|
||||
let engine = self.engine.clone();
|
||||
let join = tokio::task::spawn_blocking(move || engine.execute_ast(&ast, req));
|
||||
let join = tokio::task::spawn_blocking(move || {
|
||||
engine.execute_ast_with_deadline(&ast, req, Some(deadline))
|
||||
});
|
||||
|
||||
match tokio::time::timeout(timeout, join).await {
|
||||
Err(_) => Err(ExecError::Timeout(timeout_secs)),
|
||||
|
||||
Reference in New Issue
Block a user