Skip to main content

mhome_script_runtime/
lib.rs

1//! Native QuickJS execution, independent of Core/cloud storage and host services.
2//! Each call owns its runtime. The local future must run on a dedicated JS worker,
3//! not a request executor thread: JavaScript runs synchronously between awaits.
4mod value;
5mod worker;
6pub use worker::{Admission, ScriptWorkers, WorkerConfig};
7
8use mhome_script_api::{ScriptError, ScriptValue, ValueLimits, MAX_ARTIFACT_BYTES};
9use rquickjs::{
10    function::Args,
11    loader::{ImportAttributes, Loader, Resolver},
12    module::{Declarations, Exports, ModuleDef},
13    promise::MaybePromise,
14    AsyncContext, AsyncRuntime, Ctx, Exception, Function, Module, Promise, Value,
15};
16use std::{
17    future::Future,
18    pin::Pin,
19    sync::{
20        atomic::{AtomicUsize, Ordering},
21        Arc,
22    },
23    time::Instant,
24};
25pub use tokio_util::sync::CancellationToken;
26
27/// SDK profile understood by this runtime; consumers check it before cache use.
28pub const SDK_VERSION: &str = "0.2.0";
29
30pub type HostFuture<'a> =
31    Pin<Box<dyn Future<Output = Result<ScriptValue, ScriptError>> + Send + 'a>>;
32
33/// Host implementations own trusted tenant/Space/connection context. They must
34/// allowlist operations, authorize each call, and bound IO before allocating it.
35/// Dropping these futures releases temporary resources; it is not compensation.
36pub trait ScriptHost: Send + Sync + 'static {
37    fn call<'a>(
38        &'a self,
39        operation: &'a str,
40        args: Vec<ScriptValue>,
41        control: InvocationControl,
42    ) -> HostFuture<'a>;
43}
44
45pub struct NoHost;
46impl ScriptHost for NoHost {
47    fn call<'a>(&'a self, _: &'a str, _: Vec<ScriptValue>, _: InvocationControl) -> HostFuture<'a> {
48        Box::pin(async {
49            Err(ScriptError::new(
50                "HOST_UNAVAILABLE",
51                "This script has no host capabilities",
52            ))
53        })
54    }
55}
56
57#[derive(Clone, Debug)]
58pub struct InvocationControl {
59    pub deadline: Instant,
60    pub cancel: CancellationToken,
61    shutdown: CancellationToken,
62}
63
64impl InvocationControl {
65    pub fn new(deadline: Instant, cancel: CancellationToken) -> Self {
66        Self {
67            deadline,
68            cancel,
69            shutdown: CancellationToken::new(),
70        }
71    }
72
73    pub async fn cancelled(&self) {
74        tokio::select! {
75            _ = self.cancel.cancelled() => {},
76            _ = self.shutdown.cancelled() => {},
77        }
78    }
79
80    pub fn check(&self) -> Result<(), ScriptError> {
81        if self.cancel.is_cancelled() || self.shutdown.is_cancelled() {
82            Err(ScriptError::new("CANCELLED", "Script invocation cancelled"))
83        } else if Instant::now() >= self.deadline {
84            Err(ScriptError::new(
85                "DEADLINE_EXCEEDED",
86                "Script invocation timed out",
87            ))
88        } else {
89            Ok(())
90        }
91    }
92}
93
94#[derive(Clone, Copy, Debug)]
95pub struct ScriptLimits {
96    pub memory_bytes: usize,
97    pub stack_bytes: usize,
98    pub values: ValueLimits,
99    pub host_calls: usize,
100    pub concurrent_host_calls: usize,
101}
102
103impl Default for ScriptLimits {
104    fn default() -> Self {
105        Self {
106            memory_bytes: 32 * 1024 * 1024,
107            stack_bytes: 512 * 1024,
108            values: ValueLimits::default(),
109            host_calls: 1024,
110            concurrent_host_calls: 32,
111        }
112    }
113}
114
115struct CancelOnDrop(CancellationToken);
116impl Drop for CancelOnDrop {
117    fn drop(&mut self) {
118        self.0.cancel();
119    }
120}
121
122/// Source must already be resolved against trusted ownership, manifest and hash.
123/// This leaf executor does not grant resource access or fetch artifacts.
124pub async fn invoke(
125    source: &str,
126    args: Vec<ScriptValue>,
127    host: Arc<dyn ScriptHost>,
128    control: InvocationControl,
129    limits: ScriptLimits,
130) -> Result<ScriptValue, ScriptError> {
131    control.check()?;
132    if source.is_empty() || source.len() > MAX_ARTIFACT_BYTES {
133        return Err(ScriptError::new(
134            "INVALID_SCRIPT",
135            "JavaScript source size is out of bounds",
136        ));
137    }
138    let args = ScriptValue::List(args);
139    args.validate(limits.values)
140        .map_err(|e| ScriptError::new("INVALID_ARGUMENT", e.to_string()))?;
141    let control = InvocationControl {
142        cancel: control.cancel.child_token(),
143        ..control
144    };
145    let _cancel_on_drop = CancelOnDrop(control.cancel.clone());
146    let runtime = AsyncRuntime::new().map_err(engine_error)?;
147    runtime.set_memory_limit(limits.memory_bytes).await;
148    runtime.set_max_stack_size(limits.stack_bytes).await;
149    let interrupt = control.clone();
150    runtime
151        .set_interrupt_handler(Some(Box::new(move || interrupt.check().is_err())))
152        .await;
153    runtime.set_loader(HostResolver, HostLoader).await;
154    let rejected = Arc::new(AtomicUsize::new(0));
155    let rejection_count = rejected.clone();
156    runtime
157        .set_host_promise_rejection_tracker(Some(Box::new(move |_, _, _, handled| {
158            if handled {
159                let _ = rejection_count.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| {
160                    Some(n.saturating_sub(1))
161                });
162            } else {
163                rejection_count.fetch_add(1, Ordering::Relaxed);
164            }
165        })))
166        .await;
167    let context = AsyncContext::full(&runtime).await.map_err(engine_error)?;
168    let active = Arc::new(AtomicUsize::new(0));
169    let call = context.async_with(async |ctx| {
170        let result = async {
171            install_host(&ctx, host, control.clone(), limits, active.clone())?;
172            let (module, ready) = Module::declare(ctx.clone(), "entry.mjs", source)?.eval()?;
173            ready.into_future::<()>().await?;
174            let entry: Function = module.get("default")?;
175            let ScriptValue::List(values) = &args else {
176                unreachable!()
177            };
178            let mut arguments = Args::new(ctx.clone(), values.len());
179            for arg in values {
180                arguments.push_arg(value::to_js(&ctx, arg)?)?;
181            }
182            let result: MaybePromise = arguments.apply(&entry)?;
183            let output = result.into_future::<Value>().await?;
184            // Finish queued JS microtasks, but never drive newly detached host IO.
185            // Interrupts and this check also bound a chain of tiny Promise jobs.
186            while ctx.execute_pending_job() {
187                control.check().map_err(|e| throw_host(&ctx, e))?;
188            }
189            value::from_js(output, limits.values)
190        }
191        .await;
192        result.map_err(|error| exception_error(&ctx, error))
193    });
194    let result = tokio::select! {
195        biased;
196        _ = control.cancelled() => Err(ScriptError::new("CANCELLED", "Script invocation cancelled")),
197        _ = tokio::time::sleep_until(control.deadline.into()) => Err(ScriptError::new("DEADLINE_EXCEEDED", "Script invocation timed out")),
198        result = call => result,
199    };
200    // Interrupts report JS exceptions; expose the actual request cancellation reason.
201    control.check()?;
202    if result.is_ok() && active.load(Ordering::Acquire) != 0 {
203        return Err(ScriptError::new(
204            "UNAWAITED_HOST_CALL",
205            "Script returned before its host calls completed",
206        ));
207    }
208    if result.is_ok() && rejected.load(Ordering::Acquire) != 0 {
209        return Err(ScriptError::new(
210            "UNHANDLED_REJECTION",
211            "Script left an unhandled Promise rejection",
212        ));
213    }
214    result
215    // Dropping context/runtime destroys their pending Promise/host futures. Never
216    // re-enter failed guest code for cleanup or tear down host-owned subscriptions.
217}
218
219fn engine_error(error: rquickjs::Error) -> ScriptError {
220    ScriptError::new("SCRIPT_ERROR", bounded(&error.to_string(), 4096))
221}
222
223fn bounded(value: &str, max: usize) -> String {
224    let mut end = value.len().min(max);
225    while !value.is_char_boundary(end) {
226        end -= 1;
227    }
228    value[..end].to_owned()
229}
230
231fn exception_error(ctx: &Ctx<'_>, error: rquickjs::Error) -> ScriptError {
232    if !error.is_exception() {
233        return engine_error(error);
234    }
235    let exception = ctx.catch();
236    if let Some(object) = exception.as_object() {
237        let code = object
238            .get::<_, String>("code")
239            .unwrap_or_else(|_| "SCRIPT_ERROR".into());
240        let message = object
241            .get::<_, String>("message")
242            .unwrap_or_else(|_| "Script failed".into());
243        ScriptError::new(bounded(&code, 128), bounded(&message, 4096))
244    } else if let Some(message) = exception.as_string().and_then(|v| v.to_string().ok()) {
245        ScriptError::new("SCRIPT_ERROR", bounded(&message, 4096))
246    } else {
247        ScriptError::new("SCRIPT_ERROR", "Script threw a non-error value")
248    }
249}
250
251fn throw_host(ctx: &Ctx<'_>, error: ScriptError) -> rquickjs::Error {
252    match Exception::from_message(ctx.clone(), &bounded(&error.message, 4096)) {
253        Ok(exception) => {
254            if let Err(error) = exception.as_object().set("code", bounded(&error.code, 128)) {
255                return error;
256            }
257            exception.throw()
258        }
259        Err(error) => error,
260    }
261}
262
263struct ActiveCall(Arc<AtomicUsize>);
264impl Drop for ActiveCall {
265    fn drop(&mut self) {
266        self.0.fetch_sub(1, Ordering::AcqRel);
267    }
268}
269
270fn install_host<'js>(
271    ctx: &Ctx<'js>,
272    host: Arc<dyn ScriptHost>,
273    control: InvocationControl,
274    limits: ScriptLimits,
275    active: Arc<AtomicUsize>,
276) -> rquickjs::Result<()> {
277    let calls = Arc::new(AtomicUsize::new(0));
278    let function = Function::new(
279        ctx.clone(),
280        move |ctx: Ctx<'js>, operation: String, args: Value<'js>| {
281            control.check().map_err(|e| throw_host(&ctx, e))?;
282            if operation.is_empty() || operation.len() > 128 {
283                return Err(Exception::throw_type(&ctx, "invalid host operation"));
284            }
285            let ScriptValue::List(args) = value::from_js(args, limits.values)? else {
286                return Err(Exception::throw_type(
287                    &ctx,
288                    "host arguments must be an array",
289                ));
290            };
291            if calls.fetch_add(1, Ordering::Relaxed) >= limits.host_calls {
292                return Err(Exception::throw_range(
293                    &ctx,
294                    "script host call limit exceeded",
295                ));
296            }
297            if active
298                .fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| {
299                    (n < limits.concurrent_host_calls).then_some(n + 1)
300                })
301                .is_err()
302            {
303                return Err(Exception::throw_range(
304                    &ctx,
305                    "script concurrent host call limit exceeded",
306                ));
307            }
308            let guard = ActiveCall(active.clone());
309            let host = host.clone();
310            let control = control.clone();
311            let future_ctx = ctx.clone();
312            Promise::wrap_future(&ctx, async move {
313                let _guard = guard;
314                control.check().map_err(|e| throw_host(&future_ctx, e))?;
315                let result = tokio::select! {
316                biased;
317                _ = control.cancelled() => Err(ScriptError::new("CANCELLED", "Host call cancelled")),
318                _ = tokio::time::sleep_until(control.deadline.into()) => Err(ScriptError::new("DEADLINE_EXCEEDED", "Host call timed out")),
319                result = host.call(&operation, args, control.clone()) => result,
320            }.map_err(|e| throw_host(&future_ctx, e))?;
321                control.check().map_err(|e| throw_host(&future_ctx, e))?;
322                result
323                    .validate(limits.values)
324                    .map_err(|e| Exception::throw_range(&future_ctx, &e.to_string()))?;
325                value::to_js(&future_ctx, &result)
326            })
327        },
328    )?;
329    // Function has a safe JsLifetime implementation. The native module consumes
330    // this private value during evaluation; it is never a guest-writable global.
331    ctx.store_userdata(function)
332        .map_err(|_| Exception::throw_internal(ctx, "host bindings already borrowed"))?;
333    Ok(())
334}
335
336const HOST_MODULE: &str = "@mhome/script/host";
337struct HostResolver;
338impl Resolver for HostResolver {
339    fn resolve<'js>(
340        &mut self,
341        _: &Ctx<'js>,
342        base: &str,
343        name: &str,
344        attributes: Option<ImportAttributes<'js>>,
345    ) -> rquickjs::Result<String> {
346        if name == HOST_MODULE && attributes.is_none() {
347            Ok(name.into())
348        } else {
349            Err(rquickjs::Error::new_resolving_message(
350                base,
351                name,
352                "only bundled code and platform host imports are supported",
353            ))
354        }
355    }
356}
357struct HostLoader;
358impl Loader for HostLoader {
359    fn load<'js>(
360        &mut self,
361        ctx: &Ctx<'js>,
362        name: &str,
363        _: Option<ImportAttributes<'js>>,
364    ) -> rquickjs::Result<Module<'js>> {
365        if name == HOST_MODULE {
366            Module::declare_def::<HostModule, _>(ctx.clone(), name)
367        } else {
368            Err(rquickjs::Error::new_loading(name))
369        }
370    }
371}
372struct HostModule;
373impl ModuleDef for HostModule {
374    fn declare<'js>(declarations: &Declarations<'js>) -> rquickjs::Result<()> {
375        declarations.declare("call")?;
376        Ok(())
377    }
378    fn evaluate<'js>(ctx: &Ctx<'js>, exports: &Exports<'js>) -> rquickjs::Result<()> {
379        let function = ctx
380            .remove_userdata::<Function<'js>>()
381            .map_err(|_| Exception::throw_internal(ctx, "host bindings borrowed"))?
382            .ok_or_else(|| Exception::throw_internal(ctx, "host bindings unavailable"))?;
383        exports.export("call", *function)?;
384        Ok(())
385    }
386}