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:
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user