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

@@ -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()
})?;