Files
PiCloud/crates/executor-core/src/sdk/invoke.rs
MechaCat02 8b62b137c0 fix(executor): cap per-execution durable fan-out width
`trigger_depth` bounds how DEEP a trigger/invoke chain runs, but nothing bounded
how WIDE one execution could fan out. A single anonymous request running
`for i in 0..1_000_000 { invoke_async("w", #{}) }` costs a few Rhai ops plus one
cheap outbox INSERT per iteration, so within the op / wall-clock budget it could
write ~10^5 durable rows — each dispatched as its own execution — flooding the
outbox and dispatcher (a durable-amplification DoS).

Add a per-execution ceiling on DURABLE emissions (`invoke_async`,
`pubsub::publish_durable`, `queue::enqueue`, and the shared-topic/-queue
variants), env `PICLOUD_MAX_EMISSIONS_PER_EXECUTION` (default 1000). It's a
thread-local counter wrapped by a re-entrancy-aware `EmissionBudgetScope` around
every `execute_ast`: the OUTERMOST scope resets it, so a fresh dispatched
handler (a new pooled-thread task) gets a full budget while a synchronous
`invoke()` / interceptor re-entry nested in the same call stack SHARES it (fan-
out counted across the whole synchronous chain). The outermost Drop re-zeroes
the counter so a pooled thread never leaks a count into the next task.

Pinned by an `emit_budget` unit test (outermost resets, nested shares, ceiling
trips); the invoke/queue/pubsub/workflow journeys (small counts) stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:27:32 +02:00

392 lines
14 KiB
Rust

