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

@@ -727,3 +727,76 @@ fn an_after_hook_sees_the_result_and_cannot_roll_back() {
"the after-hook must see result==true and deny, but the delete must have persisted"
);
}
// --- §9.4 M5: per-interceptor timeout -------------------------------------
/// A guard that never returns — it must be interrupted by its per-hook timeout
/// rather than hanging the guarded write.
const SLOW_GUARD: &str = r#"
loop {}
"#;
/// A writer whose single guarded set triggers the runaway guard.
const SLOW_WRITER: &str = r#"
let denied = false;
try { kv::collection("c").set("k", 1); } catch(e) { denied = true; }
#{ denied: denied }
"#;
/// M5: a `timeout_ms` on the marker bounds the hook — a runaway `loop {}` guard
/// is interrupted and the op is DENIED (fail closed) within the budget, rather
/// than hanging the write path. The write must not persist.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn a_runaway_interceptor_is_denied_by_its_timeout() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let app = common::unique_slug("to-app");
let _a = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app])
.assert()
.success();
let dir = manifest_dir();
fs::write(dir.path().join("scripts/guard.rhai"), SLOW_GUARD).unwrap();
fs::write(dir.path().join("scripts/writer.rhai"), SLOW_WRITER).unwrap();
fs::write(
dir.path().join("picloud.toml"),
format!(
"[app]\nslug = \"{app}\"\nname = \"TO\"\n\n\
[[scripts]]\nname = \"guard\"\nfile = \"scripts/guard.rhai\"\n\n\
[[scripts]]\nname = \"writer\"\nfile = \"scripts/writer.rhai\"\n\n\
[[interceptors]]\nscript = \"guard\"\nops = [\"set\"]\ntimeout_ms = 100\n"
),
)
.unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(dir.path().join("picloud.toml"))
.assert()
.success();
let body = invoke_body(&env, &app_script_id(&env, &app, "writer"));
assert_eq!(
body,
serde_json::json!({ "denied": true }),
"a runaway interceptor must be timed out and the op denied (fail closed)"
);
// The write must not have landed.
let k = String::from_utf8(
common::pic_as(&env)
.args(["kv", "get", "--app", &app, "--collection", "c", "k"])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
k.trim() == "null" || k.trim() == "()" || k.trim().is_empty(),
"the timed-out write must NOT persist, got: {k}"
);
}