From fb7536a763be78c391aa4e62682bc85c0bb79158 Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Sat, 12 Sep 2026 02:46:17 +0200 Subject: [PATCH 01/17] Harden PHP Fiber execution and bridge value conversion Track active contexts per engine, refresh the QuickJS stack boundary, block unsafe Fiber switches, apply callback timeouts, and bound nested values. Flush dropped callback references at each outer entry and test recovery. --- docs/architecture.md | 18 +++---- src/bridge.rs | 27 +++++----- src/callback.rs | 26 ++++------ src/engine.rs | 101 +++++++++++++++++++++--------------- src/lib.rs | 9 ++-- src/marshal.rs | 79 +++++++++++++++++++++++++--- stubs/php_quickjs.stubs.php | 6 ++- tests/php/12_fibers.php | 46 ++++++++++++++++ 8 files changed, 219 insertions(+), 93 deletions(-) create mode 100644 tests/php/12_fibers.php diff --git a/docs/architecture.md b/docs/architecture.md index 2a0a24b..25b5813 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -118,15 +118,15 @@ encoding (outgoing), `unwrap()` replaces refs with callables after decoding `Js\Callback::__invoke` (`callback.rs`) re-enters the realm and calls `globalThis.__invokeJs(id, argsBytes)`, which looks up `jsFns[id]` and runs it. -The subtlety is **re-entrancy**. A JS callback is often invoked *synchronously -while a host call is already running* (e.g. `php.mapEach(xs, fn)` — PHP calls -`fn` immediately). At that point the runtime is already locked inside a -`Context::with`; calling `with` again would deadlock. So while any host call (or -eval) is active, the live `Ctx` pointer is published on a thread-local -**current-context stack** (`engine.rs`), and `Js\Callback` reuses it instead of -re-locking. Only when invoked *between* evals (no realm active) does it acquire -the lock fresh on the persistent realm. A re-entrancy **depth cap** (200) bounds -runaway PHP→JS→PHP→… recursion. +The subtlety is **re-entrancy**. Each engine records its own active context +while inside `Context::with`. A nested callback reuses that context instead of +acquiring the runtime lock again. A callback owned by another engine enters its +own context. The active pointer is cleared by a scope guard on return, including +errors. A re-entrancy depth cap (200) bounds recursive bridge calls. + +At an outer entry, QuickJS's stack limit is refreshed for the current PHP Fiber. +Zend Fiber switching is blocked while native borrows are live. The same guard +arms and clears the execution deadline for evals and callbacks. ## Capability handles diff --git a/src/bridge.rs b/src/bridge.rs index d483ccb..df6ec34 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -7,7 +7,7 @@ //! callable, and returns the msgpack-encoded result. Adding a capability never //! changes this ABI. -use crate::engine::{push_ctx, Engine}; +use crate::engine::Engine; use crate::error::{throw_host_error, HostError}; use crate::handles::HandleTable; use crate::manifest::ManifestEntry; @@ -197,10 +197,7 @@ pub fn install<'js>(ctx: &Ctx<'js>, state: Rc) -> rquickjs::Result< .as_bytes() .ok_or_else(|| Exception::throw_type(&ctx, "__host args must be a Uint8Array"))?; let args = decode_args(bytes).map_err(|e| Exception::throw_type(&ctx, &e))?; - let result = { - let _guard = push_ctx(&ctx); - host_call(&host_state, &name, args) - }; + let result = host_call(&host_state, &name, args); match result { Ok(r) => encode_result(&ctx, r), Err(err) => Err(throw_host_error(&ctx, &err)), @@ -221,10 +218,7 @@ pub fn install<'js>(ctx: &Ctx<'js>, state: Rc) -> rquickjs::Result< Exception::throw_type(&ctx, "__php_invoke args must be a Uint8Array") })?; let args = decode_args(bytes).map_err(|e| Exception::throw_type(&ctx, &e))?; - let result = { - let _guard = push_ctx(&ctx); - php_fn_call(&php_state, id as u64, args) - }; + let result = php_fn_call(&php_state, id as u64, args); match result { Ok(r) => encode_result(&ctx, r), Err(err) => Err(throw_host_error(&ctx, &err)), @@ -238,13 +232,18 @@ pub fn install<'js>(ctx: &Ctx<'js>, state: Rc) -> rquickjs::Result< ctx.eval::<(), _>(RUNTIME_JS)?; ctx.eval::<(), _>(build_facade(&state.names()))?; - // Release JS callbacks whose PHP wrappers were dropped since the last eval. + flush_pending_deletions(ctx, &state) +} + +pub(crate) fn flush_pending_deletions(ctx: &Ctx<'_>, state: &BridgeState) -> rquickjs::Result<()> { let stale = state.take_pending_deletions(); if !stale.is_empty() { - if let Ok(del) = globals.get::<_, Function>("__deleteJsFn") { - for id in stale { - let _ = del.call::<_, ()>((id as f64,)); - } + // A fresh isolated realm has no registry; its preceding realm is gone. + let Ok(del) = ctx.globals().get::<_, Function>("__deleteJsFn") else { + return Ok(()); + }; + for id in stale { + del.call::<_, ()>((id as f64,))?; } } Ok(()) diff --git a/src/callback.rs b/src/callback.rs index de644e5..8203a90 100644 --- a/src/callback.rs +++ b/src/callback.rs @@ -5,7 +5,7 @@ //! invoked from PHP it re-enters JS — reusing the live context if a host call //! is already in flight, else acquiring the runtime lock afresh. -use crate::engine::{current_ctx_ptr, Engine}; +use crate::engine::Engine; use crate::marshal::{middle_to_zval, zval_to_middle, MiddleValue}; use ext_php_rs::prelude::*; use ext_php_rs::types::Zval; @@ -62,7 +62,7 @@ impl JsCallback { // original PHP class) or becomes a QuickJSEvalException. let ret: Value = invoke .call((id as f64, arg_bytes)) - .map_err(|e| crate::error::js_error_to_php(ctx, e))?; + .map_err(|e| engine.callback_error(ctx, e))?; let ta = TypedArray::::from_value(ret).map_err(|e| { PhpException::default(format!("JS callback did not return bytes: {e}")) })?; @@ -74,22 +74,14 @@ impl JsCallback { middle_to_zval(&mv, &engine.state).map_err(PhpException::default) }; - // Reuse the live context if we are nested inside a host call; otherwise - // acquire the runtime lock on the persistent realm. Reusing avoids a - // deadlock from re-locking. - match current_ctx_ptr() { - Some(ptr) => { - let ctx = unsafe { Ctx::from_raw(ptr) }; - run(&ctx) - } - None => match self.engine.shared_ctx() { - Some(ctx) => ctx.with(|c| run(&c)), - // Isolated mode: the realm that owned this callback is gone. - None => Err(PhpException::default( - "JS callback invoked outside its eval (isolated QuickJS instance)".to_owned(), - )), - }, + if !self.engine.is_active() && self.engine.shared_ctx().is_none() { + return Err(PhpException::default( + "JS callback invoked outside its eval (isolated QuickJS instance)".to_owned(), + )); } + self.engine + .eval_in(run) + .map_err(|e| PhpException::default(e.to_string()))? } } diff --git a/src/engine.rs b/src/engine.rs index 2ca53af..5d13fd0 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -23,11 +23,12 @@ pub struct Engine { /// realm is created per eval and discarded afterwards). shared_ctx: Option, depth: Cell, - /// Per-eval wall-clock deadline; `None` when no eval is in flight. + active_ctx: Cell>>, + /// Per-entry wall-clock deadline; `None` when no eval is in flight. deadline: Rc>>, /// Set by the interrupt handler when it aborts on the deadline. timed_out: Rc>, - /// Per-eval timeout; `None` disables the wall-clock guard. + /// Per-entry timeout; `None` disables the wall-clock guard. timeout: Option, } @@ -58,6 +59,7 @@ impl Engine { transpile: TranspileCache::new(256), shared_ctx, depth: Cell::new(0), + active_ctx: Cell::new(None), deadline, timed_out, timeout: (timeout_ms > 0).then(|| Duration::from_millis(timeout_ms)), @@ -89,26 +91,59 @@ impl Engine { self.shared_ctx.as_ref() } - /// Run `f` inside an eval realm: the persistent one in shared mode, or a - /// fresh, single-use realm in isolated mode. The realm's `Ctx` is published - /// on the current-context stack for the duration so PHP-side callbacks - /// (and the GC `Drop` cleanup) re-use it instead of re-locking the runtime. + pub fn is_active(&self) -> bool { + self.active_ctx.get().is_some() + } + + /// Run on the current PHP stack. Reentrant callbacks reuse this engine's + /// context; a callback belonging to a different engine gets its own context. pub fn eval_in(&self, f: impl FnOnce(&Ctx<'_>) -> R) -> rquickjs::Result { - fn run(ctx: &Context, f: impl FnOnce(&Ctx<'_>) -> R) -> R { + if let Some(ptr) = self.active_ctx.get() { + // SAFETY: the outer Context::with owns this context and its lock. + // Fiber switching is blocked until that call returns. + let ctx = unsafe { Ctx::from_raw(ptr) }; + return Ok(f(&ctx)); + } + let run = |ctx: &Context| { ctx.with(|c| { - let _guard = push_ctx(&c); - f(&c) + // SAFETY: c belongs to this locked runtime. PHP Fibers can enter + // on a different native stack, so refresh QuickJS's stack limit + // at the outer boundary only (never during JS recursion). + unsafe { + rquickjs::qjs::JS_UpdateStackTop(ctx.get_runtime_ptr()); + zend_fiber_switch_block(); + } + self.active_ctx.set(Some(c.as_raw())); + self.arm_deadline(); + let _guard = ExecutionGuard { engine: self }; + crate::bridge::flush_pending_deletions(&c, &self.state)?; + Ok(f(&c)) }) - } + }; match &self.shared_ctx { - Some(ctx) => Ok(run(ctx, f)), + Some(ctx) => run(ctx), None => { let ctx = Context::full(&self.rt)?; - Ok(run(&ctx, f)) + run(&ctx) } } } + pub fn callback_error( + &self, + ctx: &Ctx<'_>, + err: rquickjs::Error, + ) -> ext_php_rs::exception::PhpException { + if self.timed_out() { + // Consume the interrupted JS exception before the next entry. + drop(ctx.catch()); + return ext_php_rs::exception::PhpException::from_class::< + crate::exceptions::QuickJSTimeoutException, + >("JavaScript callback execution timed out".to_owned()); + } + crate::error::js_error_to_php(ctx, err) + } + /// Enter one level of cross-boundary nesting; errors if the cap is hit. pub fn enter(&self) -> Result, String> { let d = self.depth.get(); @@ -133,40 +168,22 @@ impl Drop for DepthGuard<'_> { } } -// --------------------------------------------------------------------------- -// current-context stack -// -// While a host call runs, the live `Ctx` is valid but its `'js` lifetime -// cannot be named in PHP-facing code. We stash the raw pointer so a PHP-held JS -// callback can be invoked *synchronously* during a host call by reusing the -// already-locked context instead of re-locking the runtime (which would -// deadlock). Single-threaded (PHP NTS), so a thread-local stack is sufficient. -// --------------------------------------------------------------------------- - -thread_local! { - static CTX_STACK: std::cell::RefCell>> = - const { std::cell::RefCell::new(Vec::new()) }; +// These Zend APIs maintain a nesting counter. Blocking switches prevents PHP +// from suspending while Rust borrows and the QuickJS runtime lock are live. +unsafe extern "C" { + fn zend_fiber_switch_block(); + fn zend_fiber_switch_unblock(); } -/// Publish the current context on the stack until the returned guard drops. -#[must_use] -pub fn push_ctx(ctx: &Ctx<'_>) -> CtxGuard { - CTX_STACK.with(|s| s.borrow_mut().push(ctx.as_raw())); - CtxGuard +struct ExecutionGuard<'a> { + engine: &'a Engine, } -/// RAII guard that pops the current context when dropped (even on unwind). -pub struct CtxGuard; - -impl Drop for CtxGuard { +impl Drop for ExecutionGuard<'_> { fn drop(&mut self) { - CTX_STACK.with(|s| { - s.borrow_mut().pop(); - }); + self.engine.active_ctx.set(None); + self.engine.disarm_deadline(); + // SAFETY: paired with the block in eval_in, including error unwinding. + unsafe { zend_fiber_switch_unblock() }; } } - -/// The innermost active context, if a host call is currently on the stack. -pub fn current_ctx_ptr() -> Option> { - CTX_STACK.with(|s| s.borrow().last().copied()) -} diff --git a/src/lib.rs b/src/lib.rs index 49232a3..8399f53 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,7 +34,7 @@ impl QuickJS { /// Construct a sandbox. All limits default to unbounded; pass non-zero /// values to contain resource abuse: /// - `memoryLimit`: max heap bytes (alloc-bomb guard) - /// - `timeoutMs`: wall-clock budget per `eval` (infinite-loop guard) + /// - `timeoutMs`: wall-clock budget per eval or callback /// - `maxStack`: max native stack bytes /// - `isolated`: when true, each `eval()` runs in a fresh global realm (its /// own world); cross-eval globals and persistent JS callbacks are not @@ -74,6 +74,11 @@ impl QuickJS { /// Evaluate JS source and marshal the result back to a PHP value. The /// `php.*` facade is installed fresh from the current manifest first. pub fn eval(&self, code: String) -> PhpResult { + if self.engine.is_active() { + return Err(PhpException::default( + "Cannot eval while JavaScript is executing; use a JS callback".to_owned(), + )); + } // TypeScript fast path: transpile to JS (types erased, esnext) before // QuickJS ever sees the source. Transpile/syntax errors surface here, // located at their original TS line/column. @@ -93,7 +98,6 @@ impl QuickJS { })?; let state = self.engine.state.clone(); - self.engine.arm_deadline(); let outcome = self.engine.eval_in(|ctx| { let map = module.map_json.clone(); let eval_err = |e| self.classify_js_error(ctx, e, map.as_deref(), &module.module_id); @@ -107,7 +111,6 @@ impl QuickJS { let middle = js_to_middle(ctx, value, &state).map_err(&eval_err)?; middle_to_zval(&middle, &state).map_err(PhpException::default) }); - self.engine.disarm_deadline(); match outcome { Ok(r) => r, Err(e) => Err(to_php_err(e)), diff --git a/src/marshal.rs b/src/marshal.rs index beaf7bb..4a63d5c 100644 --- a/src/marshal.rs +++ b/src/marshal.rs @@ -212,6 +212,24 @@ pub fn js_to_middle<'js>( value: Value<'js>, _state: &BridgeState, ) -> rquickjs::Result { + js_to_middle_bounded(ctx, value, _state, 0, &mut 16_777_216, true) +} + +fn js_to_middle_bounded<'js>( + ctx: &Ctx<'js>, + value: Value<'js>, + _state: &BridgeState, + depth: usize, + budget: &mut usize, + functions: bool, +) -> rquickjs::Result { + if depth > 64 || *budget < 64 { + return Err(rquickjs::Exception::throw_type( + ctx, + "bridge value exceeds depth or size limit", + )); + } + *budget -= 64; if value.is_null() || value.is_undefined() { return Ok(MiddleValue::Null); } @@ -225,9 +243,19 @@ pub fn js_to_middle<'js>( return Ok(int_or_float(value.as_float().unwrap())); } if let Some(s) = value.as_string() { - return Ok(MiddleValue::Str(s.to_string()?)); + let text = s.to_string()?; + *budget = budget.checked_sub(text.len()).ok_or_else(|| { + rquickjs::Exception::throw_type(ctx, "bridge value exceeds size limit") + })?; + return Ok(MiddleValue::Str(text)); } if value.is_function() { + if !functions { + return Err(rquickjs::Exception::throw_type( + ctx, + "direct messages cannot contain functions", + )); + } // Register the function JS-side; PHP receives an opaque id. let register: Function = ctx.globals().get("__registerJsFn")?; let id: f64 = register.call((value.clone(),))?; @@ -237,15 +265,31 @@ pub fn js_to_middle<'js>( if value.is_object() { if let Ok(ta) = TypedArray::::from_value(value.clone()) { if let Some(bytes) = ta.as_bytes() { + *budget = budget.checked_sub(bytes.len()).ok_or_else(|| { + rquickjs::Exception::throw_type(ctx, "bridge value exceeds size limit") + })?; return Ok(MiddleValue::Bytes(bytes.to_vec())); } } } if value.is_array() { let arr = value.into_array().unwrap(); + if arr.len() > *budget / 64 { + return Err(rquickjs::Exception::throw_type( + ctx, + "bridge array exceeds size limit", + )); + } let mut out = Vec::with_capacity(arr.len()); for i in 0..arr.len() { - out.push(js_to_middle(ctx, arr.get(i)?, _state)?); + out.push(js_to_middle_bounded( + ctx, + arr.get(i)?, + _state, + depth + 1, + budget, + functions, + )?); } return Ok(MiddleValue::Array(out)); } @@ -254,7 +298,13 @@ pub fn js_to_middle<'js>( let mut out = Vec::new(); for entry in obj.props::() { let (k, v) = entry?; - out.push((k, js_to_middle(ctx, v, _state)?)); + *budget = budget.checked_sub(k.len()).ok_or_else(|| { + rquickjs::Exception::throw_type(ctx, "bridge value exceeds size limit") + })?; + out.push(( + k, + js_to_middle_bounded(ctx, v, _state, depth + 1, budget, functions)?, + )); } return Ok(MiddleValue::Map(out)); } @@ -318,6 +368,17 @@ pub fn middle_to_js<'js>( /// a [`MiddleValue::JsFn`] ref; any other PHP callable is registered host-side /// as a [`MiddleValue::PhpFn`]. pub fn zval_to_middle(zv: &Zval, state: &BridgeState) -> Result { + zval_to_middle_depth(zv, state, 0) +} + +fn zval_to_middle_depth( + zv: &Zval, + state: &BridgeState, + depth: usize, +) -> Result { + if depth > 64 { + return Err("PHP bridge value exceeds maximum depth (64)".to_owned()); + } if zv.is_null() { return Ok(MiddleValue::Null); } @@ -341,7 +402,7 @@ pub fn zval_to_middle(zv: &Zval, state: &BridgeState) -> Result Result Result { +fn hashtable_to_middle( + ht: &ZendHashTable, + state: &BridgeState, + depth: usize, +) -> Result { if ht.has_sequential_keys() { let mut out = Vec::with_capacity(ht.len()); for (_, v) in ht.iter() { - out.push(zval_to_middle(v, state)?); + out.push(zval_to_middle_depth(v, state, depth + 1)?); } Ok(MiddleValue::Array(out)) } else { @@ -375,7 +440,7 @@ fn hashtable_to_middle(ht: &ZendHashTable, state: &BridgeState) -> Result s.to_owned(), ArrayKey::ZendString(s) => s.try_into().unwrap_or_default(), }; - out.push((key, zval_to_middle(v, state)?)); + out.push((key, zval_to_middle_depth(v, state, depth + 1)?)); } Ok(MiddleValue::Map(out)) } diff --git a/stubs/php_quickjs.stubs.php b/stubs/php_quickjs.stubs.php index 1cbc4ca..01a5fd1 100644 --- a/stubs/php_quickjs.stubs.php +++ b/stubs/php_quickjs.stubs.php @@ -1,5 +1,7 @@ eval('(n) => n * 2'); +$fiber = new Fiber(function () use ($js, $callback) { + eq(3, $js->eval('1 + 2'), 'main-stack engine runs in a Fiber'); + eq(42, $callback(21), 'main-stack callback runs in a Fiber'); +}); +$fiber->start(); +eq(6, $callback(3), 'callback returns to main stack'); + +$inside = null; +(new Fiber(function () use (&$inside) { $inside = new QuickJS(); $inside->eval('globalThis.createdInFiber = 23;'); }))->start(); +eq(23, $inside->eval('createdInFiber'), 'engine outlives its creation Fiber'); + +$other = new QuickJS(); +$otherCallback = $other->eval('(n) => n + 100'); +$js->register('other', fn($n) => $otherCallback($n)); +eq(107, $js->eval('php.other(7)'), 'cross-engine callback uses its own context'); + +$js->register('suspend', fn() => Fiber::suspend()); +$suspending = new Fiber(function () use ($js) { + throws(fn() => $js->eval('php.suspend()'), Throwable::class, 'switching inside active JS is rejected'); + Fiber::suspend('outside'); +}); +eq('outside', $suspending->start(), 'switching is restored after returning from JS'); +$suspending->resume(); +eq(3, $js->eval('1 + 2'), 'engine remains usable after rejected switch'); + +$js->register('apply', fn($fn) => $fn()); +(new Fiber(function () use ($js) { + eq(9, $js->eval('php.apply(() => 9)'), 'same-engine synchronous reentrancy still works'); +}))->start(); +$isolated = new QuickJS(isolated: true); +(new Fiber(function () use ($isolated) { + eq(42, $isolated->eval('6 * 7'), 'isolated context can be created on another Fiber stack'); +}))->start(); +$js->register('reenterEval', fn() => $js->eval('1')); +throws(fn() => $js->eval('php.reenterEval()'), Throwable::class, 'reentrant eval is rejected instead of deadlocking'); +eq(3, $js->eval('1 + 2'), 'engine recovers after rejected eval'); + +$loop = $js->eval('() => { while (true) {} }'); +throws(fn() => $loop(), QuickJSTimeoutException::class, 'saved callbacks obey the execution timeout'); +eq(3, $js->eval('1 + 2'), 'engine recovers after callback timeout'); +done(); From 3bd1d50fc2ac9fc3cb80e7f40bcc1480b97bf977 Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Sat, 12 Sep 2026 02:46:18 +0200 Subject: [PATCH 02/17] Add explicit bounded Promise job execution Expose hasPendingJobs and runJobs for host event loops. Enforce per-batch job and wall-time budgets, reject isolated or reentrant pumping, and cover Promise resolution, callback cleanup, timeouts, and Fiber resumption. --- README.md | 7 ++++ docs/api.md | 22 +++++++++++ docs/architecture.md | 3 +- docs/async.md | 49 +++++++++++++++++++++++ src/engine.rs | 56 +++++++++++++++++++++++++++ src/lib.rs | 46 +++++++++++++++++++++- stubs/php_quickjs.stubs.php | 8 +++- tests/php/11_jobs.php | 77 +++++++++++++++++++++++++++++++++++++ tests/php/12_fibers.php | 6 +++ 9 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 docs/async.md create mode 100644 tests/php/11_jobs.php diff --git a/README.md b/README.md index 076d37b..14d6b93 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,13 @@ both ways — remapping to TS coordinates on the way out. → **[docs/architecture.md](docs/architecture.md)** for the full design. +## Asynchronous execution + +Use `executePendingJobs()` to advance Promise continuations in bounded batches and +`hasPendingJobs()` to check for ready work. The host owns timers and I/O. +Instances may be used sequentially from PHP Fibers, but host callbacks must +return before switching Fibers. See [Promise jobs and Fibers](docs/async.md). + ## Scope This is an *embedder*, not a standalone defence against hostile code. The capability diff --git a/docs/api.md b/docs/api.md index d87532a..809ff73 100644 --- a/docs/api.md +++ b/docs/api.md @@ -43,3 +43,25 @@ both from the same source of truth. Diagnostic helper: send a PHP value through the full marshaling pipeline (PHP → MiddleValue → JS → MiddleValue → PHP) and return the result. Useful for testing value fidelity across the boundary; not needed in normal use. + + +### `hasPendingJobs(): bool` + +Whether a Promise continuation is ready to run. An unresolved Promise waiting +for host I/O is not a ready job. Available in shared mode only. + +### `executePendingJobs(int $maxJobs = 100): int` + +Execute up to `maxJobs` ready Promise jobs and return the number executed. The +budget must be positive. Returns immediately when the queue is empty; never +waits for external I/O. Jobs queued by a running job count towards the same +budget. The constructor's `timeoutMs` also applies to this call, including an +individual job that does not return; a timeout raises `QuickJSTimeoutException`. + +Only shared mode supports job pumping. Calling `executePendingJobs()` from an active JS +call is rejected. `eval()` and `Js\Callback` do not drain jobs implicitly. +Promise rejections retain JavaScript semantics: use `.catch()`/rejection handlers; +`executePendingJobs()` is not an unhandled-rejection reporting API. + +See [asynchronous execution](async.md) for host event loop integration and PHP +Fiber boundaries. diff --git a/docs/architecture.md b/docs/architecture.md index 25b5813..59d21e3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -126,7 +126,8 @@ errors. A re-entrancy depth cap (200) bounds recursive bridge calls. At an outer entry, QuickJS's stack limit is refreshed for the current PHP Fiber. Zend Fiber switching is blocked while native borrows are live. The same guard -arms and clears the execution deadline for evals and callbacks. +arms and clears the execution deadline for evals, callbacks, and job batches. +See [asynchronous execution](async.md). ## Capability handles diff --git a/docs/async.md b/docs/async.md new file mode 100644 index 0000000..ca0a4ab --- /dev/null +++ b/docs/async.md @@ -0,0 +1,49 @@ +# Promise jobs and PHP Fibers + +QuickJS provides Promises, but the host owns I/O and scheduling. The extension +exposes `hasPendingJobs()` and `executePendingJobs($maxJobs = 100)` so a PHP event loop can +advance JavaScript without blocking on network activity. No event loop library +is required by the extension. + +```php +$js = new QuickJS(timeoutMs: 100); +$resolve = null; +$js->register('capture', function ($fn) use (&$resolve) { $resolve = $fn; }); +$js->register('completed', function ($value) { echo $value, "\n"; }); +$js->eval('new Promise(resolve => php.capture(resolve)).then(php.completed); void 0;'); + +// Later, after host I/O completes and outside an active JS call: +$resolve(42); +$js->executePendingJobs(); // prints 42 +``` + +Keep the instance in shared mode (`isolated: false`, the default). `executePendingJobs()` +and `hasPendingJobs()` reject isolated mode, whose contexts do not survive their +eval boundary. A Promise waiting for I/O will not keep `hasPendingJobs()` true. + +For an event loop, execute a bounded batch after delivering I/O results. If jobs +remain, schedule another batch on a later loop turn. Do not busy-wait on pending +Promises, and do not drain an unbounded self-scheduling queue before servicing +I/O. Resolve an application-level PHP Future from a registered completion +callback; PHP Futures are not converted to JS Promises automatically. + +## Fiber boundaries + +An instance or saved callback may be used sequentially from different PHP +Fibers. At each outer JS entry the extension refreshes QuickJS's native stack +limit. Nested callbacks reuse the owning engine's context and retain the outer +execution deadline. Saved callbacks and job batches obey `timeoutMs` too. + +PHP must return from the extension before switching Fibers. Switching while JS +is active is rejected by Zend: a suspended Rust/QuickJS stack would keep live +borrows and a runtime lock. Register callbacks that enqueue work and return; +perform asynchronous I/O after control returns to PHP. This also applies to +starting another Fiber synchronously from a host callback. + +Synchronous JS → PHP → JS callbacks remain supported, including callbacks owned +by a different QuickJS instance. Calling `eval()` or `executePendingJobs()` reentrantly on +the active instance is rejected; use a saved JS callback for synchronous reentry. + +The execution deadline interrupts JavaScript, not blocking PHP/C code in a host +callback. Keep host callbacks short. The extension remains single-threaded/NTS; +Fiber support does not add ZTS or parallel-thread support. diff --git a/src/engine.rs b/src/engine.rs index 5d13fd0..3ac87ed 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -33,6 +33,62 @@ pub struct Engine { } impl Engine { + pub fn run_jobs(&self, ctx: &Ctx<'_>, max_jobs: i64) -> ext_php_rs::prelude::PhpResult { + let mut count = 0; + while count < max_jobs { + let mut job_ctx = std::ptr::null_mut(); + // SAFETY: eval_in owns the runtime lock. QuickJS returns a + // borrowed context pointer on failure, valid in this runtime. + let result = unsafe { + rquickjs::qjs::JS_ExecutePendingJob( + rquickjs::qjs::JS_GetRuntime(ctx.as_raw().as_ptr()), + &mut job_ctx, + ) + }; + if self.timed_out() + || self + .deadline + .get() + .is_some_and(|deadline| Instant::now() >= deadline) + { + self.timed_out.set(true); + // Check wall time even if short jobs never reach QuickJS's + // interrupt poll, including jobs that call slow PHP callbacks. + // Promise reactions can turn an interrupt into a rejection; + // still surface the execution budget to the host. + if result < 0 { + let c = unsafe { + rquickjs::Ctx::from_raw( + std::ptr::NonNull::new(job_ctx).expect("job error context"), + ) + }; + drop(c.catch()); + } + return Err(ext_php_rs::exception::PhpException::from_class::< + crate::exceptions::QuickJSTimeoutException, + >( + "JavaScript job execution timed out".to_owned() + )); + } + if result < 0 { + let c = unsafe { + rquickjs::Ctx::from_raw( + std::ptr::NonNull::new(job_ctx).expect("job error context"), + ) + }; + return Err(crate::error::js_error_to_php( + &c, + rquickjs::Error::Exception, + )); + } + if result == 0 { + break; + } + count += 1; + } + Ok(count) + } + pub fn new( memory_limit: usize, timeout_ms: u64, diff --git a/src/lib.rs b/src/lib.rs index 8399f53..6601407 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,7 +34,7 @@ impl QuickJS { /// Construct a sandbox. All limits default to unbounded; pass non-zero /// values to contain resource abuse: /// - `memoryLimit`: max heap bytes (alloc-bomb guard) - /// - `timeoutMs`: wall-clock budget per eval or callback + /// - `timeoutMs`: wall-clock budget per eval, callback, or job batch /// - `maxStack`: max native stack bytes /// - `isolated`: when true, each `eval()` runs in a fresh global realm (its /// own world); cross-eval globals and persistent JS callbacks are not @@ -117,6 +117,41 @@ impl QuickJS { } } + /// Whether Promise jobs are ready. This does not include pending host I/O. + pub fn hasPendingJobs(&self) -> PhpResult { + self.require_shared_jobs()?; + self.engine + .eval_in(|ctx| { + // SAFETY: the context and its runtime are locked by eval_in. + unsafe { + rquickjs::qjs::JS_IsJobPending(rquickjs::qjs::JS_GetRuntime( + ctx.as_raw().as_ptr(), + )) + } + }) + .map_err(to_php_err) + } + + /// Execute at most maxJobs ready jobs; never waits for host I/O. Jobs are + /// explicit, so eval/callback execution keeps its synchronous behavior. + #[php(defaults(maxJobs = 100))] + pub fn executePendingJobs(&self, maxJobs: i64) -> PhpResult { + self.require_shared_jobs()?; + if maxJobs <= 0 { + return Err(PhpException::default( + "maxJobs must be greater than zero".to_owned(), + )); + } + if self.engine.is_active() { + return Err(PhpException::default( + "Cannot run jobs while JavaScript is executing".to_owned(), + )); + } + self.engine + .eval_in(|ctx| self.engine.run_jobs(ctx, maxJobs)) + .map_err(to_php_err)? + } + /// Return the registration manifest as an array of `['name'=>..., 'types'=>...]`. pub fn manifest(&self) -> PhpResult { let state = &self.engine.state; @@ -191,6 +226,15 @@ impl QuickJS { } impl QuickJS { + fn require_shared_jobs(&self) -> PhpResult<()> { + if self.engine.shared_ctx().is_none() { + return Err(PhpException::default( + "Promise jobs require shared mode (isolated: false)".to_owned(), + )); + } + Ok(()) + } + /// Map a JS-side failure to the most specific PHP exception class: timeout /// (deadline tripped), memory (heap limit), else a generic eval error. For /// the eval case the JS stack is remapped to TypeScript coordinates via the diff --git a/stubs/php_quickjs.stubs.php b/stubs/php_quickjs.stubs.php index 01a5fd1..32879fc 100644 --- a/stubs/php_quickjs.stubs.php +++ b/stubs/php_quickjs.stubs.php @@ -13,7 +13,7 @@ class QuickJS { /** * @param int|null $memoryLimit Max heap bytes (0/null = unbounded). - * @param int|null $timeoutMs Per-eval/callback wall-clock budget in ms (0/null = unbounded). + * @param int|null $timeoutMs Per-eval/callback/job-batch wall-clock budget in ms (0/null = unbounded). * @param int|null $maxStack Max native stack bytes (0/null = engine default). * @param bool $isolated Run each eval() in its own fresh global realm. */ @@ -35,6 +35,12 @@ public function eval(string $code): mixed {} /** The registration manifest: a list of `['name' => string, 'types' => ?string]`. */ public function manifest(): array {} + /** Whether Promise jobs are ready (shared mode only). Does not include host I/O. */ + public function hasPendingJobs(): bool {} + + /** Execute at most maxJobs ready jobs without waiting for I/O; returns the count. */ + public function executePendingJobs(int $maxJobs = 100): int {} + /** Generate a TypeScript `.d.ts` declaration for the `php` global. */ public function dts(): string {} diff --git a/tests/php/11_jobs.php b/tests/php/11_jobs.php new file mode 100644 index 0000000..b0baa9c --- /dev/null +++ b/tests/php/11_jobs.php @@ -0,0 +1,77 @@ +hasPendingJobs(), 'new runtime has no jobs'); +eq(0, $js->executePendingJobs(), 'empty queue returns immediately'); +$js->eval('globalThis.answer = 0; Promise.resolve(21).then(n => { answer = n * 2; }); void 0;'); +eq(0, $js->eval('answer'), 'eval does not implicitly drain jobs'); +eq(true, $js->hasPendingJobs(), 'Promise reaction is pending'); +eq(1, $js->executePendingJobs(1), 'one job executed'); +eq(42, $js->eval('answer'), 'reaction executed'); +eq(false, $js->hasPendingJobs(), 'queue drained'); + +$resolve = null; +$js->register('capture', function ($fn) use (&$resolve) { $resolve = $fn; }); +$js->eval('globalThis.later = 0; new Promise(resolve => php.capture(resolve)).then(n => { later = n; }); void 0;'); +eq(false, $js->hasPendingJobs(), 'unresolved Promise is not a ready job'); +$resolve(17); +eq(true, $js->hasPendingJobs(), 'host resolution enqueues a reaction'); +$js->executePendingJobs(); +eq(17, $js->eval('later'), 'host-resolved Promise completes'); + +$js->eval('globalThis.failure = null; Promise.resolve().then(() => { throw new Error("expected"); }).catch(e => { failure = e.message; }); void 0;'); +$js->executePendingJobs(); +eq('expected', $js->eval('failure'), 'rejections retain JS catch semantics'); + +$js->eval('globalThis.jobs = 0; globalThis.keepGoing = true; function again() { jobs++; if (keepGoing) Promise.resolve().then(again); } Promise.resolve().then(again); void 0;'); +eq(7, $js->executePendingJobs(7), 'self-scheduling queue is bounded'); +eq(7, $js->eval('jobs'), 'job limit is exact'); +eq(true, $js->hasPendingJobs(), 'remaining work stays queued'); +$js->eval('keepGoing = false; void 0;'); +$js->executePendingJobs(); +eq(false, $js->hasPendingJobs(), 'queue can finish on a later turn'); +throws(fn() => $js->executePendingJobs(0), Throwable::class, 'zero budget rejected'); +throws(fn() => $js->executePendingJobs(-1), Throwable::class, 'negative budget rejected'); + +$js->register('nested', fn() => $js->executePendingJobs()); +throws(fn() => $js->eval('php.nested()'), Throwable::class, 'reentrant draining rejected instead of deadlocking'); +$js->register('apply', fn($fn) => $fn(6)); +$js->eval('globalThis.nestedResult = 0; Promise.resolve().then(() => { nestedResult = php.apply(n => n * 7); }); void 0;'); +$js->executePendingJobs(); +eq(42, $js->eval('nestedResult'), 'jobs can synchronously call PHP and JS'); + +$isolated = new QuickJS(isolated: true); +throws(fn() => $isolated->hasPendingJobs(), Throwable::class, 'isolated jobs rejected'); +throws(fn() => $isolated->executePendingJobs(), Throwable::class, 'isolated draining rejected'); + +$limited = new QuickJS(timeoutMs: 20); +$limited->eval('Promise.resolve().then(() => { while (true) {} }); void 0;'); +throws(fn() => $limited->executePendingJobs(), QuickJSTimeoutException::class, 'single runaway job observes execution timeout'); +eq(3, $limited->eval('1 + 2'), 'engine recovers after job timeout'); +// Host calls cannot be interrupted, but the next job must respect the batch budget. +$batch = new QuickJS(timeoutMs: 20); +$slowCalls = 0; +$batch->register('slow', function () use (&$slowCalls) { ++$slowCalls; usleep(40000); }); +$batch->eval('for (let i = 0; i < 3; i++) Promise.resolve().then(() => php.slow()); void 0;'); +throws(fn() => $batch->executePendingJobs(), QuickJSTimeoutException::class, 'short jobs enforce wall time between host calls'); +eq(1, $slowCalls, 'expired batch does not execute the next host call'); +eq(true, $batch->hasPendingJobs(), 'timeout preserves jobs not yet executed'); +eq(3, $batch->eval('1 + 2'), 'engine recovers after host-call batch timeout'); + +// Callback-only event loops must not require eval() to release callback entries. +$cleanup = new QuickJS(); +[$make, $count] = $cleanup->eval('[() => () => 1, () => __jsFnCount()]'); +for ($i = 0; $i < 20; ++$i) { + $temporary = $make(); + unset($temporary); +} +eq(2, $count(), 'outer callback entries flush released callback references'); +$observedCount = null; +$cleanup->register('observe', function ($n) use (&$observedCount) { $observedCount = $n; }); +$cleanup->eval('Promise.resolve().then(() => php.observe(__jsFnCount())); void 0;'); +$temporary = $make(); +unset($temporary); +$cleanup->executePendingJobs(); +eq(2, $observedCount, 'job batches flush released callback references before execution'); +done(); diff --git a/tests/php/12_fibers.php b/tests/php/12_fibers.php index 95f94ad..028de13 100644 --- a/tests/php/12_fibers.php +++ b/tests/php/12_fibers.php @@ -6,8 +6,14 @@ $fiber = new Fiber(function () use ($js, $callback) { eq(3, $js->eval('1 + 2'), 'main-stack engine runs in a Fiber'); eq(42, $callback(21), 'main-stack callback runs in a Fiber'); + $js->eval('globalThis.value = 0; Promise.resolve().then(() => { value = 17; }); void 0;'); + Fiber::suspend(); + $js->executePendingJobs(); + eq(17, $js->eval('value'), 'jobs run after Fiber resumption'); }); $fiber->start(); +eq(true, $js->hasPendingJobs(), 'ready jobs visible from main stack'); +$fiber->resume(); eq(6, $callback(3), 'callback returns to main stack'); $inside = null; From 72ee5739b48a02d22f6ec4e73db1ada8f403d6c6 Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Sat, 12 Sep 2026 02:46:18 +0200 Subject: [PATCH 03/17] Add direct batched dispatch without MessagePack round trips Convert callback arguments and queued guest messages natively. Bound queue size, reject non-data payloads, discard partial messages on failure, and return job progress to the host for asynchronous scheduling. --- docs/api.md | 9 ++++ docs/async.md | 35 +++++++++++++++ src/bridge.rs | 50 ++++++++++++++++++++- src/callback.rs | 88 ++++++++++++++++++++++++++++++++++++- src/marshal.rs | 9 ++++ stubs/php_quickjs.stubs.php | 2 + tests/php/13_dispatch.php | 42 ++++++++++++++++++ 7 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 tests/php/13_dispatch.php diff --git a/docs/api.md b/docs/api.md index 809ff73..9cf692a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -65,3 +65,12 @@ Promise rejections retain JavaScript semantics: use `.catch()`/rejection handler See [asynchronous execution](async.md) for host event loop integration and PHP Fiber boundaries. + +### `Js\Callback::dispatch(?array $args, int $maxJobs = 100): array` + +Invoke a saved callback and collect direct guest messages while advancing a +bounded Promise job batch. `null` arguments only drain jobs. Returns +`array{messages: list, jobs: int, pending: bool}`. +Requires shared mode and cannot be called reentrantly. See +[batched direct dispatch](async.md#batched-direct-dispatch) for the message +format, error recovery and limits. diff --git a/docs/async.md b/docs/async.md index ca0a4ab..5edc79f 100644 --- a/docs/async.md +++ b/docs/async.md @@ -47,3 +47,38 @@ the active instance is rejected; use a saved JS callback for synchronous reentry The execution deadline interrupts JavaScript, not blocking PHP/C code in a host callback. Keep host callbacks short. The extension remains single-threaded/NTS; Fiber support does not add ZTS or parallel-thread support. + +## Batched direct dispatch + +`Js\Callback::dispatch(?array $args, int $maxJobs = 100)` invokes a saved +callback with a positional argument list, then executes at most `maxJobs` Promise +jobs. Passing `null` skips invocation and only advances queued jobs. Its result is +`['messages' => [[kind, payload], ...], 'jobs' => int, 'pending' => bool]`. + +```php +$dispatch = $js->eval('(kind, payload) => __quickjsEmit(kind, payload)'); +$batch = $dispatch->dispatch(['result', ['answer' => 42]]); +// $batch['messages'] === [['result', ['answer' => 42]]] +``` + +During a batch, guest code can call `__quickjsEmit(kind, payload)` to enqueue a +message without invoking PHP. The host processes the returned messages after +QuickJS returns, so asynchronous PHP handlers may suspend safely there. Emitting +outside `dispatch()` throws. Dispatch uses native value conversion without +a MessagePack encode/decode round trip; valid UTF-8 strings stay strings and +binary PHP strings become `Uint8Array` and round-trip byte-for-byte. + +Message payloads accept null, booleans, numbers, strings, `Uint8Array`, arrays and +objects containing data; functions are rejected. JS conversion allows at most +64 nesting levels and 16 MiB of accounted payload storage, including container +overhead. A batch queue allows 4096 messages and 32 MiB of accounted storage. +These host-side caps are separate from QuickJS's `memoryLimit`. Cycles, oversized +values and queue overflow throw JS errors. PHP input nesting is also limited to +64 levels. Errors escaping a batch discard its partial messages; remaining +Promise jobs stay queued, so applications must decide whether to resume or +abandon that operation. Callback return values are ignored. + +Timeouts are checked between jobs as well as by QuickJS's interrupt hook. A +blocking PHP callback cannot be interrupted, but no further job starts after +its batch deadline has elapsed. Dropped callback registry entries are reclaimed +at the next outer engine entry, including callback-only and job-only loops. diff --git a/src/bridge.rs b/src/bridge.rs index df6ec34..9861d4b 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -11,7 +11,7 @@ use crate::engine::Engine; use crate::error::{throw_host_error, HostError}; use crate::handles::HandleTable; use crate::manifest::ManifestEntry; -use crate::marshal::{middle_to_zval, zval_to_middle, MiddleValue}; +use crate::marshal::{js_to_data, middle_to_zval, zval_to_middle, MiddleValue}; use ext_php_rs::convert::IntoZvalDyn; use ext_php_rs::types::{ZendCallable, Zval}; use rquickjs::{Ctx, Exception, Function, TypedArray, Value}; @@ -41,6 +41,9 @@ pub struct BridgeState { /// JS-callback ids whose PHP wrapper was dropped, awaiting release from the /// JS registry (deferred to the next eval boundary; see `JsCallback::drop`). pending_fn_deletions: RefCell>, + pub messages: RefCell>, + pub collecting: Cell, + pub message_bytes: Cell, } impl BridgeState { @@ -185,6 +188,40 @@ fn encode_result<'js>(ctx: &Ctx<'js>, result: MiddleValue) -> rquickjs::Result(ctx: &Ctx<'js>, state: Rc) -> rquickjs::Result<()> { let globals = ctx.globals(); + // Explicit data-only output queue for dispatch. No PHP callback runs here. + let queue_state = state.clone(); + globals.set( + "__quickjsEmit", + Function::new( + ctx.clone(), + move |ctx: Ctx<'js>, kind: String, payload: Value<'js>| -> rquickjs::Result<()> { + if !queue_state.collecting.get() { + return Err(Exception::throw_type( + &ctx, + "__quickjsEmit requires dispatch", + )); + } + let value = js_to_data(&ctx, payload, &queue_state)?; + let size = message_size(&value) + .saturating_add(kind.len()) + .saturating_add(128); + let total = queue_state.message_bytes.get().saturating_add(size); + if total > 33_554_432 || queue_state.messages.borrow().len() >= 4096 { + return Err(Exception::throw_type( + &ctx, + "dispatch message queue limit exceeded", + )); + } + queue_state.message_bytes.set(total); + queue_state + .messages + .borrow_mut() + .push(MiddleValue::Array(vec![MiddleValue::Str(kind), value])); + Ok(()) + }, + )?, + )?; + // The single JS -> host capability entry point. let host_state = state.clone(); let host = Function::new( @@ -297,3 +334,14 @@ mod tests { assert!(src.contains("Object.freeze")); } } + +// Conservative allocation accounting includes each value/container slot. +fn message_size(value: &MiddleValue) -> usize { + 64 + match value { + MiddleValue::Str(s) => s.len(), + MiddleValue::Bytes(b) => b.len(), + MiddleValue::Array(a) => a.iter().map(message_size).sum(), + MiddleValue::Map(m) => m.iter().map(|(k, v)| k.len() + message_size(v)).sum(), + _ => 0, + } +} diff --git a/src/callback.rs b/src/callback.rs index 8203a90..794074e 100644 --- a/src/callback.rs +++ b/src/callback.rs @@ -6,7 +6,7 @@ //! is already in flight, else acquiring the runtime lock afresh. use crate::engine::Engine; -use crate::marshal::{middle_to_zval, zval_to_middle, MiddleValue}; +use crate::marshal::{middle_to_js, middle_to_zval, zval_to_middle, MiddleValue}; use ext_php_rs::prelude::*; use ext_php_rs::types::Zval; use rquickjs::{Ctx, Function, TypedArray, Value}; @@ -87,6 +87,80 @@ impl JsCallback { #[php_impl] impl JsCallback { + /// Direct, data-only dispatch followed by a bounded job batch. Pass null + /// instead of an argument list to continue jobs without invoking the callback. + /// Returns queued messages, executed job count, and pending-job status. + #[php(defaults(maxJobs = 100))] + pub fn dispatch(&self, args: &Zval, maxJobs: i64) -> PhpResult { + if maxJobs <= 0 { + return Err(PhpException::default( + "maxJobs must be greater than zero".to_owned(), + )); + } + if self.engine.shared_ctx().is_none() { + return Err(PhpException::default( + "dispatch requires shared mode".to_owned(), + )); + } + if self.engine.is_active() { + return Err(PhpException::default( + "Cannot dispatch while JavaScript is executing".to_owned(), + )); + } + let middle = zval_to_middle(args, &self.engine.state).map_err(PhpException::default)?; + if !matches!(middle, MiddleValue::Null | MiddleValue::Array(_)) { + return Err(PhpException::default( + "args must be a list or null".to_owned(), + )); + } + let _guard = self.engine.enter().map_err(PhpException::default)?; + self.engine.state.collecting.set(true); + let _messages = MessageGuard { + state: &self.engine.state, + }; + self.engine + .eval_in(|ctx| { + if let MiddleValue::Array(items) = &middle { + let get: Function = ctx + .globals() + .get("__getJsFn") + .map_err(|e| self.engine.callback_error(ctx, e))?; + let fun: Function = get + .call((self.id as f64,)) + .map_err(|e| self.engine.callback_error(ctx, e))?; + let mut call_args = rquickjs::function::Args::new(ctx.clone(), items.len()); + for item in items { + call_args + .push_arg( + middle_to_js(ctx, item, &self.engine.state) + .map_err(|e| self.engine.callback_error(ctx, e))?, + ) + .map_err(|e| self.engine.callback_error(ctx, e))?; + } + // Dispatch is a notification; its return value is deliberately ignored. + fun.call_arg::(call_args) + .map_err(|e| self.engine.callback_error(ctx, e))?; + } + let jobs = self.engine.run_jobs(ctx, maxJobs)?; + let pending = unsafe { + rquickjs::qjs::JS_IsJobPending(rquickjs::qjs::JS_GetRuntime( + ctx.as_raw().as_ptr(), + )) + }; + let messages = std::mem::take(&mut *self.engine.state.messages.borrow_mut()); + middle_to_zval( + &MiddleValue::Map(vec![ + ("messages".to_owned(), MiddleValue::Array(messages)), + ("jobs".to_owned(), MiddleValue::Int(jobs)), + ("pending".to_owned(), MiddleValue::Bool(pending)), + ]), + &self.engine.state, + ) + .map_err(PhpException::default) + }) + .map_err(|e| PhpException::default(e.to_string()))? + } + /// Invoke the JS callback: `$cb(...$args)`. pub fn __invoke(&self, args: &[&Zval]) -> PhpResult { self.invoke_inner(args) @@ -97,3 +171,15 @@ impl JsCallback { self.invoke_inner(args) } } + +/// Clear partial output on failure as well as successful drains. +struct MessageGuard<'a> { + state: &'a crate::bridge::BridgeState, +} +impl Drop for MessageGuard<'_> { + fn drop(&mut self) { + self.state.collecting.set(false); + self.state.messages.borrow_mut().clear(); + self.state.message_bytes.set(0); + } +} diff --git a/src/marshal.rs b/src/marshal.rs index 4a63d5c..a0e14a2 100644 --- a/src/marshal.rs +++ b/src/marshal.rs @@ -215,6 +215,15 @@ pub fn js_to_middle<'js>( js_to_middle_bounded(ctx, value, _state, 0, &mut 16_777_216, true) } +/// Direct messages accept data only and share one allocation budget per payload. +pub fn js_to_data<'js>( + ctx: &Ctx<'js>, + value: Value<'js>, + state: &BridgeState, +) -> rquickjs::Result { + js_to_middle_bounded(ctx, value, state, 0, &mut 16_777_216, false) +} + fn js_to_middle_bounded<'js>( ctx: &Ctx<'js>, value: Value<'js>, diff --git a/stubs/php_quickjs.stubs.php b/stubs/php_quickjs.stubs.php index 32879fc..843e6ef 100644 --- a/stubs/php_quickjs.stubs.php +++ b/stubs/php_quickjs.stubs.php @@ -65,6 +65,8 @@ public function roundtrip(mixed $value): mixed {} */ class Callback { + /** @return array{messages: list, jobs: int, pending: bool} */ + public function dispatch(?array $args, int $maxJobs = 100): array {} public function __invoke(mixed ...$args): mixed {} public function call(mixed ...$args): mixed {} } diff --git a/tests/php/13_dispatch.php b/tests/php/13_dispatch.php new file mode 100644 index 0000000..00cf90b --- /dev/null +++ b/tests/php/13_dispatch.php @@ -0,0 +1,42 @@ +eval('(kind, value) => { __quickjsEmit(kind, value); }'); +foreach (['', "nul\0tail", 'Привет 🌍', "\xff\xfe" . str_repeat('a', 65536)] as $value) { + eq([['data', $value]], $send->dispatch(['data', $value])['messages'], 'direct payload preserves bytes'); +} +eq(['messages' => [], 'jobs' => 0, 'pending' => false], $send->dispatch(null), 'empty drain'); +throws(fn() => $send->dispatch([], 0), Throwable::class, 'invalid budget'); +throws(fn() => $send->dispatch(['named' => 1]), Throwable::class, 'argument map rejected'); +throws(fn() => $q->eval('__quickjsEmit("bad", 1)'), Throwable::class, 'emission outside batch rejected'); +$fail = $q->eval('() => { __quickjsEmit("partial", 1); throw new Error("failure"); }'); +throws(fn() => $fail->dispatch([]), QuickJSEvalException::class, 'dispatch errors surfaced'); +eq([], $send->dispatch(null)['messages'], 'failed batch discards partial output'); +$cycle = $q->eval('() => { const a = {}; a.self = a; __quickjsEmit("cycle", a); }'); +throws(fn() => $cycle->dispatch([]), Throwable::class, 'cyclic output rejected'); +$fun = $q->eval('() => __quickjsEmit("function", () => 1)'); +throws(fn() => $fun->dispatch([]), Throwable::class, 'function output rejected'); +$large = $q->eval('() => __quickjsEmit("large", new Uint8Array(16777217))'); +throws(fn() => $large->dispatch([]), Throwable::class, 'oversized payload rejected'); +$flood = $q->eval('() => { for (let i=0;i<4097;i++) __quickjsEmit("many", i); }'); +throws(fn() => $flood->dispatch([]), Throwable::class, 'message queue bounded'); +eq([], $send->dispatch(null)['messages'], 'queue recovers after limit'); +$chain = $q->eval('() => { Promise.resolve().then(() => __quickjsEmit("job", 1)).then(() => __quickjsEmit("job", 2)); }'); +$first = $chain->dispatch([], 1); +eq([['job', 1]], $first['messages'], 'first bounded job'); +eq(true, $first['pending'], 'continuation pending'); +eq([['job', 2]], $chain->dispatch(null, 1)['messages'], 'continuation drained'); +(new Fiber(function () use ($send) { eq([['fiber', 42]], $send->dispatch(['fiber', 42])['messages'], 'batch on Fiber stack'); }))->start(); +$q->register('reenter', fn() => $send->dispatch(null)); +$nested = $q->eval('() => php.reenter()'); +throws(fn() => $nested->dispatch([]), Throwable::class, 'reentrant dispatch rejected'); +eq([['ok', 1]], $send->dispatch(['ok', 1])['messages'], 'reentrant failure recovers'); +$byteFlood = $q->eval('() => { const a = new Uint8Array(12000000); for(let i=0;i<3;i++) __quickjsEmit("bytes", a); }'); +throws(fn() => $byteFlood->dispatch([]), Throwable::class, 'queue byte limit enforced across messages'); +$getter = $q->eval('() => __quickjsEmit("getter", {get value() { throw new Error("getter failed"); }})'); +throws(fn() => $getter->dispatch([]), QuickJSEvalException::class, 'getter error is propagated'); +$deep = 1; +for ($i = 0; $i < 66; $i++) { $deep = [$deep]; } +throws(fn() => $send->dispatch(['deep', $deep]), Throwable::class, 'PHP input depth bounded'); +eq([['ok', 2]], $send->dispatch(['ok', 2])['messages'], 'conversion failures leave usable batch'); +done(); From 3513e24acc1a8a0797e903f6637879580551e417 Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Sat, 12 Sep 2026 02:46:19 +0200 Subject: [PATCH 04/17] Document source installation and support macOS extension builds --- .gitignore | 1 + Makefile | 3 ++- README.md | 2 +- docs/install.md | 8 ++++++-- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 4fd98b0..fe41959 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target **/*.rs.bk *.so +.* diff --git a/Makefile b/Makefile index e9ca43b..c0165c9 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,8 @@ else CARGO_FLAGS := endif -EXT := $(CURDIR)/target/$(PROFILE)/libphp_quickjs.so +EXT_SUFFIX := $(if $(filter Darwin,$(shell uname -s)),dylib,so) +EXT := $(CURDIR)/target/$(PROFILE)/libphp_quickjs.$(EXT_SUFFIX) PHP := php -d extension=$(EXT) .PHONY: all build release test test-rust test-php stubs example clean fmt diff --git a/README.md b/README.md index 14d6b93..3303b7d 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Or build from source (Rust 1.96+, clang, PHP dev headers — a plain cargo `cdyl ```sh git clone https://github.com/eddmann/php-quickjs && cd php-quickjs -make build +make release ``` → Full platform matrix, Docker, and AWS Lambda / Bref instructions: diff --git a/docs/install.md b/docs/install.md index 3afc52c..2d2a82c 100644 --- a/docs/install.md +++ b/docs/install.md @@ -108,8 +108,12 @@ Requires Rust 1.96+, clang, and PHP 8.4/8.5 dev headers (`php-config`). ```sh git clone https://github.com/eddmann/php-quickjs && cd php-quickjs -make release # -> target/release/libphp_quickjs.so (or .dylib on macOS) -make test # optional: Rust unit tests + PHP suite +export PHP="$(command -v php)" PHP_CONFIG="$(command -v php-config)" +cargo build --release # -> target/release/libphp_quickjs.so (or .dylib on macOS) +cargo test --lib +# Do not export PHP when invoking Makefile: it adds the extension flag itself. +unset PHP +make test-php PROFILE=release ``` To build a Lambda-compatible binary locally, build inside the Bref image so it From 73da3b1da9da9f90aa5731c90321864756c236fc Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Sat, 12 Sep 2026 03:28:26 +0200 Subject: [PATCH 05/17] Harden bridge conversion and execution deadlines --- docs/async.md | 16 +- src/bridge.rs | 94 +++++-- src/callback.rs | 119 ++++---- src/engine.rs | 69 +++-- src/lib.rs | 31 +-- src/marshal.rs | 432 ++++++++++++++++++----------- tests/php/02_marshal_roundtrip.php | 18 ++ tests/php/12_fibers.php | 7 + tests/php/13_dispatch.php | 24 ++ 9 files changed, 478 insertions(+), 332 deletions(-) diff --git a/docs/async.md b/docs/async.md index 5edc79f..78b9dcc 100644 --- a/docs/async.md +++ b/docs/async.md @@ -45,7 +45,7 @@ by a different QuickJS instance. Calling `eval()` or `executePendingJobs()` reen the active instance is rejected; use a saved JS callback for synchronous reentry. The execution deadline interrupts JavaScript, not blocking PHP/C code in a host -callback. Keep host callbacks short. The extension remains single-threaded/NTS; +callback. An overrun throws when PHP returns to the extension. Keep host callbacks short. The extension remains single-threaded/NTS; Fiber support does not add ZTS or parallel-thread support. ## Batched direct dispatch @@ -69,16 +69,22 @@ a MessagePack encode/decode round trip; valid UTF-8 strings stay strings and binary PHP strings become `Uint8Array` and round-trip byte-for-byte. Message payloads accept null, booleans, numbers, strings, `Uint8Array`, arrays and -objects containing data; functions are rejected. JS conversion allows at most -64 nesting levels and 16 MiB of accounted payload storage, including container -overhead. A batch queue allows 4096 messages and 32 MiB of accounted storage. +objects containing data; functions are rejected. PHP arguments likewise accept only +data, not Closure objects or saved JS callbacks; use call() for callable arguments. +Each output payload and the complete PHP argument list are limited to 64 nesting +levels and 16 MiB of accounted storage, including container overhead. Generic +eval(), call() and roundtrip() retain the depth limit but have no transport byte cap. A batch queue allows 4096 messages and 32 MiB of accounted storage. These host-side caps are separate from QuickJS's `memoryLimit`. Cycles, oversized values and queue overflow throw JS errors. PHP input nesting is also limited to 64 levels. Errors escaping a batch discard its partial messages; remaining Promise jobs stay queued, so applications must decide whether to resume or abandon that operation. Callback return values are ignored. -Timeouts are checked between jobs as well as by QuickJS's interrupt hook. A +Timeouts are checked before and after each job and at native-call return, as well as by QuickJS's interrupt hook. A blocking PHP callback cannot be interrupted, but no further job starts after its batch deadline has elapsed. Dropped callback registry entries are reclaimed at the next outer engine entry, including callback-only and job-only loops. + +Failed generic value conversion rolls back callback registrations. Passing a saved +JS callback as an argument requires the same owning QuickJS instance; invoking a +foreign callback directly from PHP remains supported. diff --git a/src/bridge.rs b/src/bridge.rs index 9861d4b..ed82ddf 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -41,9 +41,7 @@ pub struct BridgeState { /// JS-callback ids whose PHP wrapper was dropped, awaiting release from the /// JS registry (deferred to the next eval boundary; see `JsCallback::drop`). pending_fn_deletions: RefCell>, - pub messages: RefCell>, - pub collecting: Cell, - pub message_bytes: Cell, + batch: RefCell>, } impl BridgeState { @@ -100,6 +98,28 @@ impl BridgeState { id } + pub(crate) fn release_php_fns(&self, ids: &[u64]) { + // Drop captured PHP values after releasing the RefCell borrow: PHP + // destructors may call back into the extension. + let removed: Vec<_> = { + let mut functions = self.php_funcs.borrow_mut(); + ids.iter().filter_map(|id| functions.remove(id)).collect() + }; + drop(removed); + } + + pub(crate) fn begin_batch(&self) -> BatchGuard<'_> { + *self.batch.borrow_mut() = Some(MessageBatch::default()); + BatchGuard { state: self } + } + + pub(crate) fn take_messages(&self) -> Vec { + self.batch + .borrow_mut() + .take() + .map_or_else(Vec::new, |batch| batch.messages) + } + pub fn get_php_fn(&self, id: u64) -> Option { self.php_funcs.borrow().get(&id).map(Zval::shallow_clone) } @@ -195,28 +215,22 @@ pub fn install<'js>(ctx: &Ctx<'js>, state: Rc) -> rquickjs::Result< Function::new( ctx.clone(), move |ctx: Ctx<'js>, kind: String, payload: Value<'js>| -> rquickjs::Result<()> { - if !queue_state.collecting.get() { + if queue_state.batch.borrow().is_none() { return Err(Exception::throw_type( &ctx, "__quickjsEmit requires dispatch", )); } - let value = js_to_data(&ctx, payload, &queue_state)?; - let size = message_size(&value) - .saturating_add(kind.len()) - .saturating_add(128); - let total = queue_state.message_bytes.get().saturating_add(size); - if total > 33_554_432 || queue_state.messages.borrow().len() >= 4096 { - return Err(Exception::throw_type( - &ctx, - "dispatch message queue limit exceeded", - )); - } - queue_state.message_bytes.set(total); - queue_state - .messages - .borrow_mut() - .push(MiddleValue::Array(vec![MiddleValue::Str(kind), value])); + // Conversion may invoke getters, including nested emit calls; + // never hold the queue borrow while JavaScript can execute. + let (value, bytes) = js_to_data(&ctx, payload)?; + let mut active = queue_state.batch.borrow_mut(); + let batch = active + .as_mut() + .ok_or_else(|| Exception::throw_type(&ctx, "inactive dispatch"))?; + batch + .push(kind, value, bytes) + .map_err(|e| Exception::throw_type(&ctx, e))?; Ok(()) }, )?, @@ -335,13 +349,37 @@ mod tests { } } -// Conservative allocation accounting includes each value/container slot. -fn message_size(value: &MiddleValue) -> usize { - 64 + match value { - MiddleValue::Str(s) => s.len(), - MiddleValue::Bytes(b) => b.len(), - MiddleValue::Array(a) => a.iter().map(message_size).sum(), - MiddleValue::Map(m) => m.iter().map(|(k, v)| k.len() + message_size(v)).sum(), - _ => 0, +const MAX_BATCH_MESSAGES: usize = 4096; +const MAX_BATCH_BYTES: usize = 32 * 1024 * 1024; +const MESSAGE_OVERHEAD: usize = 128; + +#[derive(Default)] +struct MessageBatch { + messages: Vec, + bytes: usize, +} +impl MessageBatch { + fn push(&mut self, kind: String, value: MiddleValue, bytes: usize) -> Result<(), &'static str> { + let total = self + .bytes + .saturating_add(bytes) + .saturating_add(kind.len()) + .saturating_add(MESSAGE_OVERHEAD); + if total > MAX_BATCH_BYTES || self.messages.len() >= MAX_BATCH_MESSAGES { + return Err("dispatch message queue limit exceeded"); + } + self.bytes = total; + self.messages + .push(MiddleValue::Array(vec![MiddleValue::Str(kind), value])); + Ok(()) + } +} + +pub(crate) struct BatchGuard<'a> { + state: &'a BridgeState, +} +impl Drop for BatchGuard<'_> { + fn drop(&mut self) { + self.state.batch.borrow_mut().take(); } } diff --git a/src/callback.rs b/src/callback.rs index 794074e..ffef00b 100644 --- a/src/callback.rs +++ b/src/callback.rs @@ -6,9 +6,11 @@ //! is already in flight, else acquiring the runtime lock afresh. use crate::engine::Engine; -use crate::marshal::{middle_to_js, middle_to_zval, zval_to_middle, MiddleValue}; +use crate::marshal::{ + arguments_to_middle, data_arguments, middle_to_js, middle_to_zval, MiddleValue, +}; use ext_php_rs::prelude::*; -use ext_php_rs::types::Zval; +use ext_php_rs::types::{ZendHashTable, Zval}; use rquickjs::{Ctx, Function, TypedArray, Value}; use std::rc::Rc; @@ -41,10 +43,8 @@ impl JsCallback { fn invoke_inner(&self, args: &[&Zval]) -> PhpResult { let _guard = self.engine.enter().map_err(PhpException::default)?; - let mut middle_args = Vec::with_capacity(args.len()); - for a in args { - middle_args.push(zval_to_middle(a, &self.engine.state).map_err(PhpException::default)?); - } + let middle_args = + arguments_to_middle(args, &self.engine.state).map_err(PhpException::default)?; let payload = MiddleValue::Array(middle_args) .to_msgpack() .map_err(|e| PhpException::default(e.to_string()))?; @@ -79,9 +79,7 @@ impl JsCallback { "JS callback invoked outside its eval (isolated QuickJS instance)".to_owned(), )); } - self.engine - .eval_in(run) - .map_err(|e| PhpException::default(e.to_string()))? + self.engine.eval_in(run) } } @@ -91,7 +89,7 @@ impl JsCallback { /// instead of an argument list to continue jobs without invoking the callback. /// Returns queued messages, executed job count, and pending-job status. #[php(defaults(maxJobs = 100))] - pub fn dispatch(&self, args: &Zval, maxJobs: i64) -> PhpResult { + pub fn dispatch(&self, args: Option<&ZendHashTable>, maxJobs: i64) -> PhpResult { if maxJobs <= 0 { return Err(PhpException::default( "maxJobs must be greater than zero".to_owned(), @@ -107,58 +105,49 @@ impl JsCallback { "Cannot dispatch while JavaScript is executing".to_owned(), )); } - let middle = zval_to_middle(args, &self.engine.state).map_err(PhpException::default)?; - if !matches!(middle, MiddleValue::Null | MiddleValue::Array(_)) { - return Err(PhpException::default( - "args must be a list or null".to_owned(), - )); - } + let middle = args + .map(|args| data_arguments(args, &self.engine.state)) + .transpose() + .map_err(PhpException::default)?; let _guard = self.engine.enter().map_err(PhpException::default)?; - self.engine.state.collecting.set(true); - let _messages = MessageGuard { - state: &self.engine.state, - }; - self.engine - .eval_in(|ctx| { - if let MiddleValue::Array(items) = &middle { - let get: Function = ctx - .globals() - .get("__getJsFn") - .map_err(|e| self.engine.callback_error(ctx, e))?; - let fun: Function = get - .call((self.id as f64,)) - .map_err(|e| self.engine.callback_error(ctx, e))?; - let mut call_args = rquickjs::function::Args::new(ctx.clone(), items.len()); - for item in items { - call_args - .push_arg( - middle_to_js(ctx, item, &self.engine.state) - .map_err(|e| self.engine.callback_error(ctx, e))?, - ) - .map_err(|e| self.engine.callback_error(ctx, e))?; - } - // Dispatch is a notification; its return value is deliberately ignored. - fun.call_arg::(call_args) + let _batch = self.engine.state.begin_batch(); + self.engine.eval_in(|ctx| { + if let Some(MiddleValue::Array(items)) = &middle { + let get: Function = ctx + .globals() + .get("__getJsFn") + .map_err(|e| self.engine.callback_error(ctx, e))?; + let fun: Function = get + .call((self.id as f64,)) + .map_err(|e| self.engine.callback_error(ctx, e))?; + let mut call_args = rquickjs::function::Args::new(ctx.clone(), items.len()); + for item in items { + call_args + .push_arg( + middle_to_js(ctx, item, &self.engine.state) + .map_err(|e| self.engine.callback_error(ctx, e))?, + ) .map_err(|e| self.engine.callback_error(ctx, e))?; } - let jobs = self.engine.run_jobs(ctx, maxJobs)?; - let pending = unsafe { - rquickjs::qjs::JS_IsJobPending(rquickjs::qjs::JS_GetRuntime( - ctx.as_raw().as_ptr(), - )) - }; - let messages = std::mem::take(&mut *self.engine.state.messages.borrow_mut()); - middle_to_zval( - &MiddleValue::Map(vec![ - ("messages".to_owned(), MiddleValue::Array(messages)), - ("jobs".to_owned(), MiddleValue::Int(jobs)), - ("pending".to_owned(), MiddleValue::Bool(pending)), - ]), - &self.engine.state, - ) - .map_err(PhpException::default) - }) - .map_err(|e| PhpException::default(e.to_string()))? + // Dispatch is a notification; its return value is deliberately ignored. + fun.call_arg::(call_args) + .map_err(|e| self.engine.callback_error(ctx, e))?; + } + let jobs = self.engine.run_jobs(ctx, maxJobs)?; + let pending = unsafe { + rquickjs::qjs::JS_IsJobPending(rquickjs::qjs::JS_GetRuntime(ctx.as_raw().as_ptr())) + }; + let messages = self.engine.state.take_messages(); + middle_to_zval( + &MiddleValue::Map(vec![ + ("messages".to_owned(), MiddleValue::Array(messages)), + ("jobs".to_owned(), MiddleValue::Int(jobs)), + ("pending".to_owned(), MiddleValue::Bool(pending)), + ]), + &self.engine.state, + ) + .map_err(PhpException::default) + }) } /// Invoke the JS callback: `$cb(...$args)`. @@ -171,15 +160,3 @@ impl JsCallback { self.invoke_inner(args) } } - -/// Clear partial output on failure as well as successful drains. -struct MessageGuard<'a> { - state: &'a crate::bridge::BridgeState, -} -impl Drop for MessageGuard<'_> { - fn drop(&mut self) { - self.state.collecting.set(false); - self.state.messages.borrow_mut().clear(); - self.state.message_bytes.set(0); - } -} diff --git a/src/engine.rs b/src/engine.rs index 3ac87ed..61aa556 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -3,6 +3,7 @@ use crate::bridge::BridgeState; use crate::sandbox; use crate::transpile::TranspileCache; +use ext_php_rs::prelude::*; use rquickjs::{Context, Ctx, Runtime}; use std::cell::Cell; use std::ptr::NonNull; @@ -33,49 +34,33 @@ pub struct Engine { } impl Engine { - pub fn run_jobs(&self, ctx: &Ctx<'_>, max_jobs: i64) -> ext_php_rs::prelude::PhpResult { + fn check_deadline(&self, ctx: &Ctx<'_>) -> PhpResult<()> { + if self.timed_out() || self.deadline.get().is_some_and(|d| Instant::now() >= d) { + self.timed_out.set(true); + drop(ctx.catch()); + return Err(PhpException::from_class::< + crate::exceptions::QuickJSTimeoutException, + >("JavaScript execution timed out".to_owned())); + } + Ok(()) + } + + pub fn run_jobs(&self, ctx: &Ctx<'_>, max_jobs: i64) -> PhpResult { let mut count = 0; while count < max_jobs { + self.check_deadline(ctx)?; let mut job_ctx = std::ptr::null_mut(); - // SAFETY: eval_in owns the runtime lock. QuickJS returns a - // borrowed context pointer on failure, valid in this runtime. + // SAFETY: eval_in holds the runtime lock. The returned context is + // borrowed from that runtime; Ctx::from_raw acquires its own ref. let result = unsafe { rquickjs::qjs::JS_ExecutePendingJob( rquickjs::qjs::JS_GetRuntime(ctx.as_raw().as_ptr()), &mut job_ctx, ) }; - if self.timed_out() - || self - .deadline - .get() - .is_some_and(|deadline| Instant::now() >= deadline) - { - self.timed_out.set(true); - // Check wall time even if short jobs never reach QuickJS's - // interrupt poll, including jobs that call slow PHP callbacks. - // Promise reactions can turn an interrupt into a rejection; - // still surface the execution budget to the host. - if result < 0 { - let c = unsafe { - rquickjs::Ctx::from_raw( - std::ptr::NonNull::new(job_ctx).expect("job error context"), - ) - }; - drop(c.catch()); - } - return Err(ext_php_rs::exception::PhpException::from_class::< - crate::exceptions::QuickJSTimeoutException, - >( - "JavaScript job execution timed out".to_owned() - )); - } + self.check_deadline(ctx)?; if result < 0 { - let c = unsafe { - rquickjs::Ctx::from_raw( - std::ptr::NonNull::new(job_ctx).expect("job error context"), - ) - }; + let c = unsafe { Ctx::from_raw(NonNull::new(job_ctx).expect("job error context")) }; return Err(crate::error::js_error_to_php( &c, rquickjs::Error::Exception, @@ -153,12 +138,15 @@ impl Engine { /// Run on the current PHP stack. Reentrant callbacks reuse this engine's /// context; a callback belonging to a different engine gets its own context. - pub fn eval_in(&self, f: impl FnOnce(&Ctx<'_>) -> R) -> rquickjs::Result { + pub fn eval_in(&self, f: impl FnOnce(&Ctx<'_>) -> PhpResult) -> PhpResult { if let Some(ptr) = self.active_ctx.get() { // SAFETY: the outer Context::with owns this context and its lock. // Fiber switching is blocked until that call returns. let ctx = unsafe { Ctx::from_raw(ptr) }; - return Ok(f(&ctx)); + self.check_deadline(&ctx)?; + let result = f(&ctx); + self.check_deadline(&ctx)?; + return result; } let run = |ctx: &Context| { ctx.with(|c| { @@ -172,14 +160,19 @@ impl Engine { self.active_ctx.set(Some(c.as_raw())); self.arm_deadline(); let _guard = ExecutionGuard { engine: self }; - crate::bridge::flush_pending_deletions(&c, &self.state)?; - Ok(f(&c)) + crate::bridge::flush_pending_deletions(&c, &self.state) + .map_err(|e| self.callback_error(&c, e))?; + self.check_deadline(&c)?; + let result = f(&c); + self.check_deadline(&c)?; + result }) }; match &self.shared_ctx { Some(ctx) => run(ctx), None => { - let ctx = Context::full(&self.rt)?; + let ctx = + Context::full(&self.rt).map_err(|e| PhpException::default(e.to_string()))?; run(&ctx) } } diff --git a/src/lib.rs b/src/lib.rs index 6601407..e006c8a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -98,7 +98,7 @@ impl QuickJS { })?; let state = self.engine.state.clone(); - let outcome = self.engine.eval_in(|ctx| { + self.engine.eval_in(|ctx| { let map = module.map_json.clone(); let eval_err = |e| self.classify_js_error(ctx, e, map.as_deref(), &module.module_id); bridge::install(ctx, state.clone()).map_err(&eval_err)?; @@ -110,26 +110,18 @@ impl QuickJS { .map_err(&eval_err)?; let middle = js_to_middle(ctx, value, &state).map_err(&eval_err)?; middle_to_zval(&middle, &state).map_err(PhpException::default) - }); - match outcome { - Ok(r) => r, - Err(e) => Err(to_php_err(e)), - } + }) } /// Whether Promise jobs are ready. This does not include pending host I/O. pub fn hasPendingJobs(&self) -> PhpResult { self.require_shared_jobs()?; - self.engine - .eval_in(|ctx| { - // SAFETY: the context and its runtime are locked by eval_in. - unsafe { - rquickjs::qjs::JS_IsJobPending(rquickjs::qjs::JS_GetRuntime( - ctx.as_raw().as_ptr(), - )) - } + self.engine.eval_in(|ctx| { + // SAFETY: the context and its runtime are locked by eval_in. + Ok(unsafe { + rquickjs::qjs::JS_IsJobPending(rquickjs::qjs::JS_GetRuntime(ctx.as_raw().as_ptr())) }) - .map_err(to_php_err) + }) } /// Execute at most maxJobs ready jobs; never waits for host I/O. Jobs are @@ -149,7 +141,6 @@ impl QuickJS { } self.engine .eval_in(|ctx| self.engine.run_jobs(ctx, maxJobs)) - .map_err(to_php_err)? } /// Return the registration manifest as an array of `['name'=>..., 'types'=>...]`. @@ -210,18 +201,14 @@ impl QuickJS { pub fn roundtrip(&self, value: &Zval) -> PhpResult { let state = self.engine.state.clone(); let middle = zval_to_middle(value, &state).map_err(PhpException::default)?; - let outcome = self.engine.eval_in(|ctx| { + self.engine.eval_in(|ctx| { // Runtime support must exist for any function reconstruction. bridge::install(ctx, state.clone()) .map_err(|e| PhpException::default(error::js_error_message(ctx, e)))?; let js = middle_to_js(ctx, &middle, &state).map_err(to_php_err)?; let back = js_to_middle(ctx, js, &state).map_err(to_php_err)?; middle_to_zval(&back, &state).map_err(PhpException::default) - }); - match outcome { - Ok(r) => r, - Err(e) => Err(to_php_err(e)), - } + }) } } diff --git a/src/marshal.rs b/src/marshal.rs index a0e14a2..bb1f64d 100644 --- a/src/marshal.rs +++ b/src/marshal.rs @@ -205,122 +205,173 @@ impl<'de> Deserialize<'de> for MapKey { // JS <-> MiddleValue // --------------------------------------------------------------------------- -/// Convert a JS value into the neutral representation. Functions are registered -/// in the JS-side registry and travel as a [`MiddleValue::JsFn`] ref. +pub const MAX_VALUE_DEPTH: usize = 64; +pub const MAX_DATA_BYTES: usize = 16 * 1024 * 1024; +pub const VALUE_OVERHEAD: usize = 64; + +/// The direct transport has a byte budget; the generic API keeps its previous +/// unlimited byte size. Both paths bound recursion before visiting a value. +#[derive(Default)] +struct ConversionBudget { + limit: Option, + used: usize, +} +impl ConversionBudget { + fn direct() -> Self { + Self { + limit: Some(MAX_DATA_BYTES), + used: 0, + } + } + fn remaining(&self) -> Option { + self.limit.map(|limit| limit - self.used) + } + fn charge(&mut self, bytes: usize) -> Result<(), &'static str> { + let used = self + .used + .checked_add(bytes) + .ok_or("bridge value exceeds size limit")?; + if self.limit.is_some_and(|limit| used > limit) { + return Err("bridge value exceeds size limit"); + } + self.used = used; + Ok(()) + } + fn node(&mut self, depth: usize) -> Result<(), &'static str> { + if depth > MAX_VALUE_DEPTH { + return Err("bridge value exceeds maximum depth (64)"); + } + self.charge(VALUE_OVERHEAD) + } +} + +/// Failed conversion never hands the registered functions to PHP. Defer their +/// deletion just like dropped wrappers, without executing JS during unwinding. pub fn js_to_middle<'js>( ctx: &Ctx<'js>, value: Value<'js>, - _state: &BridgeState, + state: &BridgeState, ) -> rquickjs::Result { - js_to_middle_bounded(ctx, value, _state, 0, &mut 16_777_216, true) + let mut conversion = JsConversion { + ctx, + budget: ConversionBudget::default(), + functions: true, + registered: Vec::new(), + }; + let result = conversion.convert(value, 0); + if result.is_err() { + for id in conversion.registered { + state.queue_fn_deletion(id); + } + } + result } -/// Direct messages accept data only and share one allocation budget per payload. +/// Return the accounted size together with data, without traversing it twice. pub fn js_to_data<'js>( ctx: &Ctx<'js>, value: Value<'js>, - state: &BridgeState, -) -> rquickjs::Result { - js_to_middle_bounded(ctx, value, state, 0, &mut 16_777_216, false) +) -> rquickjs::Result<(MiddleValue, usize)> { + let mut conversion = JsConversion { + ctx, + budget: ConversionBudget::direct(), + functions: false, + registered: Vec::new(), + }; + let data = conversion.convert(value, 0)?; + Ok((data, conversion.budget.used)) } -fn js_to_middle_bounded<'js>( - ctx: &Ctx<'js>, - value: Value<'js>, - _state: &BridgeState, - depth: usize, - budget: &mut usize, +struct JsConversion<'a, 'js> { + ctx: &'a Ctx<'js>, + budget: ConversionBudget, functions: bool, -) -> rquickjs::Result { - if depth > 64 || *budget < 64 { - return Err(rquickjs::Exception::throw_type( - ctx, - "bridge value exceeds depth or size limit", - )); - } - *budget -= 64; - if value.is_null() || value.is_undefined() { - return Ok(MiddleValue::Null); - } - if let Some(b) = value.as_bool() { - return Ok(MiddleValue::Bool(b)); - } - if value.is_int() { - return Ok(MiddleValue::Int(value.as_int().unwrap() as i64)); - } - if value.is_float() { - return Ok(int_or_float(value.as_float().unwrap())); - } - if let Some(s) = value.as_string() { - let text = s.to_string()?; - *budget = budget.checked_sub(text.len()).ok_or_else(|| { - rquickjs::Exception::throw_type(ctx, "bridge value exceeds size limit") - })?; - return Ok(MiddleValue::Str(text)); - } - if value.is_function() { - if !functions { - return Err(rquickjs::Exception::throw_type( - ctx, - "direct messages cannot contain functions", - )); - } - // Register the function JS-side; PHP receives an opaque id. - let register: Function = ctx.globals().get("__registerJsFn")?; - let id: f64 = register.call((value.clone(),))?; - return Ok(MiddleValue::JsFn(id as u64)); - } - // Uint8Array -> Bytes (checked before the generic object branch). - if value.is_object() { - if let Ok(ta) = TypedArray::::from_value(value.clone()) { - if let Some(bytes) = ta.as_bytes() { - *budget = budget.checked_sub(bytes.len()).ok_or_else(|| { - rquickjs::Exception::throw_type(ctx, "bridge value exceeds size limit") - })?; - return Ok(MiddleValue::Bytes(bytes.to_vec())); + registered: Vec, +} +impl<'js> JsConversion<'_, 'js> { + fn convert(&mut self, value: Value<'js>, depth: usize) -> rquickjs::Result { + let ctx = self.ctx; + self.budget + .node(depth) + .map_err(|e| rquickjs::Exception::throw_type(ctx, e))?; + if value.is_null() || value.is_undefined() { + return Ok(MiddleValue::Null); + } + if let Some(b) = value.as_bool() { + return Ok(MiddleValue::Bool(b)); + } + if value.is_int() { + return Ok(MiddleValue::Int(value.as_int().unwrap() as i64)); + } + if value.is_float() { + return Ok(int_or_float(value.as_float().unwrap())); + } + if let Some(s) = value.as_string() { + let text = s.to_string()?; + self.budget + .charge(text.len()) + .map_err(|e| rquickjs::Exception::throw_type(ctx, e))?; + return Ok(MiddleValue::Str(text)); + } + if value.is_function() { + if !self.functions { + return Err(rquickjs::Exception::throw_type( + ctx, + "direct messages cannot contain functions", + )); } + // Register the function JS-side; PHP receives an opaque id. + let register: Function = ctx.globals().get("__registerJsFn")?; + let id: f64 = register.call((value.clone(),))?; + self.registered.push(id as u64); + return Ok(MiddleValue::JsFn(id as u64)); } + // Uint8Array -> Bytes (checked before the generic object branch). + if value.is_object() { + if let Ok(ta) = TypedArray::::from_value(value.clone()) { + if let Some(bytes) = ta.as_bytes() { + self.budget + .charge(bytes.len()) + .map_err(|e| rquickjs::Exception::throw_type(ctx, e))?; + return Ok(MiddleValue::Bytes(bytes.to_vec())); + } + } + } + if value.is_array() { + let arr = value.into_array().unwrap(); + if self + .budget + .remaining() + .is_some_and(|remaining| arr.len() > remaining / VALUE_OVERHEAD) + { + return Err(rquickjs::Exception::throw_type( + ctx, + "bridge array exceeds size limit", + )); + } + let mut out = Vec::with_capacity(arr.len()); + for i in 0..arr.len() { + out.push(self.convert(arr.get(i)?, depth + 1)?); + } + return Ok(MiddleValue::Array(out)); + } + if value.is_object() { + let obj = value.into_object().unwrap(); + let mut out = Vec::new(); + for entry in obj.props::() { + let (k, v) = entry?; + self.budget + .charge(k.len()) + .map_err(|e| rquickjs::Exception::throw_type(ctx, e))?; + out.push((k, self.convert(v, depth + 1)?)); + } + return Ok(MiddleValue::Map(out)); + } + Err(rquickjs::Exception::throw_type( + ctx, + "unsupported JS value type", + )) } - if value.is_array() { - let arr = value.into_array().unwrap(); - if arr.len() > *budget / 64 { - return Err(rquickjs::Exception::throw_type( - ctx, - "bridge array exceeds size limit", - )); - } - let mut out = Vec::with_capacity(arr.len()); - for i in 0..arr.len() { - out.push(js_to_middle_bounded( - ctx, - arr.get(i)?, - _state, - depth + 1, - budget, - functions, - )?); - } - return Ok(MiddleValue::Array(out)); - } - if value.is_object() { - let obj = value.into_object().unwrap(); - let mut out = Vec::new(); - for entry in obj.props::() { - let (k, v) = entry?; - *budget = budget.checked_sub(k.len()).ok_or_else(|| { - rquickjs::Exception::throw_type(ctx, "bridge value exceeds size limit") - })?; - out.push(( - k, - js_to_middle_bounded(ctx, v, _state, depth + 1, budget, functions)?, - )); - } - return Ok(MiddleValue::Map(out)); - } - Err(rquickjs::Exception::throw_type( - ctx, - "unsupported JS value type", - )) } /// Convert the neutral representation into a JS value. @@ -373,85 +424,130 @@ pub fn middle_to_js<'js>( // PHP Zval <-> MiddleValue // --------------------------------------------------------------------------- -/// Convert a PHP value into the neutral representation. A `Js\Callback` becomes -/// a [`MiddleValue::JsFn`] ref; any other PHP callable is registered host-side -/// as a [`MiddleValue::PhpFn`]. +/// Registrations are committed only once the complete input is valid. pub fn zval_to_middle(zv: &Zval, state: &BridgeState) -> Result { - zval_to_middle_depth(zv, state, 0) + let mut conversion = PhpConversion::new(state, true); + let value = conversion.convert(zv, 0)?; + conversion.registered.clear(); + Ok(value) } -fn zval_to_middle_depth( - zv: &Zval, +pub fn arguments_to_middle( + args: &[&Zval], state: &BridgeState, - depth: usize, -) -> Result { - if depth > 64 { - return Err("PHP bridge value exceeds maximum depth (64)".to_owned()); - } - if zv.is_null() { - return Ok(MiddleValue::Null); - } - if zv.is_bool() { - return Ok(MiddleValue::Bool(zv.bool().unwrap_or(false))); - } - if zv.is_long() { - return Ok(MiddleValue::Int(zv.long().unwrap())); - } - if zv.is_double() { - return Ok(MiddleValue::Float(zv.double().unwrap())); - } - if zv.is_string() { - // PHP strings are byte strings. Preserve valid UTF-8 as a string; - // anything else (binary data) crosses as bytes -> JS Uint8Array. - let bytes = zv.zend_str().map(|zs| zs.as_bytes()).unwrap_or(&[]); - return Ok(match std::str::from_utf8(bytes) { - Ok(s) => MiddleValue::Str(s.to_owned()), - Err(_) => MiddleValue::Bytes(bytes.to_owned()), - }); +) -> Result, String> { + let mut conversion = PhpConversion::new(state, true); + let result = args + .iter() + .map(|arg| conversion.convert(arg, 0)) + .collect::, _>>()?; + conversion.registered.clear(); + Ok(result) +} + +pub fn data_arguments(args: &ZendHashTable, state: &BridgeState) -> Result { + if !args.has_sequential_keys() { + return Err("args must be a list or null".to_owned()); } - if zv.is_array() { - let ht = zv.array().unwrap(); - return hashtable_to_middle(ht, state, depth); + let mut conversion = PhpConversion::new(state, false); + conversion.budget.node(0)?; + conversion.array(args, 0) +} + +struct PhpConversion<'a> { + state: &'a BridgeState, + budget: ConversionBudget, + functions: bool, + registered: Vec, +} +impl<'a> PhpConversion<'a> { + fn new(state: &'a BridgeState, functions: bool) -> Self { + Self { + state, + budget: if functions { + ConversionBudget::default() + } else { + ConversionBudget::direct() + }, + functions, + registered: Vec::new(), + } } - // A returned Js\Callback maps back to its original JS function. - if zv.is_object() { + fn convert(&mut self, zv: &Zval, depth: usize) -> Result { + self.budget.node(depth)?; + if zv.is_null() { + return Ok(MiddleValue::Null); + } + if zv.is_bool() { + return Ok(MiddleValue::Bool(zv.bool().unwrap_or(false))); + } + if zv.is_long() { + return Ok(MiddleValue::Int(zv.long().unwrap())); + } + if zv.is_double() { + return Ok(MiddleValue::Float(zv.double().unwrap())); + } + if zv.is_string() { + let bytes = zv.zend_str().map(|zs| zs.as_bytes()).unwrap_or(&[]); + self.budget.charge(bytes.len())?; + return Ok(match std::str::from_utf8(bytes) { + Ok(s) => MiddleValue::Str(s.to_owned()), + Err(_) => MiddleValue::Bytes(bytes.to_owned()), + }); + } + if let Some(array) = zv.array() { + return self.array(array, depth); + } + if !self.functions { + return Err("direct dispatch arguments must contain data only".to_owned()); + } if let Some(cb) = zv.extract::<&ZendClassObject>() { + let owner = self.state.engine().ok_or("engine no longer available")?; + if !std::rc::Rc::ptr_eq(&owner, &cb.engine) { + return Err("JS callback belongs to a different QuickJS instance".to_owned()); + } return Ok(MiddleValue::JsFn(cb.id)); } + if zv.is_callable() { + let id = self.state.register_php_fn(zv); + self.registered.push(id); + return Ok(MiddleValue::PhpFn(id)); + } + Err("unsupported PHP value type for marshaling".to_owned()) } - // Any other callable (closure, [obj, 'method'] is caught above as array) is - // registered host-side and handed to JS as a callable wrapper. - if zv.is_callable() { - return Ok(MiddleValue::PhpFn(state.register_php_fn(zv))); + fn array(&mut self, ht: &ZendHashTable, depth: usize) -> Result { + if self + .budget + .remaining() + .is_some_and(|remaining| ht.len() > remaining / VALUE_OVERHEAD) + { + return Err("bridge array exceeds size limit".to_owned()); + } + if ht.has_sequential_keys() { + let mut out = Vec::with_capacity(ht.len()); + for (_, value) in ht.iter() { + out.push(self.convert(value, depth + 1)?); + } + Ok(MiddleValue::Array(out)) + } else { + let mut out = Vec::with_capacity(ht.len()); + for (key, value) in ht.iter() { + let key = match key { + ArrayKey::Long(i) => i.to_string(), + ArrayKey::String(s) => s, + ArrayKey::Str(s) => s.to_owned(), + ArrayKey::ZendString(s) => s.try_into().unwrap_or_default(), + }; + self.budget.charge(key.len())?; + out.push((key, self.convert(value, depth + 1)?)); + } + Ok(MiddleValue::Map(out)) + } } - Err("unsupported PHP value type for marshaling".to_owned()) } - -/// A PHP array becomes an [`MiddleValue::Array`] when its keys are the -/// sequential `0..n`, otherwise an insertion-ordered [`MiddleValue::Map`]. -fn hashtable_to_middle( - ht: &ZendHashTable, - state: &BridgeState, - depth: usize, -) -> Result { - if ht.has_sequential_keys() { - let mut out = Vec::with_capacity(ht.len()); - for (_, v) in ht.iter() { - out.push(zval_to_middle_depth(v, state, depth + 1)?); - } - Ok(MiddleValue::Array(out)) - } else { - let mut out = Vec::with_capacity(ht.len()); - for (k, v) in ht.iter() { - let key = match k { - ArrayKey::Long(i) => i.to_string(), - ArrayKey::String(s) => s, - ArrayKey::Str(s) => s.to_owned(), - ArrayKey::ZendString(s) => s.try_into().unwrap_or_default(), - }; - out.push((key, zval_to_middle_depth(v, state, depth + 1)?)); - } - Ok(MiddleValue::Map(out)) +impl Drop for PhpConversion<'_> { + fn drop(&mut self) { + self.state.release_php_fns(&self.registered); } } diff --git a/tests/php/02_marshal_roundtrip.php b/tests/php/02_marshal_roundtrip.php index 4b4c4be..2e356bf 100644 --- a/tests/php/02_marshal_roundtrip.php +++ b/tests/php/02_marshal_roundtrip.php @@ -33,4 +33,22 @@ $bytes = "\x00\x01\x02\xff"; eq($bytes, $js->eval('new Uint8Array([0,1,2,255])'), 'Uint8Array -> binary string'); + +$baseline = $js->eval('__jsFnCount()'); +throws(fn() => $js->eval('[() => 42, Symbol("bad")]'), Throwable::class, 'failed conversion rejects unsupported JS value'); +eq($baseline, $js->eval('__jsFnCount()'), 'failed conversion releases registered JS functions'); +eq(16777217, strlen($js->eval('"x".repeat(16777217)')), 'generic eval is not limited by transport byte cap'); +$call = $js->eval('(fn) => fn()'); +$own = $js->eval('() => 99'); +eq(99, $call($own), 'same-engine callbacks remain supported'); +$other = new QuickJS(); +$foreign = $other->eval('() => 42'); +throws(fn() => $call($foreign), Throwable::class, 'foreign callback arguments rejected'); +$captured = new stdClass(); +$weak = WeakReference::create($captured); +$closure = fn() => $captured; +try { $call($closure, new stdClass()); } catch (Throwable) {} +unset($closure, $captured); +eq(null, $weak->get(), 'failed argument conversion releases PHP closures'); + done(); diff --git a/tests/php/12_fibers.php b/tests/php/12_fibers.php index 028de13..4e952a6 100644 --- a/tests/php/12_fibers.php +++ b/tests/php/12_fibers.php @@ -49,4 +49,11 @@ $loop = $js->eval('() => { while (true) {} }'); throws(fn() => $loop(), QuickJSTimeoutException::class, 'saved callbacks obey the execution timeout'); eq(3, $js->eval('1 + 2'), 'engine recovers after callback timeout'); + +$short = new QuickJS(timeoutMs: 20); +$short->register('slow', function () { usleep(50000); return 42; }); +$slow = $short->eval('() => php.slow()'); +throws(fn() => $slow(), QuickJSTimeoutException::class, 'blocking callback timeout detected on return'); +eq(3, $short->eval('1+2'), 'engine recovers after blocking callback timeout'); + done(); diff --git a/tests/php/13_dispatch.php b/tests/php/13_dispatch.php index 00cf90b..5c5063c 100644 --- a/tests/php/13_dispatch.php +++ b/tests/php/13_dispatch.php @@ -39,4 +39,28 @@ for ($i = 0; $i < 66; $i++) { $deep = [$deep]; } throws(fn() => $send->dispatch(['deep', $deep]), Throwable::class, 'PHP input depth bounded'); eq([['ok', 2]], $send->dispatch(['ok', 2])['messages'], 'conversion failures leave usable batch'); + +eq('?array', (string) (new ReflectionMethod($send, 'dispatch'))->getParameters()[0]->getType(), 'native argument type matches contract'); +throws(fn() => $send->dispatch(['callback', fn() => 1]), Throwable::class, 'direct input rejects PHP closures'); +throws(fn() => $send->dispatch(['callback', $send]), Throwable::class, 'direct input rejects JS callbacks'); +throws(fn() => $send->dispatch(['large', str_repeat('x', 16777217)]), Throwable::class, 'direct input byte budget enforced'); +$captured = new stdClass(); +$weak = WeakReference::create($captured); +$closure = fn() => $captured; +try { $send->dispatch(['named' => $closure]); } catch (Throwable) {} +unset($closure, $captured); +eq(null, $weak->get(), 'invalid direct arguments retain no PHP closures'); +$getterEmit = $q->eval('() => __quickjsEmit("outer", {get value() { __quickjsEmit("inner", 1); return 2; }})'); +eq([['inner', 1], ['outer', ['value' => 2]]], $getterEmit->dispatch([])['messages'], 'getter can emit without borrowing active queue'); +$short = new QuickJS(timeoutMs: 20); +$effects = 0; +$short->register('slow', fn() => usleep(50000)); +$short->register('effect', function () use (&$effects) { $effects++; }); +$late = $short->eval('() => { Promise.resolve().then(() => php.effect()); php.slow(); }'); +throws(fn() => $late->dispatch([]), QuickJSTimeoutException::class, 'expired invocation does not start first job'); +eq(0, $effects, 'expired dispatch has no job side effects'); +eq(true, $short->hasPendingJobs(), 'timed out dispatch preserves pending jobs'); +$late->dispatch(null); +eq(1, $effects, 'caller can explicitly resume pending jobs'); + done(); From 7ba5eafceba67b01d778e931d9a150be9ef33a28 Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Sat, 12 Sep 2026 03:43:58 +0200 Subject: [PATCH 06/17] Document data-only dispatch arguments --- stubs/php_quickjs.stubs.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/stubs/php_quickjs.stubs.php b/stubs/php_quickjs.stubs.php index 843e6ef..4af9111 100644 --- a/stubs/php_quickjs.stubs.php +++ b/stubs/php_quickjs.stubs.php @@ -65,7 +65,10 @@ public function roundtrip(mixed $value): mixed {} */ class Callback { - /** @return array{messages: list, jobs: int, pending: bool} */ + /** + * @param list|null $args Data-only positional arguments. + * @return array{messages: list, jobs: int, pending: bool} + */ public function dispatch(?array $args, int $maxJobs = 100): array {} public function __invoke(mixed ...$args): mixed {} public function call(mixed ...$args): mixed {} From 09c44dae546dcfb99202bbda3c7fcb2a965821cb Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Tue, 29 Sep 2026 17:23:24 +0200 Subject: [PATCH 07/17] Add Docker Compose development environment Build and test with PHP 8.5 and Rust 1.96.1 using a bind-mounted checkout. Keep Cargo and Composer caches in .cache and install integration-test dependencies in CI. --- .dockerignore | 3 + .github/workflows/test.yml | 3 +- .gitignore | 1 + Dockerfile-dev | 33 +++++++ Makefile | 8 +- README.md | 9 ++ composer.json | 9 ++ composer.lock | 172 +++++++++++++++++++++++++++++++++++++ docker-compose.yml | 18 ++++ docs/install.md | 20 +++++ rust-toolchain.toml | 2 +- 11 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile-dev create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e2c703a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.git +.cache +target diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 992c801..844870a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,7 +31,7 @@ jobs: phpts: nts - name: Set up Rust - uses: dtolnay/rust-toolchain@1.96 + uses: dtolnay/rust-toolchain@1.96.1 with: components: rustfmt @@ -54,6 +54,7 @@ jobs: - name: PHP integration suite run: | + composer install --no-interaction --prefer-dist EXT="$(pwd)/target/debug/libphp_quickjs.so" php -d extension="$EXT" -r 'exit(class_exists("QuickJS") ? 0 : 1);' \ || { echo "extension failed to load"; exit 1; } diff --git a/.gitignore b/.gitignore index fe41959..d40bf14 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +/vendor **/*.rs.bk *.so .* diff --git a/Dockerfile-dev b/Dockerfile-dev new file mode 100644 index 0000000..d67dbd4 --- /dev/null +++ b/Dockerfile-dev @@ -0,0 +1,33 @@ +FROM composer:2 AS composer + +FROM php:8.5-cli-bookworm + +ARG RUST_VERSION=1.96.1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + clang \ + curl \ + git \ + libclang-dev \ + make \ + pkg-config \ + unzip \ + && rm -rf /var/lib/apt/lists/* + +ENV CARGO_HOME=/opt/cargo \ + RUSTUP_HOME=/opt/rustup \ + PATH=/opt/cargo/bin:${PATH} + +COPY --from=composer /usr/bin/composer /usr/local/bin/composer + +RUN curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \ + | sh -s -- -y --no-modify-path --profile minimal --default-toolchain "${RUST_VERSION}" \ + && rustup component add rustfmt clippy --toolchain "${RUST_VERSION}" + +WORKDIR /workspace + +ENV CARGO_TARGET_DIR=/workspace/target/docker + +CMD ["make", "test"] diff --git a/Makefile b/Makefile index c0165c9..7bfc1df 100644 --- a/Makefile +++ b/Makefile @@ -11,10 +11,11 @@ CARGO_FLAGS := endif EXT_SUFFIX := $(if $(filter Darwin,$(shell uname -s)),dylib,so) -EXT := $(CURDIR)/target/$(PROFILE)/libphp_quickjs.$(EXT_SUFFIX) +TARGET_DIR := $(if $(CARGO_TARGET_DIR),$(CARGO_TARGET_DIR),$(CURDIR)/target) +EXT := $(TARGET_DIR)/$(PROFILE)/libphp_quickjs.$(EXT_SUFFIX) PHP := php -d extension=$(EXT) -.PHONY: all build release test test-rust test-php stubs example clean fmt +.PHONY: all build release test test-rust test-php test-docker stubs example clean fmt all: build @@ -27,6 +28,9 @@ release: # Rust unit tests (marshaling, manifest, facade) + the PHP integration suite. test: build test-rust test-php +test-docker: + docker compose run --rm --build dev + test-rust: cargo test --lib diff --git a/README.md b/README.md index 3303b7d..49d696a 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,15 @@ git clone https://github.com/eddmann/php-quickjs && cd php-quickjs make release ``` +To build and test with PHP 8.5 + Rust 1.96.1 in Docker: + +```sh +docker compose run --rm --build dev +``` + +The repository is mounted at `/workspace`; Cargo output stays in +`target/docker`, and dependency caches stay under `.cache/`. + → Full platform matrix, Docker, and AWS Lambda / Bref instructions: **[docs/install.md](docs/install.md)**. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..f87dcfa --- /dev/null +++ b/composer.json @@ -0,0 +1,9 @@ +{ + "name": "xtrime/php-quickjs-dev", + "description": "Development dependencies for php-quickjs integration tests", + "license": "MIT", + "require-dev": { + "amphp/amp": "^3.1", + "revolt/event-loop": "^1.0" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..a122a21 --- /dev/null +++ b/composer.lock @@ -0,0 +1,172 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "fe33434037e80a5da91beec643f5f566", + "packages": [], + "packages-dev": [ + { + "name": "amphp/amp", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/amp.git", + "reference": "73c38b323ff8d790abf0f76c56fcc892bda4111a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/amp/zipball/73c38b323ff8d790abf0f76c56fcc892bda4111a", + "reference": "73c38b323ff8d790abf0f76c56fcc892bda4111a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Future/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A non-blocking concurrency framework for PHP applications.", + "homepage": "https://amphp.org/amp", + "keywords": [ + "async", + "asynchronous", + "awaitable", + "concurrency", + "event", + "event-loop", + "future", + "non-blocking", + "promise" + ], + "support": { + "issues": "https://github.com/amphp/amp/issues", + "source": "https://github.com/amphp/amp/tree/v3.1.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-07-19T17:59:20+00:00" + }, + { + "name": "revolt/event-loop", + "version": "v1.0.9", + "source": { + "type": "git", + "url": "https://github.com/revoltphp/event-loop.git", + "reference": "44061cf513e53c6200372fc935ac42271566295d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/44061cf513e53c6200372fc935ac42271566295d", + "reference": "44061cf513e53c6200372fc935ac42271566295d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-json": "*", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Revolt\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "ceesjank@gmail.com" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Rock-solid event loop for concurrent PHP applications.", + "keywords": [ + "async", + "asynchronous", + "concurrency", + "event", + "event-loop", + "non-blocking", + "scheduler" + ], + "support": { + "issues": "https://github.com/revoltphp/event-loop/issues", + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.9" + }, + "time": "2026-05-16T17:55:38+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9632373 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + dev: + build: + context: . + dockerfile: Dockerfile-dev + image: php-quickjs-dev:php85-rust196 + working_dir: /workspace + volumes: + - .:/workspace + environment: + CARGO_HOME: /workspace/.cache/cargo + COMPOSER_CACHE_DIR: /workspace/.cache/composer + entrypoint: + - bash + - -c + - 'composer install --no-interaction --prefer-dist && exec "$$@"' + - -- + command: [make, test] diff --git a/docs/install.md b/docs/install.md index 2d2a82c..ebd7bcb 100644 --- a/docs/install.md +++ b/docs/install.md @@ -19,6 +19,26 @@ Releases attach these artifacts (per PHP 8.4 / 8.5, NTS): ## Self-hosted (Linux / macOS / Docker) +For local development without installing Rust, PHP headers, or Composer on the +host, use the included development image: + +```sh +docker compose run --rm --build dev +docker compose run --rm --build dev make release +``` + +`Dockerfile-dev` pins PHP 8.5 and Rust 1.96.1. Compose mounts the checkout at +`/workspace`, writes build artifacts to `target/docker`, and keeps Cargo and +Composer downloads in `.cache/cargo` and `.cache/composer` (ignored by Git). +It creates no Docker-managed volumes. + +The default command runs the test suite after installing Composer dependencies. +Pass another command to run tools in the same environment, for example: + +```sh +docker compose run --rm dev cargo fmt --check +``` + Download the `.so`/`.dylib` matching your PHP version and arch, then enable it: ```ini diff --git a/rust-toolchain.toml b/rust-toolchain.toml index e3bc9b0..620e3ef 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,6 +1,6 @@ [toolchain] # 1.96+ required: oxc 0.137 uses stabilized `if let` guards. -channel = "1.96" +channel = "1.96.1" # Pin rustfmt here so any toolchain rustup resolves from this file (e.g. when # `cargo fmt` triggers an install) always has the component — CI's separate # component install can land on a different toolchain resolution otherwise. From 8a428ba084c51ec9a09b71c949eb2de7430c5f00 Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Tue, 29 Sep 2026 17:24:31 +0200 Subject: [PATCH 08/17] Await JavaScript promises through Revolt suspensions Automatically await eval and saved callback results using QuickJS Promise state. Preserve Fiber ownership, queue foreign callback calls with their results and errors, and wake external waits at the execution deadline. Share native callback conversion and cover Revolt/Amp flows with regression tests. --- README.md | 13 +-- docs/api.md | 6 +- docs/architecture.md | 19 ++-- docs/async.md | 60 ++++++----- src/callback.rs | 82 +++++++-------- src/engine.rs | 203 +++++++++++++++++++++++++++++++++--- src/js/runtime.js | 15 ++- src/lib.rs | 4 +- stubs/php_quickjs.stubs.php | 5 +- tests/php/11_jobs.php | 20 ++++ tests/php/12_fibers.php | 11 +- tests/php/14_async.php | 66 ++++++++++++ 12 files changed, 380 insertions(+), 124 deletions(-) create mode 100644 tests/php/14_async.php diff --git a/README.md b/README.md index 49d696a..8209b3a 100644 --- a/README.md +++ b/README.md @@ -114,18 +114,19 @@ PHP (trusted) ──ext-php-rs──► Rust bridge ──rquickjs──► ``` Everything the guest reaches goes through a single `__host` import and a flat dispatch -table; the namespaced `php.*` tree is frozen JS built from your registrations. Values -cross as MessagePack, functions as references backed by registries, and errors bridge +table; the namespaced `php.*` tree is frozen JS built from your registrations. Host +calls use MessagePack; eval and saved callbacks use native conversion. Functions +cross as registry references, and errors bridge both ways — remapping to TS coordinates on the way out. → **[docs/architecture.md](docs/architecture.md)** for the full design. ## Asynchronous execution -Use `executePendingJobs()` to advance Promise continuations in bounded batches and -`hasPendingJobs()` to check for ready work. The host owns timers and I/O. -Instances may be used sequentially from PHP Fibers, but host callbacks must -return before switching Fibers. See [Promise jobs and Fibers](docs/async.md). +`eval()` and `Js\Callback` automatically await returned Promises. External I/O +yields through Revolt using php-tokio's Fiber model; rejections become PHP +exceptions. Manual job APIs remain available for detached work. See +[asynchronous execution](docs/async.md). ## Scope diff --git a/docs/api.md b/docs/api.md index 9cf692a..04b02f9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -18,7 +18,7 @@ surfaced by `dts()`. This flat registry is the **entire** trust boundary. ### `eval(string $code): mixed` -Run TypeScript or JavaScript and marshal the result back to PHP. Errors raise a +Run TypeScript or JavaScript, await a returned Promise, and marshal its result to PHP. Errors raise a `QuickJSEvalException` located at the original TS line/column (see [errors](errors.md)). ### `grant(mixed $resource): int` / `resolve(int $h): mixed` / `revoke(int $h): bool` @@ -59,7 +59,9 @@ budget. The constructor's `timeoutMs` also applies to this call, including an individual job that does not return; a timeout raises `QuickJSTimeoutException`. Only shared mode supports job pumping. Calling `executePendingJobs()` from an active JS -call is rejected. `eval()` and `Js\Callback` do not drain jobs implicitly. +call is rejected. `eval()` and `Js\Callback` automatically await a Promise they +return, including the jobs needed to settle it. They do not drain unrelated, +detached jobs when their own result is not a Promise. Promise rejections retain JavaScript semantics: use `.catch()`/rejection handlers; `executePendingJobs()` is not an unhandled-rejection reporting API. diff --git a/docs/architecture.md b/docs/architecture.md index 59d21e3..2243ebe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -109,14 +109,14 @@ the real callable is held in a registry on the owning side. **JS-side** registry (`jsFns` in `runtime.js`); PHP receives a `Js\Callback` object holding the integer `id`. -`runtime.js` does the wrapping: `wrap()` replaces functions with refs before -encoding (outgoing), `unwrap()` replaces refs with callables after decoding -(incoming). The host (Rust) only ever sees the tagged refs. +For JS → PHP calls, `wrap()` replaces functions with refs before encoding and +`unwrap()` restores them after decoding. Saved JS callbacks use native value +conversion in Rust. ### Invoking a JS function from PHP -`Js\Callback::__invoke` (`callback.rs`) re-enters the realm and calls -`globalThis.__invokeJs(id, argsBytes)`, which looks up `jsFns[id]` and runs it. +`Js\Callback::__invoke` (`callback.rs`) looks up the function with `__getJsFn`, +converts its arguments, invokes it, and awaits any returned Promise. The subtlety is **re-entrancy**. Each engine records its own active context while inside `Context::with`. A nested callback reuses that context instead of @@ -125,9 +125,12 @@ own context. The active pointer is cleared by a scope guard on return, including errors. A re-entrancy depth cap (200) bounds recursive bridge calls. At an outer entry, QuickJS's stack limit is refreshed for the current PHP Fiber. -Zend Fiber switching is blocked while native borrows are live. The same guard -arms and clears the execution deadline for evals, callbacks, and job batches. -See [asynchronous execution](async.md). +The engine records that Fiber as its owner. Like php-tokio, a host callback may +suspend while its native Rust/QuickJS stack remains alive, but another Fiber may +not enter the same engine. External Promise callbacks are queued by Revolt and +executed after the owner resumes. The same entry guard arms and clears the +execution deadline for evals, callbacks, and job batches. See +[asynchronous execution](async.md). ## Capability handles diff --git a/docs/async.md b/docs/async.md index 78b9dcc..015abbd 100644 --- a/docs/async.md +++ b/docs/async.md @@ -1,9 +1,22 @@ -# Promise jobs and PHP Fibers +# Async functions, Promise jobs and PHP Fibers -QuickJS provides Promises, but the host owns I/O and scheduling. The extension -exposes `hasPendingJobs()` and `executePendingJobs($maxJobs = 100)` so a PHP event loop can -advance JavaScript without blocking on network activity. No event loop library -is required by the extension. +`eval()` and `Js\Callback` automatically await returned Promises and thenables: + +```php +$double = $js->eval('async n => { await 0; return n * 2; }'); +echo $double(21); // 42 +echo $js->eval('(async () => 42)()'); // 42 +``` + +Rejections become PHP exceptions. For external I/O, install and autoload +`revolt/event-loop`. The current flow yields through `EventLoop::getSuspension()`; +`timeoutMs` wakes it if the Promise does not settle in time. Like php-tokio, +registered PHP callbacks may await I/O while the native stack remains suspended. + +## Detached low-level jobs + +For detached work whose Promise is not returned to PHP, use `hasPendingJobs()` +and `executePendingJobs($maxJobs = 100)`. This manual mode needs no event loop. ```php $js = new QuickJS(timeoutMs: 100); @@ -17,36 +30,21 @@ $resolve(42); $js->executePendingJobs(); // prints 42 ``` -Keep the instance in shared mode (`isolated: false`, the default). `executePendingJobs()` -and `hasPendingJobs()` reject isolated mode, whose contexts do not survive their -eval boundary. A Promise waiting for I/O will not keep `hasPendingJobs()` true. - -For an event loop, execute a bounded batch after delivering I/O results. If jobs -remain, schedule another batch on a later loop turn. Do not busy-wait on pending -Promises, and do not drain an unbounded self-scheduling queue before servicing -I/O. Resolve an application-level PHP Future from a registered completion -callback; PHP Futures are not converted to JS Promises automatically. +Manual jobs require shared mode (`isolated: false`). `hasPendingJobs()` counts +ready jobs, not pending I/O. Run bounded batches after I/O; never busy-wait. +Automatic awaiting stops when the returned Promise settles, leaving later jobs +queued. PHP Future objects are not automatically converted to JS Promises. ## Fiber boundaries -An instance or saved callback may be used sequentially from different PHP -Fibers. At each outer JS entry the extension refreshes QuickJS's native stack -limit. Nested callbacks reuse the owning engine's context and retain the outer -execution deadline. Saved callbacks and job batches obey `timeoutMs` too. - -PHP must return from the extension before switching Fibers. Switching while JS -is active is rejected by Zend: a suspended Rust/QuickJS stack would keep live -borrows and a runtime lock. Register callbacks that enqueue work and return; -perform asynchronous I/O after control returns to PHP. This also applies to -starting another Fiber synchronously from a host callback. - -Synchronous JS → PHP → JS callbacks remain supported, including callbacks owned -by a different QuickJS instance. Calling `eval()` or `executePendingJobs()` reentrantly on -the active instance is rejected; use a saved JS callback for synchronous reentry. +Each active engine belongs to one PHP Fiber. While it awaits a Promise, saved +callbacks from other Revolt Fibers are queued for the owner; their callers wait +for the result or exception. Other concurrent entry is rejected. Sequential use +from different Fibers is supported; the extension remains single-threaded/NTS. -The execution deadline interrupts JavaScript, not blocking PHP/C code in a host -callback. An overrun throws when PHP returns to the extension. Keep host callbacks short. The extension remains single-threaded/NTS; -Fiber support does not add ZTS or parallel-thread support. +Nested JS → PHP → JS calls reuse the owner's context and deadline. Reentrant +`eval()` and job pumping are rejected; use a saved callback instead. +Blocking PHP/C callbacks cannot be interrupted: an overrun throws on return. ## Batched direct dispatch diff --git a/src/callback.rs b/src/callback.rs index ffef00b..edfabbe 100644 --- a/src/callback.rs +++ b/src/callback.rs @@ -7,11 +7,11 @@ use crate::engine::Engine; use crate::marshal::{ - arguments_to_middle, data_arguments, middle_to_js, middle_to_zval, MiddleValue, + arguments_to_middle, data_arguments, js_to_middle, middle_to_js, middle_to_zval, MiddleValue, }; use ext_php_rs::prelude::*; use ext_php_rs::types::{ZendHashTable, Zval}; -use rquickjs::{Ctx, Function, TypedArray, Value}; +use rquickjs::{Ctx, Function, Value}; use std::rc::Rc; #[php_class] @@ -45,33 +45,15 @@ impl JsCallback { let middle_args = arguments_to_middle(args, &self.engine.state).map_err(PhpException::default)?; - let payload = MiddleValue::Array(middle_args) - .to_msgpack() - .map_err(|e| PhpException::default(e.to_string()))?; let id = self.id; let engine = self.engine.clone(); + if self.engine.active_on_other_fiber()? { + return self.engine.queue_callback(id, middle_args); + } + let run = move |ctx: &Ctx<'_>| -> PhpResult { - let globals = ctx.globals(); - let invoke: Function = globals - .get("__invokeJs") - .map_err(|e| PhpException::default(format!("__invokeJs missing: {e}")))?; - let arg_bytes = TypedArray::new(ctx.clone(), payload.clone()) - .map_err(|e| PhpException::default(e.to_string()))?; - // A JS error here re-surfaces a host exception (unwrapped to its - // original PHP class) or becomes a QuickJSEvalException. - let ret: Value = invoke - .call((id as f64, arg_bytes)) - .map_err(|e| engine.callback_error(ctx, e))?; - let ta = TypedArray::::from_value(ret).map_err(|e| { - PhpException::default(format!("JS callback did not return bytes: {e}")) - })?; - let bytes = ta - .as_bytes() - .ok_or_else(|| PhpException::default("detached result buffer".to_owned()))?; - let mv = MiddleValue::from_msgpack(bytes) - .map_err(|e| PhpException::default(e.to_string()))?; - middle_to_zval(&mv, &engine.state).map_err(PhpException::default) + invoke_callback(ctx, &engine, id, &middle_args) }; if !self.engine.is_active() && self.engine.shared_ctx().is_none() { @@ -83,6 +65,37 @@ impl JsCallback { } } +pub(crate) fn invoke_callback( + ctx: &Ctx<'_>, + engine: &Engine, + id: u64, + args: &[MiddleValue], +) -> PhpResult { + let map_error = |e| engine.callback_error(ctx, e); + let value = call_js(ctx, engine, id, args)?; + let value = engine.await_value(ctx, value, map_error)?; + let middle = js_to_middle(ctx, value, &engine.state).map_err(map_error)?; + middle_to_zval(&middle, &engine.state).map_err(PhpException::default) +} + +fn call_js<'js>( + ctx: &Ctx<'js>, + engine: &Engine, + id: u64, + args: &[MiddleValue], +) -> PhpResult> { + let map_error = |e| engine.callback_error(ctx, e); + let get: Function = ctx.globals().get("__getJsFn").map_err(&map_error)?; + let function: Function = get.call((id as f64,)).map_err(&map_error)?; + let mut call_args = rquickjs::function::Args::new(ctx.clone(), args.len()); + for arg in args { + call_args + .push_arg(middle_to_js(ctx, arg, &engine.state).map_err(&map_error)?) + .map_err(&map_error)?; + } + function.call_arg(call_args).map_err(map_error) +} + #[php_impl] impl JsCallback { /// Direct, data-only dispatch followed by a bounded job batch. Pass null @@ -113,25 +126,8 @@ impl JsCallback { let _batch = self.engine.state.begin_batch(); self.engine.eval_in(|ctx| { if let Some(MiddleValue::Array(items)) = &middle { - let get: Function = ctx - .globals() - .get("__getJsFn") - .map_err(|e| self.engine.callback_error(ctx, e))?; - let fun: Function = get - .call((self.id as f64,)) - .map_err(|e| self.engine.callback_error(ctx, e))?; - let mut call_args = rquickjs::function::Args::new(ctx.clone(), items.len()); - for item in items { - call_args - .push_arg( - middle_to_js(ctx, item, &self.engine.state) - .map_err(|e| self.engine.callback_error(ctx, e))?, - ) - .map_err(|e| self.engine.callback_error(ctx, e))?; - } // Dispatch is a notification; its return value is deliberately ignored. - fun.call_arg::(call_args) - .map_err(|e| self.engine.callback_error(ctx, e))?; + call_js(ctx, &self.engine, self.id, items)?; } let jobs = self.engine.run_jobs(ctx, maxJobs)?; let pending = unsafe { diff --git a/src/engine.rs b/src/engine.rs index 61aa556..7244168 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,11 +1,19 @@ //! Owns the QuickJS runtime/context and the re-entrancy machinery. use crate::bridge::BridgeState; +use crate::marshal::MiddleValue; use crate::sandbox; use crate::transpile::TranspileCache; -use ext_php_rs::prelude::*; -use rquickjs::{Context, Ctx, Runtime}; -use std::cell::Cell; +use ext_php_rs::{ + closure::Closure, + convert::{IntoZval, IntoZvalDyn}, + prelude::*, + types::Zval, + zend::Function as PhpFunction, +}; +use rquickjs::{Context, Ctx, Function, Promise, Runtime, Value}; +use std::cell::{Cell, RefCell}; +use std::collections::VecDeque; use std::ptr::NonNull; use std::rc::Rc; use std::time::{Duration, Instant}; @@ -25,6 +33,13 @@ pub struct Engine { shared_ctx: Option, depth: Cell, active_ctx: Cell>>, + /// Identity of the PHP Fiber which owns `active_ctx` (zero is {main}). + /// A suspended native stack may only be resumed by this same Fiber. + active_fiber: Cell>, + /// Calls arriving from Revolt while the owner waits on a Promise are queued; + /// the owner executes them after its Suspension resumes. + queued_callbacks: RefCell>, + waiter: RefCell>>, /// Per-entry wall-clock deadline; `None` when no eval is in flight. deadline: Rc>>, /// Set by the interrupt handler when it aborts on the deadline. @@ -33,7 +48,46 @@ pub struct Engine { timeout: Option, } +struct QueuedCallback { + id: u64, + args: Vec, + suspension: Zval, + result: Rc>>>, +} + +struct Waiter { + suspension: Zval, + woken: Cell, +} + +impl Waiter { + fn wake(&self) -> PhpResult<()> { + if !self.woken.replace(true) { + self.suspension.try_call_method("resume", vec![])?; + } + Ok(()) + } +} + +fn call_php(class: &str, method: &str, args: Vec<&dyn IntoZvalDyn>) -> PhpResult { + PhpFunction::try_from_method(class, method) + .ok_or_else(|| { + PhpException::default(format!( + "{class}::{method} is unavailable; autoload revolt/event-loop for external I/O" + )) + })? + .try_call(args) + .map_err(Into::into) +} + impl Engine { + fn current_fiber_id() -> PhpResult { + let fiber = call_php("Fiber", "getCurrent", vec![])?; + Ok(fiber + .object() + .map_or(0, |object| object as *const _ as usize)) + } + fn check_deadline(&self, ctx: &Ctx<'_>) -> PhpResult<()> { if self.timed_out() || self.deadline.get().is_some_and(|d| Instant::now() >= d) { self.timed_out.set(true); @@ -74,6 +128,114 @@ impl Engine { Ok(count) } + /// Await returned Promises, yielding to Revolt when host I/O is pending. + pub fn await_value<'js>( + &self, + ctx: &Ctx<'js>, + value: Value<'js>, + map_js_error: impl Fn(rquickjs::Error) -> PhpException, + ) -> PhpResult> { + let promise = match value.as_promise() { + Some(promise) => Some(promise.clone()), + None => { + let normalize: Function = + ctx.globals().get("__asPromise").map_err(&map_js_error)?; + normalize + .call::<_, Option>((value.clone(),)) + .map_err(&map_js_error)? + } + }; + let Some(promise) = promise else { + return Ok(value); + }; + + loop { + self.check_deadline(ctx)?; + if let Some(result) = promise.result() { + return result.map_err(map_js_error); + } + if self.run_jobs(ctx, 1)? == 0 { + self.suspend_on_revolt()?; + self.check_deadline(ctx)?; + self.drain_queued_callbacks(ctx)?; + } + } + } + + fn suspend_on_revolt(&self) -> PhpResult<()> { + let suspension = call_php("Revolt\\EventLoop", "getSuspension", vec![])?; + let waiter = Rc::new(Waiter { + suspension: suspension.shallow_clone(), + woken: Cell::new(false), + }); + let timer = self + .deadline + .get() + .map(|deadline| -> PhpResult { + let waiter = waiter.clone(); + let callback = Closure::wrap( + Box::new(move || waiter.wake()) as Box PhpResult<()>> + ) + .into_zval(false)?; + let callback = call_php("Closure", "fromCallable", vec![&callback])?; + let delay = deadline + .saturating_duration_since(Instant::now()) + .as_secs_f64(); + call_php("Revolt\\EventLoop", "delay", vec![&delay, &callback]) + }) + .transpose()?; + *self.waiter.borrow_mut() = Some(waiter); + let result = suspension.try_call_method("suspend", vec![]); + self.waiter.borrow_mut().take(); + if let Some(timer) = timer { + call_php("Revolt\\EventLoop", "cancel", vec![&timer])?; + } + result.map(|_| ()).map_err(Into::into) + } + + fn drain_queued_callbacks(&self, ctx: &Ctx<'_>) -> PhpResult<()> { + loop { + self.check_deadline(ctx)?; + let Some(queued) = self.queued_callbacks.borrow_mut().pop_front() else { + return Ok(()); + }; + let result = crate::callback::invoke_callback(ctx, self, queued.id, &queued.args); + *queued.result.borrow_mut() = Some(result); + queued.suspension.try_call_method("resume", vec![])?; + } + } + + pub fn active_on_other_fiber(&self) -> PhpResult { + let current = Self::current_fiber_id()?; + Ok(self.is_active() + && self + .active_fiber + .get() + .is_some_and(|owner| owner != current)) + } + + pub fn queue_callback(&self, id: u64, args: Vec) -> PhpResult { + let waiter = self.waiter.borrow().clone().ok_or_else(|| { + PhpException::default("QuickJS engine is active on another PHP Fiber".to_owned()) + })?; + let suspension = call_php("Revolt\\EventLoop", "getSuspension", vec![])?; + let result = Rc::new(RefCell::new(None)); + self.queued_callbacks + .borrow_mut() + .push_back(QueuedCallback { + id, + args, + suspension: suspension.shallow_clone(), + result: result.clone(), + }); + waiter.wake()?; + suspension.try_call_method("suspend", vec![])?; + let result = result.borrow_mut().take().ok_or_else(|| { + PhpException::default("JS callback resumed before completion".to_owned()) + })?; + result + } + pub fn new( memory_limit: usize, timeout_ms: u64, @@ -101,6 +263,9 @@ impl Engine { shared_ctx, depth: Cell::new(0), active_ctx: Cell::new(None), + active_fiber: Cell::new(None), + queued_callbacks: RefCell::new(VecDeque::new()), + waiter: RefCell::new(None), deadline, timed_out, timeout: (timeout_ms > 0).then(|| Duration::from_millis(timeout_ms)), @@ -139,9 +304,15 @@ impl Engine { /// Run on the current PHP stack. Reentrant callbacks reuse this engine's /// context; a callback belonging to a different engine gets its own context. pub fn eval_in(&self, f: impl FnOnce(&Ctx<'_>) -> PhpResult) -> PhpResult { + let current_fiber = Self::current_fiber_id()?; if let Some(ptr) = self.active_ctx.get() { + if self.active_fiber.get() != Some(current_fiber) { + return Err(PhpException::default( + "QuickJS engine is active on another PHP Fiber".to_owned(), + )); + } // SAFETY: the outer Context::with owns this context and its lock. - // Fiber switching is blocked until that call returns. + // A suspended native stack may only be resumed by its owning Fiber. let ctx = unsafe { Ctx::from_raw(ptr) }; self.check_deadline(&ctx)?; let result = f(&ctx); @@ -150,14 +321,13 @@ impl Engine { } let run = |ctx: &Context| { ctx.with(|c| { - // SAFETY: c belongs to this locked runtime. PHP Fibers can enter - // on a different native stack, so refresh QuickJS's stack limit - // at the outer boundary only (never during JS recursion). + // SAFETY: the runtime is locked; refresh its stack limit for + // this PHP Fiber at the outer entry, never during recursion. unsafe { rquickjs::qjs::JS_UpdateStackTop(ctx.get_runtime_ptr()); - zend_fiber_switch_block(); } self.active_ctx.set(Some(c.as_raw())); + self.active_fiber.set(Some(current_fiber)); self.arm_deadline(); let _guard = ExecutionGuard { engine: self }; crate::bridge::flush_pending_deletions(&c, &self.state) @@ -217,13 +387,6 @@ impl Drop for DepthGuard<'_> { } } -// These Zend APIs maintain a nesting counter. Blocking switches prevents PHP -// from suspending while Rust borrows and the QuickJS runtime lock are live. -unsafe extern "C" { - fn zend_fiber_switch_block(); - fn zend_fiber_switch_unblock(); -} - struct ExecutionGuard<'a> { engine: &'a Engine, } @@ -231,8 +394,14 @@ struct ExecutionGuard<'a> { impl Drop for ExecutionGuard<'_> { fn drop(&mut self) { self.engine.active_ctx.set(None); + self.engine.active_fiber.set(None); self.engine.disarm_deadline(); - // SAFETY: paired with the block in eval_in, including error unwinding. - unsafe { zend_fiber_switch_unblock() }; + let pending = std::mem::take(&mut *self.engine.queued_callbacks.borrow_mut()); + for queued in pending { + *queued.result.borrow_mut() = Some(Err(PhpException::default( + "JavaScript callback canceled: owning call ended".to_owned(), + ))); + let _ = queued.suspension.try_call_method("resume", vec![]); + } } } diff --git a/src/js/runtime.js b/src/js/runtime.js index 7545539..9bfe9f0 100644 --- a/src/js/runtime.js +++ b/src/js/runtime.js @@ -90,16 +90,13 @@ if (!globalThis.__rt) { return unwrap(mp.decode(globalThis.__php_invoke(id, mp.encode(wrap(args))))); } - // host -> JS: invoke a JS function previously handed to PHP (called by Rust). - function invokeJs(id, argsBytes) { - var fn = jsFns[id]; - if (!fn) throw new Error("unknown JS callback id " + id); - var args = unwrap(mp.decode(argsBytes)); - var r = fn.apply(null, args); - return mp.encode(wrap(r)); + // Normalize thenables; Rust reads settlement through QuickJS's Promise API. + function asPromise(value) { + return value !== null && + (typeof value === "object" || typeof value === "function") + ? Promise.resolve(value) : null; } - globalThis.__invokeJs = invokeJs; // Used by the host to register a bare JS function value (e.g. an eval result // that is a function) so it can be handed to PHP as a Js\Callback. globalThis.__registerJsFn = registerFn; @@ -110,6 +107,7 @@ if (!globalThis.__rt) { // Called when a PHP-side Js\Callback is garbage-collected, to release its // entry from the registry. globalThis.__deleteJsFn = deleteFn; + globalThis.__asPromise = asPromise; // Test/diagnostic helper: number of live JS callbacks held for PHP. globalThis.__jsFnCount = function () { return Object.keys(jsFns).length; @@ -123,7 +121,6 @@ if (!globalThis.__rt) { unwrap: unwrap, callHost: callHost, callPhp: callPhp, - invokeJs: invokeJs, }; })(); } diff --git a/src/lib.rs b/src/lib.rs index e006c8a..0b28405 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -108,6 +108,7 @@ impl QuickJS { let value: rquickjs::Value = ctx .eval_with_options(module.js.as_bytes(), opts) .map_err(&eval_err)?; + let value = self.engine.await_value(ctx, value, eval_err)?; let middle = js_to_middle(ctx, value, &state).map_err(&eval_err)?; middle_to_zval(&middle, &state).map_err(PhpException::default) }) @@ -124,8 +125,7 @@ impl QuickJS { }) } - /// Execute at most maxJobs ready jobs; never waits for host I/O. Jobs are - /// explicit, so eval/callback execution keeps its synchronous behavior. + /// Execute at most maxJobs ready jobs without waiting for host I/O. #[php(defaults(maxJobs = 100))] pub fn executePendingJobs(&self, maxJobs: i64) -> PhpResult { self.require_shared_jobs()?; diff --git a/stubs/php_quickjs.stubs.php b/stubs/php_quickjs.stubs.php index 4af9111..2e2a88b 100644 --- a/stubs/php_quickjs.stubs.php +++ b/stubs/php_quickjs.stubs.php @@ -29,7 +29,7 @@ public function __construct(?int $memoryLimit = null, ?int $timeoutMs = null, ?i */ public function register(string $name, callable $callable, ?string $types = null): void {} - /** Evaluate JS source and marshal the result back to a PHP value. */ + /** Evaluate JS source and marshal the result back to PHP, awaiting a returned Promise. */ public function eval(string $code): mixed {} /** The registration manifest: a list of `['name' => string, 'types' => ?string]`. */ @@ -70,7 +70,10 @@ class Callback * @return array{messages: list, jobs: int, pending: bool} */ public function dispatch(?array $args, int $maxJobs = 100): array {} + /** Invoke the JS function, awaiting a returned Promise. */ public function __invoke(mixed ...$args): mixed {} + + /** Invoke the JS function, awaiting a returned Promise. */ public function call(mixed ...$args): mixed {} } } diff --git a/tests/php/11_jobs.php b/tests/php/11_jobs.php index b0baa9c..b3be98c 100644 --- a/tests/php/11_jobs.php +++ b/tests/php/11_jobs.php @@ -4,6 +4,26 @@ $js = new QuickJS(); eq(false, $js->hasPendingJobs(), 'new runtime has no jobs'); eq(0, $js->executePendingJobs(), 'empty queue returns immediately'); + +eq(42, $js->eval('(async () => 42)()'), 'fulfilled async eval is awaited automatically'); +eq(42, $js->eval('(async () => { await 0; return 42; })()'), 'async eval drains its own jobs'); +$asyncDouble = $js->eval('async n => { await 0; return n * 2; }'); +eq(42, $asyncDouble(21), 'async JS callback returns its fulfilled value to PHP'); +throws( + fn() => $js->eval('(async () => { await 0; throw new Error("async boom"); })()'), + QuickJSEvalException::class, + 'async rejection becomes a PHP evaluation exception' +); +eq(42, $js->eval('({ then(resolve) { resolve(42); } })'), 'thenables are awaited'); +eq(42, $js->eval('({ get then() { if (this.read) throw new Error("then read twice"); this.read = true; return resolve => resolve(42); } })'), 'thenable getter is read once'); +eq(42, (new QuickJS(isolated: true))->eval('(async () => { await 0; return 42; })()'), 'isolated eval awaits its Promise'); +$stop = new QuickJS(); +eq(42, $stop->eval('globalThis.detached = 0; Promise.resolve().then(() => { Promise.resolve().then(() => { detached = 1; }); return 42; })'), 'await stops when its Promise settles'); +eq(0, $stop->eval('detached'), 'await leaves later detached jobs queued'); +$stop->executePendingJobs(); +eq(1, $stop->eval('detached'), 'detached jobs can still be drained explicitly'); +throws(fn() => (new QuickJS())->eval('new Promise(() => {})'), Throwable::class, 'external wait without autoloaded Revolt fails clearly'); + $js->eval('globalThis.answer = 0; Promise.resolve(21).then(n => { answer = n * 2; }); void 0;'); eq(0, $js->eval('answer'), 'eval does not implicitly drain jobs'); eq(true, $js->hasPendingJobs(), 'Promise reaction is pending'); diff --git a/tests/php/12_fibers.php b/tests/php/12_fibers.php index 4e952a6..0cdfb8e 100644 --- a/tests/php/12_fibers.php +++ b/tests/php/12_fibers.php @@ -25,14 +25,15 @@ $js->register('other', fn($n) => $otherCallback($n)); eq(107, $js->eval('php.other(7)'), 'cross-engine callback uses its own context'); -$js->register('suspend', fn() => Fiber::suspend()); +$js->register('suspendValue', fn() => Fiber::suspend('inside JS')); $suspending = new Fiber(function () use ($js) { - throws(fn() => $js->eval('php.suspend()'), Throwable::class, 'switching inside active JS is rejected'); - Fiber::suspend('outside'); + eq(42, $js->eval('php.suspendValue(); 42'), 'host callback resumes on its owning Fiber'); }); -eq('outside', $suspending->start(), 'switching is restored after returning from JS'); +eq('inside JS', $suspending->start(), 'host callback may suspend like php-tokio'); +throws(fn() => $js->eval('1'), Throwable::class, 'another Fiber cannot enter a suspended engine'); $suspending->resume(); -eq(3, $js->eval('1 + 2'), 'engine remains usable after rejected switch'); +eq(true, $suspending->isTerminated(), 'owning Fiber finishes after resumption'); +eq(3, $js->eval('1 + 2'), 'engine remains usable after Fiber resumption'); $js->register('apply', fn($fn) => $fn()); (new Fiber(function () use ($js) { diff --git a/tests/php/14_async.php b/tests/php/14_async.php new file mode 100644 index 0000000..e7a8c8b --- /dev/null +++ b/tests/php/14_async.php @@ -0,0 +1,66 @@ +register('later', static function ($resolve): void { + Revolt\EventLoop::delay(0.001, static fn() => $resolve(42)); +}); +eq(42, $js->eval('new Promise(resolve => php.later(resolve))'), 'main flow awaits external resolution'); +eq(42, Amp\async(fn() => $js->eval('new Promise(resolve => php.later(resolve))'))->await(), 'Amp Fiber awaits external resolution'); +eq([], Revolt\EventLoop::getIdentifiers(), 'completion cancels the deadline timer'); + +$js->register('sleep', static function (): int { Amp\delay(0.001); return 42; }); +eq(42, Amp\async(fn() => $js->eval('(async () => php.sleep())()'))->await(), 'registered PHP callbacks may await Amp I/O'); + +// An event-loop Fiber must receive the callback result, including async results. +foreach (['resolve => { resolve(42); return 7; }', 'async resolve => { await 0; resolve(42); return 7; }'] as $source) { + $callback = $js->eval($source); + $future = null; + $js->register('later', static function ($resolve) use ($callback, &$future): void { + $future = Amp\async(static fn() => $callback($resolve)); + }); + eq(42, $js->eval('new Promise(resolve => php.later(resolve))'), 'queued callback resolves the owning Promise'); + eq(7, $future->await(), 'queued callback returns its value to the calling Fiber'); +} + +$failing = $js->eval('() => { throw new TypeError("queued failure"); }'); +$js->register('later', static function ($resolve) use ($failing, &$future): void { + $future = Amp\async(static function () use ($failing, $resolve): void { + try { $failing(); } finally { $resolve(42); } + }); +}); +eq(42, $js->eval('new Promise(resolve => php.later(resolve))'), 'owner continues after a queued callback fails'); +throws(fn() => $future->await(), QuickJSEvalException::class, 'queued callback delivers its exception to the calling Fiber'); + +$futures = []; +$js->register('all', static function ($callback) use (&$futures): void { + for ($i = 0; $i < 10; ++$i) { + $futures[] = Amp\async(static fn() => $callback($i)); + } +}); +eq(45, $js->eval('new Promise(resolve => { let count = 0, sum = 0; php.all(n => { sum += n; if (++count === 10) resolve(sum); return n * 2; }); })'), 'multiple event-loop callbacks settle one Promise'); +eq(range(0, 18, 2), array_map(static fn($future) => $future->await(), $futures), 'each queued caller receives its own result'); + +$limited = new QuickJS(timeoutMs: 20); +$start = hrtime(true); +throws(fn() => $limited->eval('new Promise(() => {})'), QuickJSTimeoutException::class, 'external wait obeys timeoutMs'); +ok((hrtime(true) - $start) / 1e6 < 1000, 'deadline wakes a Promise without external events'); +eq(3, $limited->eval('1 + 2'), 'engine recovers after an external wait timeout'); +eq([], Revolt\EventLoop::getIdentifiers(), 'timeout leaves no timer behind'); +throws(fn() => Amp\async(fn() => $limited->eval('new Promise(() => {})'))->await(), QuickJSTimeoutException::class, 'deadline wakes an Amp Fiber too'); +eq(3, $limited->eval('1 + 2'), 'engine recovers after an Amp Fiber timeout'); + +// Delay the owner after a foreign callback is queued, letting its deadline expire. +$limited->register('cancel', static function ($callback) use (&$future): void { + $future = Amp\async(static fn() => $callback()); + Revolt\EventLoop::queue(static fn() => usleep(40000)); +}); +throws(fn() => Amp\async(fn() => $limited->eval('new Promise(() => php.cancel(() => { globalThis.late = true; return 42; }))'))->await(), QuickJSTimeoutException::class, 'expired owner does not execute queued callbacks'); +throws(fn() => $future->await(), Exception::class, 'canceled callback wakes its caller with an exception'); +eq('undefined', $limited->eval('typeof late'), 'canceled callback has no side effects'); +done(); From 70b7c7d59f6d26f1ae22699fec3caf13b912b692 Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Tue, 29 Sep 2026 22:18:39 +0200 Subject: [PATCH 09/17] Stabilize async callbacks and native message API --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 16 +++- docs/api.md | 19 ++-- docs/architecture.md | 17 ++-- docs/async.md | 122 +++++++++--------------- src/bridge.rs | 112 +++++++++++----------- src/callback.rs | 63 +++---------- src/engine.rs | 162 ++++++++++++++++++++++++++------ src/lib.rs | 24 ++++- src/manifest.rs | 7 +- src/marshal.rs | 38 ++------ src/sandbox.rs | 8 +- stubs/php_quickjs.stubs.php | 13 ++- tests/php/08_dts_generation.php | 1 + tests/php/11_jobs.php | 1 + tests/php/13_dispatch.php | 66 ------------- tests/php/13_messages.php | 30 ++++++ tests/php/14_async.php | 31 ++++++ tests/php/15_timeout.php | 8 ++ tests/php/16_async_messages.php | 25 +++++ 21 files changed, 426 insertions(+), 341 deletions(-) delete mode 100644 tests/php/13_dispatch.php create mode 100644 tests/php/13_messages.php create mode 100644 tests/php/15_timeout.php create mode 100644 tests/php/16_async_messages.php diff --git a/Cargo.lock b/Cargo.lock index 1dcf29b..827ab74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1678,7 +1678,7 @@ dependencies = [ [[package]] name = "php_quickjs" -version = "0.0.2" +version = "0.0.3" dependencies = [ "ext-php-rs", "lru", diff --git a/Cargo.toml b/Cargo.toml index 18ac07a..8ce220d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "php_quickjs" -version = "0.0.2" +version = "0.0.3" edition = "2021" rust-version = "1.96" description = "Embed a QuickJS sandbox in PHP with typed, bidirectional communication" diff --git a/README.md b/README.md index 8209b3a..d21a3b6 100644 --- a/README.md +++ b/README.md @@ -113,11 +113,12 @@ PHP (trusted) ──ext-php-rs──► Rust bridge ──rquickjs──► eval() __host(name, bytes) frozen php.* facade ``` -Everything the guest reaches goes through a single `__host` import and a flat dispatch -table; the namespaced `php.*` tree is frozen JS built from your registrations. Host -calls use MessagePack; eval and saved callbacks use native conversion. Functions -cross as registry references, and errors bridge -both ways — remapping to TS coordinates on the way out. +Registered PHP capabilities go through one `__host` entry point and a flat +dispatch table; the namespaced `php.*` tree is frozen JS built from your +registrations. The separate `quickjs.postMessage()` sink copies data into a +bounded native queue. Host calls use MessagePack; eval, saved callbacks and +messages use native conversion. Functions cross as registry references, and +errors bridge both ways with TS source locations. → **[docs/architecture.md](docs/architecture.md)** for the full design. @@ -128,6 +129,11 @@ yields through Revolt using php-tokio's Fiber model; rejections become PHP exceptions. Manual job APIs remain available for detached work. See [asynchronous execution](docs/async.md). +Async consumers can send data-only notifications with `quickjs.postMessage(value)` +and read them using `QuickJS::drainMessages()`. The native queue snapshots values +at send time and has a configurable aggregate byte limit. `timeoutMs` remains an +optional wall-clock limit for a complete call, including Promise waits. + ## Scope This is an *embedder*, not a standalone defence against hostile code. The capability diff --git a/docs/api.md b/docs/api.md index 04b02f9..fdcc461 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,11 +4,13 @@ The extension exposes a single `QuickJS` class. For the bigger picture see [architecture](architecture.md); for realms and the callback lifecycle see [execution modes](execution-modes.md). -### `new QuickJS(?int $memoryLimit = null, ?int $timeoutMs = null, ?int $maxStack = null, bool $isolated = false)` +### `new QuickJS(?int $memoryLimit = null, ?int $timeoutMs = null, ?int $maxStack = null, bool $isolated = false, ?int $maxQueuedMessageBytes = null)` Limits default to unbounded; pass non-zero values to contain resource abuse. `isolated: true` runs each `eval()` in a fresh realm (see [execution modes](execution-modes.md)). +`maxQueuedMessageBytes` defaults to 32 MiB and must be positive. `timeoutMs` +bounds the complete call, including Promise waits; `null` disables it. ### `register(string $name, callable $fn, ?string $types = null): void` @@ -35,8 +37,8 @@ $js->register('db.query', fn(int $handle, string $sql) => $js->resolve($handle)- ### `manifest(): array` / `dts(): string` -The registration manifest and a generated TypeScript `.d.ts` for the `php` global, -both from the same source of truth. +The registration manifest and generated TypeScript `.d.ts` for the `php` and +`quickjs` globals. The `php` declaration comes from the registration manifest. ### `roundtrip(mixed $value): mixed` @@ -68,11 +70,8 @@ Promise rejections retain JavaScript semantics: use `.catch()`/rejection handler See [asynchronous execution](async.md) for host event loop integration and PHP Fiber boundaries. -### `Js\Callback::dispatch(?array $args, int $maxJobs = 100): array` +### `drainMessages(): array` -Invoke a saved callback and collect direct guest messages while advancing a -bounded Promise job batch. `null` arguments only drain jobs. Returns -`array{messages: list, jobs: int, pending: bool}`. -Requires shared mode and cannot be called reentrantly. See -[batched direct dispatch](async.md#batched-direct-dispatch) for the message -format, error recovery and limits. +Return and clear messages sent by JS through `quickjs.postMessage(value)`. +Values are copied at send time and contain data only. This method does not +enter JS or execute jobs. See [asynchronous execution](async.md) for limits. diff --git a/docs/architecture.md b/docs/architecture.md index 2243ebe..d2120fc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,7 +5,7 @@ ``` ┌─ PHP (trusted, full Zend) ──┐ ┌─ Rust extension ─┐ ┌─ QuickJS (untrusted) ─┐ │ $js->register(...) │ │ owns the engine │ │ php.module.fn() │ -│ $js->eval(tsCode) │◄─►│ ONE __host import│◄─►│ frozen php.* facade │ +│ $js->eval(tsCode) │◄─►│ host capabilities│◄─►│ frozen php.* facade │ │ $js->grant($obj) │ │ msgpack marshal │ │ guest TS-as-JS │ └─────────────────────────────┘ └───────────────────┘ └────────────────────────┘ ext-php-rs (zval ↔ Rust) rquickjs (Rust ↔ JSValue) @@ -15,8 +15,9 @@ It is **one process, one thread**. The Rust extension is a `cdylib` that PHP loads natively; `QuickJS`, `Js\Callback`, and the `QuickJS*Exception` classes are real PHP classes implemented in Rust. -The design has a single key principle: **the namespacing is cosmetic; the trust -boundary is a flat dispatch table reached through one host import.** +The capability trust boundary is a flat dispatch table reached through +`__host`. `quickjs.postMessage()` is a separate data-only output sink with a +bounded native queue. ## How one call flows, end to end @@ -40,8 +41,8 @@ Take `php.math.add(2, 3)` from a guest script. 3. `__rt.callHost` (`src/js/runtime.js`) **msgpack-encodes** the argument array and calls `__host("math.add", bytes)`. -4. `__host` is the **single** native function Rust injects into the realm — the - entire JS→host entry point. In `bridge.rs` it: +4. `__host` is the native entry point for registered PHP capabilities. In + `bridge.rs` it: - decodes the msgpack payload to a `MiddleValue` list, - looks `"math.add"` up in the **dispatch table** (rejects if not registered — this is the trust boundary), @@ -51,9 +52,9 @@ Take `php.math.add(2, 3)` from a guest script. 5. The result travels back `zval → MiddleValue → msgpack bytes`, and `__rt` decodes it in the realm. `5` lands in the guest. -Adding a capability never changes this ABI — there is exactly one import and one -dispatch table. The flat, dotted-name list (`manifest()`) is the complete audit -surface. +Adding a capability never changes this ABI: all registered capabilities use +one dispatch table. The flat, dotted-name list (`manifest()`) is the complete +audit surface for PHP callables. ### The facade is generated, and frozen diff --git a/docs/async.md b/docs/async.md index 015abbd..3da3dff 100644 --- a/docs/async.md +++ b/docs/async.md @@ -1,88 +1,60 @@ # Async functions, Promise jobs and PHP Fibers -`eval()` and `Js\Callback` automatically await returned Promises and thenables: +`eval()` and `Js\Callback` automatically await returned Promises and thenables. +Rejected Promises become PHP exceptions. External I/O yields through +`Revolt\EventLoop::getSuspension()`; autoload `revolt/event-loop` when guest code +waits on host I/O. -```php -$double = $js->eval('async n => { await 0; return n * 2; }'); -echo $double(21); // 42 -echo $js->eval('(async () => 42)()'); // 42 -``` - -Rejections become PHP exceptions. For external I/O, install and autoload -`revolt/event-loop`. The current flow yields through `EventLoop::getSuspension()`; -`timeoutMs` wakes it if the Promise does not settle in time. Like php-tokio, -registered PHP callbacks may await I/O while the native stack remains suspended. +`timeoutMs` is an optional wall-clock deadline for a complete `eval()`, callback +or manual job batch, including Promise waits. With `null` it is disabled. PHP +consumers can impose operation deadlines with their own futures and cancellation. +Canceling a PHP future does not interrupt synchronous JS already running in the +PHP process. -## Detached low-level jobs +## Native messages -For detached work whose Promise is not returned to PHP, use `hasPendingJobs()` -and `executePendingJobs($maxJobs = 100)`. This manual mode needs no event loop. +JavaScript can send a data-only value to PHP: -```php -$js = new QuickJS(timeoutMs: 100); -$resolve = null; -$js->register('capture', function ($fn) use (&$resolve) { $resolve = $fn; }); -$js->register('completed', function ($value) { echo $value, "\n"; }); -$js->eval('new Promise(resolve => php.capture(resolve)).then(php.completed); void 0;'); - -// Later, after host I/O completes and outside an active JS call: -$resolve(42); -$js->executePendingJobs(); // prints 42 +```js +quickjs.postMessage({type: 'result', id: 7, value: 42}); ``` -Manual jobs require shared mode (`isolated: false`). `hasPendingJobs()` counts -ready jobs, not pending I/O. Run bounded batches after I/O; never busy-wait. -Automatic awaiting stops when the returned Promise settles, leaving later jobs -queued. PHP Future objects are not automatically converted to JS Promises. - -## Fiber boundaries +`postMessage()` copies the value immediately. It accepts null, booleans, numbers, +strings, `Uint8Array`, arrays, and objects containing those types. Functions, +symbols and cycles are rejected. Nesting is limited to 64 levels. The `quickjs` +global and its method are frozen. PHP retrieves and clears the queue with +`$js->drainMessages()`; this does not enter JS or execute Promise jobs. -Each active engine belongs to one PHP Fiber. While it awaits a Promise, saved -callbacks from other Revolt Fibers are queued for the owner; their callers wait -for the result or exception. Other concurrent entry is rejected. Sequential use -from different Fibers is supported; the extension remains single-threaded/NTS. - -Nested JS → PHP → JS calls reuse the owner's context and deadline. Reentrant -`eval()` and job pumping are rejected; use a saved callback instead. -Blocking PHP/C callbacks cannot be interrupted: an overrun throws on return. - -## Batched direct dispatch - -`Js\Callback::dispatch(?array $args, int $maxJobs = 100)` invokes a saved -callback with a positional argument list, then executes at most `maxJobs` Promise -jobs. Passing `null` skips invocation and only advances queued jobs. Its result is -`['messages' => [[kind, payload], ...], 'jobs' => int, 'pending' => bool]`. +The queue has a configurable aggregate accounted-byte limit (32 MiB by default): ```php -$dispatch = $js->eval('(kind, payload) => __quickjsEmit(kind, payload)'); -$batch = $dispatch->dispatch(['result', ['answer' => 42]]); -// $batch['messages'] === [['result', ['answer' => 42]]] +$js = new QuickJS(maxQueuedMessageBytes: 32 * 1024 * 1024); ``` -During a batch, guest code can call `__quickjsEmit(kind, payload)` to enqueue a -message without invoking PHP. The host processes the returned messages after -QuickJS returns, so asynchronous PHP handlers may suspend safely there. Emitting -outside `dispatch()` throws. Dispatch uses native value conversion without -a MessagePack encode/decode round trip; valid UTF-8 strings stay strings and -binary PHP strings become `Uint8Array` and round-trip byte-for-byte. - -Message payloads accept null, booleans, numbers, strings, `Uint8Array`, arrays and -objects containing data; functions are rejected. PHP arguments likewise accept only -data, not Closure objects or saved JS callbacks; use call() for callable arguments. -Each output payload and the complete PHP argument list are limited to 64 nesting -levels and 16 MiB of accounted storage, including container overhead. Generic -eval(), call() and roundtrip() retain the depth limit but have no transport byte cap. A batch queue allows 4096 messages and 32 MiB of accounted storage. -These host-side caps are separate from QuickJS's `memoryLimit`. Cycles, oversized -values and queue overflow throw JS errors. PHP input nesting is also limited to -64 levels. Errors escaping a batch discard its partial messages; remaining -Promise jobs stay queued, so applications must decide whether to resume or -abandon that operation. Callback return values are ignored. - -Timeouts are checked before and after each job and at native-call return, as well as by QuickJS's interrupt hook. A -blocking PHP callback cannot be interrupted, but no further job starts after -its batch deadline has elapsed. Dropped callback registry entries are reclaimed -at the next outer engine entry, including callback-only and job-only loops. - -Failed generic value conversion rolls back callback registrations. Passing a saved -JS callback as an argument requires the same owning QuickJS instance; invoking a -foreign callback directly from PHP remains supported. +Accounting includes container and per-message overhead. A message that exceeds +the remaining budget throws in its sending operation. Earlier messages remain +queued, so a request handler can catch the error and report that request's +failure after draining space. There is no separate message-count or per-message +byte limit. + +## Detached jobs + +`hasPendingJobs()` reports ready Promise jobs; unresolved host I/O is not a ready +job. `executePendingJobs($maxJobs = 100)` executes at most that many ready jobs +and returns their count without waiting for I/O. Both methods require shared +mode. `eval()` and callbacks await a Promise they return; detached work can be +pumped explicitly. + +## Fiber scheduling + +Each active engine runs on one PHP Fiber. Callbacks arriving from another Fiber +while the owner awaits are queued. The owner starts each queued callback and +tracks its Promise independently; a pending callback does not prevent the owner +from handling another queued callback. Ready jobs run in bounded quanta of 100, +with control returned to Revolt between full quanta. Nested JS → PHP → JS calls +reuse the owner's context and wall-clock deadline. Reentrant `eval()` and manual +job pumping are rejected. + +Blocking PHP and synchronous JS cannot be interrupted by PHP future cancellation. +If `timeoutMs` is set, the QuickJS interrupt hook can interrupt synchronous JS; +a blocking PHP callback is checked when it returns. diff --git a/src/bridge.rs b/src/bridge.rs index ed82ddf..13307cf 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -41,12 +41,15 @@ pub struct BridgeState { /// JS-callback ids whose PHP wrapper was dropped, awaiting release from the /// JS registry (deferred to the next eval boundary; see `JsCallback::drop`). pending_fn_deletions: RefCell>, - batch: RefCell>, + messages: RefCell, } impl BridgeState { - pub fn new() -> Rc { - Rc::new(Self::default()) + pub fn new(max_queued_message_bytes: usize) -> Rc { + Rc::new(Self { + messages: RefCell::new(MessageQueue::new(max_queued_message_bytes)), + ..Self::default() + }) } /// Queue a JS-callback id for release at the next eval boundary. @@ -108,16 +111,16 @@ impl BridgeState { drop(removed); } - pub(crate) fn begin_batch(&self) -> BatchGuard<'_> { - *self.batch.borrow_mut() = Some(MessageBatch::default()); - BatchGuard { state: self } + fn push_message(&self, value: MiddleValue, bytes: usize) -> Result<(), &'static str> { + self.messages.borrow_mut().push(value, bytes) } - pub(crate) fn take_messages(&self) -> Vec { - self.batch - .borrow_mut() - .take() - .map_or_else(Vec::new, |batch| batch.messages) + fn message_capacity(&self) -> usize { + self.messages.borrow().remaining() + } + + pub(crate) fn drain_messages(&self) -> Vec { + self.messages.borrow_mut().drain() } pub fn get_php_fn(&self, id: u64) -> Option { @@ -208,34 +211,26 @@ fn encode_result<'js>(ctx: &Ctx<'js>, result: MiddleValue) -> rquickjs::Result(ctx: &Ctx<'js>, state: Rc) -> rquickjs::Result<()> { let globals = ctx.globals(); - // Explicit data-only output queue for dispatch. No PHP callback runs here. - let queue_state = state.clone(); - globals.set( - "__quickjsEmit", - Function::new( + // Snapshot before borrowing the queue: getters can execute arbitrary JS. + if globals + .get::<_, Option>("quickjs")? + .is_none() + { + let message_state = state.clone(); + let post_message = Function::new( ctx.clone(), - move |ctx: Ctx<'js>, kind: String, payload: Value<'js>| -> rquickjs::Result<()> { - if queue_state.batch.borrow().is_none() { - return Err(Exception::throw_type( - &ctx, - "__quickjsEmit requires dispatch", - )); - } - // Conversion may invoke getters, including nested emit calls; - // never hold the queue borrow while JavaScript can execute. - let (value, bytes) = js_to_data(&ctx, payload)?; - let mut active = queue_state.batch.borrow_mut(); - let batch = active - .as_mut() - .ok_or_else(|| Exception::throw_type(&ctx, "inactive dispatch"))?; - batch - .push(kind, value, bytes) - .map_err(|e| Exception::throw_type(&ctx, e))?; - Ok(()) + move |ctx: Ctx<'js>, payload: Value<'js>| -> rquickjs::Result<()> { + let (value, bytes) = js_to_data(&ctx, payload, message_state.message_capacity())?; + message_state + .push_message(value, bytes) + .map_err(|e| Exception::throw_type(&ctx, e)) }, - )?, - )?; - + )?; + let quickjs = rquickjs::Object::new(ctx.clone())?; + quickjs.set("postMessage", post_message)?; + globals.set("quickjs", quickjs)?; + ctx.eval::<(), _>("Object.freeze(globalThis.quickjs); Object.defineProperty(globalThis, 'quickjs', {value: globalThis.quickjs, writable: false, configurable: false})")?; + } // The single JS -> host capability entry point. let host_state = state.clone(); let host = Function::new( @@ -349,37 +344,46 @@ mod tests { } } -const MAX_BATCH_MESSAGES: usize = 4096; -const MAX_BATCH_BYTES: usize = 32 * 1024 * 1024; +pub const DEFAULT_MAX_QUEUED_MESSAGE_BYTES: usize = 32 * 1024 * 1024; const MESSAGE_OVERHEAD: usize = 128; +/// Detached data snapshots waiting for a PHP drain. #[derive(Default)] -struct MessageBatch { +struct MessageQueue { messages: Vec, bytes: usize, + limit: usize, } -impl MessageBatch { - fn push(&mut self, kind: String, value: MiddleValue, bytes: usize) -> Result<(), &'static str> { +impl MessageQueue { + fn new(limit: usize) -> Self { + Self { + messages: Vec::new(), + bytes: 0, + limit, + } + } + + fn push(&mut self, value: MiddleValue, bytes: usize) -> Result<(), &'static str> { let total = self .bytes .saturating_add(bytes) - .saturating_add(kind.len()) .saturating_add(MESSAGE_OVERHEAD); - if total > MAX_BATCH_BYTES || self.messages.len() >= MAX_BATCH_MESSAGES { - return Err("dispatch message queue limit exceeded"); + if total > self.limit { + return Err("message queue limit exceeded"); } self.bytes = total; - self.messages - .push(MiddleValue::Array(vec![MiddleValue::Str(kind), value])); + self.messages.push(value); Ok(()) } -} -pub(crate) struct BatchGuard<'a> { - state: &'a BridgeState, -} -impl Drop for BatchGuard<'_> { - fn drop(&mut self) { - self.state.batch.borrow_mut().take(); + fn drain(&mut self) -> Vec { + self.bytes = 0; + std::mem::take(&mut self.messages) + } + + fn remaining(&self) -> usize { + self.limit + .saturating_sub(self.bytes) + .saturating_sub(MESSAGE_OVERHEAD) } } diff --git a/src/callback.rs b/src/callback.rs index edfabbe..a9d7bff 100644 --- a/src/callback.rs +++ b/src/callback.rs @@ -7,10 +7,10 @@ use crate::engine::Engine; use crate::marshal::{ - arguments_to_middle, data_arguments, js_to_middle, middle_to_js, middle_to_zval, MiddleValue, + arguments_to_middle, js_to_middle, middle_to_js, middle_to_zval, MiddleValue, }; use ext_php_rs::prelude::*; -use ext_php_rs::types::{ZendHashTable, Zval}; +use ext_php_rs::types::Zval; use rquickjs::{Ctx, Function, Value}; use std::rc::Rc; @@ -74,11 +74,20 @@ pub(crate) fn invoke_callback( let map_error = |e| engine.callback_error(ctx, e); let value = call_js(ctx, engine, id, args)?; let value = engine.await_value(ctx, value, map_error)?; + finish_callback(ctx, engine, value) +} + +pub(crate) fn finish_callback<'js>( + ctx: &Ctx<'js>, + engine: &Engine, + value: Value<'js>, +) -> PhpResult { + let map_error = |e| engine.callback_error(ctx, e); let middle = js_to_middle(ctx, value, &engine.state).map_err(map_error)?; middle_to_zval(&middle, &engine.state).map_err(PhpException::default) } -fn call_js<'js>( +pub(crate) fn call_js<'js>( ctx: &Ctx<'js>, engine: &Engine, id: u64, @@ -98,54 +107,6 @@ fn call_js<'js>( #[php_impl] impl JsCallback { - /// Direct, data-only dispatch followed by a bounded job batch. Pass null - /// instead of an argument list to continue jobs without invoking the callback. - /// Returns queued messages, executed job count, and pending-job status. - #[php(defaults(maxJobs = 100))] - pub fn dispatch(&self, args: Option<&ZendHashTable>, maxJobs: i64) -> PhpResult { - if maxJobs <= 0 { - return Err(PhpException::default( - "maxJobs must be greater than zero".to_owned(), - )); - } - if self.engine.shared_ctx().is_none() { - return Err(PhpException::default( - "dispatch requires shared mode".to_owned(), - )); - } - if self.engine.is_active() { - return Err(PhpException::default( - "Cannot dispatch while JavaScript is executing".to_owned(), - )); - } - let middle = args - .map(|args| data_arguments(args, &self.engine.state)) - .transpose() - .map_err(PhpException::default)?; - let _guard = self.engine.enter().map_err(PhpException::default)?; - let _batch = self.engine.state.begin_batch(); - self.engine.eval_in(|ctx| { - if let Some(MiddleValue::Array(items)) = &middle { - // Dispatch is a notification; its return value is deliberately ignored. - call_js(ctx, &self.engine, self.id, items)?; - } - let jobs = self.engine.run_jobs(ctx, maxJobs)?; - let pending = unsafe { - rquickjs::qjs::JS_IsJobPending(rquickjs::qjs::JS_GetRuntime(ctx.as_raw().as_ptr())) - }; - let messages = self.engine.state.take_messages(); - middle_to_zval( - &MiddleValue::Map(vec![ - ("messages".to_owned(), MiddleValue::Array(messages)), - ("jobs".to_owned(), MiddleValue::Int(jobs)), - ("pending".to_owned(), MiddleValue::Bool(pending)), - ]), - &self.engine.state, - ) - .map_err(PhpException::default) - }) - } - /// Invoke the JS callback: `$cb(...$args)`. pub fn __invoke(&self, args: &[&Zval]) -> PhpResult { self.invoke_inner(args) diff --git a/src/engine.rs b/src/engine.rs index 7244168..4f6b0c7 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -11,7 +11,7 @@ use ext_php_rs::{ types::Zval, zend::Function as PhpFunction, }; -use rquickjs::{Context, Ctx, Function, Promise, Runtime, Value}; +use rquickjs::{Context, Ctx, Function, Persistent, Promise, Runtime, Value}; use std::cell::{Cell, RefCell}; use std::collections::VecDeque; use std::ptr::NonNull; @@ -39,6 +39,8 @@ pub struct Engine { /// Calls arriving from Revolt while the owner waits on a Promise are queued; /// the owner executes them after its Suspension resumes. queued_callbacks: RefCell>, + /// Promise results of queued callbacks, kept alive across driver entries. + pending_callbacks: RefCell>, waiter: RefCell>>, /// Per-entry wall-clock deadline; `None` when no eval is in flight. deadline: Rc>>, @@ -51,10 +53,27 @@ pub struct Engine { struct QueuedCallback { id: u64, args: Vec, + reply: CallbackReply, +} + +struct CallbackReply { suspension: Zval, result: Rc>>>, } +impl CallbackReply { + fn finish(self, result: PhpResult) -> PhpResult<()> { + *self.result.borrow_mut() = Some(result); + self.suspension.try_call_method("resume", vec![])?; + Ok(()) + } +} + +struct PendingCallback { + reply: CallbackReply, + promise: Persistent>, +} + struct Waiter { suspension: Zval, woken: Cell, @@ -69,6 +88,12 @@ impl Waiter { } } +fn wake_callback(waiter: Rc) -> PhpResult { + let callback = Closure::wrap(Box::new(move || waiter.wake()) as Box PhpResult<()>>) + .into_zval(false)?; + call_php("Closure", "fromCallable", vec![&callback]) +} + fn call_php(class: &str, method: &str, args: Vec<&dyn IntoZvalDyn>) -> PhpResult { PhpFunction::try_from_method(class, method) .ok_or_else(|| { @@ -89,7 +114,8 @@ impl Engine { } fn check_deadline(&self, ctx: &Ctx<'_>) -> PhpResult<()> { - if self.timed_out() || self.deadline.get().is_some_and(|d| Instant::now() >= d) { + let now = Instant::now(); + if self.timed_out() || self.deadline.get().is_some_and(|d| now >= d) { self.timed_out.set(true); drop(ctx.catch()); return Err(PhpException::from_class::< @@ -129,40 +155,57 @@ impl Engine { } /// Await returned Promises, yielding to Revolt when host I/O is pending. - pub fn await_value<'js>( + fn promise_for_value<'js>( &self, ctx: &Ctx<'js>, value: Value<'js>, map_js_error: impl Fn(rquickjs::Error) -> PhpException, - ) -> PhpResult> { - let promise = match value.as_promise() { - Some(promise) => Some(promise.clone()), + ) -> PhpResult>> { + match value.as_promise() { + Some(promise) => Ok(Some(promise.clone())), None => { let normalize: Function = ctx.globals().get("__asPromise").map_err(&map_js_error)?; - normalize - .call::<_, Option>((value.clone(),)) - .map_err(&map_js_error)? + normalize.call((value,)).map_err(map_js_error) } - }; + } + } + + pub fn await_value<'js>( + &self, + ctx: &Ctx<'js>, + value: Value<'js>, + map_js_error: impl Fn(rquickjs::Error) -> PhpException, + ) -> PhpResult> { + let promise = self.promise_for_value(ctx, value.clone(), &map_js_error)?; let Some(promise) = promise else { return Ok(value); }; + let mut jobs_in_quantum = 0; loop { self.check_deadline(ctx)?; + self.drain_queued_callbacks(ctx)?; + self.complete_pending_callbacks(ctx)?; if let Some(result) = promise.result() { return result.map_err(map_js_error); } if self.run_jobs(ctx, 1)? == 0 { - self.suspend_on_revolt()?; - self.check_deadline(ctx)?; - self.drain_queued_callbacks(ctx)?; + self.suspend_on_revolt(false)?; + jobs_in_quantum = 0; + } else { + jobs_in_quantum += 1; + if jobs_in_quantum == 100 { + if promise.result::().is_none() { + self.suspend_on_revolt(true)?; + } + jobs_in_quantum = 0; + } } } } - fn suspend_on_revolt(&self) -> PhpResult<()> { + fn suspend_on_revolt(&self, yield_now: bool) -> PhpResult<()> { let suspension = call_php("Revolt\\EventLoop", "getSuspension", vec![])?; let waiter = Rc::new(Waiter { suspension: suspension.shallow_clone(), @@ -171,20 +214,20 @@ impl Engine { let timer = self .deadline .get() + .filter(|_| !yield_now) .map(|deadline| -> PhpResult { - let waiter = waiter.clone(); - let callback = Closure::wrap( - Box::new(move || waiter.wake()) as Box PhpResult<()>> - ) - .into_zval(false)?; - let callback = call_php("Closure", "fromCallable", vec![&callback])?; + let callback = wake_callback(waiter.clone())?; let delay = deadline .saturating_duration_since(Instant::now()) .as_secs_f64(); call_php("Revolt\\EventLoop", "delay", vec![&delay, &callback]) }) .transpose()?; - *self.waiter.borrow_mut() = Some(waiter); + *self.waiter.borrow_mut() = Some(waiter.clone()); + if yield_now { + let callback = wake_callback(waiter)?; + call_php("Revolt\\EventLoop", "defer", vec![&callback])?; + } let result = suspension.try_call_method("suspend", vec![]); self.waiter.borrow_mut().take(); if let Some(timer) = timer { @@ -193,15 +236,55 @@ impl Engine { result.map(|_| ()).map_err(Into::into) } - fn drain_queued_callbacks(&self, ctx: &Ctx<'_>) -> PhpResult<()> { + pub(crate) fn complete_pending_callbacks<'js>(&self, ctx: &Ctx<'js>) -> PhpResult<()> { + let mut index = 0; + loop { + let settled = { + let pending = self.pending_callbacks.borrow(); + let Some(item) = pending.get(index) else { + return Ok(()); + }; + let promise = item + .promise + .clone() + .restore(ctx) + .map_err(|e| self.callback_error(ctx, e))?; + promise.result() + }; + if let Some(result) = settled { + let item = self.pending_callbacks.borrow_mut().remove(index); + let result = result + .map_err(|e| self.callback_error(ctx, e)) + .and_then(|value| crate::callback::finish_callback(ctx, self, value)); + item.reply.finish(result)?; + } else { + index += 1; + } + } + } + + fn drain_queued_callbacks<'js>(&self, ctx: &Ctx<'js>) -> PhpResult<()> { loop { self.check_deadline(ctx)?; let Some(queued) = self.queued_callbacks.borrow_mut().pop_front() else { return Ok(()); }; - let result = crate::callback::invoke_callback(ctx, self, queued.id, &queued.args); - *queued.result.borrow_mut() = Some(result); - queued.suspension.try_call_method("resume", vec![])?; + let QueuedCallback { id, args, reply } = queued; + match crate::callback::call_js(ctx, self, id, &args) { + Err(error) => reply.finish(Err(error))?, + Ok(value) => match self + .promise_for_value(ctx, value.clone(), |e| self.callback_error(ctx, e)) + { + Err(error) => reply.finish(Err(error))?, + Ok(Some(promise)) => { + self.pending_callbacks.borrow_mut().push(PendingCallback { + reply, + promise: Persistent::save(ctx, promise), + }) + } + Ok(None) => reply.finish(crate::callback::finish_callback(ctx, self, value))?, + }, + } } } @@ -225,8 +308,10 @@ impl Engine { .push_back(QueuedCallback { id, args, - suspension: suspension.shallow_clone(), - result: result.clone(), + reply: CallbackReply { + suspension: suspension.shallow_clone(), + result: result.clone(), + }, }); waiter.wake()?; suspension.try_call_method("suspend", vec![])?; @@ -241,6 +326,7 @@ impl Engine { timeout_ms: u64, max_stack: usize, isolated: bool, + max_queued_message_bytes: usize, ) -> rquickjs::Result> { let rt = Runtime::new()?; sandbox::apply_limits(&rt, memory_limit, max_stack); @@ -255,7 +341,7 @@ impl Engine { } else { Some(Context::full(&rt)?) }; - let state = BridgeState::new(); + let state = BridgeState::new(max_queued_message_bytes); let engine = Rc::new(Engine { rt, state: state.clone(), @@ -265,6 +351,7 @@ impl Engine { active_ctx: Cell::new(None), active_fiber: Cell::new(None), queued_callbacks: RefCell::new(VecDeque::new()), + pending_callbacks: RefCell::new(Vec::new()), waiter: RefCell::new(None), deadline, timed_out, @@ -316,6 +403,7 @@ impl Engine { let ctx = unsafe { Ctx::from_raw(ptr) }; self.check_deadline(&ctx)?; let result = f(&ctx); + self.complete_pending_callbacks(&ctx)?; self.check_deadline(&ctx)?; return result; } @@ -334,6 +422,7 @@ impl Engine { .map_err(|e| self.callback_error(&c, e))?; self.check_deadline(&c)?; let result = f(&c); + self.complete_pending_callbacks(&c)?; self.check_deadline(&c)?; result }) @@ -398,10 +487,23 @@ impl Drop for ExecutionGuard<'_> { self.engine.disarm_deadline(); let pending = std::mem::take(&mut *self.engine.queued_callbacks.borrow_mut()); for queued in pending { - *queued.result.borrow_mut() = Some(Err(PhpException::default( + let _ = queued.reply.finish(Err(PhpException::default( "JavaScript callback canceled: owning call ended".to_owned(), ))); - let _ = queued.suspension.try_call_method("resume", vec![]); } + if self.engine.shared_ctx.is_none() { + let pending = std::mem::take(&mut *self.engine.pending_callbacks.borrow_mut()); + for item in pending { + let _ = item.reply.finish(Err(PhpException::default( + "JavaScript callback canceled: isolated realm ended".to_owned(), + ))); + } + } + } +} + +impl Drop for Engine { + fn drop(&mut self) { + self.pending_callbacks.get_mut().clear(); } } diff --git a/src/lib.rs b/src/lib.rs index 0b28405..0d9261a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,21 +36,35 @@ impl QuickJS { /// - `memoryLimit`: max heap bytes (alloc-bomb guard) /// - `timeoutMs`: wall-clock budget per eval, callback, or job batch /// - `maxStack`: max native stack bytes + /// - `maxQueuedMessageBytes`: maximum accounted bytes waiting in the message queue /// - `isolated`: when true, each `eval()` runs in a fresh global realm (its /// own world); cross-eval globals and persistent JS callbacks are not /// kept. Defaults to false (one shared, persistent realm per instance). - #[php(defaults(memoryLimit = None, timeoutMs = None, maxStack = None, isolated = false))] + #[php(defaults(memoryLimit = None, timeoutMs = None, maxStack = None, isolated = false, maxQueuedMessageBytes = None))] pub fn __construct( memoryLimit: Option, timeoutMs: Option, maxStack: Option, isolated: bool, + maxQueuedMessageBytes: Option, ) -> PhpResult { + let max_queued_message_bytes = match maxQueuedMessageBytes { + None => bridge::DEFAULT_MAX_QUEUED_MESSAGE_BYTES, + Some(limit) if limit > 0 => usize::try_from(limit).map_err(|_| { + PhpException::default("maxQueuedMessageBytes is too large".to_owned()) + })?, + _ => { + return Err(PhpException::default( + "maxQueuedMessageBytes must be positive".to_owned(), + )) + } + }; let engine = Engine::new( memoryLimit.unwrap_or(0).max(0) as usize, timeoutMs.unwrap_or(0).max(0) as u64, maxStack.unwrap_or(0).max(0) as usize, isolated, + max_queued_message_bytes, ) .map_err(to_php_err)?; Ok(QuickJS { engine }) @@ -143,6 +157,13 @@ impl QuickJS { .eval_in(|ctx| self.engine.run_jobs(ctx, maxJobs)) } + /// Drain messages emitted with `quickjs.postMessage`. Safe to call after + /// an awaiting driver yields; no JS entry or Promise job is executed. + pub fn drainMessages(&self) -> PhpResult { + let messages = marshal::MiddleValue::Array(self.engine.state.drain_messages()); + middle_to_zval(&messages, &self.engine.state).map_err(PhpException::default) + } + /// Return the registration manifest as an array of `['name'=>..., 'types'=>...]`. pub fn manifest(&self) -> PhpResult { let state = &self.engine.state; @@ -269,6 +290,7 @@ fn to_php_err(e: E) -> PhpException { #[php_module] pub fn module(module: ModuleBuilder) -> ModuleBuilder { module + .version(env!("CARGO_PKG_VERSION")) // Exceptions first so subclasses can resolve their parent class entry. .class::() .class::() diff --git a/src/manifest.rs b/src/manifest.rs index 037891f..5251155 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -43,10 +43,14 @@ fn build_tree(entries: &[ManifestEntry]) -> Node { root } -/// Generate a `.d.ts` declaration for the `php` global from the manifest. +/// Generate `.d.ts` declarations for the native message API and registered PHP capabilities. pub fn to_dts(entries: &[ManifestEntry]) -> String { let tree = build_tree(entries); let mut out = String::from("// Generated by php-quickjs. Do not edit by hand.\n"); + out.push_str("type QuickJSMessage = null | undefined | boolean | number | string | Uint8Array | QuickJSMessage[] | { [key: string]: QuickJSMessage };\n"); + out.push_str( + "declare const quickjs: Readonly<{ postMessage(value: QuickJSMessage): void }>;\n", + ); out.push_str("declare const php: "); write_node(&tree, 0, &mut out); out.push_str(";\n"); @@ -138,6 +142,7 @@ mod tests { ]; let dts = to_dts(&entries); assert!(dts.contains("db: {")); + assert!(dts.contains("declare const quickjs:")); assert!(dts.contains("query(sql: string): unknown[];")); assert!(dts.contains("execute(...args: any[]): any;")); assert!(dts.contains("info(msg: string): void;")); diff --git a/src/marshal.rs b/src/marshal.rs index bb1f64d..f3195f1 100644 --- a/src/marshal.rs +++ b/src/marshal.rs @@ -206,20 +206,19 @@ impl<'de> Deserialize<'de> for MapKey { // --------------------------------------------------------------------------- pub const MAX_VALUE_DEPTH: usize = 64; -pub const MAX_DATA_BYTES: usize = 16 * 1024 * 1024; pub const VALUE_OVERHEAD: usize = 64; -/// The direct transport has a byte budget; the generic API keeps its previous -/// unlimited byte size. Both paths bound recursion before visiting a value. +/// Messages have a byte budget; the generic API remains unbounded in bytes. +/// Both paths bound recursion before visiting a value. #[derive(Default)] struct ConversionBudget { limit: Option, used: usize, } impl ConversionBudget { - fn direct() -> Self { + fn with_limit(limit: usize) -> Self { Self { - limit: Some(MAX_DATA_BYTES), + limit: Some(limit), used: 0, } } @@ -271,10 +270,11 @@ pub fn js_to_middle<'js>( pub fn js_to_data<'js>( ctx: &Ctx<'js>, value: Value<'js>, + max_bytes: usize, ) -> rquickjs::Result<(MiddleValue, usize)> { let mut conversion = JsConversion { ctx, - budget: ConversionBudget::direct(), + budget: ConversionBudget::with_limit(max_bytes), functions: false, registered: Vec::new(), }; @@ -426,7 +426,7 @@ pub fn middle_to_js<'js>( /// Registrations are committed only once the complete input is valid. pub fn zval_to_middle(zv: &Zval, state: &BridgeState) -> Result { - let mut conversion = PhpConversion::new(state, true); + let mut conversion = PhpConversion::new(state); let value = conversion.convert(zv, 0)?; conversion.registered.clear(); Ok(value) @@ -436,7 +436,7 @@ pub fn arguments_to_middle( args: &[&Zval], state: &BridgeState, ) -> Result, String> { - let mut conversion = PhpConversion::new(state, true); + let mut conversion = PhpConversion::new(state); let result = args .iter() .map(|arg| conversion.convert(arg, 0)) @@ -445,31 +445,16 @@ pub fn arguments_to_middle( Ok(result) } -pub fn data_arguments(args: &ZendHashTable, state: &BridgeState) -> Result { - if !args.has_sequential_keys() { - return Err("args must be a list or null".to_owned()); - } - let mut conversion = PhpConversion::new(state, false); - conversion.budget.node(0)?; - conversion.array(args, 0) -} - struct PhpConversion<'a> { state: &'a BridgeState, budget: ConversionBudget, - functions: bool, registered: Vec, } impl<'a> PhpConversion<'a> { - fn new(state: &'a BridgeState, functions: bool) -> Self { + fn new(state: &'a BridgeState) -> Self { Self { state, - budget: if functions { - ConversionBudget::default() - } else { - ConversionBudget::direct() - }, - functions, + budget: ConversionBudget::default(), registered: Vec::new(), } } @@ -498,9 +483,6 @@ impl<'a> PhpConversion<'a> { if let Some(array) = zv.array() { return self.array(array, depth); } - if !self.functions { - return Err("direct dispatch arguments must contain data only".to_owned()); - } if let Some(cb) = zv.extract::<&ZendClassObject>() { let owner = self.state.engine().ok_or("engine no longer available")?; if !std::rc::Rc::ptr_eq(&owner, &cb.engine) { diff --git a/src/sandbox.rs b/src/sandbox.rs index a5238fa..1bba861 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -30,11 +30,13 @@ pub fn install_interrupt( deadline: Rc>>, timed_out: Rc>, ) { - rt.set_interrupt_handler(Some(Box::new(move || match deadline.get() { - Some(dl) if Instant::now() >= dl => { + rt.set_interrupt_handler(Some(Box::new(move || { + let now = Instant::now(); + if deadline.get().is_some_and(|dl| now >= dl) { timed_out.set(true); true + } else { + false } - _ => false, }))); } diff --git a/stubs/php_quickjs.stubs.php b/stubs/php_quickjs.stubs.php index 2e2a88b..99f86ef 100644 --- a/stubs/php_quickjs.stubs.php +++ b/stubs/php_quickjs.stubs.php @@ -16,8 +16,9 @@ class QuickJS * @param int|null $timeoutMs Per-eval/callback/job-batch wall-clock budget in ms (0/null = unbounded). * @param int|null $maxStack Max native stack bytes (0/null = engine default). * @param bool $isolated Run each eval() in its own fresh global realm. + * @param int|null $maxQueuedMessageBytes Max accounted bytes in the message queue (null = 32 MiB). */ - public function __construct(?int $memoryLimit = null, ?int $timeoutMs = null, ?int $maxStack = null, bool $isolated = false) {} + public function __construct(?int $memoryLimit = null, ?int $timeoutMs = null, ?int $maxStack = null, bool $isolated = false, ?int $maxQueuedMessageBytes = null) {} /** * Register a PHP callable under a flat, dotted capability name, callable @@ -41,7 +42,10 @@ public function hasPendingJobs(): bool {} /** Execute at most maxJobs ready jobs without waiting for I/O; returns the count. */ public function executePendingJobs(int $maxJobs = 100): int {} - /** Generate a TypeScript `.d.ts` declaration for the `php` global. */ + /** Drain bounded messages emitted by quickjs.postMessage() without entering JS. */ + public function drainMessages(): array {} + + /** Generate TypeScript `.d.ts` declarations for the `php` and `quickjs` globals. */ public function dts(): string {} /** Grant JS an opaque integer handle to a live PHP value. */ @@ -65,11 +69,6 @@ public function roundtrip(mixed $value): mixed {} */ class Callback { - /** - * @param list|null $args Data-only positional arguments. - * @return array{messages: list, jobs: int, pending: bool} - */ - public function dispatch(?array $args, int $maxJobs = 100): array {} /** Invoke the JS function, awaiting a returned Promise. */ public function __invoke(mixed ...$args): mixed {} diff --git a/tests/php/08_dts_generation.php b/tests/php/08_dts_generation.php index 058a1af..5078151 100644 --- a/tests/php/08_dts_generation.php +++ b/tests/php/08_dts_generation.php @@ -19,6 +19,7 @@ // --- dts() is generated from the same manifest --------------------------- $dts = $js->dts(); ok(str_contains($dts, 'declare const php:'), 'dts declares the php global'); +ok(str_contains($dts, 'declare const quickjs:'), 'dts declares the native message API'); ok(str_contains($dts, 'db: {'), 'dts nests dotted names into namespaces'); ok(str_contains($dts, 'query(handle: number, sql: string): unknown[];'), 'typed signature emitted verbatim'); ok(str_contains($dts, 'execute(...args: any[]): any;'), 'untyped leaf falls back to any'); diff --git a/tests/php/11_jobs.php b/tests/php/11_jobs.php index b3be98c..e68c35c 100644 --- a/tests/php/11_jobs.php +++ b/tests/php/11_jobs.php @@ -15,6 +15,7 @@ 'async rejection becomes a PHP evaluation exception' ); eq(42, $js->eval('({ then(resolve) { resolve(42); } })'), 'thenables are awaited'); +eq(42, $js->eval('let chain = Promise.resolve(42); for (let i = 0; i < 100; i++) chain = chain.then(value => value); chain'), 'settled Promise at quantum boundary needs no event loop'); eq(42, $js->eval('({ get then() { if (this.read) throw new Error("then read twice"); this.read = true; return resolve => resolve(42); } })'), 'thenable getter is read once'); eq(42, (new QuickJS(isolated: true))->eval('(async () => { await 0; return 42; })()'), 'isolated eval awaits its Promise'); $stop = new QuickJS(); diff --git a/tests/php/13_dispatch.php b/tests/php/13_dispatch.php deleted file mode 100644 index 5c5063c..0000000 --- a/tests/php/13_dispatch.php +++ /dev/null @@ -1,66 +0,0 @@ -eval('(kind, value) => { __quickjsEmit(kind, value); }'); -foreach (['', "nul\0tail", 'Привет 🌍', "\xff\xfe" . str_repeat('a', 65536)] as $value) { - eq([['data', $value]], $send->dispatch(['data', $value])['messages'], 'direct payload preserves bytes'); -} -eq(['messages' => [], 'jobs' => 0, 'pending' => false], $send->dispatch(null), 'empty drain'); -throws(fn() => $send->dispatch([], 0), Throwable::class, 'invalid budget'); -throws(fn() => $send->dispatch(['named' => 1]), Throwable::class, 'argument map rejected'); -throws(fn() => $q->eval('__quickjsEmit("bad", 1)'), Throwable::class, 'emission outside batch rejected'); -$fail = $q->eval('() => { __quickjsEmit("partial", 1); throw new Error("failure"); }'); -throws(fn() => $fail->dispatch([]), QuickJSEvalException::class, 'dispatch errors surfaced'); -eq([], $send->dispatch(null)['messages'], 'failed batch discards partial output'); -$cycle = $q->eval('() => { const a = {}; a.self = a; __quickjsEmit("cycle", a); }'); -throws(fn() => $cycle->dispatch([]), Throwable::class, 'cyclic output rejected'); -$fun = $q->eval('() => __quickjsEmit("function", () => 1)'); -throws(fn() => $fun->dispatch([]), Throwable::class, 'function output rejected'); -$large = $q->eval('() => __quickjsEmit("large", new Uint8Array(16777217))'); -throws(fn() => $large->dispatch([]), Throwable::class, 'oversized payload rejected'); -$flood = $q->eval('() => { for (let i=0;i<4097;i++) __quickjsEmit("many", i); }'); -throws(fn() => $flood->dispatch([]), Throwable::class, 'message queue bounded'); -eq([], $send->dispatch(null)['messages'], 'queue recovers after limit'); -$chain = $q->eval('() => { Promise.resolve().then(() => __quickjsEmit("job", 1)).then(() => __quickjsEmit("job", 2)); }'); -$first = $chain->dispatch([], 1); -eq([['job', 1]], $first['messages'], 'first bounded job'); -eq(true, $first['pending'], 'continuation pending'); -eq([['job', 2]], $chain->dispatch(null, 1)['messages'], 'continuation drained'); -(new Fiber(function () use ($send) { eq([['fiber', 42]], $send->dispatch(['fiber', 42])['messages'], 'batch on Fiber stack'); }))->start(); -$q->register('reenter', fn() => $send->dispatch(null)); -$nested = $q->eval('() => php.reenter()'); -throws(fn() => $nested->dispatch([]), Throwable::class, 'reentrant dispatch rejected'); -eq([['ok', 1]], $send->dispatch(['ok', 1])['messages'], 'reentrant failure recovers'); -$byteFlood = $q->eval('() => { const a = new Uint8Array(12000000); for(let i=0;i<3;i++) __quickjsEmit("bytes", a); }'); -throws(fn() => $byteFlood->dispatch([]), Throwable::class, 'queue byte limit enforced across messages'); -$getter = $q->eval('() => __quickjsEmit("getter", {get value() { throw new Error("getter failed"); }})'); -throws(fn() => $getter->dispatch([]), QuickJSEvalException::class, 'getter error is propagated'); -$deep = 1; -for ($i = 0; $i < 66; $i++) { $deep = [$deep]; } -throws(fn() => $send->dispatch(['deep', $deep]), Throwable::class, 'PHP input depth bounded'); -eq([['ok', 2]], $send->dispatch(['ok', 2])['messages'], 'conversion failures leave usable batch'); - -eq('?array', (string) (new ReflectionMethod($send, 'dispatch'))->getParameters()[0]->getType(), 'native argument type matches contract'); -throws(fn() => $send->dispatch(['callback', fn() => 1]), Throwable::class, 'direct input rejects PHP closures'); -throws(fn() => $send->dispatch(['callback', $send]), Throwable::class, 'direct input rejects JS callbacks'); -throws(fn() => $send->dispatch(['large', str_repeat('x', 16777217)]), Throwable::class, 'direct input byte budget enforced'); -$captured = new stdClass(); -$weak = WeakReference::create($captured); -$closure = fn() => $captured; -try { $send->dispatch(['named' => $closure]); } catch (Throwable) {} -unset($closure, $captured); -eq(null, $weak->get(), 'invalid direct arguments retain no PHP closures'); -$getterEmit = $q->eval('() => __quickjsEmit("outer", {get value() { __quickjsEmit("inner", 1); return 2; }})'); -eq([['inner', 1], ['outer', ['value' => 2]]], $getterEmit->dispatch([])['messages'], 'getter can emit without borrowing active queue'); -$short = new QuickJS(timeoutMs: 20); -$effects = 0; -$short->register('slow', fn() => usleep(50000)); -$short->register('effect', function () use (&$effects) { $effects++; }); -$late = $short->eval('() => { Promise.resolve().then(() => php.effect()); php.slow(); }'); -throws(fn() => $late->dispatch([]), QuickJSTimeoutException::class, 'expired invocation does not start first job'); -eq(0, $effects, 'expired dispatch has no job side effects'); -eq(true, $short->hasPendingJobs(), 'timed out dispatch preserves pending jobs'); -$late->dispatch(null); -eq(1, $effects, 'caller can explicitly resume pending jobs'); - -done(); diff --git a/tests/php/13_messages.php b/tests/php/13_messages.php new file mode 100644 index 0000000..8d025ad --- /dev/null +++ b/tests/php/13_messages.php @@ -0,0 +1,30 @@ +eval('(value) => quickjs.postMessage(value)'); +foreach (['', "nul\0tail", 'Привет 🌍', "\xff\xfe"] as $value) { + $send($value); + eq([$value], $js->drainMessages(), 'message preserves data and bytes'); +} +eq([], $js->drainMessages(), 'drain clears the queue'); +eq(true, $js->eval('Object.isFrozen(quickjs) && Object.getOwnPropertyDescriptor(globalThis, "quickjs").writable === false'), 'message API is immutable'); +eq('undefined', $js->eval('typeof __quickjsEmitAsync'), 'beta emit API removed'); +eq(false, method_exists($send, 'dispatch'), 'beta dispatch API removed'); + +$js->eval('quickjs.postMessage({value: 1})'); +throws(fn() => $js->eval('quickjs.postMessage(() => 1)'), QuickJSEvalException::class, 'function rejected'); +eq([['value' => 1]], $js->drainMessages(), 'earlier messages survive a failed emission'); +throws(fn() => $js->eval('const cycle = {}; cycle.self = cycle; quickjs.postMessage(cycle)'), QuickJSEvalException::class, 'cycle rejected'); +eq([], $js->drainMessages(), 'cycle did not enter queue'); + +$js->eval('quickjs.postMessage({get value() { quickjs.postMessage("nested"); return 2; }})'); +eq(['nested', ['value' => 2]], $js->drainMessages(), 'getter can emit without borrowing queue'); +$js->eval('quickjs.postMessage(new Uint8Array(600))'); +throws(fn() => $js->eval('quickjs.postMessage(new Uint8Array(600))'), QuickJSEvalException::class, 'aggregate byte limit enforced'); +eq(1, count($js->drainMessages()), 'overflow preserves queued result'); +$js->eval('quickjs.postMessage(42)'); +eq([42], $js->drainMessages(), 'queue recovers after drain'); + +done(); diff --git a/tests/php/14_async.php b/tests/php/14_async.php index e7a8c8b..63c080b 100644 --- a/tests/php/14_async.php +++ b/tests/php/14_async.php @@ -46,6 +46,37 @@ eq(45, $js->eval('new Promise(resolve => { let count = 0, sum = 0; php.all(n => { sum += n; if (++count === 10) resolve(sum); return n * 2; }); })'), 'multiple event-loop callbacks settle one Promise'); eq(range(0, 18, 2), array_map(static fn($future) => $future->await(), $futures), 'each queued caller receives its own result'); +$first = $js->eval('() => new Promise(resolve => { globalThis.resolveFirst = resolve; })'); +$second = $js->eval('resolveOwner => { resolveFirst(41); resolveOwner(7); return 42; }'); +$js->register('startConcurrent', static function ($resolveOwner) use ($first, $second, &$futures): void { + $futures = [ + Amp\async(static fn() => $first()), + Amp\async(static fn() => $second($resolveOwner)), + ]; +}); +eq(7, $js->eval('new Promise(resolve => php.startConcurrent(resolve))'), 'second callback can settle the first pending Promise'); +eq([41, 42], array_map(static fn($future) => $future->await(), $futures), 'concurrent callback results resume independently'); + +$waiting = $js->eval('() => new Promise(resolve => { globalThis.finishWaiting = resolve; })'); +$js->register('startPending', static function ($resolveOwner) use ($waiting, &$future): void { + $future = Amp\async(static fn() => $waiting()); + Revolt\EventLoop::defer(static fn() => $resolveOwner(8)); +}); +eq(8, $js->eval('new Promise(resolve => php.startPending(resolve))'), 'owner may finish while a callback Promise is pending'); +$js->eval('finishWaiting(43)'); +$js->executePendingJobs(); +eq(43, $future->await(), 'pending callback survives the owner entry'); + +$fair = new QuickJS(timeoutMs: 1000); +$stop = $fair->eval('() => { globalThis.stopped = true; }'); +$fair->register('scheduleStop', static function () use ($stop): void { + Revolt\EventLoop::delay(0.001, static fn() => $stop()); +}); +eq(1, $fair->eval('globalThis.stopped = false; php.scheduleStop(); new Promise(resolve => { + function spin() { if (stopped) resolve(1); else Promise.resolve().then(spin); } + spin(); +})'), 'microtask chain yields to Revolt timers'); + $limited = new QuickJS(timeoutMs: 20); $start = hrtime(true); throws(fn() => $limited->eval('new Promise(() => {})'), QuickJSTimeoutException::class, 'external wait obeys timeoutMs'); diff --git a/tests/php/15_timeout.php b/tests/php/15_timeout.php new file mode 100644 index 0000000..6a69df4 --- /dev/null +++ b/tests/php/15_timeout.php @@ -0,0 +1,8 @@ + $js->eval('while (true) {}'), QuickJSTimeoutException::class, 'existing wall-clock timeout interrupts JS'); +eq(3, $js->eval('1 + 2'), 'engine recovers after timeout'); +eq(4, (new QuickJS())->eval('Promise.resolve(4)'), 'Promise result is awaited without a timeout'); +done(); diff --git a/tests/php/16_async_messages.php b/tests/php/16_async_messages.php new file mode 100644 index 0000000..91899db --- /dev/null +++ b/tests/php/16_async_messages.php @@ -0,0 +1,25 @@ +eval('globalThis.value = { nested: { x: 1 }, bytes: new Uint8Array([0, 255]) }; quickjs.postMessage(value); value.nested.x = 9; value.bytes[0] = 42'); +$messages = $js->drainMessages(); +eq(1, $messages[0]['nested']['x'], 'postMessage snapshots nested data'); +eq("\0\xff", $messages[0]['bytes'], 'postMessage snapshots binary data'); +eq([], $js->drainMessages(), 'drain clears the queue'); + +$js->eval('quickjs.postMessage(new Uint8Array(17 * 1024 * 1024))'); +eq(17 * 1024 * 1024, strlen($js->drainMessages()[0]), 'message may exceed the old 16 MiB single-value cap'); +throws(fn() => new QuickJS(maxQueuedMessageBytes: 0), Throwable::class, 'queue budget must be positive'); + +$js->eval('try { quickjs.postMessage(new Uint8Array(33554433)); } catch (error) { quickjs.postMessage(error.message); }'); +$messages = $js->drainMessages(); +ok(str_contains($messages[0], 'size limit'), 'oversized result rejects only that operation'); + +$js = new QuickJS(maxQueuedMessageBytes: 256); +$js->eval('quickjs.postMessage(1)'); +throws(fn() => $js->eval('quickjs.postMessage(2)'), QuickJSEvalException::class, 'configured queue is bounded'); +eq([1], $js->drainMessages(), 'queue survives rejection'); +$js->eval('quickjs.postMessage(3)'); +eq([3], $js->drainMessages(), 'queue recovers after drain'); +done(); From 289c409717aca5aa705b27085d952fb7813285ff Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Wed, 30 Sep 2026 01:44:48 +0200 Subject: [PATCH 10/17] Use native JS-PHP bridge without MessagePack --- Cargo.lock | 32 --- Cargo.toml | 3 - README.md | 10 +- docs/README.md | 12 +- docs/api.md | 2 +- docs/architecture.md | 43 ++-- docs/errors.md | 2 +- docs/execution-modes.md | 4 +- src/bridge.rs | 96 +++----- src/callback.rs | 2 +- src/error.rs | 2 +- src/js/msgpack.js | 367 ----------------------------- src/js/runtime.js | 148 +++--------- src/lib.rs | 2 +- src/marshal.rs | 234 +----------------- tests/php/03_register_dispatch.php | 20 +- 16 files changed, 121 insertions(+), 858 deletions(-) delete mode 100644 src/js/msgpack.js diff --git a/Cargo.lock b/Cargo.lock index 827ab74..effd0fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1683,10 +1683,7 @@ dependencies = [ "ext-php-rs", "lru", "oxc", - "rmp-serde", "rquickjs", - "serde", - "serde_bytes", "serde_json", "sourcemap", ] @@ -1849,25 +1846,6 @@ dependencies = [ "serde", ] -[[package]] -name = "rmp" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" -dependencies = [ - "num-traits", -] - -[[package]] -name = "rmp-serde" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" -dependencies = [ - "rmp", - "serde", -] - [[package]] name = "ropey" version = "1.6.1" @@ -2044,16 +2022,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde_bytes" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" -dependencies = [ - "serde", - "serde_core", -] - [[package]] name = "serde_core" version = "1.0.228" diff --git a/Cargo.toml b/Cargo.toml index 8ce220d..a066024 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,9 +24,6 @@ rquickjs = { version = "0.12", default-features = false, features = [ "classes", "array-buffer", ] } -serde = { version = "1", features = ["derive"] } -serde_bytes = "0.11" -rmp-serde = "1" serde_json = "1" # TypeScript fast path: transpile guest TS -> JS in-process, remap errors. diff --git a/README.md b/README.md index d21a3b6..7aaa8c8 100644 --- a/README.md +++ b/README.md @@ -110,15 +110,15 @@ The repository is mounted at `/workspace`; Cargo output stays in ``` PHP (trusted) ──ext-php-rs──► Rust bridge ──rquickjs──► QuickJS (untrusted) register() dispatch table php.module.fn() - eval() __host(name, bytes) frozen php.* facade + eval() __host(name, args) frozen php.* facade ``` -Registered PHP capabilities go through one `__host` entry point and a flat +Registered PHP capabilities go through one direct native entry point and a flat dispatch table; the namespaced `php.*` tree is frozen JS built from your registrations. The separate `quickjs.postMessage()` sink copies data into a -bounded native queue. Host calls use MessagePack; eval, saved callbacks and -messages use native conversion. Functions cross as registry references, and -errors bridge both ways with TS source locations. +bounded native queue. Host calls, eval, saved callbacks and messages use native +conversion. Functions cross as registry references, and errors bridge both +ways with TS source locations. → **[docs/architecture.md](docs/architecture.md)** for the full design. diff --git a/docs/README.md b/docs/README.md index e483de0..b75bbd9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,7 +7,7 @@ user-facing API and quick start, see the [project README](../README.md). Lambda (Bref), and macOS, plus building from source. - **[API reference](api.md)** — the `QuickJS` class and every method. - **[Architecture](architecture.md)** — the three worlds (PHP / Rust / QuickJS), - the single `__host` bridge, how a call flows end to end, value marshaling, and + the direct host bridge, how a call flows end to end, value marshaling, and bidirectional function passing. - **[Execution modes](execution-modes.md)** — what a *realm* is, shared vs. isolated mode, the realm lifecycle, and how the JS-callback registry is kept @@ -34,15 +34,14 @@ php -d extension=$(pwd)/target/debug/libphp_quickjs.so examples/kitchen_sink.php | `src/engine.rs` | Owns the QuickJS `Runtime`; realm lifecycle (shared vs isolated); deadline + re-entrancy state; the current-context stack. | | `src/sandbox.rs` | In-engine resource containment: the memory limit, native stack size, and wall-clock deadline (interrupt handler). | | `src/transpile.rs` | TypeScript → JavaScript via oxc, plus the content-hash transpile cache. | -| `src/bridge.rs` | The `__host` / `__php_invoke` imports, the dispatch table, the frozen `php.*` facade, and registries. | -| `src/marshal.rs` | `JS value ↔ MiddleValue ↔ PHP zval`, with native-msgpack (de)serialization. | +| `src/bridge.rs` | The native host imports, dispatch table, frozen `php.*` facade, and registries. | +| `src/marshal.rs` | Native `JS value ↔ MiddleValue ↔ PHP zval` conversion. | | `src/callback.rs` | `Js\Callback` — a JS function wrapped as an invocable PHP object. | | `src/handles.rs` | The capability handle table (`int → live zval`). | | `src/error.rs` | Error bridging both ways; JS-stack remapping to TS coordinates. | | `src/exceptions.rs` | The typed `QuickJS*Exception` classes and rich exception construction. | | `src/manifest.rs` | The registration manifest and `.d.ts` generation. | -| `src/js/msgpack.js` | The in-sandbox MessagePack codec (byte-compatible with `MiddleValue`). | -| `src/js/runtime.js` | The in-sandbox runtime: function-ref wrap/unwrap and the JS callback registry. | +| `src/js/runtime.js` | Host dispatch and the JS callback registry. | ## Stack @@ -54,8 +53,7 @@ php -d extension=$(pwd)/target/debug/libphp_quickjs.so examples/kitchen_sink.php refcounting bug class. - **[`oxc`](https://github.com/oxc-project/oxc)** — the TypeScript transform and source maps, in-process. -- **`rmp-serde`** (msgpack) and **`sourcemap`** for the wire format and error - remapping. +- **`sourcemap`** — error remapping. ## Threading diff --git a/docs/api.md b/docs/api.md index fdcc461..e89600e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -16,7 +16,7 @@ bounds the complete call, including Promise waits; `null` disables it. Expose a PHP callable to JS under a flat, dotted name — it becomes `php.(...)` in the guest. `$types` is an optional TypeScript signature -surfaced by `dts()`. This flat registry is the **entire** trust boundary. +surfaced by `dts()`. This flat registry is the PHP callback allowlist. ### `eval(string $code): mixed` diff --git a/docs/architecture.md b/docs/architecture.md index d2120fc..4f4ec39 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,7 +6,7 @@ ┌─ PHP (trusted, full Zend) ──┐ ┌─ Rust extension ─┐ ┌─ QuickJS (untrusted) ─┐ │ $js->register(...) │ │ owns the engine │ │ php.module.fn() │ │ $js->eval(tsCode) │◄─►│ host capabilities│◄─►│ frozen php.* facade │ -│ $js->grant($obj) │ │ msgpack marshal │ │ guest TS-as-JS │ +│ $js->grant($obj) │ │ native marshal │ │ guest TS-as-JS │ └─────────────────────────────┘ └───────────────────┘ └────────────────────────┘ ext-php-rs (zval ↔ Rust) rquickjs (Rust ↔ JSValue) ``` @@ -34,32 +34,29 @@ Take `php.math.add(2, 3)` from a guest script. ```js php.math.add = function () { - return globalThis.__rt.callHost("math.add", Array.prototype.slice.call(arguments)); + return globalThis.__host("math.add", Array.prototype.slice.call(arguments)); }; ``` -3. `__rt.callHost` (`src/js/runtime.js`) **msgpack-encodes** the argument array - and calls `__host("math.add", bytes)`. - -4. `__host` is the native entry point for registered PHP capabilities. In +3. `__host` is the native entry point for registered PHP capabilities. In `bridge.rs` it: - - decodes the msgpack payload to a `MiddleValue` list, + - converts JS values to a `MiddleValue` list, - looks `"math.add"` up in the **dispatch table** (rejects if not registered — this is the trust boundary), - converts each arg `MiddleValue → zval`, - calls the PHP callable via `ZendCallable::try_call`. -5. The result travels back `zval → MiddleValue → msgpack bytes`, and `__rt` - decodes it in the realm. `5` lands in the guest. +4. The result travels back `zval → MiddleValue → JS value`. `5` lands in the + guest without a byte serialization round-trip. -Adding a capability never changes this ABI: all registered capabilities use +Adding a capability never changes the dispatch mechanism: all capabilities use one dispatch table. The flat, dotted-name list (`manifest()`) is the complete audit surface for PHP callables. ### The facade is generated, and frozen `bridge.rs::build_facade` walks the manifest's dotted names into a nested object -tree, makes each leaf a function calling `__rt.callHost("dotted.name", args)`, then +tree, makes each leaf a function calling `__host("dotted.name", args)`, then **deep-freezes** the whole tree. Freezing is a security requirement, not a nicety: a guest must not be able to reassign `php.http.get` to fool other code. The facade is (re)built at the start of every `eval` so newly registered @@ -67,15 +64,12 @@ capabilities appear. ## Value marshaling -Each side implements exactly one conversion against a neutral middle type, -`marshal.rs::MiddleValue`, which (de)serializes to **native** msgpack (not -serde's tagged-enum form), so the in-sandbox JS codec interoperates byte-for-byte. +Each side converts against a neutral middle type, `marshal.rs::MiddleValue`. +All values cross through native conversion. ``` JS value ──js_to_middle──► MiddleValue ──middle_to_zval──► PHP zval JS value ◄─middle_to_js─── MiddleValue ◄─zval_to_middle─── PHP zval - │ - msgpack bytes (the __host wire form) ``` | JS | MiddleValue | PHP | @@ -94,25 +88,20 @@ Notes: - A PHP array with sequential `0..n` keys becomes a JS `Array`; otherwise a JS object. A non-UTF-8 PHP string crosses as bytes (a `Uint8Array`). - Integers beyond 2^53 lose precision when represented as JS numbers. -- Why msgpack at all, in one process? It gives a clean, binary-safe, documented - ABI for the one `__host` import, and a single canonical serialization that both - the Rust and JS sides share. ## Bidirectional functions -Functions can't be msgpack-encoded, so they cross as **tagged references** and -the real callable is held in a registry on the owning side. +Functions cross as **registry references**: the real callable remains on its +owning side. -- **`{"$__phpfn": id}`** — a PHP callable handed to JS. The callable is stored in - a host-side registry (`bridge.rs`); JS receives a wrapper function that routes +- **`PhpFn(id)`** — a PHP callable handed to JS. The callable is stored in a + host-side registry (`bridge.rs`); JS receives a wrapper function that routes back through `__php_invoke(id, …)`. -- **`{"$__jsfn": id}`** — a JS function handed to PHP. The function is stored in a +- **`JsFn(id)`** — a JS function handed to PHP. The function is stored in a **JS-side** registry (`jsFns` in `runtime.js`); PHP receives a `Js\Callback` object holding the integer `id`. -For JS → PHP calls, `wrap()` replaces functions with refs before encoding and -`unwrap()` restores them after decoding. Saved JS callbacks use native value -conversion in Rust. +Rust converts these references without JS-side wrapping or decoding. ### Invoking a JS function from PHP diff --git a/docs/errors.md b/docs/errors.md index f027be4..97392ae 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -68,7 +68,7 @@ it. On a throw, `error.rs` reads the JS stack (generated-JS coordinates), and fo each frame referencing the guest module it looks the position up in the module's **source map** (kept host-side from transpilation) and rewrites it to the original TS `line:col`. Frames that don't reference the guest module — the -`__rt`/facade plumbing — are dropped. +facade plumbing — are dropped. ### Non-`Error` throws are surfaced, not lost diff --git a/docs/execution-modes.md b/docs/execution-modes.md index 0d7570f..3209a4b 100644 --- a/docs/execution-modes.md +++ b/docs/execution-modes.md @@ -98,7 +98,7 @@ holds only the integer id (in a `Js\Callback`). Two requirements pull against each other: 1. **Persist across evals (shared mode).** The bridge is (re)installed every - eval; `runtime.js` is therefore guarded (`if (!globalThis.__rt) …`) so a + eval; `runtime.js` is therefore guarded (`if (!globalThis.__registerJsFn) …`) so a re-install does **not** recreate `jsFns`. The registry survives for the realm's life, so a stored callback keeps working after later evals. @@ -106,7 +106,7 @@ each other: garbage-collected. But deletion can't happen *eagerly* in `Drop`: a JS function can round-trip PHP→JS within a single host call (e.g. `php.identity(fn)` returns `fn` straight back to JS), which drops a transient wrapper while JS - still needs the entry — deleting then would race the unwrap. So `Drop` only + still needs the entry — deleting then would race reconstruction. So `Drop` only **queues** the id (touching no JS, no locks), and the queued ids are flushed at the **next eval boundary**, when no round-trip is in flight. diff --git a/src/bridge.rs b/src/bridge.rs index 13307cf..b09d9ba 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -1,26 +1,21 @@ -//! The trust boundary: one `__host` import into JS, a flat dispatch table of -//! registered PHP callables, and the frozen `php.*` facade generated from the -//! manifest. -//! -//! The `__host(name, argsBytes)` ABI is byte-based: the guest encodes its -//! argument array to msgpack, the host decodes it, dispatches to the PHP -//! callable, and returns the msgpack-encoded result. Adding a capability never -//! changes this ABI. +//! The trust boundary: native host imports dispatch registered PHP callables +//! through a flat allowlist and the frozen `php.*` facade. use crate::engine::Engine; use crate::error::{throw_host_error, HostError}; use crate::handles::HandleTable; use crate::manifest::ManifestEntry; -use crate::marshal::{js_to_data, middle_to_zval, zval_to_middle, MiddleValue}; +use crate::marshal::{ + js_to_data, js_to_middle, middle_to_js, middle_to_zval, zval_to_middle, MiddleValue, +}; use ext_php_rs::convert::IntoZvalDyn; use ext_php_rs::types::{ZendCallable, Zval}; -use rquickjs::{Ctx, Exception, Function, TypedArray, Value}; +use rquickjs::{Ctx, Exception, Function, Value}; use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::rc::{Rc, Weak}; -/// The msgpack codec and runtime support injected into every sandbox context. -const MSGPACK_JS: &str = include_str!("js/msgpack.js"); +/// Runtime support injected into each context. const RUNTIME_JS: &str = include_str!("js/runtime.js"); /// Shared host-side state behind the bridge. Single-threaded (PHP NTS), so @@ -189,25 +184,27 @@ fn php_fn_call( call_php(&callable_zv, &args, state) } -/// Decode the msgpack arg payload from a host import into a list of values. -fn decode_args(bytes: &[u8]) -> Result, String> { - match MiddleValue::from_msgpack(bytes).map_err(|e| e.to_string())? { - MiddleValue::Array(a) => Ok(a), - other => Ok(vec![other]), +fn invoke_host<'js>( + ctx: &Ctx<'js>, + state: &BridgeState, + payload: Value<'js>, + call: impl FnOnce(Vec) -> Result, +) -> rquickjs::Result> { + if !payload.is_array() { + return Err(Exception::throw_type( + ctx, + "host arguments must be an array", + )); } + let MiddleValue::Array(args) = js_to_middle(ctx, payload, state)? else { + unreachable!("a JS array converts to MiddleValue::Array") + }; + let result = call(args).map_err(|error| throw_host_error(ctx, &error))?; + middle_to_js(ctx, &result) } -/// Encode a host result back to a msgpack `Uint8Array` for JS. -fn encode_result<'js>(ctx: &Ctx<'js>, result: MiddleValue) -> rquickjs::Result> { - let out = result - .to_msgpack() - .map_err(|e| Exception::throw_type(ctx, &format!("encode failed: {e}")))?; - Ok(TypedArray::new(ctx.clone(), out)?.into_value()) -} - -/// Install the bridge into a context: the `__host`/`__php_invoke` native -/// imports, the msgpack codec, the runtime support, and the frozen `php.*` -/// facade. Call once per `eval`, before guest code runs. +/// Install native imports, runtime support, and frozen +/// `php.*` facade. Call once per `eval`, before guest code runs. pub fn install<'js>(ctx: &Ctx<'js>, state: Rc) -> rquickjs::Result<()> { let globals = ctx.globals(); @@ -231,23 +228,14 @@ pub fn install<'js>(ctx: &Ctx<'js>, state: Rc) -> rquickjs::Result< globals.set("quickjs", quickjs)?; ctx.eval::<(), _>("Object.freeze(globalThis.quickjs); Object.defineProperty(globalThis, 'quickjs', {value: globalThis.quickjs, writable: false, configurable: false})")?; } - // The single JS -> host capability entry point. + // JS -> PHP capability calls use the same native conversion as eval. let host_state = state.clone(); let host = Function::new( ctx.clone(), - move |ctx: Ctx<'js>, - name: String, - args_bytes: TypedArray<'js, u8>| - -> rquickjs::Result> { - let bytes = args_bytes - .as_bytes() - .ok_or_else(|| Exception::throw_type(&ctx, "__host args must be a Uint8Array"))?; - let args = decode_args(bytes).map_err(|e| Exception::throw_type(&ctx, &e))?; - let result = host_call(&host_state, &name, args); - match result { - Ok(r) => encode_result(&ctx, r), - Err(err) => Err(throw_host_error(&ctx, &err)), - } + move |ctx: Ctx<'js>, name: String, payload: Value<'js>| -> rquickjs::Result> { + invoke_host(&ctx, &host_state, payload, |args| { + host_call(&host_state, &name, args) + }) }, )?; globals.set("__host", host)?; @@ -256,25 +244,15 @@ pub fn install<'js>(ctx: &Ctx<'js>, state: Rc) -> rquickjs::Result< let php_state = state.clone(); let php_invoke = Function::new( ctx.clone(), - move |ctx: Ctx<'js>, - id: f64, - args_bytes: TypedArray<'js, u8>| - -> rquickjs::Result> { - let bytes = args_bytes.as_bytes().ok_or_else(|| { - Exception::throw_type(&ctx, "__php_invoke args must be a Uint8Array") - })?; - let args = decode_args(bytes).map_err(|e| Exception::throw_type(&ctx, &e))?; - let result = php_fn_call(&php_state, id as u64, args); - match result { - Ok(r) => encode_result(&ctx, r), - Err(err) => Err(throw_host_error(&ctx, &err)), - } + move |ctx: Ctx<'js>, id: f64, payload: Value<'js>| -> rquickjs::Result> { + invoke_host(&ctx, &php_state, payload, |args| { + php_fn_call(&php_state, id as u64, args) + }) }, )?; globals.set("__php_invoke", php_invoke)?; - // Codec, runtime support, then the frozen facade. - ctx.eval::<(), _>(MSGPACK_JS)?; + // Runtime support must precede the frozen facade. ctx.eval::<(), _>(RUNTIME_JS)?; ctx.eval::<(), _>(build_facade(&state.names()))?; @@ -312,7 +290,7 @@ fn build_facade(names: &[String]) -> String { } let leaf = format!("{path}[{}]", js_string(parts[parts.len() - 1])); src.push_str(&format!( - "{leaf} = function(){{ return globalThis.__rt.callHost({}, Array.prototype.slice.call(arguments)); }};\n", + "{leaf} = function(){{ return globalThis.__host({}, Array.prototype.slice.call(arguments)); }};\n", js_string(name) )); } @@ -339,7 +317,7 @@ mod tests { let src = build_facade(&["db.query".into(), "log.info".into()]); assert!(src.contains("php[\"db\"] = php[\"db\"] || {};")); assert!(src.contains("php[\"db\"][\"query\"] = function()")); - assert!(src.contains("callHost(\"db.query\"")); + assert!(src.contains("__host(\"db.query\"")); assert!(src.contains("Object.freeze")); } } diff --git a/src/callback.rs b/src/callback.rs index a9d7bff..67062f9 100644 --- a/src/callback.rs +++ b/src/callback.rs @@ -99,7 +99,7 @@ pub(crate) fn call_js<'js>( let mut call_args = rquickjs::function::Args::new(ctx.clone(), args.len()); for arg in args { call_args - .push_arg(middle_to_js(ctx, arg, &engine.state).map_err(&map_error)?) + .push_arg(middle_to_js(ctx, arg).map_err(&map_error)?) .map_err(&map_error)?; } function.call_arg(call_args).map_err(map_error) diff --git a/src/error.rs b/src/error.rs index 7fcb09d..cecd08a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -158,7 +158,7 @@ pub fn js_error_to_php(ctx: &Ctx<'_>, err: JsError) -> PhpException { /// Remap a JS stack back to TypeScript coordinates, keeping only guest frames. /// /// Frames that reference `module_id` have their `:line:col` rewritten to the -/// original TS position; internal host frames (the msgpack/runtime bootstrap +/// original TS position; internal host frames (the runtime bootstrap /// and the `php.*` facade wrappers) are dropped so the trace reads like a plain /// TS stack. Returns `None` if the map cannot be parsed or no guest frame /// remains. diff --git a/src/js/msgpack.js b/src/js/msgpack.js deleted file mode 100644 index b5cc123..0000000 --- a/src/js/msgpack.js +++ /dev/null @@ -1,367 +0,0 @@ -// Minimal MessagePack codec for the php-quickjs __host bridge. -// -// Covers exactly the types crossing the boundary: null, bool, int, float64, -// str (utf-8), bin (Uint8Array), array, and string-keyed map (plain object). -// It must stay byte-compatible with the Rust `MiddleValue` serde impl -// (src/marshal.rs), which reads/writes *native* msgpack types. -// -// Installed once per realm as globalThis.__mp = { encode, decode }. -if (!globalThis.__mp) (function () { - "use strict"; - - // QuickJS has no TextEncoder/TextDecoder (those are WHATWG, not ES), so - // UTF-8 is handled manually here. - function utf8Encode(str) { - var bytes = []; - for (var i = 0; i < str.length; i++) { - var c = str.charCodeAt(i); - if (c < 0x80) { - bytes.push(c); - } else if (c < 0x800) { - bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); - } else if (c >= 0xd800 && c <= 0xdbff) { - // High surrogate; combine with the following low surrogate. - var c2 = str.charCodeAt(++i); - var cp = 0x10000 + ((c & 0x3ff) << 10) + (c2 & 0x3ff); - bytes.push( - 0xf0 | (cp >> 18), - 0x80 | ((cp >> 12) & 0x3f), - 0x80 | ((cp >> 6) & 0x3f), - 0x80 | (cp & 0x3f) - ); - } else { - bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); - } - } - return bytes; - } - - function utf8Decode(bytes, start, len) { - var out = ""; - var i = start; - var end = start + len; - while (i < end) { - var c = bytes[i++]; - if (c < 0x80) { - out += String.fromCharCode(c); - } else if (c < 0xe0) { - out += String.fromCharCode(((c & 0x1f) << 6) | (bytes[i++] & 0x3f)); - } else if (c < 0xf0) { - var b1 = bytes[i++]; - var b2 = bytes[i++]; - out += String.fromCharCode( - ((c & 0x0f) << 12) | ((b1 & 0x3f) << 6) | (b2 & 0x3f) - ); - } else { - var d1 = bytes[i++]; - var d2 = bytes[i++]; - var d3 = bytes[i++]; - var cp = - ((c & 0x07) << 18) | - ((d1 & 0x3f) << 12) | - ((d2 & 0x3f) << 6) | - (d3 & 0x3f); - cp -= 0x10000; - out += String.fromCharCode(0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff)); - } - } - return out; - } - - // ---- encoder ---------------------------------------------------------- - function Writer() { - this.bytes = []; - } - Writer.prototype.u8 = function (b) { - this.bytes.push(b & 0xff); - }; - Writer.prototype.u16 = function (n) { - this.u8(n >> 8); - this.u8(n); - }; - Writer.prototype.u32 = function (n) { - this.u8(n >> 24); - this.u8(n >> 16); - this.u8(n >> 8); - this.u8(n); - }; - Writer.prototype.raw = function (arr) { - for (var i = 0; i < arr.length; i++) this.bytes.push(arr[i] & 0xff); - }; - - function encodeInt(w, n) { - if (n >= 0) { - if (n <= 0x7f) { - w.u8(n); - } else if (n <= 0xff) { - w.u8(0xcc); - w.u8(n); - } else if (n <= 0xffff) { - w.u8(0xcd); - w.u16(n); - } else if (n <= 0xffffffff) { - w.u8(0xce); - w.u32(n); - } else { - // uint64 - w.u8(0xcf); - writeBig(w, n); - } - } else { - if (n >= -32) { - w.u8(0xe0 | (n + 32)); - } else if (n >= -128) { - w.u8(0xd0); - w.u8(n & 0xff); - } else if (n >= -32768) { - w.u8(0xd1); - w.u16(n & 0xffff); - } else if (n >= -2147483648) { - w.u8(0xd2); - w.u32(n >>> 0); - } else { - // int64 - w.u8(0xd3); - writeBig(w, n); - } - } - } - - // Write a 64-bit integer (best effort; exact up to 2^53). - function writeBig(w, n) { - var big = BigInt(n); - if (big < 0n) big = (1n << 64n) + big; - for (var i = 7; i >= 0; i--) { - w.u8(Number((big >> BigInt(i * 8)) & 0xffn)); - } - } - - function encodeValue(w, v) { - if (v === null || v === undefined) { - w.u8(0xc0); - } else if (v === true) { - w.u8(0xc3); - } else if (v === false) { - w.u8(0xc2); - } else if (typeof v === "number") { - if (Number.isInteger(v)) { - encodeInt(w, v); - } else { - w.u8(0xcb); - var buf = new ArrayBuffer(8); - new DataView(buf).setFloat64(0, v, false); - w.raw(new Uint8Array(buf)); - } - } else if (typeof v === "bigint") { - // Encode as int64/uint64. - if (v >= 0n) w.u8(0xcf); - else w.u8(0xd3); - writeBig(w, v); - } else if (typeof v === "string") { - var enc = utf8Encode(v); - var len = enc.length; - if (len <= 31) { - w.u8(0xa0 | len); - } else if (len <= 0xff) { - w.u8(0xd9); - w.u8(len); - } else if (len <= 0xffff) { - w.u8(0xda); - w.u16(len); - } else { - w.u8(0xdb); - w.u32(len); - } - w.raw(enc); - } else if (v instanceof Uint8Array) { - var blen = v.length; - if (blen <= 0xff) { - w.u8(0xc4); - w.u8(blen); - } else if (blen <= 0xffff) { - w.u8(0xc5); - w.u16(blen); - } else { - w.u8(0xc6); - w.u32(blen); - } - w.raw(v); - } else if (Array.isArray(v)) { - var alen = v.length; - if (alen <= 15) { - w.u8(0x90 | alen); - } else if (alen <= 0xffff) { - w.u8(0xdc); - w.u16(alen); - } else { - w.u8(0xdd); - w.u32(alen); - } - for (var i = 0; i < alen; i++) encodeValue(w, v[i]); - } else if (typeof v === "object") { - var keys = Object.keys(v); - var mlen = keys.length; - if (mlen <= 15) { - w.u8(0x80 | mlen); - } else if (mlen <= 0xffff) { - w.u8(0xde); - w.u16(mlen); - } else { - w.u8(0xdf); - w.u32(mlen); - } - for (var k = 0; k < mlen; k++) { - encodeValue(w, keys[k]); - encodeValue(w, v[keys[k]]); - } - } else { - throw new TypeError("cannot msgpack-encode value of type " + typeof v); - } - } - - function encode(v) { - var w = new Writer(); - encodeValue(w, v); - return new Uint8Array(w.bytes); - } - - // ---- decoder ---------------------------------------------------------- - function Reader(bytes) { - this.b = bytes; - this.p = 0; - this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - } - Reader.prototype.u8 = function () { - return this.b[this.p++]; - }; - Reader.prototype.u16 = function () { - var n = this.view.getUint16(this.p, false); - this.p += 2; - return n; - }; - Reader.prototype.u32 = function () { - var n = this.view.getUint32(this.p, false); - this.p += 4; - return n; - }; - Reader.prototype.i8 = function () { - var n = this.view.getInt8(this.p); - this.p += 1; - return n; - }; - Reader.prototype.i16 = function () { - var n = this.view.getInt16(this.p, false); - this.p += 2; - return n; - }; - Reader.prototype.i32 = function () { - var n = this.view.getInt32(this.p, false); - this.p += 4; - return n; - }; - Reader.prototype.big = function (signed) { - var hi = BigInt(this.u32()); - var lo = BigInt(this.u32()); - var n = (hi << 32n) | lo; - if (signed && n >= 1n << 63n) n -= 1n << 64n; - // Collapse to a Number when it is exactly representable. - if (n >= -9007199254740991n && n <= 9007199254740991n) return Number(n); - return n; - }; - Reader.prototype.str = function (len) { - var s = utf8Decode(this.b, this.p, len); - this.p += len; - return s; - }; - Reader.prototype.bin = function (len) { - var slice = this.b.slice(this.p, this.p + len); - this.p += len; - return slice; - }; - - function decodeValue(r) { - var c = r.u8(); - if (c <= 0x7f) return c; // positive fixint - if (c >= 0xe0) return c - 256; // negative fixint - if (c >= 0x80 && c <= 0x8f) return decodeMap(r, c & 0x0f); - if (c >= 0x90 && c <= 0x9f) return decodeArray(r, c & 0x0f); - if (c >= 0xa0 && c <= 0xbf) return r.str(c & 0x1f); - switch (c) { - case 0xc0: - return null; - case 0xc2: - return false; - case 0xc3: - return true; - case 0xc4: - return r.bin(r.u8()); - case 0xc5: - return r.bin(r.u16()); - case 0xc6: - return r.bin(r.u32()); - case 0xca: { - var f = r.view.getFloat32(r.p, false); - r.p += 4; - return f; - } - case 0xcb: { - var d = r.view.getFloat64(r.p, false); - r.p += 8; - return d; - } - case 0xcc: - return r.u8(); - case 0xcd: - return r.u16(); - case 0xce: - return r.u32(); - case 0xcf: - return r.big(false); - case 0xd0: - return r.i8(); - case 0xd1: - return r.i16(); - case 0xd2: - return r.i32(); - case 0xd3: - return r.big(true); - case 0xd9: - return r.str(r.u8()); - case 0xda: - return r.str(r.u16()); - case 0xdb: - return r.str(r.u32()); - case 0xdc: - return decodeArray(r, r.u16()); - case 0xdd: - return decodeArray(r, r.u32()); - case 0xde: - return decodeMap(r, r.u16()); - case 0xdf: - return decodeMap(r, r.u32()); - default: - throw new TypeError("unknown msgpack marker 0x" + c.toString(16)); - } - } - - function decodeArray(r, len) { - var out = new Array(len); - for (var i = 0; i < len; i++) out[i] = decodeValue(r); - return out; - } - - function decodeMap(r, len) { - var out = {}; - for (var i = 0; i < len; i++) { - var k = decodeValue(r); - out[k] = decodeValue(r); - } - return out; - } - - function decode(bytes) { - return decodeValue(new Reader(bytes)); - } - - globalThis.__mp = { encode: encode, decode: decode }; -})(); diff --git a/src/js/runtime.js b/src/js/runtime.js index 9bfe9f0..3b7ed1c 100644 --- a/src/js/runtime.js +++ b/src/js/runtime.js @@ -1,126 +1,34 @@ -// Runtime support for bidirectional function passing across the bridge. -// -// Functions cannot be msgpack-encoded, so they cross as tagged refs: -// a JS function -> { "$__jsfn": id } (id into the JS-side registry) -// a PHP callable -> { "$__phpfn": id } (id into the host-side registry) -// -// `wrap` replaces functions with refs before encoding; `unwrap` replaces refs -// with callables after decoding. The host (Rust) sees only the tagged refs. -// -// Installed once per realm: the guard keeps the JS-side function registry -// (`jsFns`) alive across re-installs, so a JS function handed to PHP survives -// for the life of the realm. Entries are freed when their Js\Callback is -// garbage-collected (host calls `__deleteJsFn`). -if (!globalThis.__rt) { - globalThis.__rt = (function () { - "use strict"; - var mp = globalThis.__mp; - var JSFN = "$__jsfn"; - var PHPFN = "$__phpfn"; - - var jsFns = {}; - var nextId = 1; - - function registerFn(fn) { - var id = nextId++; - jsFns[id] = fn; - return id; - } - function getFn(id) { - return jsFns[id]; - } - function deleteFn(id) { - delete jsFns[id]; - } - - function isPlainContainer(v) { - return v && typeof v === "object" && !(v instanceof Uint8Array); - } - - // Replace JS functions with refs (outgoing: JS -> host). - function wrap(v) { - if (typeof v === "function") { - var o = {}; - o[JSFN] = registerFn(v); - return o; - } - if (Array.isArray(v)) return v.map(wrap); - if (isPlainContainer(v)) { - var out = {}; - for (var k in v) { - if (Object.prototype.hasOwnProperty.call(v, k)) out[k] = wrap(v[k]); - } - return out; +// Function references live on their owning side of the native bridge. +// Keep the JS registry across evals in a shared realm; Rust releases entries +// when their Js\Callback wrappers are collected. +if (!globalThis.__registerJsFn) { + (function () { + "use strict"; + var jsFns = {}; + var nextId = 1; + + function registerFn(fn) { + var id = nextId++; + jsFns[id] = fn; + return id; } - return v; - } - - // Replace refs with callables (incoming: host -> JS). - function unwrap(v) { - if (isPlainContainer(v)) { - if (Object.prototype.hasOwnProperty.call(v, PHPFN)) { - return makePhpFn(v[PHPFN]); - } - if (Object.prototype.hasOwnProperty.call(v, JSFN)) { - return getFn(v[JSFN]); - } - if (Array.isArray(v)) return v.map(unwrap); - var out = {}; - for (var k in v) { - if (Object.prototype.hasOwnProperty.call(v, k)) out[k] = unwrap(v[k]); - } - return out; + function getFn(id) { return jsFns[id]; } + function deleteFn(id) { delete jsFns[id]; } + function makePhpFn(id) { + return function () { + return globalThis.__php_invoke(id, Array.prototype.slice.call(arguments)); + }; } - return v; - } - function makePhpFn(id) { - return function () { - return callPhp(id, Array.prototype.slice.call(arguments)); + globalThis.__registerJsFn = registerFn; + globalThis.__getJsFn = getFn; + globalThis.__makePhpFn = makePhpFn; + globalThis.__deleteJsFn = deleteFn; + globalThis.__asPromise = function (value) { + return value !== null && + (typeof value === "object" || typeof value === "function") + ? Promise.resolve(value) : null; }; - } - - // JS -> host capability dispatch. - function callHost(name, args) { - return unwrap(mp.decode(globalThis.__host(name, mp.encode(wrap(args))))); - } - - // JS -> host: invoke a PHP callable previously handed to JS. - function callPhp(id, args) { - return unwrap(mp.decode(globalThis.__php_invoke(id, mp.encode(wrap(args))))); - } - - // Normalize thenables; Rust reads settlement through QuickJS's Promise API. - function asPromise(value) { - return value !== null && - (typeof value === "object" || typeof value === "function") - ? Promise.resolve(value) : null; - } - - // Used by the host to register a bare JS function value (e.g. an eval result - // that is a function) so it can be handed to PHP as a Js\Callback. - globalThis.__registerJsFn = registerFn; - // Used by the host to reconstruct callables when marshaling MiddleValue -> - // JS directly (e.g. QuickJS::roundtrip of a function). - globalThis.__getJsFn = getFn; - globalThis.__makePhpFn = makePhpFn; - // Called when a PHP-side Js\Callback is garbage-collected, to release its - // entry from the registry. - globalThis.__deleteJsFn = deleteFn; - globalThis.__asPromise = asPromise; - // Test/diagnostic helper: number of live JS callbacks held for PHP. - globalThis.__jsFnCount = function () { - return Object.keys(jsFns).length; - }; - - return { - registerFn: registerFn, - getFn: getFn, - deleteFn: deleteFn, - wrap: wrap, - unwrap: unwrap, - callHost: callHost, - callPhp: callPhp, - }; + globalThis.__jsFnCount = function () { return Object.keys(jsFns).length; }; })(); } diff --git a/src/lib.rs b/src/lib.rs index 0d9261a..2043a16 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -226,7 +226,7 @@ impl QuickJS { // Runtime support must exist for any function reconstruction. bridge::install(ctx, state.clone()) .map_err(|e| PhpException::default(error::js_error_message(ctx, e)))?; - let js = middle_to_js(ctx, &middle, &state).map_err(to_php_err)?; + let js = middle_to_js(ctx, &middle).map_err(to_php_err)?; let back = js_to_middle(ctx, js, &state).map_err(to_php_err)?; middle_to_zval(&back, &state).map_err(PhpException::default) }) diff --git a/src/marshal.rs b/src/marshal.rs index f3195f1..20487af 100644 --- a/src/marshal.rs +++ b/src/marshal.rs @@ -1,30 +1,12 @@ -//! Value marshaling between three worlds via a neutral [`MiddleValue`]: -//! -//! ```text -//! JS Value <-> MiddleValue <-> PHP Zval -//! | -//! msgpack bytes (the `__host` wire format) -//! ``` -//! -//! `MiddleValue` (de)serializes to **native** msgpack types (nil/bool/int/ -//! float/str/bin/array/map) — not serde's tagged-enum form — so a JS-side -//! msgpack codec interoperates with it byte-for-byte. +//! Value marshaling between JS values and PHP zvals through [`MiddleValue`]. use crate::bridge::BridgeState; use crate::callback::JsCallback; use ext_php_rs::convert::IntoZval; use ext_php_rs::types::{ArrayKey, ZendClassObject, ZendHashTable, Zval}; use rquickjs::{Array, Ctx, Function, Object, TypedArray, Value}; -use serde::de::{Deserialize, Deserializer, MapAccess, SeqAccess, Visitor}; -use serde::ser::{Serialize, SerializeMap, SerializeSeq, Serializer}; -use std::fmt; - -/// Reserved msgpack-map keys tagging a function reference across the wire. -const JSFN_TAG: &str = "$__jsfn"; -const PHPFN_TAG: &str = "$__phpfn"; - -/// The neutral, self-describing value that bridges JS, PHP and the wire. -#[derive(Debug, Clone, PartialEq)] +/// The neutral value that bridges JS and PHP. +#[derive(Debug, Clone)] pub enum MiddleValue { Null, Bool(bool), @@ -41,18 +23,6 @@ pub enum MiddleValue { JsFn(u64), } -impl MiddleValue { - /// Encode to a msgpack byte payload (the `__host` wire form). - pub fn to_msgpack(&self) -> Result, rmp_serde::encode::Error> { - rmp_serde::to_vec(self) - } - - /// Decode a msgpack byte payload. - pub fn from_msgpack(bytes: &[u8]) -> Result { - rmp_serde::from_slice(bytes) - } -} - /// Map an `f64` to an int when it is integral and fits an `i64`, else keep it /// a float. QuickJS already stores small integral numbers as int32, so this /// only ever promotes the larger integral doubles that JS cannot tag as int. @@ -64,143 +34,6 @@ fn int_or_float(f: f64) -> MiddleValue { } } -// --------------------------------------------------------------------------- -// native-msgpack serde -// --------------------------------------------------------------------------- - -impl Serialize for MiddleValue { - fn serialize(&self, s: S) -> Result { - match self { - MiddleValue::Null => s.serialize_unit(), - MiddleValue::Bool(b) => s.serialize_bool(*b), - MiddleValue::Int(i) => s.serialize_i64(*i), - MiddleValue::Float(f) => s.serialize_f64(*f), - MiddleValue::Str(v) => s.serialize_str(v), - MiddleValue::Bytes(b) => s.serialize_bytes(b), - MiddleValue::Array(items) => { - let mut seq = s.serialize_seq(Some(items.len()))?; - for it in items { - seq.serialize_element(it)?; - } - seq.end() - } - MiddleValue::Map(entries) => { - let mut map = s.serialize_map(Some(entries.len()))?; - for (k, v) in entries { - map.serialize_entry(k, v)?; - } - map.end() - } - // Function refs travel as single-entry tagged maps. - MiddleValue::PhpFn(id) => { - let mut map = s.serialize_map(Some(1))?; - map.serialize_entry(PHPFN_TAG, &(*id as i64))?; - map.end() - } - MiddleValue::JsFn(id) => { - let mut map = s.serialize_map(Some(1))?; - map.serialize_entry(JSFN_TAG, &(*id as i64))?; - map.end() - } - } - } -} - -impl<'de> Deserialize<'de> for MiddleValue { - fn deserialize>(d: D) -> Result { - struct V; - impl<'de> Visitor<'de> for V { - type Value = MiddleValue; - fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str("a msgpack value") - } - fn visit_unit(self) -> Result { - Ok(MiddleValue::Null) - } - fn visit_none(self) -> Result { - Ok(MiddleValue::Null) - } - fn visit_bool(self, v: bool) -> Result { - Ok(MiddleValue::Bool(v)) - } - fn visit_i64(self, v: i64) -> Result { - Ok(MiddleValue::Int(v)) - } - fn visit_u64(self, v: u64) -> Result { - Ok(i64::try_from(v).map_or(MiddleValue::Float(v as f64), MiddleValue::Int)) - } - fn visit_f64(self, v: f64) -> Result { - Ok(MiddleValue::Float(v)) - } - fn visit_str(self, v: &str) -> Result { - Ok(MiddleValue::Str(v.to_owned())) - } - fn visit_string(self, v: String) -> Result { - Ok(MiddleValue::Str(v)) - } - fn visit_bytes(self, v: &[u8]) -> Result { - Ok(MiddleValue::Bytes(v.to_owned())) - } - fn visit_byte_buf(self, v: Vec) -> Result { - Ok(MiddleValue::Bytes(v)) - } - fn visit_seq>(self, mut seq: A) -> Result { - let mut out = Vec::new(); - while let Some(it) = seq.next_element()? { - out.push(it); - } - Ok(MiddleValue::Array(out)) - } - fn visit_map>(self, mut map: A) -> Result { - let mut out = Vec::new(); - while let Some((k, v)) = map.next_entry::()? { - out.push((k.0, v)); - } - // A single-entry map keyed by a reserved tag is a function ref. - if out.len() == 1 { - if let (key, MiddleValue::Int(id)) = &out[0] { - if key == JSFN_TAG { - return Ok(MiddleValue::JsFn(*id as u64)); - } - if key == PHPFN_TAG { - return Ok(MiddleValue::PhpFn(*id as u64)); - } - } - } - Ok(MiddleValue::Map(out)) - } - } - d.deserialize_any(V) - } -} - -/// A map key coerced to a string (msgpack maps may key by non-string scalars). -struct MapKey(String); -impl<'de> Deserialize<'de> for MapKey { - fn deserialize>(d: D) -> Result { - struct K; - impl<'de> Visitor<'de> for K { - type Value = MapKey; - fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str("a map key") - } - fn visit_str(self, v: &str) -> Result { - Ok(MapKey(v.to_owned())) - } - fn visit_string(self, v: String) -> Result { - Ok(MapKey(v)) - } - fn visit_i64(self, v: i64) -> Result { - Ok(MapKey(v.to_string())) - } - fn visit_u64(self, v: u64) -> Result { - Ok(MapKey(v.to_string())) - } - } - d.deserialize_any(K) - } -} - // --------------------------------------------------------------------------- // JS <-> MiddleValue // --------------------------------------------------------------------------- @@ -375,11 +208,7 @@ impl<'js> JsConversion<'_, 'js> { } /// Convert the neutral representation into a JS value. -pub fn middle_to_js<'js>( - ctx: &Ctx<'js>, - value: &MiddleValue, - _state: &BridgeState, -) -> rquickjs::Result> { +pub fn middle_to_js<'js>(ctx: &Ctx<'js>, value: &MiddleValue) -> rquickjs::Result> { Ok(match value { MiddleValue::Null => Value::new_null(ctx.clone()), MiddleValue::Bool(b) => Value::new_bool(ctx.clone(), *b), @@ -397,14 +226,14 @@ pub fn middle_to_js<'js>( MiddleValue::Array(items) => { let arr = Array::new(ctx.clone())?; for (i, it) in items.iter().enumerate() { - arr.set(i, middle_to_js(ctx, it, _state)?)?; + arr.set(i, middle_to_js(ctx, it)?)?; } arr.into_value() } MiddleValue::Map(entries) => { let obj = Object::new(ctx.clone())?; for (k, v) in entries { - obj.set(k.as_str(), middle_to_js(ctx, v, _state)?)?; + obj.set(k.as_str(), middle_to_js(ctx, v)?)?; } obj.into_value() } @@ -580,54 +409,3 @@ pub fn middle_to_zval(value: &MiddleValue, state: &BridgeState) -> Result(v: T) -> Result { - v.into_zval(false).map_err(|e| e.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn roundtrip(v: MiddleValue) { - let bytes = v.to_msgpack().expect("encode"); - let back = MiddleValue::from_msgpack(&bytes).expect("decode"); - assert_eq!(v, back); - } - - #[test] - fn msgpack_scalars() { - roundtrip(MiddleValue::Null); - roundtrip(MiddleValue::Bool(true)); - roundtrip(MiddleValue::Int(-42)); - roundtrip(MiddleValue::Int(1 << 40)); - roundtrip(MiddleValue::Float(3.5)); - roundtrip(MiddleValue::Str("héllo".to_owned())); - roundtrip(MiddleValue::Bytes(vec![0, 1, 2, 255])); - } - - #[test] - fn msgpack_nested() { - roundtrip(MiddleValue::Array(vec![ - MiddleValue::Int(1), - MiddleValue::Str("two".into()), - MiddleValue::Bool(false), - ])); - roundtrip(MiddleValue::Map(vec![ - ("a".into(), MiddleValue::Int(1)), - ( - "nested".into(), - MiddleValue::Array(vec![MiddleValue::Null, MiddleValue::Float(2.5)]), - ), - ])); - } - - #[test] - fn bytes_encode_as_msgpack_bin() { - // msgpack bin8 marker is 0xc4; ensure bytes do not serialize as an array. - let bytes = MiddleValue::Bytes(vec![1, 2, 3]).to_msgpack().unwrap(); - assert_eq!(bytes[0], 0xc4); - } -} diff --git a/tests/php/03_register_dispatch.php b/tests/php/03_register_dispatch.php index 0d011bf..2226f89 100644 --- a/tests/php/03_register_dispatch.php +++ b/tests/php/03_register_dispatch.php @@ -16,7 +16,7 @@ $js->register('fetchUser', fn(int $id) => ['id' => $id, 'name' => 'Ada', 'orders' => [1, 2, 3]]); $js->register('echo', fn($x) => $x); -// JS -> PHP -> JS through the msgpack __host bridge. +// JS -> PHP -> JS through the direct bridge. eq(7, $js->eval('php.math.add(3, 4)'), 'math.add reenters PHP'); eq('Ada has 3 orders', $js->eval(' const u = php.fetchUser(42); @@ -26,7 +26,7 @@ $js->eval('php.log.info("hello from js")'); eq(['hello from js'], $log, 'side-effecting callback ran in PHP'); -// Every marshalable type survives the msgpack round-trip JS->PHP->JS. +// Every marshalable type survives the direct round-trip JS->PHP->JS. eq(true, $js->eval('php.echo(true)'), 'bool through bridge'); eq(-12345, $js->eval('php.echo(-12345)'), 'negative int through bridge'); eq(1 << 40, $js->eval('php.echo(' . (1 << 40) . ')'), 'large int through bridge'); @@ -36,6 +36,19 @@ eq(['a' => 1, 'b' => [2, 3]], $js->eval('php.echo({a:1, b:[2,3]})'), 'nested object through bridge'); eq(null, $js->eval('php.echo(null)'), 'null through bridge'); eq("\x00\xff", $js->eval('php.echo(new Uint8Array([0,255]))'), 'bytes through bridge'); +eq(true, $js->eval('(() => { + const bytes = new Uint8Array(13 * 1024 * 1024); + bytes[0] = 255; + bytes[bytes.length - 1] = 254; + const result = php.echo(bytes); + return result instanceof Uint8Array && result.length === bytes.length + && result[0] === 255 && result[result.length - 1] === 254; +})()'), 'large binary payload through direct bridge'); +eq(true, $js->eval('(() => { + const text = "a".repeat(1024 * 1024); + return php.echo(text) === text; +})()'), 'large text payload through direct bridge'); +eq('undefined', $js->eval('typeof __mp'), 'no JS codec is installed'); // The facade is frozen: guests cannot shadow capabilities. eq(true, $js->eval('Object.isFrozen(php)'), 'php is frozen'); @@ -48,7 +61,8 @@ (bool) $js->eval('(() => { try { php.echo; return true; } catch(e){ return false; } })()'), 'registered capability is reachable' ); -$threw = $js->eval('(() => { try { __host("not.registered", __mp.encode([])); return false; } catch(e){ return true; } })()'); +$threw = $js->eval('(() => { try { __host("not.registered", []); return false; } catch(e){ return true; } })()'); eq(true, $threw, 'unknown capability throws in JS'); +eq(true, $js->eval('(() => { try { __host("echo", 1); return false; } catch(e) { return e instanceof TypeError; } })()'), 'host rejects non-array arguments'); done(); From af0b4f8182479d3588f4698846547a3e0df81a7e Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Wed, 30 Sep 2026 10:32:42 +0200 Subject: [PATCH 11/17] fix: discard isolated runtime jobs when eval completes --- docs/execution-modes.md | 20 ++++++++++++-------- src/callback.rs | 21 +++++++++++++++++++-- src/engine.rs | 20 ++++++++++++++++++-- src/marshal.rs | 1 + tests/php/07_sandbox_timeout.php | 7 +++++++ tests/php/10_scope_isolation.php | 10 ++++++++++ tests/php/11_jobs.php | 15 +++++++++++++++ tests/php/14_async.php | 14 ++++++++++++++ 8 files changed, 96 insertions(+), 12 deletions(-) diff --git a/docs/execution-modes.md b/docs/execution-modes.md index 3209a4b..422a6f7 100644 --- a/docs/execution-modes.md +++ b/docs/execution-modes.md @@ -14,10 +14,11 @@ See [`examples/modes.php`](../examples/modes.php) for the two side by side. A QuickJS **`Context`** is a *realm*: its own `globalThis`, its own intrinsics (`Array`, `JSON`, …), and its own top-level scope. The **`Runtime`** (heap, GC, -memory limit, interrupt handler) is separate and owned once per `QuickJS` -instance (`engine.rs`). +memory limit, interrupt handler, and Promise job queue) is separate. Shared mode +keeps one runtime per instance; isolated mode creates and drops a runtime for each +outer `eval()`. Nested callbacks reuse the active runtime. -The only difference between the modes is **realm lifecycle**: +The modes differ in **runtime and realm lifecycle**: | | Shared (default) | Isolated (`isolated: true`) | |---|---|---| @@ -29,7 +30,8 @@ The only difference between the modes is **realm lifecycle**: | Capability handles | work | work | | Synchronous callbacks (within an eval) | work | work | | JS callback stored in PHP, called later | works | **throws** (realm gone) | -| Memory limit | shared across evals | shared across evals (same `Runtime`) | +| Memory limit | shared across evals | applied separately to each eval | +| Detached Promise jobs | remain queued | discarded when eval ends | ## Shared mode — a persistent session @@ -55,7 +57,8 @@ on `globalThis` for the next eval to read. ## Isolated mode — a stateless runner -A fresh realm per `eval()`, discarded afterward. Think **independent script +A fresh runtime and realm per `eval()`, discarded afterward. A returned Promise +is awaited; any remaining detached jobs are discarded without being executed. Think **independent script runner**: every eval is hermetic. ```php @@ -87,7 +90,8 @@ The behavioral split comes down to **what lives in the realm vs. host-side**: modes behave identically there. - Guest globals and the **JS function registry** (`jsFns`, in `runtime.js`) live **in the realm** → shared mode keeps them, isolated mode drops them with the - realm. That single fact is the entire difference. + realm. Detached Promise jobs live in the runtime, which isolated mode also + discards. ## The JS callback registry: persistence and cleanup @@ -123,5 +127,5 @@ registry is reclaimed shortly after PHP lets go. In isolated mode the whole real no collisions, automatic per-eval cleanup). Caveat: don't stash a JS callback in PHP to fire after the eval — pass it and use it *within* the eval. - **Strongest isolation:** a brand-new `QuickJS` per tenant. That gives a fresh - `Runtime` too — a separate heap and its own memory limit — not just a fresh - realm. + host-side capability registrations and handles too. Isolated mode already + gives each eval a separate heap and memory limit. diff --git a/src/callback.rs b/src/callback.rs index 67062f9..61b8e0f 100644 --- a/src/callback.rs +++ b/src/callback.rs @@ -18,12 +18,26 @@ use std::rc::Rc; #[php(name = "Js\\Callback")] pub struct JsCallback { pub id: u64, + realm_id: u64, pub engine: Rc, } impl JsCallback { pub fn new(id: u64, engine: Rc) -> Self { - JsCallback { id, engine } + JsCallback { + id, + realm_id: engine.realm_id(), + engine, + } + } + + pub fn check_realm(&self) -> Result<(), String> { + if self.realm_id != self.engine.realm_id() + || (self.engine.shared_ctx().is_none() && !self.engine.is_active()) + { + return Err("JS callback belongs to an ended isolated eval".to_owned()); + } + Ok(()) } } @@ -34,13 +48,16 @@ impl Drop for JsCallback { /// wrapper while JS still needs the entry, so deleting eagerly would race. /// Queuing touches no JS and cannot re-enter the engine. fn drop(&mut self) { - self.engine.state.queue_fn_deletion(self.id); + if self.check_realm().is_ok() { + self.engine.state.queue_fn_deletion(self.id); + } } } impl JsCallback { /// Invoke the underlying JS function with the given (already PHP-side) args. fn invoke_inner(&self, args: &[&Zval]) -> PhpResult { + self.check_realm().map_err(PhpException::default)?; let _guard = self.engine.enter().map_err(PhpException::default)?; let middle_args = diff --git a/src/engine.rs b/src/engine.rs index 4f6b0c7..16bff9c 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -26,6 +26,10 @@ pub const MAX_DEPTH: usize = 200; pub struct Engine { pub rt: Runtime, pub state: Rc, + memory_limit: usize, + max_stack: usize, + /// Monotonic identity of an isolated entry; shared mode keeps zero. + realm_id: Cell, /// Content-addressed TS->JS transpile cache (source maps kept host-side). pub transpile: TranspileCache, /// The persistent realm in shared mode; `None` in isolated mode (a fresh @@ -345,6 +349,9 @@ impl Engine { let engine = Rc::new(Engine { rt, state: state.clone(), + memory_limit, + max_stack, + realm_id: Cell::new(0), transpile: TranspileCache::new(256), shared_ctx, depth: Cell::new(0), @@ -384,6 +391,10 @@ impl Engine { self.shared_ctx.as_ref() } + pub fn realm_id(&self) -> u64 { + self.realm_id.get() + } + pub fn is_active(&self) -> bool { self.active_ctx.get().is_some() } @@ -430,8 +441,13 @@ impl Engine { match &self.shared_ctx { Some(ctx) => run(ctx), None => { - let ctx = - Context::full(&self.rt).map_err(|e| PhpException::default(e.to_string()))?; + // Pending jobs belong to the runtime, not the context. Drop + // both at entry completion so detached jobs cannot escape. + let rt = Runtime::new().map_err(|e| PhpException::default(e.to_string()))?; + sandbox::apply_limits(&rt, self.memory_limit, self.max_stack); + sandbox::install_interrupt(&rt, self.deadline.clone(), self.timed_out.clone()); + let ctx = Context::full(&rt).map_err(|e| PhpException::default(e.to_string()))?; + self.realm_id.set(self.realm_id.get() + 1); run(&ctx) } } diff --git a/src/marshal.rs b/src/marshal.rs index 20487af..33b587c 100644 --- a/src/marshal.rs +++ b/src/marshal.rs @@ -317,6 +317,7 @@ impl<'a> PhpConversion<'a> { if !std::rc::Rc::ptr_eq(&owner, &cb.engine) { return Err("JS callback belongs to a different QuickJS instance".to_owned()); } + cb.check_realm()?; return Ok(MiddleValue::JsFn(cb.id)); } if zv.is_callable() { diff --git a/tests/php/07_sandbox_timeout.php b/tests/php/07_sandbox_timeout.php index 3de07c9..e1c21bc 100644 --- a/tests/php/07_sandbox_timeout.php +++ b/tests/php/07_sandbox_timeout.php @@ -34,6 +34,13 @@ 'alloc bomb trips the memory limit' ); +$isolatedMem = new QuickJS(memoryLimit: 2 * 1024 * 1024, isolated: true); +for ($i = 0; $i < 2; ++$i) { + throws(fn() => $isolatedMem->eval('let a = []; while (true) { a.push(new Array(100000).fill(0)); }'), + QuickJSMemoryException::class, 'fresh isolated runtime enforces its memory limit'); + eq(42, $isolatedMem->eval('42'), 'isolated runtime recovers after memory exhaustion'); +} + // --- Unbounded by default ------------------------------------------------ $free = new QuickJS(); eq(500500, $free->eval('let s=0; for (let i=0;i<=1000;i++) s+=i; s'), 'no limits by default'); diff --git a/tests/php/10_scope_isolation.php b/tests/php/10_scope_isolation.php index f1e9ea7..130a31c 100644 --- a/tests/php/10_scope_isolation.php +++ b/tests/php/10_scope_isolation.php @@ -59,4 +59,14 @@ $iso->eval('php.keep(() => 1)'); throws(fn() => $held(), \Throwable::class, 'stored callback rejected after its eval (isolated)'); +// Old ids must not resolve or delete a function in a later isolated realm. +$iso->register('tryOld', function () use (&$held) { + throws(fn() => $held(), Throwable::class, 'old callback rejected inside a later eval'); + $held = null; +}); +eq(42, $iso->eval('php.apply(n => { php.tryOld(); return n * 7; }, 6)'), 'dropping old callback preserves the current callback'); +$old = $iso->eval('() => 1'); +throws(fn() => $iso->roundtrip($old), Throwable::class, 'ended callback cannot be round-tripped into a fresh realm'); +$iso->register('returnOld', fn() => $old); +throws(fn() => $iso->eval('php.returnOld()'), Throwable::class, 'old callback cannot be marshaled into a new realm'); done(); diff --git a/tests/php/11_jobs.php b/tests/php/11_jobs.php index e68c35c..8a957b0 100644 --- a/tests/php/11_jobs.php +++ b/tests/php/11_jobs.php @@ -95,4 +95,19 @@ unset($temporary); $cleanup->executePendingJobs(); eq(2, $observedCount, 'job batches flush released callback references before execution'); +// A completed isolated entry owns neither detached jobs nor their closures. +$isolated = new QuickJS(isolated: true, timeoutMs: 20); +$effects = 0; +$isolated->register('effect', function () use (&$effects) { ++$effects; }); +eq(1, $isolated->eval('Promise.resolve().then(() => php.effect()); 1'), 'detached job is not executed eagerly'); +eq(42, $isolated->eval('(async () => { await 0; return 42; })()'), 'next isolated entry awaits its own jobs'); +eq(0, $effects, 'detached job is discarded with its isolated runtime'); +throws(fn() => $isolated->eval('Promise.resolve().then(() => php.effect()); throw new Error("stop")'), QuickJSEvalException::class, 'isolated entry can fail with jobs queued'); +$isolated->eval('(async () => { await 0; })()'); +eq(0, $effects, 'failed isolated entry discards detached jobs'); +throws(fn() => $isolated->eval('Promise.resolve().then(() => php.effect()); while (true) {}'), QuickJSTimeoutException::class, 'isolated entry times out with jobs queued'); +$isolated->eval('(async () => { await 0; })()'); +eq(0, $effects, 'timed-out isolated entry discards detached jobs'); +$isolated->register('apply', fn($fn) => $fn(6)); +eq(42, $isolated->eval('(async () => { await 0; return php.apply(async n => { await 0; return n * 7; }); })()'), 'isolated nested async callback uses the active runtime'); done(); diff --git a/tests/php/14_async.php b/tests/php/14_async.php index 63c080b..b113e14 100644 --- a/tests/php/14_async.php +++ b/tests/php/14_async.php @@ -94,4 +94,18 @@ function spin() { if (stopped) resolve(1); else Promise.resolve().then(spin); } throws(fn() => Amp\async(fn() => $limited->eval('new Promise(() => php.cancel(() => { globalThis.late = true; return 42; }))'))->await(), QuickJSTimeoutException::class, 'expired owner does not execute queued callbacks'); throws(fn() => $future->await(), Exception::class, 'canceled callback wakes its caller with an exception'); eq('undefined', $limited->eval('typeof late'), 'canceled callback has no side effects'); +// Isolated entries still receive external callbacks, but pending callback +// Promises must release their Persistent handles before their runtime is freed. +$isolated = new QuickJS(isolated: true, timeoutMs: 1000); +$isolated->register('later', static function ($resolve): void { + Revolt\EventLoop::delay(0.001, static fn() => $resolve(42)); +}); +eq(42, Amp\async(fn() => $isolated->eval('new Promise(resolve => php.later(resolve))'))->await(), 'isolated eval awaits external resolution'); +$isolated->register('startPending', static function ($callback, $resolve) use (&$future): void { + $future = Amp\async(static fn() => $callback()); + Revolt\EventLoop::defer(static fn() => $resolve(8)); +}); +eq(8, $isolated->eval('new Promise(resolve => php.startPending(() => new Promise(() => {}), resolve))'), 'isolated owner finishes with a callback Promise pending'); +throws(fn() => $future->await(), Exception::class, 'isolated entry cancels and releases pending callback'); +eq(42, $isolated->eval('(async () => { await 0; return 42; })()'), 'new isolated runtime works after pending callback cleanup'); done(); From b447871c6f61863f57e7b6b8b0b9d6047bc57347 Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Wed, 30 Sep 2026 11:08:57 +0200 Subject: [PATCH 12/17] fix: create isolated runtime only during execution --- src/engine.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/engine.rs b/src/engine.rs index 16bff9c..dc71849 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -24,7 +24,6 @@ pub const MAX_DEPTH: usize = 200; /// The QuickJS engine: runtime + context + the shared bridge state. pub struct Engine { - pub rt: Runtime, pub state: Rc, memory_limit: usize, max_stack: usize, @@ -332,22 +331,22 @@ impl Engine { isolated: bool, max_queued_message_bytes: usize, ) -> rquickjs::Result> { - let rt = Runtime::new()?; - sandbox::apply_limits(&rt, memory_limit, max_stack); let deadline = Rc::new(Cell::new(None)); let timed_out = Rc::new(Cell::new(false)); - sandbox::install_interrupt(&rt, deadline.clone(), timed_out.clone()); // Shared mode: one persistent realm. Isolated mode: a fresh realm per // eval (so each eval is its own world; cross-eval state is not kept). let shared_ctx = if isolated { None } else { + let rt = Runtime::new()?; + sandbox::apply_limits(&rt, memory_limit, max_stack); + sandbox::install_interrupt(&rt, deadline.clone(), timed_out.clone()); + // Context owns a runtime reference; no extra Engine runtime is needed. Some(Context::full(&rt)?) }; let state = BridgeState::new(max_queued_message_bytes); let engine = Rc::new(Engine { - rt, state: state.clone(), memory_limit, max_stack, From 01ece7e75d23e12be5cc2872c101167d663309ae Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Wed, 30 Sep 2026 11:10:49 +0200 Subject: [PATCH 13/17] feat: allow eval to execute JavaScript without transpilation --- docs/errors.md | 34 +++++++++++++++------- docs/execution-modes.md | 9 ++++++ src/lib.rs | 56 ++++++++++++++++++++----------------- stubs/php_quickjs.stubs.php | 12 +++++--- tests/php/18_javascript.php | 29 +++++++++++++++++++ 5 files changed, 101 insertions(+), 39 deletions(-) create mode 100644 tests/php/18_javascript.php diff --git a/docs/errors.md b/docs/errors.md index 97392ae..ed40a66 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -1,8 +1,10 @@ # Errors Errors cross the boundary in both directions, and a JS/TS error that escapes -`eval()` surfaces as a real, typed PHP exception located at its **original -TypeScript** coordinates. +`eval()` surfaces as a real, typed PHP exception. With the default +`typescript: true`, source maps recover the original TypeScript coordinates. +With `typescript: false`, JavaScript executes directly and errors retain their +original `guest.js` coordinates. ## Exception hierarchy @@ -52,18 +54,29 @@ What each accessor gives you: - **`getMessage()`** — the error text only (`"TypeError: boom"`), with a bare `Error` name elided to avoid a redundant prefix. -- **`getFile()` / `getLine()`** — the original **TS** location, so the standard - PHP idioms (`getLine()`, `getTraceAsString()`, `(string) $e`) read naturally. +- **`getFile()` / `getLine()`** — the original source location: `guest.ts` + with transpilation, or `guest.js` for direct JavaScript. - **`getJsName()`** — the JS error constructor (`TypeError`, `RangeError`, a custom subclass name, …), or the originating PHP class for a re-surfaced host error. -- **`getJsStack()`** — the stack **remapped to TS coordinates** and **filtered to - guest frames**: the internal bridge/bootstrap frames are removed, so it reads - like a plain TS trace. +- **`getJsStack()`** — with transpilation, the stack is remapped to TS + coordinates and filtered to guest frames. Direct JavaScript retains its + original stack, including any bridge frames. + +For direct JavaScript, no source map is needed: + +```php +try { + $js->eval("\n\nthrow new TypeError('boom');", typescript: false); +} catch (QuickJSEvalException $e) { + $e->getFile(); // "guest.js" + $e->getLine(); // 3 +} +``` ### How the remapping works -The module is named `guest.ts` when handed to QuickJS, so stack frames reference +With `typescript: true`, the module is named `guest.ts` when handed to QuickJS, so stack frames reference it. On a throw, `error.rs` reads the JS stack (generated-JS coordinates), and for each frame referencing the guest module it looks the position up in the module's **source map** (kept host-side from transpilation) and rewrites it to the @@ -84,8 +97,9 @@ collapsing to a generic "uncaught" string. ### Syntax / transpile errors are located A guest that doesn't parse surfaces as a `QuickJSEvalException` with -`getJsName() === 'SyntaxError'` and `getLine()` pointing at the offending TS line -(computed from the oxc diagnostic's span). +`getJsName() === 'SyntaxError'` and `getLine()` pointing at the offending source +line. With transpilation, the location comes from the Oxc diagnostic's span; +with direct JavaScript, it comes from QuickJS's original stack. ### Resource limits diff --git a/docs/execution-modes.md b/docs/execution-modes.md index 422a6f7..a3f38c7 100644 --- a/docs/execution-modes.md +++ b/docs/execution-modes.md @@ -129,3 +129,12 @@ registry is reclaimed shortly after PHP lets go. In isolated mode the whole real - **Strongest isolation:** a brand-new `QuickJS` per tenant. That gives a fresh host-side capability registrations and handles too. Isolated mode already gives each eval a separate heap and memory limit. + +## JavaScript without transpilation + +`eval(string $code, bool $typescript = true)` uses Oxc and source maps by +default. For JavaScript that is already ready to execute, pass +`$js->eval($source, typescript: false)` to skip Oxc and its transpile cache. +Both paths use the same bridge, Promise awaiting and resource limits, in both +execution modes. JavaScript errors retain their original `guest.js` coordinates; +TypeScript errors are remapped to `guest.ts`. diff --git a/src/lib.rs b/src/lib.rs index 2043a16..16f7250 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -85,31 +85,37 @@ impl QuickJS { .map_err(PhpException::default) } - /// Evaluate JS source and marshal the result back to a PHP value. The - /// `php.*` facade is installed fresh from the current manifest first. - pub fn eval(&self, code: String) -> PhpResult { + /// Evaluate source and marshal its result back to PHP. TypeScript is + /// transpiled by default; false executes JavaScript directly. + #[php(defaults(typescript = true))] + pub fn eval(&self, code: String, typescript: bool) -> PhpResult { if self.engine.is_active() { return Err(PhpException::default( "Cannot eval while JavaScript is executing; use a JS callback".to_owned(), )); } - // TypeScript fast path: transpile to JS (types erased, esnext) before - // QuickJS ever sees the source. Transpile/syntax errors surface here, - // located at their original TS line/column. - let module = self.engine.transpile.get_or_transpile(&code).map_err(|e| { - let stack = if e.line > 0 { - format!(" at guest.ts:{}:{}", e.line, e.col) - } else { - String::new() - }; - exceptions::eval_exception( - "SyntaxError".to_owned(), - e.message, - "guest.ts", - e.line, - stack, - ) - })?; + let module = if typescript { + self.engine.transpile.get_or_transpile(&code).map_err(|e| { + let stack = if e.line > 0 { + format!(" at guest.ts:{}:{}", e.line, e.col) + } else { + String::new() + }; + exceptions::eval_exception( + "SyntaxError".to_owned(), + e.message, + "guest.ts", + e.line, + stack, + ) + })? + } else { + transpile::Transpiled { + module_id: "guest.js".to_owned(), + js: Rc::from(code), + map_json: None, + } + }; let state = self.engine.state.clone(); self.engine.eval_in(|ctx| { @@ -264,11 +270,11 @@ impl QuickJS { } // Remap the guest stack to TypeScript coordinates (guest frames only), // and surface it as a structured, JS-error-like exception. - let remapped = parts - .stack - .as_deref() - .zip(map_json) - .and_then(|(stack, map)| error::remap_stack(stack, map, module_id)); + let remapped = match (parts.stack.as_deref(), map_json) { + (Some(stack), Some(map)) => error::remap_stack(stack, map, module_id), + (Some(stack), None) => Some(stack.to_owned()), + (None, _) => None, + }; let (line, _) = remapped .as_deref() .and_then(|s| error::top_frame_location(s, module_id)) diff --git a/stubs/php_quickjs.stubs.php b/stubs/php_quickjs.stubs.php index 99f86ef..fec9cb2 100644 --- a/stubs/php_quickjs.stubs.php +++ b/stubs/php_quickjs.stubs.php @@ -30,8 +30,11 @@ public function __construct(?int $memoryLimit = null, ?int $timeoutMs = null, ?i */ public function register(string $name, callable $callable, ?string $types = null): void {} - /** Evaluate JS source and marshal the result back to PHP, awaiting a returned Promise. */ - public function eval(string $code): mixed {} + /** + * Evaluate source and marshal the result back to PHP, awaiting a returned Promise. + * TypeScript is transpiled by default; false executes JavaScript directly. + */ + public function eval(string $code, bool $typescript = true): mixed {} /** The registration manifest: a list of `['name' => string, 'types' => ?string]`. */ public function manifest(): array {} @@ -83,14 +86,15 @@ class QuickJSException extends \Exception {} /** * A JavaScript/TypeScript error escaped `eval`. `getMessage()` is the clean - * error text and `getFile()`/`getLine()` carry the original TS location. + * error text and `getFile()`/`getLine()` carry the original source location + * (guest.ts after remapping, or guest.js with typescript: false). */ class QuickJSEvalException extends QuickJSException { /** The JS error constructor name (e.g. "TypeError"), or the PHP class for a re-surfaced host error. */ public function getJsName(): string {} - /** The stack trace, remapped to TypeScript coordinates and filtered to guest frames. */ + /** The remapped guest-only TypeScript stack, or the original direct JavaScript stack. */ public function getJsStack(): string {} } diff --git a/tests/php/18_javascript.php b/tests/php/18_javascript.php new file mode 100644 index 0000000..2e9e03f --- /dev/null +++ b/tests/php/18_javascript.php @@ -0,0 +1,29 @@ +eval('const answer: number = 42; answer'), 'TypeScript remains the default'); + eq(42, $js->eval('41 + 1', typescript: false), 'native JavaScript result'); + eq(42, $js->eval('(async () => { await 0; return 42; })()', false), 'native JavaScript awaits Promises'); + $js->register('apply', fn($callback) => $callback(6)); + eq(42, $js->eval('php.apply(n => n * 7)', false), 'native JavaScript shares the PHP bridge'); + $js->eval('quickjs.postMessage({ answer: 42 }); void 0', false); + eq([['answer' => 42]], $js->drainMessages(), 'native JavaScript shares the message queue'); + try { + $js->eval("\n\nthrow new TypeError('native boom')", false); + ok(false, 'native JavaScript error should throw'); + } catch (QuickJSEvalException $e) { + eq('TypeError', $e->getJsName(), 'native JS exception retains its type'); + eq('guest.js', $e->getFile(), 'native JS error has the original filename'); + eq(3, $e->getLine(), 'native JS error keeps its original line'); + ok(str_contains($e->getJsStack(), 'guest.js:3:'), 'native JS stack retains its original coordinates'); + } + throws(fn() => $js->eval('const n: number = 42;', false), QuickJSEvalException::class, 'native JS rejects TypeScript'); + throws(fn() => $js->eval('while (true) {}', false), QuickJSTimeoutException::class, 'native JS respects timeout'); + eq(42, $js->eval('42', false), 'native JS engine recovers after timeout'); + $limited = new QuickJS(isolated: $isolated, memoryLimit: 2 * 1024 * 1024); + throws(fn() => $limited->eval('let data = []; while (true) data.push(new Array(100000).fill(0));', false), QuickJSMemoryException::class, 'native JS respects heap limit'); +} + +done(); From b4915f6839760337afb988502e076f3603059304 Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Wed, 30 Sep 2026 11:11:24 +0200 Subject: [PATCH 14/17] build: preserve stubs on generation failure and check signatures --- Makefile | 5 +-- README.md | 18 +++++++++- docs/api.md | 25 +++++++++++--- stubs/php_quickjs.stubs.php | 40 +++++++++++++++++----- tests/php/17_stub_signatures.php | 59 ++++++++++++++++++++++++++++++++ 5 files changed, 132 insertions(+), 15 deletions(-) create mode 100644 tests/php/17_stub_signatures.php diff --git a/Makefile b/Makefile index 7bfc1df..8fd1f73 100644 --- a/Makefile +++ b/Makefile @@ -45,8 +45,9 @@ test-php: build # Regenerate the IDE stub for the PHP-facing classes (requires cargo-php: # cargo install cargo-php). stubs: - cargo php stubs --stdout > stubs/php_quickjs.stubs.php || \ - echo "cargo-php not installed; run 'cargo install cargo-php'" + @tmp=$$(mktemp stubs/php_quickjs.stubs.php.XXXXXX); \ + trap 'rm -f "$$tmp"' EXIT HUP INT TERM; \ + cargo php stubs --stdout > "$$tmp" && mv "$$tmp" stubs/php_quickjs.stubs.php example: build @for ex in examples/*.php; do \ diff --git a/README.md b/README.md index 7aaa8c8..f5bda28 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ directly in your PHP process. Guest code runs in an isolated context with memory time, and stack limits; PHP exposes a controlled allowlist of capabilities into JS; and values, functions, and errors cross the boundary both ways. Guest code may be TypeScript — it's transpiled in-process and runtime errors map back to the original -TS source. +TS source. Pass `typescript: false` to `eval()` for JavaScript that should run +directly without transpilation. Built in Rust with [`ext-php-rs`](https://github.com/davidcole1340/ext-php-rs) and [`rquickjs`](https://github.com/DelSkayn/rquickjs). QuickJS-NG is bundled — no system @@ -134,6 +135,14 @@ and read them using `QuickJS::drainMessages()`. The native queue snapshots value at send time and has a configurable aggregate byte limit. `timeoutMs` remains an optional wall-clock limit for a complete call, including Promise waits. +Resource budgets are separate: `memoryLimit` bounds the QuickJS heap, while +`maxQueuedMessageBytes` bounds retained native messages (32 MiB by default, +including accounting overhead). Native value conversion permits nesting up to +64 levels; it has no separate 16 MiB value limit. The TypeScript LRU cache holds +at most 256 entries and 32 MiB of source, generated JavaScript and source-map +strings. This cache budget does not bound temporary Oxc allocations. Direct +JavaScript evaluation with `typescript: false` bypasses Oxc and this cache. + ## Scope This is an *embedder*, not a standalone defence against hostile code. The capability @@ -153,6 +162,13 @@ attacker-controlled code, nest the extension inside an outer microVM / gVisor bo - [Errors](docs/errors.md) — typed exceptions, both-way bridging, and TypeScript remapping. +## Development + +`make stubs` regenerates the canonical IDE declarations atomically; failed +generation leaves the previous file intact. The PHP test suite compares these +signatures against the loaded extension. Consumers such as puphpeteer copy this +file for static analysis and should keep their copy synchronized. + ## License MIT diff --git a/docs/api.md b/docs/api.md index e89600e..56119ba 100644 --- a/docs/api.md +++ b/docs/api.md @@ -6,7 +6,8 @@ The extension exposes a single `QuickJS` class. For the bigger picture see ### `new QuickJS(?int $memoryLimit = null, ?int $timeoutMs = null, ?int $maxStack = null, bool $isolated = false, ?int $maxQueuedMessageBytes = null)` -Limits default to unbounded; pass non-zero values to contain resource abuse. +`memoryLimit` and `timeoutMs` default to unbounded; pass non-zero values to +contain resource abuse. `maxStack` defaults to the engine stack limit. `isolated: true` runs each `eval()` in a fresh realm (see [execution modes](execution-modes.md)). `maxQueuedMessageBytes` defaults to 32 MiB and must be positive. `timeoutMs` @@ -18,10 +19,17 @@ Expose a PHP callable to JS under a flat, dotted name — it becomes `php.(...)` in the guest. `$types` is an optional TypeScript signature surfaced by `dts()`. This flat registry is the PHP callback allowlist. -### `eval(string $code): mixed` +### `eval(string $code, bool $typescript = true): mixed` -Run TypeScript or JavaScript, await a returned Promise, and marshal its result to PHP. Errors raise a -`QuickJSEvalException` located at the original TS line/column (see [errors](errors.md)). +With `typescript: true`, transpile TypeScript or JavaScript with Oxc and remap +errors to the input source. With `typescript: false`, execute JavaScript directly; +errors retain the original JavaScript coordinates. Both paths await a returned +Promise and marshal the result to PHP. Errors raise `QuickJSEvalException` +(see [errors](errors.md)). + +The TypeScript cache retains at most 256 entries and 32 MiB of source, generated +JavaScript and source-map strings. An entry larger than the budget is evaluated +without caching. These are cache limits, not a bound on all Oxc allocations. ### `grant(mixed $resource): int` / `resolve(int $h): mixed` / `revoke(int $h): bool` @@ -75,3 +83,12 @@ Fiber boundaries. Return and clear messages sent by JS through `quickjs.postMessage(value)`. Values are copied at send time and contain data only. This method does not enter JS or execute jobs. See [asynchronous execution](async.md) for limits. + +## Separate resource limits + +`memoryLimit` applies to the QuickJS heap, not PHP allocations or the transpiler. +Native conversion limits values to 64 nesting levels and does not impose a +16 MiB per-value byte limit. The message queue has its own aggregate accounted +byte limit through `maxQueuedMessageBytes`, including per-message and container +overhead; draining messages releases this budget. Neither that queue budget nor +the 32 MiB TypeScript cache budget replaces the heap limit. diff --git a/stubs/php_quickjs.stubs.php b/stubs/php_quickjs.stubs.php index fec9cb2..2768798 100644 --- a/stubs/php_quickjs.stubs.php +++ b/stubs/php_quickjs.stubs.php @@ -28,7 +28,7 @@ public function __construct(?int $memoryLimit = null, ?int $timeoutMs = null, ?i * @param callable $callable * @param string|null $types Optional TypeScript signature for `dts()`. */ - public function register(string $name, callable $callable, ?string $types = null): void {} + public function register(string $name, mixed $callable, ?string $types = null): void {} /** * Evaluate source and marshal the result back to PHP, awaiting a returned Promise. @@ -36,8 +36,11 @@ public function register(string $name, callable $callable, ?string $types = null */ public function eval(string $code, bool $typescript = true): mixed {} - /** The registration manifest: a list of `['name' => string, 'types' => ?string]`. */ - public function manifest(): array {} + /** + * The registration manifest. + * @return list + */ + public function manifest(): mixed {} /** Whether Promise jobs are ready (shared mode only). Does not include host I/O. */ public function hasPendingJobs(): bool {} @@ -45,8 +48,11 @@ public function hasPendingJobs(): bool {} /** Execute at most maxJobs ready jobs without waiting for I/O; returns the count. */ public function executePendingJobs(int $maxJobs = 100): int {} - /** Drain bounded messages emitted by quickjs.postMessage() without entering JS. */ - public function drainMessages(): array {} + /** + * Drain bounded messages emitted by quickjs.postMessage() without entering JS. + * @return list + */ + public function drainMessages(): mixed {} /** Generate TypeScript `.d.ts` declarations for the `php` and `quickjs` globals. */ public function dts(): string {} @@ -72,6 +78,9 @@ public function roundtrip(mixed $value): mixed {} */ class Callback { + /** Native constructor exists, but callbacks are created only by the bridge. */ + public function __construct() {} + /** Invoke the JS function, awaiting a returned Promise. */ public function __invoke(mixed ...$args): mixed {} @@ -82,7 +91,11 @@ public function call(mixed ...$args): mixed {} namespace { /** Base class for every exception thrown by the extension. */ - class QuickJSException extends \Exception {} + class QuickJSException extends \Exception + { + /** Native constructor exists; exceptions are created by the extension. */ + public function __construct() {} + } /** * A JavaScript/TypeScript error escaped `eval`. `getMessage()` is the clean @@ -91,6 +104,9 @@ class QuickJSException extends \Exception {} */ class QuickJSEvalException extends QuickJSException { + /** Native constructor exists; exceptions are created by the extension. */ + public function __construct() {} + /** The JS error constructor name (e.g. "TypeError"), or the PHP class for a re-surfaced host error. */ public function getJsName(): string {} @@ -99,8 +115,16 @@ public function getJsStack(): string {} } /** The wall-clock deadline tripped during `eval`. */ - class QuickJSTimeoutException extends QuickJSException {} + class QuickJSTimeoutException extends QuickJSException + { + /** Native constructor exists; exceptions are created by the extension. */ + public function __construct() {} + } /** The memory limit tripped during `eval`. */ - class QuickJSMemoryException extends QuickJSException {} + class QuickJSMemoryException extends QuickJSException + { + /** Native constructor exists; exceptions are created by the extension. */ + public function __construct() {} + } } diff --git a/tests/php/17_stub_signatures.php b/tests/php/17_stub_signatures.php new file mode 100644 index 0000000..2053b90 --- /dev/null +++ b/tests/php/17_stub_signatures.php @@ -0,0 +1,59 @@ +getParameters() as $parameter) { + $parameters[] = [ + $parameter->getName(), + (string) ($parameter->getType() ?? 'mixed'), + $parameter->isPassedByReference(), + $parameter->isVariadic(), + $parameter->isOptional(), + $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null, + ]; + } + return [$method->isStatic(), $parameters, (string) ($method->getReturnType() ?? 'mixed')]; +} + +foreach (['QuickJS', 'Js\\Callback', 'QuickJSException', 'QuickJSEvalException', 'QuickJSTimeoutException', 'QuickJSMemoryException'] as $class) { + $native = new ReflectionClass($class); + $stub = new ReflectionClass('StubSignatures\\' . $class); + $nativeMethods = []; + $stubMethods = []; + foreach ($native->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->getDeclaringClass()->getName() === $class) { + $nativeMethods[] = $method->getName(); + } + } + foreach ($stub->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->getDeclaringClass()->getName() !== $stub->getName()) { + continue; + } + $stubMethods[] = $method->getName(); + ok($native->hasMethod($method->getName()), "$class::{$method->getName()} exists"); + if ($native->hasMethod($method->getName())) { + eq(signature($method), signature($native->getMethod($method->getName())), + "$class::{$method->getName()} matches canonical stub"); + } + } + sort($nativeMethods); + sort($stubMethods); + eq($stubMethods, $nativeMethods, "$class public methods match canonical stub"); +} + +done(); From 0831a11e2695cc9cb343ad32228346f126fbe928 Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Wed, 30 Sep 2026 11:11:27 +0200 Subject: [PATCH 15/17] fix: bound transpile cache retained strings to 32 MiB --- docs/transpile-cache.md | 15 ++++ src/transpile.rs | 154 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 docs/transpile-cache.md diff --git a/docs/transpile-cache.md b/docs/transpile-cache.md new file mode 100644 index 0000000..45825a7 --- /dev/null +++ b/docs/transpile-cache.md @@ -0,0 +1,15 @@ +# Transpile cache + +TypeScript evaluation caches Oxc output in a content-addressed LRU. Each engine +retains at most 256 entries and 32 MiB of string bytes across their original +sources, generated JavaScript, and source maps. Cache hits reuse the generated +strings and update recency. Oldest entries are evicted until both limits hold. + +An entry larger than 32 MiB is transpiled and executed without caching. Replacing +an entry adjusts its byte accounting; the original source is checked on every +hash hit so a collision cannot return another guest's JavaScript. + +The budget covers strings retained by the cache, not all Oxc memory, temporary +transpilation allocations, QuickJS heap, or output strings still held by an +active evaluation after cache eviction. It is independent of the message queue +limit and requires no public configuration. diff --git a/src/transpile.rs b/src/transpile.rs index fa1578f..199048a 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -130,22 +130,62 @@ impl CompilerInterface for TsCompiler { // content-addressed cache // --------------------------------------------------------------------------- +const CACHE_BYTE_BUDGET: usize = 32 * 1024 * 1024; + struct CachedModule { source: String, transpiled: Transpiled, } +impl CachedModule { + fn byte_len(&self) -> usize { + self.source.len() + + self.transpiled.js.len() + + self.transpiled.map_json.as_ref().map_or(0, |map| map.len()) + } +} + +struct CacheState { + entries: LruCache, + bytes: usize, +} + +impl CacheState { + fn insert(&mut self, key: u64, module: CachedModule, budget: usize) { + let bytes = module.byte_len(); + // Large guests still execute, without evicting useful cached modules. + if bytes > budget { + return; + } + if let Some(previous) = self.entries.pop(&key) { + self.bytes -= previous.byte_len(); + } + while self.bytes + bytes > budget || self.entries.len() == self.entries.cap().get() { + if let Some((_, oldest)) = self.entries.pop_lru() { + self.bytes -= oldest.byte_len(); + } else { + break; + } + } + self.entries.put(key, module); + self.bytes += bytes; + } +} + /// A small LRU mapping `hash(source)` -> transpiled output. Single-threaded /// (PHP NTS), so a `RefCell` is sufficient. pub struct TranspileCache { - inner: RefCell>, + inner: RefCell, } impl TranspileCache { pub fn new(capacity: usize) -> Self { let cap = NonZeroUsize::new(capacity.max(1)).unwrap(); TranspileCache { - inner: RefCell::new(LruCache::new(cap)), + inner: RefCell::new(CacheState { + entries: LruCache::new(cap), + bytes: 0, + }), } } @@ -154,7 +194,7 @@ impl TranspileCache { /// to rule out a hash collision returning the wrong JS. pub fn get_or_transpile(&self, source: &str) -> Result { let key = hash(source); - if let Some(hit) = self.inner.borrow_mut().get(&key) { + if let Some(hit) = self.inner.borrow_mut().entries.get(&key) { if hit.source == source { return Ok(hit.transpiled.clone()); } @@ -168,12 +208,13 @@ impl TranspileCache { js: Rc::from(js.as_str()), map_json: map_json.map(|m| Rc::from(m.as_str())), }; - self.inner.borrow_mut().put( + self.inner.borrow_mut().insert( key, CachedModule { source: source.to_owned(), transpiled: transpiled.clone(), }, + CACHE_BYTE_BUDGET, ); Ok(transpiled) } @@ -222,6 +263,111 @@ mod tests { let a = cache.get_or_transpile("const a: number = 1; a;").unwrap(); let b = cache.get_or_transpile("const a: number = 1; a;").unwrap(); assert_eq!(a.js, b.js); + assert!(Rc::ptr_eq(&a.js, &b.js)); + let state = cache.inner.borrow(); + assert_eq!(state.entries.len(), 1); + assert_eq!( + state.bytes, + state + .entries + .peek(&hash("const a: number = 1; a;")) + .unwrap() + .byte_len() + ); assert_eq!(a.module_id, b.module_id); } + + fn module(source: &str, js: &str, map: Option<&str>) -> CachedModule { + CachedModule { + source: source.to_owned(), + transpiled: Transpiled { + module_id: "guest.ts".to_owned(), + js: Rc::from(js), + map_json: map.map(Rc::from), + }, + } + } + + fn state(capacity: usize) -> CacheState { + CacheState { + entries: LruCache::new(NonZeroUsize::new(capacity).unwrap()), + bytes: 0, + } + } + + #[test] + fn cache_accounts_source_js_and_map_bytes() { + let mut cache = state(8); + cache.insert(1, module("é", "abc", Some("map")), 32); + assert_eq!(cache.bytes, 8); + cache.insert(2, module("src", "js", None), 32); + assert_eq!(cache.bytes, 13); + } + + #[test] + fn cache_evicts_lru_for_entry_and_byte_limits() { + let mut cache = state(2); + cache.insert(1, module("one", "js", None), 12); + cache.insert(2, module("two", "js", None), 12); + cache.entries.get(&1); + cache.insert(3, module("three", "js", None), 12); + assert!(cache.entries.peek(&2).is_none()); + assert!(cache.entries.peek(&1).is_some()); + assert_eq!(cache.bytes, 12); + + cache.insert(4, module("four", "js", None), 12); + assert!(cache.entries.peek(&1).is_none()); + assert!(cache.entries.peek(&3).is_none()); + assert_eq!(cache.entries.len(), 1); + assert_eq!(cache.bytes, 6); + } + + #[test] + fn cache_entry_limit_evicts_even_with_byte_budget_remaining() { + let mut cache = state(2); + cache.insert(1, module("one", "js", None), 100); + cache.insert(2, module("two", "js", None), 100); + cache.entries.get(&1); + cache.insert(3, module("three", "js", None), 100); + assert!(cache.entries.peek(&2).is_none()); + assert!(cache.entries.peek(&1).is_some()); + assert!(cache.entries.peek(&3).is_some()); + assert_eq!(cache.bytes, 12); + } + + #[test] + fn cache_replacements_and_oversized_entries_preserve_accounting() { + let mut cache = state(8); + cache.insert(1, module("one", "js", Some("map")), 10); + cache.insert(1, module("two", "js", None), 10); + assert_eq!(cache.bytes, 5); + assert_eq!(cache.entries.len(), 1); + cache.insert(1, module("too large", "js", None), 10); + assert_eq!(cache.bytes, 5); + assert_eq!(cache.entries.peek(&1).unwrap().source, "two"); + cache.insert(2, module("oversized", "js", None), 10); + assert_eq!(cache.entries.len(), 1); + cache.insert(2, module("small", "12345", None), 10); + assert_eq!(cache.bytes, 10); + assert!(cache.entries.peek(&1).is_none()); + } + + #[test] + fn hash_collision_transpiles_and_replaces_wrong_entry() { + let cache = TranspileCache::new(8); + let source = "const correct: number = 42; correct;"; + cache.inner.borrow_mut().insert( + hash(source), + module("different source", "wrong JS", Some("wrong map")), + CACHE_BYTE_BUDGET, + ); + let output = cache.get_or_transpile(source).unwrap(); + assert!(output.js.contains("42")); + assert!(!output.js.contains("wrong JS")); + let state = cache.inner.borrow(); + assert_eq!(state.entries.len(), 1); + let entry = state.entries.peek(&hash(source)).unwrap(); + assert_eq!(entry.source, source); + assert_eq!(state.bytes, entry.byte_len()); + } } From 2088da0b973b4f82ff27fa944d9cfafdd295b3c7 Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Wed, 30 Sep 2026 11:12:50 +0200 Subject: [PATCH 16/17] refactor: share JavaScript resource error classification --- docs/errors.md | 9 +++++---- src/engine.rs | 15 +++++++-------- src/error.rs | 21 +++++++++++++++++++-- src/lib.rs | 13 ++++++++----- tests/php/19_error_paths.php | 36 ++++++++++++++++++++++++++++++++++++ 5 files changed, 75 insertions(+), 19 deletions(-) create mode 100644 tests/php/19_error_paths.php diff --git a/docs/errors.md b/docs/errors.md index ed40a66..33faedb 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -103,10 +103,11 @@ with direct JavaScript, it comes from QuickJS's original stack. ### Resource limits -An infinite loop or over-budget script raises `QuickJSTimeoutException`; an -allocation bomb raises `QuickJSMemoryException`. These fire from the interrupt -handler / allocator and so carry no meaningful source location. The engine -recovers and remains usable afterward. +An infinite loop or over-budget eval, callback or job batch raises +`QuickJSTimeoutException`. An allocation failure from `eval()` raises +`QuickJSMemoryException`; callbacks and jobs retain their existing +`QuickJSEvalException` mapping for memory errors. Resource errors carry no +meaningful source location. The engine recovers and remains usable afterward. ## PHP exception → JS diff --git a/src/engine.rs b/src/engine.rs index dc71849..c6312cb 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -144,10 +144,7 @@ impl Engine { self.check_deadline(ctx)?; if result < 0 { let c = unsafe { Ctx::from_raw(NonNull::new(job_ctx).expect("job error context")) }; - return Err(crate::error::js_error_to_php( - &c, - rquickjs::Error::Exception, - )); + return Err(self.callback_error(&c, rquickjs::Error::Exception)); } if result == 0 { break; @@ -457,14 +454,16 @@ impl Engine { ctx: &Ctx<'_>, err: rquickjs::Error, ) -> ext_php_rs::exception::PhpException { - if self.timed_out() { - // Consume the interrupted JS exception before the next entry. - drop(ctx.catch()); + let parts = crate::error::js_error_parts(ctx, err); + if matches!( + crate::error::resource_error(&parts, self.timed_out()), + Some(crate::error::ResourceError::Timeout) + ) { return ext_php_rs::exception::PhpException::from_class::< crate::exceptions::QuickJSTimeoutException, >("JavaScript callback execution timed out".to_owned()); } - crate::error::js_error_to_php(ctx, err) + crate::error::js_error_to_php(parts) } /// Enter one level of cross-boundary nesting; errors if the cap is hit. diff --git a/src/error.rs b/src/error.rs index cecd08a..1cdb2aa 100644 --- a/src/error.rs +++ b/src/error.rs @@ -87,6 +87,24 @@ impl JsErrorParts { } } +/// Shared resource-error detection. Callers retain their existing exception +/// mapping: eval specializes memory errors; callback/jobs keep their old type. +#[derive(Clone, Copy)] +pub enum ResourceError { + Timeout, + Memory, +} + +pub fn resource_error(parts: &JsErrorParts, timed_out: bool) -> Option { + if timed_out { + Some(ResourceError::Timeout) + } else if parts.display_message().to_lowercase().contains("out of memory") { + Some(ResourceError::Memory) + } else { + None + } +} + /// Render an rquickjs error into a human-readable `Name: message` string. pub fn js_error_message(ctx: &Ctx<'_>, err: JsError) -> String { js_error_parts(ctx, err).display_message() @@ -145,8 +163,7 @@ pub fn js_error_parts(ctx: &Ctx<'_>, err: JsError) -> JsErrorParts { /// `phpClass`), the original PHP class is restored with a clean message; /// otherwise it becomes a `QuickJSEvalException`. This unwraps the /// JS->PHP->JS->PHP round trip instead of nesting `Exception:` prefixes. -pub fn js_error_to_php(ctx: &Ctx<'_>, err: JsError) -> PhpException { - let parts = js_error_parts(ctx, err); +pub fn js_error_to_php(parts: JsErrorParts) -> PhpException { if let Some(class) = parts.php_class.as_deref() { if let Some(ce) = ClassEntry::try_find(class) { return PhpException::new(parts.message, 0, ce); diff --git a/src/lib.rs b/src/lib.rs index 16f7250..d5047a7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -262,11 +262,14 @@ impl QuickJS { ) -> PhpException { let parts = error::js_error_parts(ctx, err); let message = parts.display_message(); - if self.engine.timed_out() { - return PhpException::from_class::(message); - } - if message.to_lowercase().contains("out of memory") { - return PhpException::from_class::(message); + match error::resource_error(&parts, self.engine.timed_out()) { + Some(error::ResourceError::Timeout) => { + return PhpException::from_class::(message); + } + Some(error::ResourceError::Memory) => { + return PhpException::from_class::(message); + } + None => {} } // Remap the guest stack to TypeScript coordinates (guest frames only), // and surface it as a structured, JS-error-like exception. diff --git a/tests/php/19_error_paths.php b/tests/php/19_error_paths.php new file mode 100644 index 0000000..857f3e5 --- /dev/null +++ b/tests/php/19_error_paths.php @@ -0,0 +1,36 @@ +register('fail', fn() => throw new LogicException('host failure')); +$callback = $js->eval('() => php.fail()', false); +try { + $callback(); + ok(false, 'callback PHP exception must escape'); +} catch (LogicException $e) { + eq('host failure', $e->getMessage(), 'callback restores the original PHP class and message'); +} +eq(42, $js->eval('42', false), 'engine recovers after callback PHP exception'); + +$broken = $js->eval('() => { throw new RangeError("callback failure"); }', false); +throws(fn() => $broken(), QuickJSEvalException::class, 'callback JS error keeps its previous PHP type'); +$runaway = $js->eval('() => { while (true) {} }', false); +throws(fn() => $runaway(), QuickJSTimeoutException::class, 'callback timeout keeps its specialized type'); +eq(42, $js->eval('42', false), 'engine recovers after callback timeout'); + +$limited = new QuickJS(memoryLimit: 2 * 1024 * 1024); +$allocate = $limited->eval('() => { let data = []; while (true) data.push(new Array(100000).fill(0)); }', false); +throws(fn() => $allocate(), QuickJSEvalException::class, 'callback memory error retains its existing generic type'); +unset($allocate); +eq(42, $limited->eval('42', false), 'engine recovers after callback memory error'); + +$job = new QuickJS(timeoutMs: 20); +$job->eval('Promise.resolve().then(() => { while (true) {} }); void 0', false); +throws(fn() => $job->executePendingJobs(), QuickJSTimeoutException::class, 'job timeout keeps its specialized type'); +eq(42, $job->eval('42', false), 'engine recovers after job timeout'); + +$async = $js->eval('async () => { await 0; throw new TypeError("async callback failure"); }', false); +throws(fn() => $async(), QuickJSEvalException::class, 'async callback rejection is classified once'); +eq(42, $js->eval('42', false), 'engine recovers after async callback error'); + +done(); From c9159b6fc4223272c86aad8935cf5f31cdb8c594 Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Wed, 30 Sep 2026 11:23:12 +0200 Subject: [PATCH 17/17] feat: configure transpile cache limits per QuickJS instance --- README.md | 6 +++-- docs/api.md | 8 +++--- docs/transpile-cache.md | 8 +++--- src/engine.rs | 4 ++- src/lib.rs | 21 ++++++++++++--- src/transpile.rs | 49 ++++++++++++++++++++++++++-------- stubs/php_quickjs.stubs.php | 4 ++- tests/php/20_cache_options.php | 14 ++++++++++ 8 files changed, 90 insertions(+), 24 deletions(-) create mode 100644 tests/php/20_cache_options.php diff --git a/README.md b/README.md index f5bda28..b920d43 100644 --- a/README.md +++ b/README.md @@ -138,9 +138,11 @@ optional wall-clock limit for a complete call, including Promise waits. Resource budgets are separate: `memoryLimit` bounds the QuickJS heap, while `maxQueuedMessageBytes` bounds retained native messages (32 MiB by default, including accounting overhead). Native value conversion permits nesting up to -64 levels; it has no separate 16 MiB value limit. The TypeScript LRU cache holds +64 levels; it has no separate 16 MiB value limit. The TypeScript LRU cache defaults to at most 256 entries and 32 MiB of source, generated JavaScript and source-map -strings. This cache budget does not bound temporary Oxc allocations. Direct +strings. Set constructor arguments `transpileCacheMaxBytes` and +`transpileCacheMaxEntries` to customize these limits; both are non-negative, and +`0` in either disables caching. This cache budget does not bound temporary Oxc allocations. Direct JavaScript evaluation with `typescript: false` bypasses Oxc and this cache. ## Scope diff --git a/docs/api.md b/docs/api.md index 56119ba..08bfa8d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,7 +4,7 @@ The extension exposes a single `QuickJS` class. For the bigger picture see [architecture](architecture.md); for realms and the callback lifecycle see [execution modes](execution-modes.md). -### `new QuickJS(?int $memoryLimit = null, ?int $timeoutMs = null, ?int $maxStack = null, bool $isolated = false, ?int $maxQueuedMessageBytes = null)` +### `new QuickJS(?int $memoryLimit = null, ?int $timeoutMs = null, ?int $maxStack = null, bool $isolated = false, ?int $maxQueuedMessageBytes = null, int $transpileCacheMaxBytes = 33554432, int $transpileCacheMaxEntries = 256)` `memoryLimit` and `timeoutMs` default to unbounded; pass non-zero values to contain resource abuse. `maxStack` defaults to the engine stack limit. @@ -27,9 +27,11 @@ errors retain the original JavaScript coordinates. Both paths await a returned Promise and marshal the result to PHP. Errors raise `QuickJSEvalException` (see [errors](errors.md)). -The TypeScript cache retains at most 256 entries and 32 MiB of source, generated +The TypeScript cache defaults to at most 256 entries and 32 MiB of source, generated JavaScript and source-map strings. An entry larger than the budget is evaluated -without caching. These are cache limits, not a bound on all Oxc allocations. +without caching. Set `transpileCacheMaxBytes` and `transpileCacheMaxEntries` +in the constructor to change these limits. Both must be non-negative; setting +either to `0` disables caching. These are cache limits, not a bound on all Oxc allocations. ### `grant(mixed $resource): int` / `resolve(int $h): mixed` / `revoke(int $h): bool` diff --git a/docs/transpile-cache.md b/docs/transpile-cache.md index 45825a7..9e21f10 100644 --- a/docs/transpile-cache.md +++ b/docs/transpile-cache.md @@ -1,15 +1,17 @@ # Transpile cache TypeScript evaluation caches Oxc output in a content-addressed LRU. Each engine -retains at most 256 entries and 32 MiB of string bytes across their original +defaults to retaining at most 256 entries and 32 MiB of string bytes across their original sources, generated JavaScript, and source maps. Cache hits reuse the generated strings and update recency. Oldest entries are evicted until both limits hold. -An entry larger than 32 MiB is transpiled and executed without caching. Replacing +An entry larger than the configured byte budget is transpiled and executed without caching. Replacing an entry adjusts its byte accounting; the original source is checked on every hash hit so a collision cannot return another guest's JavaScript. The budget covers strings retained by the cache, not all Oxc memory, temporary transpilation allocations, QuickJS heap, or output strings still held by an active evaluation after cache eviction. It is independent of the message queue -limit and requires no public configuration. +limit. Constructor arguments `transpileCacheMaxBytes` (default 33554432) and +`transpileCacheMaxEntries` (default 256) configure these bounds per instance. +Both must be non-negative; `0` in either argument disables caching. diff --git a/src/engine.rs b/src/engine.rs index c6312cb..4053beb 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -327,6 +327,8 @@ impl Engine { max_stack: usize, isolated: bool, max_queued_message_bytes: usize, + transpile_cache_max_entries: usize, + transpile_cache_max_bytes: usize, ) -> rquickjs::Result> { let deadline = Rc::new(Cell::new(None)); let timed_out = Rc::new(Cell::new(false)); @@ -348,7 +350,7 @@ impl Engine { memory_limit, max_stack, realm_id: Cell::new(0), - transpile: TranspileCache::new(256), + transpile: TranspileCache::new(transpile_cache_max_entries, transpile_cache_max_bytes), shared_ctx, depth: Cell::new(0), active_ctx: Cell::new(None), diff --git a/src/lib.rs b/src/lib.rs index d5047a7..6d80367 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,22 +31,25 @@ pub struct QuickJS { #[php_impl] impl QuickJS { - /// Construct a sandbox. All limits default to unbounded; pass non-zero - /// values to contain resource abuse: + /// Construct a sandbox. Heap and time limits default to unbounded: /// - `memoryLimit`: max heap bytes (alloc-bomb guard) /// - `timeoutMs`: wall-clock budget per eval, callback, or job batch /// - `maxStack`: max native stack bytes /// - `maxQueuedMessageBytes`: maximum accounted bytes waiting in the message queue + /// - `transpileCacheMaxBytes`: retained cache strings (default 32 MiB; 0 disables cache) + /// - `transpileCacheMaxEntries`: retained cache entries (default 256; 0 disables cache) /// - `isolated`: when true, each `eval()` runs in a fresh global realm (its /// own world); cross-eval globals and persistent JS callbacks are not /// kept. Defaults to false (one shared, persistent realm per instance). - #[php(defaults(memoryLimit = None, timeoutMs = None, maxStack = None, isolated = false, maxQueuedMessageBytes = None))] + #[php(defaults(memoryLimit = None, timeoutMs = None, maxStack = None, isolated = false, maxQueuedMessageBytes = None, transpileCacheMaxBytes = 33554432, transpileCacheMaxEntries = 256))] pub fn __construct( memoryLimit: Option, timeoutMs: Option, maxStack: Option, isolated: bool, maxQueuedMessageBytes: Option, + transpileCacheMaxBytes: i64, + transpileCacheMaxEntries: i64, ) -> PhpResult { let max_queued_message_bytes = match maxQueuedMessageBytes { None => bridge::DEFAULT_MAX_QUEUED_MESSAGE_BYTES, @@ -59,12 +62,24 @@ impl QuickJS { )) } }; + let transpile_cache_max_bytes = usize::try_from(transpileCacheMaxBytes).map_err(|_| { + PhpException::default( + "transpileCacheMaxBytes must be a non-negative integer fitting usize".to_owned(), + ) + })?; + let transpile_cache_max_entries = usize::try_from(transpileCacheMaxEntries).map_err(|_| { + PhpException::default( + "transpileCacheMaxEntries must be a non-negative integer fitting usize".to_owned(), + ) + })?; let engine = Engine::new( memoryLimit.unwrap_or(0).max(0) as usize, timeoutMs.unwrap_or(0).max(0) as u64, maxStack.unwrap_or(0).max(0) as usize, isolated, max_queued_message_bytes, + transpile_cache_max_entries, + transpile_cache_max_bytes, ) .map_err(to_php_err)?; Ok(QuickJS { engine }) diff --git a/src/transpile.rs b/src/transpile.rs index 199048a..e11a54b 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -130,6 +130,7 @@ impl CompilerInterface for TsCompiler { // content-addressed cache // --------------------------------------------------------------------------- +#[cfg(test)] const CACHE_BYTE_BUDGET: usize = 32 * 1024 * 1024; struct CachedModule { @@ -176,12 +177,14 @@ impl CacheState { /// (PHP NTS), so a `RefCell` is sufficient. pub struct TranspileCache { inner: RefCell, + byte_budget: usize, } impl TranspileCache { - pub fn new(capacity: usize) -> Self { + pub fn new(capacity: usize, byte_budget: usize) -> Self { let cap = NonZeroUsize::new(capacity.max(1)).unwrap(); TranspileCache { + byte_budget: if capacity == 0 { 0 } else { byte_budget }, inner: RefCell::new(CacheState { entries: LruCache::new(cap), bytes: 0, @@ -208,14 +211,16 @@ impl TranspileCache { js: Rc::from(js.as_str()), map_json: map_json.map(|m| Rc::from(m.as_str())), }; - self.inner.borrow_mut().insert( - key, - CachedModule { - source: source.to_owned(), - transpiled: transpiled.clone(), - }, - CACHE_BYTE_BUDGET, - ); + if self.byte_budget > 0 { + self.inner.borrow_mut().insert( + key, + CachedModule { + source: source.to_owned(), + transpiled: transpiled.clone(), + }, + self.byte_budget, + ); + } Ok(transpiled) } } @@ -259,7 +264,7 @@ mod tests { #[test] fn cache_hits_return_same_output() { - let cache = TranspileCache::new(8); + let cache = TranspileCache::new(8, CACHE_BYTE_BUDGET); let a = cache.get_or_transpile("const a: number = 1; a;").unwrap(); let b = cache.get_or_transpile("const a: number = 1; a;").unwrap(); assert_eq!(a.js, b.js); @@ -277,6 +282,28 @@ mod tests { assert_eq!(a.module_id, b.module_id); } + #[test] + fn configurable_limits_can_disable_or_bound_cache() { + let source = "const answer: number = 42; answer;"; + for (entries, bytes) in [(0, CACHE_BYTE_BUDGET), (8, 0), (8, 1)] { + let cache = TranspileCache::new(entries, bytes); + let first = cache.get_or_transpile(source).unwrap(); + let second = cache.get_or_transpile(source).unwrap(); + assert_eq!(first.js, second.js); + assert!(!Rc::ptr_eq(&first.js, &second.js)); + assert!(cache.inner.borrow().entries.is_empty()); + assert_eq!(cache.inner.borrow().bytes, 0); + } + let cache = TranspileCache::new(1, CACHE_BYTE_BUDGET); + let first = cache.get_or_transpile(source).unwrap(); + cache + .get_or_transpile("const other: number = 1; other;") + .unwrap(); + let repeated = cache.get_or_transpile(source).unwrap(); + assert!(!Rc::ptr_eq(&first.js, &repeated.js)); + assert_eq!(cache.inner.borrow().entries.len(), 1); + } + fn module(source: &str, js: &str, map: Option<&str>) -> CachedModule { CachedModule { source: source.to_owned(), @@ -354,7 +381,7 @@ mod tests { #[test] fn hash_collision_transpiles_and_replaces_wrong_entry() { - let cache = TranspileCache::new(8); + let cache = TranspileCache::new(8, CACHE_BYTE_BUDGET); let source = "const correct: number = 42; correct;"; cache.inner.borrow_mut().insert( hash(source), diff --git a/stubs/php_quickjs.stubs.php b/stubs/php_quickjs.stubs.php index 2768798..c7b03a8 100644 --- a/stubs/php_quickjs.stubs.php +++ b/stubs/php_quickjs.stubs.php @@ -17,8 +17,10 @@ class QuickJS * @param int|null $maxStack Max native stack bytes (0/null = engine default). * @param bool $isolated Run each eval() in its own fresh global realm. * @param int|null $maxQueuedMessageBytes Max accounted bytes in the message queue (null = 32 MiB). + * @param int $transpileCacheMaxBytes Retained cache string bytes (0 disables cache). + * @param int $transpileCacheMaxEntries Retained cache entries (0 disables cache). */ - public function __construct(?int $memoryLimit = null, ?int $timeoutMs = null, ?int $maxStack = null, bool $isolated = false, ?int $maxQueuedMessageBytes = null) {} + public function __construct(?int $memoryLimit = null, ?int $timeoutMs = null, ?int $maxStack = null, bool $isolated = false, ?int $maxQueuedMessageBytes = null, int $transpileCacheMaxBytes = 33554432, int $transpileCacheMaxEntries = 256) {} /** * Register a PHP callable under a flat, dotted capability name, callable diff --git a/tests/php/20_cache_options.php b/tests/php/20_cache_options.php new file mode 100644 index 0000000..578f87c --- /dev/null +++ b/tests/php/20_cache_options.php @@ -0,0 +1,14 @@ + 0], ['transpileCacheMaxEntries' => 0], + ['transpileCacheMaxBytes' => 1, 'transpileCacheMaxEntries' => 1]] as $options) { + $js = new QuickJS(...['isolated' => $isolated, ...$options]); + eq(42, $js->eval('const answer: number = 42; answer;'), 'TS runs with configured cache'); + eq(43, $js->eval('43', typescript: false), 'direct JS runs with configured cache'); + } +} +throws(fn() => new QuickJS(transpileCacheMaxBytes: -1), Exception::class, 'negative byte budget rejected'); +throws(fn() => new QuickJS(transpileCacheMaxEntries: -1), Exception::class, 'negative entry limit rejected'); +done();