feat(interceptors): per-interceptor wall-clock timeout (§9.4 M5)

A [[interceptors]] marker can set timeout_ms (migration 0075, CHECK > 0); a
runaway guard (loop {}) is interrupted and the op DENIED (fail closed) within
budget rather than hanging the write path. The effective deadline is
min(caller-remaining, now + timeout) — a hook can only tighten, never extend,
its caller's deadline. NULL uses PICLOUD_INTERCEPTOR_TIMEOUT_MS (default 5s).

Wiring: run_resolved_blocking splits into a core + a _with_timeout variant that
computes the effective deadline from engine::ambient_deadline() and runs via
execute_ast_with_deadline; run_one_hook passes the marker's timeout_ms (or the
env default). timeout_ms threaded end to end — manifest, plan, BundleInterceptor,
the reconcile diff (part of the mutable body: a timeout change is an Update),
insert_interceptor_tx, resolve_chain/list_for_owner/list_on_app_chain +
SealedInterceptor/InterceptorMarker, and interceptor_service → ResolvedInterceptor.
Schema snapshot re-blessed. Pinned by a journey: a loop{} guard with
timeout_ms=100 is denied and its write does not persist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-16 20:18:03 +02:00
parent 5592f1c97e
commit 8b70d52d43
12 changed files with 244 additions and 47 deletions

View File

@@ -44,6 +44,14 @@ fn current_deadline() -> Option<Instant> {
CURRENT_DEADLINE.with(Cell::get)
}
/// The current-thread execution deadline, if one is installed. Public so the
/// interceptor hook can compute a per-hook timeout as `min(ambient, now + t)` —
/// a hook must never be able to EXTEND its caller's deadline (§9.4 M5).
#[must_use]
pub fn ambient_deadline() -> Option<Instant> {
current_deadline()
}
use chrono::{DateTime, Utc};
use picloud_shared::{
ScriptId, ScriptValidator, SdkCallCx, Services, TriggerEvent, ValidatedScript, ValidationError,

View File

@@ -23,7 +23,8 @@
//! execution, makes any nested op the interceptor performs bypass interception.
use std::cell::{Cell, RefCell};
use std::sync::Arc;
use std::sync::{Arc, LazyLock};
use std::time::Duration;
use picloud_shared::{InterceptorChain, InterceptorEntry, InterceptorService, ScriptId, SdkCallCx};
use rhai::EvalAltResult;
@@ -33,7 +34,18 @@ use tokio::runtime::Handle as TokioHandle;
use crate::engine::Engine;
use crate::sandbox::Limits;
use crate::sdk::bridge::runtime_err;
use crate::sdk::invoke::run_resolved_blocking;
use crate::sdk::invoke::run_resolved_blocking_with_timeout;
/// Default per-interceptor wall-clock timeout when a marker sets no `timeout_ms`
/// (§9.4 M5). Read once from `PICLOUD_INTERCEPTOR_TIMEOUT_MS`; a runaway guard
/// (`loop {}`) is denied once this elapses rather than hanging the write path.
static DEFAULT_TIMEOUT_MS: LazyLock<u32> = LazyLock::new(|| {
std::env::var("PICLOUD_INTERCEPTOR_TIMEOUT_MS")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.filter(|&v| v > 0)
.unwrap_or(5000)
});
/// Clone-cheap bundle of the §9.4 interceptor dependencies threaded into every
/// SDK service `register` fn (M1). Carries the resolver, the `invoke()` re-entry
@@ -288,17 +300,23 @@ fn run_one_hook(
}
// Run the sealed script with the operation context as its request body, under
// the re-entrancy guard (its own writes bypass interception) and the identity
// cycle guard (its own script id is on the stack).
// the re-entrancy guard (its own writes bypass interception), the identity
// cycle guard (its own script id is on the stack), and a per-hook timeout
// (§9.4 M5 — the marker's `timeout_ms` or the env default, tightened to at
// most the caller's remaining deadline). A runaway guard is denied, not hung.
let timeout = Duration::from_millis(u64::from(
resolved.timeout_ms.unwrap_or(*DEFAULT_TIMEOUT_MS),
));
let ret = {
let _reentry = ReentryGuard::enter();
let _cycle = CycleGuard::enter(script.script_id);
run_resolved_blocking(
run_resolved_blocking_with_timeout(
self_engine,
cx,
script,
payload.clone(),
&format!("{service}::{op} interceptor `{}`", script.name),
Some(timeout),
)?
};

View File

@@ -179,12 +179,53 @@ fn invoke_blocking(
/// response `body` JSON. Shared by `invoke()` and the §9.4 interceptor hook —
/// the caller is responsible for the depth check (both do it before resolving).
/// `label` prefixes any compile/execute error.
/// Run a resolved script through the `invoke()` re-entry core, inheriting the
/// caller's ambient execution deadline.
pub(super) fn run_resolved_blocking(
self_engine: &Arc<Engine>,
cx: &Arc<SdkCallCx>,
resolved: &picloud_shared::ResolvedScript,
body_json: Json,
label: &str,
) -> Result<Json, Box<EvalAltResult>> {
run_resolved_core(
self_engine,
cx,
resolved,
body_json,
label,
crate::engine::ambient_deadline(),
)
}
/// Like [`run_resolved_blocking`] but bounds the run by a per-hook `timeout`
/// (§9.4 M5). The effective deadline is `min(ambient, now + timeout)` — a hook
/// can only ever TIGHTEN, never extend, its caller's deadline.
pub(super) fn run_resolved_blocking_with_timeout(
self_engine: &Arc<Engine>,
cx: &Arc<SdkCallCx>,
resolved: &picloud_shared::ResolvedScript,
body_json: Json,
label: &str,
timeout: Option<std::time::Duration>,
) -> Result<Json, Box<EvalAltResult>> {
let ambient = crate::engine::ambient_deadline();
let own = timeout.map(|d| std::time::Instant::now() + d);
let effective = match (ambient, own) {
(Some(a), Some(o)) => Some(a.min(o)),
(a, None) => a,
(None, o) => o,
};
run_resolved_core(self_engine, cx, resolved, body_json, label, effective)
}
fn run_resolved_core(
self_engine: &Arc<Engine>,
cx: &Arc<SdkCallCx>,
resolved: &picloud_shared::ResolvedScript,
body_json: Json,
label: &str,
deadline: Option<std::time::Instant>,
) -> Result<Json, Box<EvalAltResult>> {
let req = ExecRequest {
execution_id: ExecutionId::new(),
@@ -218,7 +259,7 @@ pub(super) fn run_resolved_blocking(
EvalAltResult::ErrorRuntime(format!("{label}: {e}").into(), rhai::Position::NONE).into()
})?;
let resp = self_engine
.execute_ast(&ast, req)
.execute_ast_with_deadline(&ast, req, deadline)
.map_err(|e| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(format!("{label}: {e}").into(), rhai::Position::NONE).into()
})?;