//! `invoke()` Rhai bridge — synchronous, same-app, re-entrant.
//!
//! ```rhai
//! let result = invoke("/api/payments/process", #{ order_id: 123 });
//! let result = invoke(script_id("uuid-..."), args);
//! ```
//!
//! Re-entrancy: the bridge captures `Arc<Engine>` (via
//! `Engine::self_arc`) and calls `engine.execute(&source, req)` directly
//! inside the caller's `spawn_blocking` thread. Same engine instance,
//! same `Services`, same `Limits` — only the per-call `SdkCallCx`
//! changes (the callee gets `trigger_depth + 1` and a fresh
//! `execution_id`).
//!
//! Cross-app rejection happens at the `InvokeService::resolve` layer —
//! every method derives `app_id` from `cx.app_id` and returns
//! `InvokeError::CrossApp` if the resolved script belongs elsewhere.
//!
//! Depth bound: `cx.trigger_depth + 1 > limits.trigger_depth_max` →
//! `InvokeError::DepthExceeded`. Shared with the trigger fan-out depth
//! limit per the design notes (no separate `invoke_depth_max`).
//!
//! Closures captured across the `invoke` boundary are rejected at the
//! args-to-JSON conversion — `FnPtr` doesn't survive serialization and
//! re-entry into a fresh engine instance is the wrong scope for it
//! anyway.
//!
//! `invoke_async()` writes an outbox row (see `InvokeService::enqueue_async`)
//! and returns immediately with the new `ExecutionId` as a string. The
//! dispatcher fires it through the standard executor path; commit 10
//! wires the dispatcher's `OutboxSourceKind::Invoke` arm.
use std::collections::BTreeMap;
use std::sync::Arc;
use base64::engine::general_purpose::STANDARD;
use base64::Engine as _;
use picloud_shared::{ExecutionId, InvokeService, InvokeTarget, ScriptId, SdkCallCx, Services};
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
use serde_json::Value as Json;
use tokio::runtime::Handle as TokioHandle;
use crate::engine::Engine;
use crate::sandbox::Limits;
use crate::sdk::bridge::{json_to_dynamic, MAX_JSON_MATERIALIZE_BYTES};
use crate::types::{ExecRequest, InvocationType};
pub(super) fn register(
engine: &mut RhaiEngine,
services: &Services,
cx: Arc<SdkCallCx>,
limits: Limits,
self_engine: Option<Arc<Engine>>,
) {
let svc = services.invoke.clone();
let mut module = Module::new();
// Sync `invoke(target, args)` — returns the callee's return value.
{
let svc = svc.clone();
let cx = cx.clone();
let self_engine = self_engine.clone();
module.set_native_fn(
"invoke",
move |target: Dynamic, args: Dynamic| -> Result<Dynamic, Box<EvalAltResult>> {
let target = parse_target(target)?;
let args_json = args_to_json(&args)?;
let self_engine = self_engine.clone().ok_or_else(|| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(
"invoke: engine back-reference not installed (test setup missing set_self_weak?)".into(),
rhai::Position::NONE,
)
.into()
})?;
invoke_blocking(&svc, &cx, target, args_json, limits, &self_engine)
},
);
}
// `invoke_async(target, args)` — fire-and-forget; returns the new
// ExecutionId as a string.
{
let svc = svc.clone();
let cx = cx.clone();
module.set_native_fn(
"invoke_async",
move |target: Dynamic, args: Dynamic| -> Result<String, Box<EvalAltResult>> {
// Audit #2: count this durable emission against the per-execution
// fan-out ceiling before doing any work.
crate::sdk::emit_budget::charge_emission("invoke_async")?;
let target = parse_target(target)?;
let args_json = args_to_json(&args)?;
let svc = svc.clone();
let cx = cx.clone();
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(
format!("invoke_async: no tokio runtime: {e}").into(),
rhai::Position::NONE,
)
.into()
})?;
let exec_id = handle
.block_on(async move { svc.enqueue_async(&cx, target, args_json).await })
.map_err(|e| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(
format!("invoke_async: {e}").into(),
rhai::Position::NONE,
)
.into()
})?;
Ok(exec_id.to_string())
},
);
}
// Register globally — `invoke(...)` and `invoke_async(...)` are
// top-level helpers, not under a `::` namespace, mirroring the
// brief's syntax.
engine.register_global_module(module.into());
}
/// The sync invoke path. Runs entirely inside the caller's
/// `spawn_blocking` thread; no gate (the outer execution is already
/// gate-admitted), no new spawn_blocking (we'd deadlock if we nested
/// inside the blocking pool).
fn invoke_blocking(
svc: &Arc<dyn InvokeService>,
cx: &Arc<SdkCallCx>,
target: InvokeTarget,
args_json: Json,
limits: Limits,
self_engine: &Arc<Engine>,
) -> Result<Dynamic, Box<EvalAltResult>> {
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(
format!("invoke: no tokio runtime: {e}").into(),
rhai::Position::NONE,
)
.into()
})?;
// Depth check — done BEFORE resolve so a depth-exceeded loop
// doesn't waste a DB round-trip.
if cx.trigger_depth + 1 > limits.trigger_depth_max {
return Err(EvalAltResult::ErrorRuntime(
format!(
"invoke: depth limit exceeded (max {})",
limits.trigger_depth_max
)
.into(),
rhai::Position::NONE,
)
.into());
}
let svc_clone = svc.clone();
let cx_clone = cx.clone();
let target_label = target.describe();
let resolved = handle
.block_on(async move { svc_clone.resolve(&cx_clone, target).await })
.map_err(|e| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(
format!("invoke({target_label}): {e}").into(),
rhai::Position::NONE,
)
.into()
})?;
// The callee's return is the response `body` JSON. Convert back to
// Dynamic for the caller. Status code + headers are dropped; the
// function-call mental model is "return value", not HTTP response.
let body = run_resolved_blocking(self_engine, cx, &resolved, args_json, &target_label)?;
Ok(json_to_dynamic(body))
}
/// Synchronous same-engine re-entry: build the callee `ExecRequest` (inheriting
/// the caller's app/principal/root/depth+1 and the callee's lexical owner),
/// compile through the per-Engine AST cache (F-P-004), execute, and return the
/// 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.
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>> {
let req = ExecRequest {
execution_id: ExecutionId::new(),
request_id: cx.request_id,
script_id: resolved.script_id,
script_name: resolved.name.clone(),
invocation_type: InvocationType::Function,
path: "/invoke".into(),
method: String::new(),
headers: BTreeMap::new(),
body: body_json,
params: BTreeMap::new(),
query: BTreeMap::new(),
rest: String::new(),
sandbox_overrides: picloud_shared::ScriptSandbox::default(),
app_id: cx.app_id,
// Lexical origin (§5.5): the callee's defining node — a group
// script's imports resolve from the group even when invoked by an
// app. `None` falls back to `App(cx.app_id)` in the engine.
script_owner: resolved.owner,
// Same-app re-entry is not a re-auth boundary — inherit the principal.
principal: cx.principal.clone(),
trigger_depth: cx.trigger_depth + 1,
root_execution_id: cx.root_execution_id,
is_dead_letter_handler: cx.is_dead_letter_handler,
event: None,
};
let ast = self_engine
.compile_for_identity(resolved.script_id, resolved.updated_at, &resolved.source)
.map_err(|e| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(format!("{label}: {e}").into(), rhai::Position::NONE).into()
})?;
let resp = self_engine
.execute_ast(&ast, req)
.map_err(|e| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(format!("{label}: {e}").into(), rhai::Position::NONE).into()
})?;
Ok(resp.body)
}
/// Accept a string (route path OR script name) or a Rhai script-id
/// custom type. The string heuristic: starts with '/' → Path, else Name.
fn parse_target(target: Dynamic) -> Result<InvokeTarget, Box<EvalAltResult>> {
if let Ok(s) = target.clone().into_string() {
if s.starts_with('/') {
return Ok(InvokeTarget::Path(s));
}
// Heuristic: 36-char UUID-like string → Id; else Name. Keeps
// `invoke("payments_worker")` and `invoke("550e8400-...")` both
// working without a separate `script_id()` constructor in v1.1.9.
if s.len() == 36 {
if let Ok(uuid) = uuid::Uuid::parse_str(&s) {
return Ok(InvokeTarget::Id(ScriptId::from(uuid)));
}
}
return Ok(InvokeTarget::Name(s));
}
let _ = target;
Err(EvalAltResult::ErrorRuntime(
"invoke: target must be a string (path or name) or a script_id".into(),
rhai::Position::NONE,
)
.into())
}
/// Convert Rhai Dynamic → JSON. Rejects FnPtr at any depth (closures
/// don't survive invoke boundaries) and is bounded by
/// [`MAX_JSON_MATERIALIZE_BYTES`] so a cheaply-aliased huge args value can't OOM
/// the node on materialization (same anti-OOM rail as `dynamic_to_json_capped`).
fn args_to_json(value: &Dynamic) -> Result<Json, Box<EvalAltResult>> {
let mut remaining = MAX_JSON_MATERIALIZE_BYTES;
args_to_json_bounded(value, &mut remaining)
}
fn args_charge(remaining: &mut usize, n: usize) -> Result<(), Box<EvalAltResult>> {
match remaining.checked_sub(n) {
Some(left) => {
*remaining = left;
Ok(())
}
None => Err(EvalAltResult::ErrorRuntime(
format!(
"invoke: args too large to serialize (exceeds the {MAX_JSON_MATERIALIZE_BYTES}-byte limit)"
)
.into(),
rhai::Position::NONE,
)
.into()),
}
}
fn args_to_json_bounded(
value: &Dynamic,
remaining: &mut usize,
) -> Result<Json, Box<EvalAltResult>> {
if value.is::<rhai::FnPtr>() {
return Err(EvalAltResult::ErrorRuntime(
"invoke: args must not contain FnPtr / closures".into(),
rhai::Position::NONE,
)
.into());
}
if value.is_blob() {
let blob = value.clone().into_blob().unwrap_or_default();
let encoded = STANDARD.encode(&blob);
args_charge(remaining, encoded.len() + 2)?;
return Ok(Json::String(encoded));
}
if value.is_unit() {
args_charge(remaining, 4)?;
return Ok(Json::Null);
}
if let Ok(b) = value.as_bool() {
args_charge(remaining, 5)?;
return Ok(Json::Bool(b));
}
if let Ok(i) = value.as_int() {
args_charge(remaining, 8)?;
return Ok(Json::Number(i.into()));
}
if let Ok(f) = value.as_float() {
args_charge(remaining, 8)?;
return Ok(serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number));
}
if value.is_string() {
let s = value.clone().into_string().unwrap_or_default();
args_charge(remaining, s.len() + 2)?;
return Ok(Json::String(s));
}
if let Some(arr) = value.clone().try_cast::<Array>() {
args_charge(remaining, 2)?;
let mut out = Vec::with_capacity(arr.len().min(1024));
for v in &arr {
args_charge(remaining, 1)?;
out.push(args_to_json_bounded(v, remaining)?);
}
return Ok(Json::Array(out));
}
if let Some(map) = value.clone().try_cast::<Map>() {
args_charge(remaining, 2)?;
let mut out = serde_json::Map::new();
for (k, v) in map {
args_charge(remaining, k.len() + 4)?;
out.insert(k.to_string(), args_to_json_bounded(&v, remaining)?);
}
return Ok(Json::Object(out));
}
let s = value.to_string();
args_charge(remaining, s.len() + 2)?;
Ok(Json::String(s))
}
/// `Limits` is `Copy`; passed by value into `register` so closures take
/// owned copies. Hint for readers wondering why it doesn't need `Arc`.
#[allow(dead_code)]
const _LIMITS_IS_COPY: fn() = || {
fn assert_copy<T: Copy>() {}
assert_copy::<Limits>();
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_target_path() {
let d = Dynamic::from("/api/foo");
let t = parse_target(d).unwrap();
assert!(matches!(t, InvokeTarget::Path(_)));
}
#[test]
fn parse_target_uuid_string_is_id() {
let d = Dynamic::from("550e8400-e29b-41d4-a716-446655440000");
let t = parse_target(d).unwrap();
assert!(matches!(t, InvokeTarget::Id(_)));
}
#[test]
fn parse_target_plain_name() {
let d = Dynamic::from("payments_worker");
let t = parse_target(d).unwrap();
match t {
InvokeTarget::Name(n) => assert_eq!(n, "payments_worker"),
other => panic!("expected Name, got {other:?}"),
}
}
#[test]
fn args_to_json_rejects_fnptr() {
let f = rhai::FnPtr::new("x").unwrap();
let d = Dynamic::from(f);
assert!(args_to_json(&d).is_err());
}
#[test]
fn args_to_json_round_trips_map() {
let mut m = Map::new();
m.insert("x".into(), Dynamic::from(42_i64));
let d = Dynamic::from(m);
let j = args_to_json(&d).unwrap();
assert_eq!(j, serde_json::json!({ "x": 42 }));
}
}