From fb7536a763be78c391aa4e62682bc85c0bb79158 Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Sat, 12 Sep 2026 02:46:17 +0200 Subject: [PATCH 01/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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(); From e44a35aa93202b31c2432acb541b5f2c8420c050 Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Mon, 26 Oct 2026 13:32:18 +0100 Subject: [PATCH 18/29] Run QuickJS on owned native workers with bidirectional PHP RPC Replace PHP-stack evaluation with one dedicated runtime thread. Preserve callbacks, handles, Promise jobs and isolated evals through owned wire values; enforce lifecycle, deadlines, bounded admission and fork checks. Bump the extension to 0.1.0. Validated: 21 PHP scripts on macOS/PHP 8.5 and Linux/PHP 8.4/8.5, 12 Rust unit tests, and cargo fmt. --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 8 + docs/architecture.md | 57 ++- docs/async.md | 108 ++-- src/bridge.rs | 157 +----- src/callback.rs | 112 +---- src/engine.rs | 726 +++++++++++---------------- src/error.rs | 6 +- src/handles.rs | 7 +- src/lib.rs | 158 ++---- src/marshal.rs | 10 +- src/sandbox.rs | 24 - src/worker.rs | 953 ++++++++++++++++++++++++++++++++++++ stubs/php_quickjs.stubs.php | 6 + tests/php/13_messages.php | 3 +- tests/php/14_async.php | 14 +- tests/php/21_worker.php | 112 +++++ 18 files changed, 1540 insertions(+), 925 deletions(-) create mode 100644 src/worker.rs create mode 100644 tests/php/21_worker.php diff --git a/Cargo.lock b/Cargo.lock index effd0fe..fef2cba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1678,7 +1678,7 @@ dependencies = [ [[package]] name = "php_quickjs" -version = "0.0.3" +version = "0.1.0" dependencies = [ "ext-php-rs", "lru", diff --git a/Cargo.toml b/Cargo.toml index a066024..abdc8dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "php_quickjs" -version = "0.0.3" +version = "0.1.0" 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 b920d43..27e35d2 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,14 @@ TypeScript — it's transpiled in-process and runtime errors map back to the ori TS source. Pass `typescript: false` to `eval()` for JavaScript that should run directly without transpilation. +Since 0.1.0, each runtime and its TypeScript compiler run on a dedicated native +thread. PHP callbacks, objects and closures remain on the PHP thread. With +Revolt autoloaded, waiting suspends the calling Fiber rather than the PHP thread. +Synchronous `php.*` callbacks and `Js\Callback` keep their existing API, including +nested calls. `QuickJS::executionMode()` returns `worker`; there is no inline mode. +Call `$js->close()` to interrupt JS, join its worker and release PHP capabilities. +Close live runtimes before `fork()` and create new runtimes in the child; inherited runtimes reject entries in the child. + 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 library required. diff --git a/docs/architecture.md b/docs/architecture.md index 4f4ec39..13d10cd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -11,7 +11,7 @@ ext-php-rs (zval ↔ Rust) rquickjs (Rust ↔ JSValue) ``` -It is **one process, one thread**. The Rust extension is a `cdylib` that PHP +It is **one process with a dedicated native thread per QuickJS instance**. 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. @@ -23,7 +23,7 @@ bounded native queue. Take `php.math.add(2, 3)` from a guest script. -1. **`eval(tsCode)`** (`lib.rs` → `transpile.rs`) runs oxc: types are stripped, +1. **`eval(tsCode)`** (`lib.rs` → `engine.rs` → worker RPC → `transpile.rs`) runs oxc on the worker: types are stripped, the target is esnext (a near-identity transform), and the output is JS plus a source map. The map is cached **host-side**, keyed by a content hash; it never enters the sandbox. QuickJS only ever sees JavaScript. @@ -38,16 +38,14 @@ Take `php.math.add(2, 3)` from a guest script. }; ``` -3. `__host` is the native entry point for registered PHP capabilities. In - `bridge.rs` it: - - 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`. +3. `__host` in `worker.rs` converts JS arguments to owned `MiddleValue` + data and sends a host-call request. `engine.rs` receives it on the PHP thread. + `bridge.rs` looks the name up in the allowlist, converts arguments to Zend + values and invokes the PHP callable. Only this PHP thread touches Zend. -4. The result travels back `zval → MiddleValue → JS value`. `5` lands in the - guest without a byte serialization round-trip. +4. The reply travels back through the command channel as owned Rust data. The + worker reconstructs its JS result. No JSON encoding is involved. While waiting + for this reply it can service nested JS function requests from PHP. Adding a capability never changes the dispatch mechanism: all capabilities use one dispatch table. The flat, dotted-name list (`manifest()`) is the complete @@ -59,8 +57,8 @@ audit surface for PHP callables. 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 -capabilities appear. +The facade is rebuilt when the registration manifest changes. Its frozen object +is reused and rebound at entry otherwise, avoiding repeated compilation. ## Value marshaling @@ -108,19 +106,26 @@ Rust converts these references without JS-side wrapping or decoding. `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 -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. -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). +The PHP wrapper holds an integer function ID and an owning engine reference; +actual JS objects never cross threads. PHP callable and resource handles likewise +stay on the PHP side. Owned `MiddleValue` data is `Send`; no unsafe `Send` shim is +used to move a runtime, Zend value, context or function between threads. + +`worker.rs` owns the runtime and processes requests, host replies and ready jobs. +Returned Promises are tracked independently. A synchronous host wait can process +nested function requests, preventing PHP -> JS -> PHP deadlocks. A depth cap of +200 and inherited deadlines bound recursion. Isolated runtimes cancel remaining +callback requests before releasing their context. + +`engine.rs` owns PHP dispatch and Revolt suspensions. A notification socket wakes +waiting Fibers; host callbacks run on the calling PHP thread and may suspend. +Without an autoloaded Revolt driver, synchronous requests use a blocking receive +loop and unresolved external waits fail clearly. See [asynchronous execution](async.md). + +The cached facade and pending Promise roots are explicitly released before +context teardown, including panic unwinding, so native closure cycles cannot +retain a dead runtime. `close()` interrupts and joins the worker, then releases +PHP capabilities and handles. The engine validates its creator PID on entry. ## Capability handles diff --git a/docs/async.md b/docs/async.md index 3da3dff..6745b71 100644 --- a/docs/async.md +++ b/docs/async.md @@ -1,60 +1,72 @@ -# Async functions, Promise jobs and PHP Fibers +# Worker execution, Promise jobs and PHP Fibers -`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. +Since 0.1.0, each `QuickJS` owns a native OS thread. The runtime, contexts, +JavaScript values, Promise jobs, TypeScript compiler and transpile cache stay on +that thread. `QuickJS::executionMode()` returns `worker`; there is no inline mode. +The PHP API remains synchronous in shape: `eval()` and `Js\Callback` await their +result, including returned Promises and thenables. Detached jobs remain queued +until `executePendingJobs()` or a returned Promise requires them. -`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. +## PHP scheduling -## Native messages +Autoload `revolt/event-loop` to suspend the calling PHP Fiber during JS execution. +A nonblocking Unix notification socket wakes Revolt for completed requests and +PHP capability calls. The watcher is removed when no PHP callers remain. Without +Revolt, simple synchronous calls use a blocking receive loop on the PHP thread; +external Promise waits fail with an explicit autoload error. Guest code still +runs on its worker in both cases. + +JS calls such as `php.math.add(2, 3)` preserve their synchronous semantics. The +worker sends copied arguments and waits for PHP to dispatch the allowlisted +capability on the original PHP thread. A PHP callback can await Amp I/O. Its +Fiber yields, and other PHP Fibers and independent JS workers can progress. +CPU-bound PHP callbacks still block PHP and should be offloaded separately. + +Nested PHP -> `Js\Callback` calls are serviced by the same worker while it awaits +its host reply. Promise results are tracked independently, so one pending +callback does not prevent another callback from resolving it. Nested calls share +the active wall-clock deadline and have a depth limit of 200. Reentrant `eval()` +and manual job pumping are rejected. A callback may complete on its worker while +PHP is blocked; this differs from the old PHP-thread execution order. -JavaScript can send a data-only value to PHP: +## Deadlines and shutdown -```js -quickjs.postMessage({type: 'result', id: 7, value: 42}); -``` +`timeoutMs` bounds an eval, callback or job batch, including host waits. `null` +disables it. QuickJS's interrupt hook checks the deadline on the worker. A +blocking PHP callback cannot itself be interrupted; its late reply is discarded +if the worker has already timed out. The runtime can recover after a timeout. -`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. +Call `$js->close()` to interrupt executing JS, reject pending calls, join the +worker and release registered PHP capabilities, handles and queued messages. +Close is idempotent; new entries and registrations fail afterwards. The last +owner also joins the worker when destroyed. A saved `Js\Callback` retains its +owning runtime until released or explicitly closed. PHP Future cancellation +alone does not call `close()`; consumers decide whether to abort the whole runtime. -The queue has a configurable aggregate accounted-byte limit (32 MiB by default): +Close live runtimes before `fork()`, then create new runtimes in the child. An inherited runtime cannot be used in a child. +Linux and macOS are supported; notification sockets use `php://fd`. + +## Native messages -```php -$js = new QuickJS(maxQueuedMessageBytes: 32 * 1024 * 1024); -``` +`quickjs.postMessage(data)` snapshots null, booleans, numbers, strings, +`Uint8Array`, arrays and records. Functions, symbols, cycles and nesting beyond +64 levels are rejected. The `quickjs` global and method are frozen. PHP retrieves +and clears messages with `$js->drainMessages()` without entering JS. -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. +The shared native queue has an aggregate accounted-byte limit, 32 MiB by default, +configured through `maxQueuedMessageBytes`. Accounting includes containers and +per-message overhead. Conversion runs outside the queue mutex: JS getters may +call PHP, which may drain the queue. A rejected message leaves older messages +intact. Generic values returned from eval/callbacks have no separate byte cap. +Transport commands are bounded to 256 outstanding PHP operations; nested bridge +calls also obey the depth limit. These are admission and message budgets, not a +process RSS limit. Copies and the JS heap consume additional memory. ## 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. +`hasPendingJobs()` reports ready Promise jobs, excluding unresolved I/O. +`executePendingJobs($maxJobs = 100)` executes at most that many ready jobs and +returns their count without waiting for external I/O. Both require shared mode. +The worker awaits only the returned Promise, leaving later detached jobs queued. +Isolated mode discards its context and runtime, including detached jobs and +unfinished nested callbacks, at the end of each eval. diff --git a/src/bridge.rs b/src/bridge.rs index b09d9ba..87d1923 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -2,21 +2,18 @@ //! through a flat allowlist and the frozen `php.*` facade. use crate::engine::Engine; -use crate::error::{throw_host_error, HostError}; +use crate::error::HostError; use crate::handles::HandleTable; use crate::manifest::ManifestEntry; -use crate::marshal::{ - js_to_data, js_to_middle, middle_to_js, middle_to_zval, zval_to_middle, MiddleValue, -}; +use crate::marshal::{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, Value}; use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::rc::{Rc, Weak}; /// Runtime support injected into each context. -const RUNTIME_JS: &str = include_str!("js/runtime.js"); +pub(crate) const RUNTIME_JS: &str = include_str!("js/runtime.js"); /// Shared host-side state behind the bridge. Single-threaded (PHP NTS), so /// `Rc`/`RefCell` interior mutability is sufficient and correct. @@ -36,15 +33,11 @@ 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>, - messages: RefCell, } impl BridgeState { - pub fn new(max_queued_message_bytes: usize) -> Rc { - Rc::new(Self { - messages: RefCell::new(MessageQueue::new(max_queued_message_bytes)), - ..Self::default() - }) + pub fn new() -> Rc { + Rc::new(Self { ..Self::default() }) } /// Queue a JS-callback id for release at the next eval boundary. @@ -52,7 +45,7 @@ impl BridgeState { self.pending_fn_deletions.borrow_mut().push(id); } - fn take_pending_deletions(&self) -> Vec { + pub(crate) fn take_pending_deletions(&self) -> Vec { std::mem::take(&mut self.pending_fn_deletions.borrow_mut()) } @@ -74,7 +67,8 @@ impl BridgeState { if !callable.is_callable() { return Err(format!("value registered as '{name}' is not callable")); } - self.dispatch + let previous = self + .dispatch .borrow_mut() .insert(name.clone(), callable.shallow_clone()); let mut manifest = self.manifest.borrow_mut(); @@ -83,6 +77,8 @@ impl BridgeState { } else { manifest.push(ManifestEntry { name, types }); } + drop(manifest); + drop(previous); Ok(()) } @@ -106,18 +102,6 @@ impl BridgeState { drop(removed); } - fn push_message(&self, value: MiddleValue, bytes: usize) -> Result<(), &'static str> { - self.messages.borrow_mut().push(value, bytes) - } - - 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 { self.php_funcs.borrow().get(&id).map(Zval::shallow_clone) } @@ -126,17 +110,19 @@ impl BridgeState { self.manifest.borrow().clone() } - fn names(&self) -> Vec { - self.manifest - .borrow() - .iter() - .map(|e| e.name.clone()) - .collect() + pub fn clear(&self) { + let dispatch = std::mem::take(&mut *self.dispatch.borrow_mut()); + let functions = std::mem::take(&mut *self.php_funcs.borrow_mut()); + self.manifest.borrow_mut().clear(); + self.pending_fn_deletions.borrow_mut().clear(); + self.handles.clear(); + drop(dispatch); + drop(functions); } } /// Invoke a PHP callable with already-marshaled args, returning its result. -fn call_php( +pub(crate) fn call_php( callable_zv: &Zval, args: &[MiddleValue], state: &BridgeState, @@ -158,7 +144,7 @@ fn call_php( /// Dispatch a named host call. Returns `Err` for an unknown capability (the /// trust-boundary rejection) or a failed call. -fn host_call( +pub(crate) fn host_call( state: &BridgeState, name: &str, args: Vec, @@ -173,7 +159,7 @@ fn host_call( } /// Invoke an anonymous PHP callable (one previously handed to JS) by id. -fn php_fn_call( +pub(crate) fn php_fn_call( state: &BridgeState, id: u64, args: Vec, @@ -184,97 +170,8 @@ fn php_fn_call( call_php(&callable_zv, &args, state) } -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) -} - -/// 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(); - - // 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>, 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})")?; - } - // 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, payload: Value<'js>| -> rquickjs::Result> { - invoke_host(&ctx, &host_state, payload, |args| { - host_call(&host_state, &name, args) - }) - }, - )?; - globals.set("__host", host)?; - - // JS -> host: invoke an anonymous PHP callable handed to JS earlier. - let php_state = state.clone(); - let php_invoke = Function::new( - ctx.clone(), - 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)?; - - // Runtime support must precede the frozen facade. - ctx.eval::<(), _>(RUNTIME_JS)?; - ctx.eval::<(), _>(build_facade(&state.names()))?; - - 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() { - // 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(()) -} - /// Generate the JS that builds the frozen `php.*` tree from the dotted names. -fn build_facade(names: &[String]) -> String { +pub(crate) fn build_facade(names: &[String]) -> String { let mut src = String::from( "(function(){\n\ var php = {};\n", @@ -327,13 +224,13 @@ const MESSAGE_OVERHEAD: usize = 128; /// Detached data snapshots waiting for a PHP drain. #[derive(Default)] -struct MessageQueue { +pub(crate) struct MessageQueue { messages: Vec, bytes: usize, limit: usize, } impl MessageQueue { - fn new(limit: usize) -> Self { + pub(crate) fn new(limit: usize) -> Self { Self { messages: Vec::new(), bytes: 0, @@ -341,7 +238,7 @@ impl MessageQueue { } } - fn push(&mut self, value: MiddleValue, bytes: usize) -> Result<(), &'static str> { + pub(crate) fn push(&mut self, value: MiddleValue, bytes: usize) -> Result<(), &'static str> { let total = self .bytes .saturating_add(bytes) @@ -354,12 +251,12 @@ impl MessageQueue { Ok(()) } - fn drain(&mut self) -> Vec { + pub(crate) fn drain(&mut self) -> Vec { self.bytes = 0; std::mem::take(&mut self.messages) } - fn remaining(&self) -> usize { + pub(crate) 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 61b8e0f..4f2ff09 100644 --- a/src/callback.rs +++ b/src/callback.rs @@ -1,19 +1,7 @@ -//! `Js\Callback`: a PHP-facing wrapper around a JS function handed to PHP. -//! -//! The JS function itself never leaves the engine; the callback holds only an -//! integer id into the JS-side registry plus a handle back to the engine. When -//! 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::Engine; -use crate::marshal::{ - arguments_to_middle, js_to_middle, middle_to_js, middle_to_zval, MiddleValue, -}; -use ext_php_rs::prelude::*; -use ext_php_rs::types::Zval; -use rquickjs::{Ctx, Function, Value}; +//! PHP proxy for a function which remains owned by the JavaScript worker. +use crate::{engine::Engine, marshal::arguments_to_middle}; +use ext_php_rs::{prelude::*, types::Zval}; use std::rc::Rc; - #[php_class] #[php(name = "Js\\Callback")] pub struct JsCallback { @@ -21,115 +9,41 @@ pub struct JsCallback { realm_id: u64, pub engine: Rc, } - impl JsCallback { pub fn new(id: u64, engine: Rc) -> Self { - JsCallback { + Self { 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()) + || (self.engine.isolated() && !self.engine.is_active()) { - return Err("JS callback belongs to an ended isolated eval".to_owned()); + return Err("JS callback belongs to an ended isolated eval".into()); } Ok(()) } -} - -impl Drop for JsCallback { - /// Queue this callback's registry entry for release. Deletion is deferred - /// (not done here) and flushed at the next eval boundary: a JS function can - /// be round-tripped PHP->JS within a single host call, dropping a transient - /// 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) { - 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 = - arguments_to_middle(args, &self.engine.state).map_err(PhpException::default)?; - 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 { - invoke_callback(ctx, &engine, id, &middle_args) - }; - - 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) + let args = arguments_to_middle(args, &self.engine.state).map_err(PhpException::default)?; + self.engine.callback(self.id, args) } } - -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)?; - 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) -} - -pub(crate) 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).map_err(&map_error)?) - .map_err(&map_error)?; +impl Drop for JsCallback { + fn drop(&mut self) { + if self.check_realm().is_ok() { + self.engine.state.queue_fn_deletion(self.id) + } } - function.call_arg(call_args).map_err(map_error) } - #[php_impl] impl JsCallback { - /// Invoke the JS callback: `$cb(...$args)`. pub fn __invoke(&self, args: &[&Zval]) -> PhpResult { self.invoke_inner(args) } - - /// Explicit form: `$cb->call([...$args])`. pub fn call(&self, args: &[&Zval]) -> PhpResult { self.invoke_inner(args) } diff --git a/src/engine.rs b/src/engine.rs index 4053beb..5f691b1 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,9 +1,9 @@ -//! 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; +//! PHP-thread facade. Only owned wire values cross into the JavaScript thread. +use crate::{ + bridge::BridgeState, + marshal::{middle_to_zval, MiddleValue}, + worker::{self, Command, Config, Event, Operation, Request, WireError}, +}; use ext_php_rs::{ closure::Closure, convert::{IntoZval, IntoZvalDyn}, @@ -11,464 +11,118 @@ use ext_php_rs::{ types::Zval, zend::Function as PhpFunction, }; -use rquickjs::{Context, Ctx, Function, Persistent, 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}; - -/// Maximum nesting depth across the JS<->PHP boundary, guarding runaway mutual -/// recursion (which would otherwise overflow the native stack). +use std::{ + cell::{Cell, RefCell}, + collections::HashMap, + io::Read, + rc::{Rc, Weak}, +}; pub const MAX_DEPTH: usize = 200; -/// The QuickJS engine: runtime + context + the shared bridge state. pub struct Engine { 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 - /// realm is created per eval and discarded afterwards). - shared_ctx: Option, + port: worker::Port, + isolated: bool, + realm: Cell, + sequence: Cell, + active_eval: Cell>, + root_calls: Cell, 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>, - /// 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>>, - /// Set by the interrupt handler when it aborts on the deadline. - timed_out: Rc>, - /// Per-entry timeout; `None` disables the wall-clock guard. - timeout: Option, -} - -struct QueuedCallback { - id: u64, - args: Vec, - reply: CallbackReply, + results: RefCell>>, + waiters: RefCell>>, + watcher: RefCell>, + weak: Weak, } - -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, + sleeping: Cell, } - impl Waiter { fn wake(&self) -> PhpResult<()> { - if !self.woken.replace(true) { + if self.sleeping.replace(false) { self.suspension.try_call_method("resume", vec![])?; } Ok(()) } } - -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 { +pub(crate) 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" + "{class}::{method} unavailable; autoload revolt/event-loop" )) })? .try_call(args) .map_err(Into::into) } - +fn function(name: &str, args: Vec<&dyn IntoZvalDyn>) -> PhpResult { + PhpFunction::try_from_function(name) + .ok_or_else(|| PhpException::default(format!("PHP function {name} unavailable")))? + .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<()> { - 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::< - 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 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, - ) - }; - self.check_deadline(ctx)?; - if result < 0 { - let c = unsafe { Ctx::from_raw(NonNull::new(job_ctx).expect("job error context")) }; - return Err(self.callback_error(&c, rquickjs::Error::Exception)); - } - if result == 0 { - break; - } - count += 1; - } - Ok(count) - } - - /// Await returned Promises, yielding to Revolt when host I/O is pending. - fn promise_for_value<'js>( - &self, - ctx: &Ctx<'js>, - value: Value<'js>, - map_js_error: impl Fn(rquickjs::Error) -> PhpException, - ) -> 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((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(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, yield_now: bool) -> 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() - .filter(|_| !yield_now) - .map(|deadline| -> PhpResult { - 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.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 { - call_php("Revolt\\EventLoop", "cancel", vec![&timer])?; - } - result.map(|_| ()).map_err(Into::into) - } - - 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 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))?, - }, - } - } - } - - 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, - reply: CallbackReply { - 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, 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)); - - // 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 { - state: state.clone(), + cache_entries: usize, + cache_bytes: usize, + ) -> PhpResult> { + let state = BridgeState::new(); + let port = worker::Port::new(Config { memory_limit, + timeout_ms, max_stack, - realm_id: Cell::new(0), - transpile: TranspileCache::new(transpile_cache_max_entries, transpile_cache_max_bytes), - shared_ctx, + isolated, + max_queued_message_bytes, + cache_entries, + cache_bytes, + }) + .map_err(WireError::into_php)?; + let engine = Rc::new_cyclic(|weak| Self { + state: state.clone(), + port, + isolated, + realm: Cell::new(0), + sequence: Cell::new(0), + active_eval: Cell::new(None), + root_calls: Cell::new(0), depth: Cell::new(0), - 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, - timeout: (timeout_ms > 0).then(|| Duration::from_millis(timeout_ms)), + results: RefCell::new(HashMap::new()), + waiters: RefCell::new(HashMap::new()), + watcher: RefCell::new(None), + weak: weak.clone(), }); - // Close the cycle so the bridge can reach back into the engine when it - // needs to invoke JS callbacks held by PHP. state.set_engine(Rc::downgrade(&engine)); Ok(engine) } - - /// Arm the wall-clock deadline for an eval and clear the timeout flag. - pub fn arm_deadline(&self) { - self.timed_out.set(false); - self.deadline.set(self.timeout.map(|t| Instant::now() + t)); - } - - /// Disarm the deadline once an eval completes. - pub fn disarm_deadline(&self) { - self.deadline.set(None); + pub fn close(&self) -> PhpResult<()> { + self.port.check_pid().map_err(WireError::into_php)?; + self.port.close(); + self.remove_watcher(); + self.state.clear(); + let _ = self.port.drain_messages(); + let waiters = self.waiters.borrow().values().cloned().collect::>(); + for waiter in waiters { + waiter.wake()?; + } + Ok(()) } - - /// Whether the last eval was aborted by the wall-clock guard. - pub fn timed_out(&self) -> bool { - self.timed_out.get() + pub fn ensure_open(&self) -> PhpResult<()> { + self.port.check().map_err(WireError::into_php) } - - /// The persistent realm, if this engine has one (shared mode). - pub fn shared_ctx(&self) -> Option<&Context> { - self.shared_ctx.as_ref() + pub fn isolated(&self) -> bool { + self.isolated } - pub fn realm_id(&self) -> u64 { - self.realm_id.get() + self.realm.get() } - pub fn is_active(&self) -> bool { - self.active_ctx.get().is_some() + self.active_eval.get().is_some() || self.root_calls.get() > 0 } - - /// 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. - // 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); - self.complete_pending_callbacks(&ctx)?; - self.check_deadline(&ctx)?; - return result; - } - let run = |ctx: &Context| { - ctx.with(|c| { - // 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()); - } - 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) - .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 - }) - }; - match &self.shared_ctx { - Some(ctx) => run(ctx), - None => { - // 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) - } - } - } - - pub fn callback_error( - &self, - ctx: &Ctx<'_>, - err: rquickjs::Error, - ) -> ext_php_rs::exception::PhpException { - 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(parts) - } - - /// Enter one level of cross-boundary nesting; errors if the cap is hit. pub fn enter(&self) -> Result, String> { let d = self.depth.get(); if d >= MAX_DEPTH { @@ -479,47 +133,213 @@ impl Engine { self.depth.set(d + 1); Ok(DepthGuard { depth: &self.depth }) } -} - -/// RAII guard that decrements the depth counter on drop. -pub struct DepthGuard<'a> { - depth: &'a Cell, -} - -impl Drop for DepthGuard<'_> { - fn drop(&mut self) { - self.depth.set(self.depth.get() - 1); + pub fn eval(&self, code: String, typescript: bool) -> PhpResult { + if self.is_active() { + return Err(PhpException::default( + "Cannot eval while JavaScript is executing; use a JS callback".into(), + )); + } + if self.isolated { + self.realm.set(self.realm.get() + 1) + } + self.request(Operation::Eval { code, typescript }) } -} - -struct ExecutionGuard<'a> { - engine: &'a Engine, -} - -impl Drop for ExecutionGuard<'_> { - fn drop(&mut self) { - self.engine.active_ctx.set(None); - self.engine.active_fiber.set(None); - self.engine.disarm_deadline(); - let pending = std::mem::take(&mut *self.engine.queued_callbacks.borrow_mut()); - for queued in pending { - let _ = queued.reply.finish(Err(PhpException::default( - "JavaScript callback canceled: owning call ended".to_owned(), - ))); + pub fn callback(&self, id: u64, args: Vec) -> PhpResult { + self.request(Operation::Callback { id, args }) + } + pub fn jobs(&self, max: i64) -> PhpResult { + if self.is_active() { + return Err(PhpException::default( + "Cannot run jobs while JavaScript is executing".into(), + )); } - 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(), - ))); + self.request(Operation::Jobs(max)) + } + pub fn has_jobs(&self) -> PhpResult { + self.request(Operation::HasJobs) + } + pub fn roundtrip(&self, value: MiddleValue) -> PhpResult { + self.request(Operation::Roundtrip(value)) + } + pub fn drain_messages(&self) -> PhpResult> { + self.port.drain_messages().map_err(WireError::into_php) + } + fn send(&self, command: Command) -> PhpResult<()> { + self.port.send(command).map_err(WireError::into_php) + } + fn request(&self, op: Operation) -> PhpResult { + self.port.check().map_err(WireError::into_php)?; + if self.waiters.borrow().len() >= 256 { + return Err(PhpException::default( + "Too many pending QuickJS operations (256)".into(), + )); + } + let id = self.sequence.get() + 1; + self.sequence.set(id); + let eval = matches!(&op, Operation::Eval { .. }); + let parent = self.active_eval.get(); + let root_call = !eval && parent.is_none() && !matches!(&op, Operation::HasJobs); + if root_call { + self.root_calls.set(self.root_calls.get() + 1) + } + if eval { + self.active_eval.set(Some(id)) + } + let result = (|| { + let suspension = PhpFunction::try_from_method("Revolt\\EventLoop", "getSuspension") + .map(|f| f.try_call(vec![]).map_err(PhpException::from)) + .transpose()?; + let waiter = suspension.map(|s| { + Rc::new(Waiter { + suspension: s, + sleeping: Cell::new(false), + }) + }); + if let Some(w) = &waiter { + self.waiters.borrow_mut().insert(id, w.clone()); + self.ensure_watcher()?; } + self.send(Command::Request(Request { + id, + op, + names: self + .state + .manifest_snapshot() + .into_iter() + .map(|e| e.name) + .collect(), + deletions: self.state.take_pending_deletions(), + parent, + }))?; + loop { + while let Ok(event) = self.port.receive() { + self.event(event, waiter.is_some())?; + } + let result = self.results.borrow_mut().remove(&id); + if let Some(result) = result { + let data = result.map_err(WireError::into_php)?; + return middle_to_zval(&data, &self.state).map_err(PhpException::default); + } + self.port.check().map_err(WireError::into_php)?; + if let Some(w) = &waiter { + w.sleeping.set(true); + let result = w.suspension.try_call_method("suspend", vec![]); + w.sleeping.set(false); + result?; + } else { + let event = self.port.receive_blocking().map_err(WireError::into_php)?; + self.event(event, false)?; + } + } + })(); + self.waiters.borrow_mut().remove(&id); + if self.waiters.borrow().is_empty() { + self.remove_watcher() + } + if eval { + self.active_eval.set(None) + } + if root_call { + self.root_calls.set(self.root_calls.get() - 1) + } + result + } + fn event(&self, event: Event, has_loop: bool) -> PhpResult<()> { + match event { + Event::Done { id, result } => { + self.results.borrow_mut().insert(id, result); + let waiter = self.waiters.borrow().get(&id).cloned(); + if let Some(w) = waiter { + w.wake()? + } + } + Event::Host { + token, + target, + args, + } => { + let result = match target { + worker::HostTarget::Named(name) => { + crate::bridge::host_call(&self.state, &name, args) + } + worker::HostTarget::Function(id) => { + crate::bridge::php_fn_call(&self.state, id, args) + } + }; + self.send(Command::HostReply { token, result })?; + } + Event::NeedsLoop { id } => { + if !has_loop { + self.send(Command::Abort { + id, + message: "Autoload revolt/event-loop to await external I/O".into(), + })?; + } + } + Event::Stopped(message) => return Err(PhpException::default(message)), + } + Ok(()) + } + fn ensure_watcher(&self) -> PhpResult<()> { + if self.watcher.borrow().is_some() { + return Ok(()); + } + use std::os::fd::AsRawFd; + let filename = format!("php://fd/{}", self.port.wake.as_raw_fd()); + let stream = function("fopen", vec![&filename, &"r"])?; + if stream.is_false() { + return Err(PhpException::default( + "Cannot open QuickJS notification descriptor".into(), + )); + } + function("stream_set_blocking", vec![&stream, &false])?; + let weak = self.weak.clone(); + let native = Closure::wrap(Box::new(move || -> PhpResult<()> { + if let Some(engine) = weak.upgrade() { + let mut buf = [0u8; 512]; + loop { + match (&engine.port.wake).read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + } + let waiters = engine + .waiters + .borrow() + .values() + .cloned() + .collect::>(); + for w in waiters { + w.wake()? + } + } + Ok(()) + }) as Box PhpResult<()>>) + .into_zval(false)?; + let callback = call_php("Closure", "fromCallable", vec![&native])?; + let watcher = call_php("Revolt\\EventLoop", "onReadable", vec![&stream, &callback])?; + *self.watcher.borrow_mut() = Some((watcher, stream)); + Ok(()) + } + fn remove_watcher(&self) { + let watcher = self.watcher.borrow_mut().take(); + if let Some((watcher, stream)) = watcher { + let _ = call_php("Revolt\\EventLoop", "cancel", vec![&watcher]); + let _ = function("fclose", vec![&stream]); } } } - impl Drop for Engine { fn drop(&mut self) { - self.pending_callbacks.get_mut().clear(); + self.remove_watcher(); + self.port.close(); + } +} +pub struct DepthGuard<'a> { + depth: &'a Cell, +} +impl Drop for DepthGuard<'_> { + fn drop(&mut self) { + self.depth.set(self.depth.get() - 1) } } diff --git a/src/error.rs b/src/error.rs index 1cdb2aa..5176365 100644 --- a/src/error.rs +++ b/src/error.rs @@ -98,7 +98,11 @@ pub enum ResourceError { 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") { + } else if parts + .display_message() + .to_lowercase() + .contains("out of memory") + { Some(ResourceError::Memory) } else { None diff --git a/src/handles.rs b/src/handles.rs index 2cd8a20..6974f07 100644 --- a/src/handles.rs +++ b/src/handles.rs @@ -34,9 +34,14 @@ impl HandleTable { /// Drop a handle, releasing the host-side reference. Returns whether it /// existed. pub fn revoke(&self, id: i64) -> bool { - self.map.borrow_mut().remove(&id).is_some() + let value = self.map.borrow_mut().remove(&id); + value.is_some() } + pub fn clear(&self) { + let values = std::mem::take(&mut *self.map.borrow_mut()); + drop(values); + } pub fn len(&self) -> usize { self.map.borrow().len() } diff --git a/src/lib.rs b/src/lib.rs index 6d80367..0364c67 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,10 +17,10 @@ mod manifest; mod marshal; mod sandbox; mod transpile; +mod worker; use engine::Engine; -use exceptions::{QuickJSMemoryException, QuickJSTimeoutException}; -use marshal::{js_to_middle, middle_to_js, middle_to_zval, zval_to_middle}; +use marshal::{middle_to_zval, zval_to_middle}; /// An embedded QuickJS sandbox with a typed, bidirectional PHP bridge. #[php_class] @@ -67,11 +67,13 @@ impl QuickJS { "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 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, @@ -80,20 +82,15 @@ impl QuickJS { max_queued_message_bytes, transpile_cache_max_entries, transpile_cache_max_bytes, - ) - .map_err(to_php_err)?; + )?; Ok(QuickJS { engine }) } /// Register a PHP callable under a flat, dotted capability name, optionally /// with a TypeScript signature used for `.d.ts` generation. The name /// becomes callable from JS as `php.(...)`. - pub fn register( - &mut self, - name: String, - callable: &Zval, - types: Option, - ) -> PhpResult<()> { + pub fn register(&self, name: String, callable: &Zval, types: Option) -> PhpResult<()> { + self.engine.ensure_open()?; self.engine .state .register(name, callable, types) @@ -104,60 +101,23 @@ impl QuickJS { /// 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(), - )); - } - 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, - } - }; + self.engine.eval(code, typescript) + } - let state = self.engine.state.clone(); - 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)?; - // Name the module so stack frames are attributable and remappable. - let mut opts = rquickjs::context::EvalOptions::default(); - opts.filename = Some(module.module_id.clone()); - 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) - }) + /// JavaScript always executes on a dedicated native thread. + pub fn executionMode() -> &'static str { + "worker" + } + + /// Stop this runtime and release its native worker. Further entries fail. + pub fn close(&self) -> PhpResult<()> { + self.engine.close() } /// 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. - Ok(unsafe { - rquickjs::qjs::JS_IsJobPending(rquickjs::qjs::JS_GetRuntime(ctx.as_raw().as_ptr())) - }) - }) + Ok(self.engine.has_jobs()?.bool().unwrap_or(false)) } /// Execute at most maxJobs ready jobs without waiting for host I/O. @@ -169,19 +129,13 @@ impl QuickJS { "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)) + Ok(self.engine.jobs(maxJobs)?.long().unwrap_or(0)) } /// 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()); + let messages = marshal::MiddleValue::Array(self.engine.drain_messages()?); middle_to_zval(&messages, &self.engine.state).map_err(PhpException::default) } @@ -218,8 +172,9 @@ impl QuickJS { /// Grant JS access to a live PHP value (e.g. a database connection) as an /// opaque integer handle. The value is kept host-side; JS only ever sees /// the id and must pass it back to a capability to use it. - pub fn grant(&self, resource: &Zval) -> i64 { - self.engine.state.handles.grant(resource) + pub fn grant(&self, resource: &Zval) -> PhpResult { + self.engine.ensure_open()?; + Ok(self.engine.state.handles.grant(resource)) } /// Resolve a handle previously returned by `grant()` back to its live PHP @@ -243,72 +198,19 @@ 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)?; - 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).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) - }) + self.engine.roundtrip(middle) } } impl QuickJS { fn require_shared_jobs(&self) -> PhpResult<()> { - if self.engine.shared_ctx().is_none() { + if self.engine.isolated() { 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 - /// module's source map and surfaced in the message. - fn classify_js_error( - &self, - ctx: &rquickjs::Ctx<'_>, - err: rquickjs::Error, - map_json: Option<&str>, - module_id: &str, - ) -> PhpException { - let parts = error::js_error_parts(ctx, err); - let message = parts.display_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. - 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)) - .unwrap_or((0, 0)); - exceptions::eval_exception( - parts.name, - message, - module_id, - line, - remapped.unwrap_or_default(), - ) - } -} - -fn to_php_err(e: E) -> PhpException { - PhpException::default(e.to_string()) } #[php_module] diff --git a/src/marshal.rs b/src/marshal.rs index 33b587c..18671c4 100644 --- a/src/marshal.rs +++ b/src/marshal.rs @@ -79,11 +79,7 @@ impl ConversionBudget { /// 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, -) -> rquickjs::Result { +pub fn js_to_middle<'js>(ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result { let mut conversion = JsConversion { ctx, budget: ConversionBudget::default(), @@ -93,7 +89,9 @@ pub fn js_to_middle<'js>( let result = conversion.convert(value, 0); if result.is_err() { for id in conversion.registered { - state.queue_fn_deletion(id); + if let Ok(del) = ctx.globals().get::<_, Function>("__deleteJsFn") { + let _ = del.call::<_, ()>((id as f64,)); + } } } result diff --git a/src/sandbox.rs b/src/sandbox.rs index 1bba861..14b85a9 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -6,10 +6,6 @@ //! extension inside an outer microVM/gVisor boundary. use rquickjs::Runtime; -use std::cell::Cell; -use std::rc::Rc; -use std::time::Instant; - /// Apply the static limits. A value of `0` means "leave unbounded" (QuickJS /// treats a literal 0 limit as zero bytes, so we simply skip the call). pub fn apply_limits(rt: &Runtime, memory_limit: usize, max_stack: usize) { @@ -20,23 +16,3 @@ pub fn apply_limits(rt: &Runtime, memory_limit: usize, max_stack: usize) { rt.set_max_stack_size(max_stack); } } - -/// Install the wall-clock interrupt handler. It fires between QuickJS -/// operations; returning `true` aborts execution. When it trips on the -/// deadline it records that fact so the eval boundary can raise a timeout -/// (rather than a generic) exception. -pub fn install_interrupt( - rt: &Runtime, - deadline: Rc>>, - timed_out: Rc>, -) { - 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 - } - }))); -} diff --git a/src/worker.rs b/src/worker.rs new file mode 100644 index 0000000..5de5b9b --- /dev/null +++ b/src/worker.rs @@ -0,0 +1,953 @@ +//! Runtime ownership and RPC. This module never calls Zend or retains PHP values. +use crate::{ + bridge::{build_facade, MessageQueue, RUNTIME_JS}, + error::{self, HostError, JsErrorParts}, + marshal::{js_to_data, js_to_middle, middle_to_js, MiddleValue as Data}, + transpile::{TranspileCache, Transpiled}, +}; +use ext_php_rs::prelude::PhpException; +use rquickjs::{Context, Ctx, Exception, Function, Promise, Runtime, Value}; +use std::{ + cell::{Cell, RefCell}, + collections::{HashMap, HashSet, VecDeque}, + io::Write, + os::unix::net::UnixStream, + rc::Rc, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, Arc, Mutex, + }, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; + +pub struct Config { + pub memory_limit: usize, + pub timeout_ms: u64, + pub max_stack: usize, + pub isolated: bool, + pub max_queued_message_bytes: usize, + pub cache_entries: usize, + pub cache_bytes: usize, +} +pub enum Operation { + Eval { code: String, typescript: bool }, + Callback { id: u64, args: Vec }, + Jobs(i64), + HasJobs, + Roundtrip(Data), +} +pub struct Request { + pub id: u64, + pub op: Operation, + pub names: Vec, + pub deletions: Vec, + pub parent: Option, +} +pub enum HostTarget { + Named(String), + Function(u64), +} +pub enum Command { + Request(Request), + HostReply { + token: u64, + result: Result, + }, + Abort { + id: u64, + message: String, + }, + Stop, +} +pub enum Event { + Done { + id: u64, + result: Result, + }, + Host { + token: u64, + target: HostTarget, + args: Vec, + }, + NeedsLoop { + id: u64, + }, + Stopped(String), +} +pub enum WireError { + Internal(String), + Timeout, + Guest { + parts: JsErrorParts, + module: Option, + map: Option, + eval: bool, + }, + Syntax { + message: String, + line: u32, + col: u32, + }, +} +impl WireError { + pub fn into_php(self) -> PhpException { + match self { + Self::Internal(s) => PhpException::default(s), + Self::Timeout => { + PhpException::from_class::( + "JavaScript execution timed out".into(), + ) + } + Self::Syntax { message, line, col } => crate::exceptions::eval_exception( + "SyntaxError".into(), + message, + "guest.ts", + line, + format!(" at guest.ts:{line}:{col}"), + ), + Self::Guest { + parts, + module, + map, + eval, + } => { + if !eval { + return error::js_error_to_php(parts); + } + let message = parts.display_message(); + if matches!( + error::resource_error(&parts, false), + Some(error::ResourceError::Memory) + ) { + return PhpException::from_class::( + message, + ); + } + let module = module.unwrap_or_else(|| "guest.js".into()); + let stack = match (parts.stack.as_deref(), map.as_deref()) { + (Some(s), Some(m)) => error::remap_stack(s, m, &module), + (Some(s), None) => Some(s.into()), + _ => None, + }; + let line = stack + .as_deref() + .and_then(|s| error::top_frame_location(s, &module)) + .map_or(0, |x| x.0); + crate::exceptions::eval_exception( + parts.name, + message, + &module, + line, + stack.unwrap_or_default(), + ) + } + } + } +} +pub struct Port { + inner: std::mem::ManuallyDrop, + pid: u32, +} +pub struct PortInner { + tx: mpsc::SyncSender, + rx: mpsc::Receiver, + pub wake: UnixStream, + worker: RefCell>>, + stop: Arc, + messages: Arc>, +} +impl std::ops::Deref for Port { + type Target = PortInner; + fn deref(&self) -> &PortInner { + &self.inner + } +} +impl Port { + pub fn new(config: Config) -> Result { + let (wake, notify) = UnixStream::pair().map_err(|e| WireError::Internal(e.to_string()))?; + wake.set_nonblocking(true) + .map_err(|e| WireError::Internal(e.to_string()))?; + notify + .set_nonblocking(true) + .map_err(|e| WireError::Internal(e.to_string()))?; + let (tx, commands) = mpsc::sync_channel(256); + let (events, rx) = mpsc::channel(); + let stop = Arc::new(AtomicBool::new(false)); + let messages = Arc::new(Mutex::new(MessageQueue::new( + config.max_queued_message_bytes, + ))); + let closed = stop.clone(); + let queue = messages.clone(); + let (ready_tx, ready) = mpsc::sync_channel(1); + let worker = thread::Builder::new() + .name("php-quickjs".into()) + .stack_size( + config + .max_stack + .saturating_add(4 * 1024 * 1024) + .max(8 * 1024 * 1024), + ) + .spawn(move || { + let driver = Rc::new(Driver { + commands: RefCell::new(commands), + events, + notify, + stop: closed.clone(), + messages: queue, + cache: TranspileCache::new(config.cache_entries, config.cache_bytes), + config, + token: Cell::new(0), + }); + let outcome = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| driver.run(ready_tx))); + closed.store(true, Ordering::SeqCst); + if !matches!(outcome, Ok(Ok(()))) { + driver.emit(Event::Stopped( + "QuickJS worker terminated unexpectedly".into(), + )); + } + driver.notify(); + }) + .map_err(|e| WireError::Internal(e.to_string()))?; + let initialized = ready.recv().unwrap_or_else(|_| { + Err(WireError::Internal( + "QuickJS worker initialization failed".into(), + )) + }); + if let Err(error) = initialized { + stop.store(true, Ordering::SeqCst); + let _ = tx.try_send(Command::Stop); + let _ = worker.join(); + return Err(error); + } + Ok(Self { + inner: std::mem::ManuallyDrop::new(PortInner { + tx, + rx, + wake, + worker: RefCell::new(Some(worker)), + stop, + messages, + }), + pid: std::process::id(), + }) + } + pub fn check_pid(&self) -> Result<(), WireError> { + if self.pid != std::process::id() { + return Err(WireError::Internal( + "QuickJS cannot be used after fork; create runtimes in the child".into(), + )); + } + Ok(()) + } + pub fn check(&self) -> Result<(), WireError> { + self.check_pid()?; + if self.stop.load(Ordering::SeqCst) { + return Err(WireError::Internal("QuickJS worker is closed".into())); + } + Ok(()) + } + pub fn send(&self, command: Command) -> Result<(), WireError> { + self.check()?; + self.tx + .try_send(command) + .map_err(|_| WireError::Internal("QuickJS command queue is full or closed".into())) + } + pub fn receive(&self) -> Result { + self.rx.try_recv() + } + pub fn receive_blocking(&self) -> Result { + self.rx + .recv() + .map_err(|_| WireError::Internal("QuickJS worker disconnected".into())) + } + pub fn drain_messages(&self) -> Result, WireError> { + self.check_pid()?; + Ok(self + .messages + .lock() + .unwrap_or_else(|e| e.into_inner()) + .drain()) + } + pub fn close(&self) { + if self.pid != std::process::id() { + return; + } + self.stop.store(true, Ordering::SeqCst); + let _ = self.tx.try_send(Command::Stop); + if let Some(worker) = self.worker.borrow_mut().take() { + let _ = worker.join(); + } + } +} +impl Drop for Port { + fn drop(&mut self) { + if self.pid == std::process::id() { + self.close(); + // SAFETY: the owner has joined its worker; inner is dropped exactly once. + unsafe { + std::mem::ManuallyDrop::drop(&mut self.inner); + } + } + // After fork, do not touch inherited channel/mutex state: its lock owner + // may no longer exist. Child process exit reclaims these inherited resources. + } +} +struct Driver { + commands: RefCell>, + events: mpsc::Sender, + notify: UnixStream, + stop: Arc, + messages: Arc>, + cache: TranspileCache, + config: Config, + token: Cell, +} +impl Driver { + fn notify(&self) { + let _ = (&self.notify).write(&[1]); + } + fn emit(&self, event: Event) { + let _ = self.events.send(event); + self.notify(); + } + fn run(self: &Rc, ready: mpsc::SyncSender>) -> Result<(), String> { + if self.config.isolated { + let _ = ready.send(Ok(())); + loop { + let command = self.commands.borrow().recv().map_err(|e| e.to_string())?; + if self.stop.load(Ordering::SeqCst) { + return Ok(()); + } + match command { + Command::Stop => return Ok(()), + Command::Request(request) => { + let id = request.id; + if !matches!( + &request.op, + Operation::Eval { .. } | Operation::Roundtrip(_) + ) { + self.emit(Event::Done { + id, + result: Err(WireError::Internal( + "JS callback belongs to an ended isolated eval".into(), + )), + }); + continue; + } + self.context(Some(request), None)?; + } + _ => {} + } + } + } else { + self.context(None, Some(ready)) + } + } + fn context( + self: &Rc, + first: Option, + ready: Option>>, + ) -> Result<(), String> { + let rt = Runtime::new().map_err(|e| e.to_string())?; + crate::sandbox::apply_limits(&rt, self.config.memory_limit, self.config.max_stack); + let deadline = Rc::new(Cell::new(None::)); + let timed_out = Rc::new(Cell::new(false)); + let interrupted = self.stop.clone(); + let dl = deadline.clone(); + let expired = timed_out.clone(); + rt.set_interrupt_handler(Some(Box::new(move || { + if interrupted.load(Ordering::SeqCst) { + return true; + } + if dl.get().is_some_and(|d| Instant::now() >= d) { + expired.set(true); + return true; + } + false + }))); + let context = Context::full(&rt).map_err(|e| e.to_string())?; + let root = first.as_ref().map(|r| r.id); + context.with(|ctx| { + let session = Rc::new(Session { + driver: self.clone(), + pending: RefCell::new(Vec::new()), + replies: RefCell::new(HashMap::new()), + host_waits: RefCell::new(HashSet::new()), + entries: RefCell::new(Vec::new()), + deadline, + timed_out, + failed: RefCell::new(VecDeque::new()), + root, + root_finished: Cell::new(false), + facade: RefCell::new(None), + js_depth: Cell::new(0), + }); + let _cleanup = SessionCleanup(session.clone()); + install(&ctx, session.clone()).map_err(|e| error::js_error_message(&ctx, e))?; + if let Some(ready) = ready { + let _ = ready.send(Ok(())); + } + if let Some(first) = first { + session.request(&ctx, first); + } + while !self.stop.load(Ordering::SeqCst) && !session.root_finished.get() { + session.advance(&ctx); + if session.root_finished.get() { + break; + } + let command = if session.pending.borrow().is_empty() { + self.commands.borrow().recv().ok() + } else if jobs_pending(&ctx) { + self.commands.borrow().try_recv().ok() + } else { + session.ask_loop(); + match self + .commands + .borrow() + .recv_timeout(Duration::from_millis(5)) + { + Ok(c) => Some(c), + Err(mpsc::RecvTimeoutError::Timeout) => None, + Err(_) => break, + } + }; + if let Some(command) = command { + if !session.command(&ctx, command) { + break; + } + } + } + let pending = std::mem::take(&mut *session.pending.borrow_mut()); + for item in pending { + self.emit(Event::Done { + id: item.id, + result: Err(WireError::Internal( + "JS callback canceled: owning isolated eval ended".into(), + )), + }); + } + session.replies.borrow_mut().clear(); + Ok(()) + }) + } +} +#[derive(Clone, Default)] +struct ErrorContext { + module: Option, + eval: bool, +} +struct Pending<'js> { + id: u64, + parent: Option, + promise: Promise<'js>, + context: ErrorContext, + deadline: Option, + asked: bool, + settle_depth: usize, +} +struct Session<'js> { + driver: Rc, + pending: RefCell>>, + replies: RefCell>>, + host_waits: RefCell>, + entries: RefCell>>, + deadline: Rc>>, + timed_out: Rc>, + failed: RefCell>, + root: Option, + root_finished: Cell, + facade: RefCell, rquickjs::Object<'js>)>>, + js_depth: Cell, +} +// Native closures retain Session. Break its JS roots before the context is freed, +// including panic unwinding; otherwise a cached facade could retain the runtime. +struct SessionCleanup<'js>(Rc>); +impl Drop for SessionCleanup<'_> { + fn drop(&mut self) { + self.0.pending.borrow_mut().clear(); + self.0.facade.borrow_mut().take(); + } +} +fn jobs_pending(ctx: &Ctx<'_>) -> bool { + unsafe { rquickjs::qjs::JS_IsJobPending(rquickjs::qjs::JS_GetRuntime(ctx.as_raw().as_ptr())) } +} +impl<'js> Session<'js> { + fn update_deadline(&self) { + let entries = self.entries.borrow(); + // An independent pending Promise must not shorten a new synchronous entry. + // Awaited jobs inherit their own active budget through the entry stack. + self.deadline.set(if entries.is_empty() { + self.pending + .borrow() + .iter() + .filter_map(|p| p.deadline) + .min() + } else { + entries.iter().copied().flatten().min() + }); + } + fn error(&self, ctx: &Ctx<'js>, e: rquickjs::Error, context: &ErrorContext) -> WireError { + let parts = error::js_error_parts(ctx, e); + if self.timed_out.replace(false) { + return WireError::Timeout; + } + WireError::Guest { + parts, + module: context.module.as_ref().map(|m| m.module_id.clone()), + map: context + .module + .as_ref() + .and_then(|m| m.map_json.as_ref().map(|s| s.to_string())), + eval: context.eval, + } + } + fn finish(&self, id: u64, result: Result) { + if result.is_err() { + let mut failed = self.failed.borrow_mut(); + failed.push_back(id); + if failed.len() > 256 { + failed.pop_front(); + } + drop(failed); + let mut canceled = Vec::new(); + self.pending.borrow_mut().retain(|p| { + if p.parent == Some(id) { + canceled.push(p.id); + false + } else { + true + } + }); + for id in canceled { + self.driver.emit(Event::Done { + id, + result: Err(WireError::Internal( + "JS callback canceled: owning call ended".into(), + )), + }); + } + } + self.driver.emit(Event::Done { id, result }); + if self.root == Some(id) { + self.root_finished.set(true) + } + } + fn command(&self, ctx: &Ctx<'js>, command: Command) -> bool { + match command { + Command::Stop => return false, + Command::Request(request) => self.request(ctx, request), + Command::HostReply { token, result } => { + if self.host_waits.borrow().contains(&token) { + self.replies.borrow_mut().insert(token, result); + } + } + Command::Abort { id, message } => { + self.pending.borrow_mut().retain(|p| p.id != id); + self.finish(id, Err(WireError::Internal(message))); + self.update_deadline(); + } + } + true + } + fn request(&self, ctx: &Ctx<'js>, request: Request) { + let Request { + id, + op, + names, + deletions, + parent, + } = request; + if parent.is_some_and(|p| self.failed.borrow().contains(&p)) { + self.finish( + id, + Err(WireError::Internal( + "JS callback canceled: owning call ended".into(), + )), + ); + return; + } + let inherited = self + .entries + .borrow() + .iter() + .copied() + .flatten() + .chain( + self.pending + .borrow() + .iter() + .filter(|p| Some(p.id) == parent) + .filter_map(|p| p.deadline), + ) + .min(); + let deadline = inherited.or_else(|| { + (self.driver.config.timeout_ms > 0) + .then(|| Instant::now() + Duration::from_millis(self.driver.config.timeout_ms)) + }); + if deadline.is_some_and(|d| Instant::now() >= d) { + self.finish(id, Err(WireError::Timeout)); + return; + } + if self.entries.borrow().len() >= crate::engine::MAX_DEPTH { + self.finish( + id, + Err(WireError::Internal( + "maximum bridge re-entrancy depth exceeded".into(), + )), + ); + return; + } + self.entries.borrow_mut().push(deadline); + self.update_deadline(); + self.timed_out.set(false); + let await_result = matches!(&op, Operation::Eval { .. } | Operation::Callback { .. }); + let context = ErrorContext { + eval: matches!(&op, Operation::Eval { .. }), + module: None, + }; + self.js_depth.set(self.js_depth.get() + 1); + let result = self.start(ctx, op, &names, &deletions, context); + match result { + Ok((value, context)) => { + if self.timed_out.get() || deadline.is_some_and(|d| Instant::now() >= d) { + self.timed_out.set(false); + self.finish(id, Err(WireError::Timeout)); + } else if !await_result { + self.finish( + id, + js_to_middle(ctx, value).map_err(|e| self.error(ctx, e, &context)), + ); + } else { + let promise_result = (|| { + if let Some(promise) = value.as_promise() { + return Ok(Some(promise.clone())); + } + let normalize: Function = ctx.globals().get("__asPromise")?; + normalize.call((value.clone(),)) + })(); + match promise_result { + Err(e) => self.finish(id, Err(self.error(ctx, e, &context))), + Ok(Some(promise)) => { + if let Some(result) = promise.result::() { + let result = result + .and_then(|v| js_to_middle(ctx, v)) + .map_err(|e| self.error(ctx, e, &context)); + self.finish(id, result) + } else { + self.pending.borrow_mut().push(Pending { + id, + parent, + promise, + context, + deadline, + asked: false, + settle_depth: self.js_depth.get() - 1, + }) + } + } + Ok(None) => self.finish( + id, + js_to_middle(ctx, value).map_err(|e| self.error(ctx, e, &context)), + ), + } + } + } + Err(e) => self.finish(id, Err(e)), + } + self.js_depth.set(self.js_depth.get() - 1); + self.entries.borrow_mut().pop(); + self.update_deadline(); + } + fn start( + &self, + ctx: &Ctx<'js>, + op: Operation, + names: &[String], + deletions: &[u64], + mut context: ErrorContext, + ) -> Result<(Value<'js>, ErrorContext), WireError> { + let prep = (|| -> rquickjs::Result<()> { + if !deletions.is_empty() { + let delete: Function = ctx.globals().get("__deleteJsFn")?; + for id in deletions { + delete.call::<_, ()>((*id as f64,))?; + } + } + let cached = self + .facade + .borrow() + .as_ref() + .filter(|(current, _)| current == names) + .map(|(_, object)| object.clone()); + if let Some(object) = cached { + ctx.globals().set("php", object)?; + } else { + ctx.eval::<(), _>(build_facade(names))?; + let object = ctx.globals().get("php")?; + *self.facade.borrow_mut() = Some((names.to_vec(), object)); + } + Ok(()) + })(); + prep.map_err(|e| self.error(ctx, e, &context))?; + let value = match op { + Operation::Eval { code, typescript } => { + let module = if typescript { + self.driver + .cache + .get_or_transpile(&code) + .map_err(|e| WireError::Syntax { + message: e.message, + line: e.line, + col: e.col, + })? + } else { + Transpiled { + module_id: "guest.js".into(), + js: Rc::from(code), + map_json: None, + } + }; + context.module = Some(module.clone()); + let mut options = rquickjs::context::EvalOptions::default(); + options.filename = Some(module.module_id.clone()); + ctx.eval_with_options(module.js.as_bytes(), options) + .map_err(|e| self.error(ctx, e, &context))? + } + Operation::Callback { id, args } => { + let get: Function = ctx + .globals() + .get("__getJsFn") + .map_err(|e| self.error(ctx, e, &context))?; + let function: Function = get + .call((id as f64,)) + .map_err(|e| self.error(ctx, e, &context))?; + 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).map_err(|e| self.error(ctx, e, &context))?, + ) + .map_err(|e| self.error(ctx, e, &context))?; + } + function + .call_arg(call_args) + .map_err(|e| self.error(ctx, e, &context))? + } + Operation::Jobs(max) => { + let mut n = 0; + while n < max { + if self.deadline.get().is_some_and(|d| Instant::now() >= d) { + return Err(WireError::Timeout); + } + if !self.job(ctx, &context)? { + break; + } + n += 1; + self.settle(ctx); + } + Value::new_float(ctx.clone(), n as f64) + } + Operation::HasJobs => Value::new_bool(ctx.clone(), jobs_pending(ctx)), + Operation::Roundtrip(value) => { + middle_to_js(ctx, &value).map_err(|e| self.error(ctx, e, &context))? + } + }; + Ok((value, context)) + } + fn job(&self, ctx: &Ctx<'js>, context: &ErrorContext) -> Result { + let mut job_ctx = std::ptr::null_mut(); + self.entries.borrow_mut().push(self.deadline.get()); + self.update_deadline(); + self.js_depth.set(self.js_depth.get() + 1); + let result = unsafe { + rquickjs::qjs::JS_ExecutePendingJob( + rquickjs::qjs::JS_GetRuntime(ctx.as_raw().as_ptr()), + &mut job_ctx, + ) + }; + self.js_depth.set(self.js_depth.get() - 1); + self.entries.borrow_mut().pop(); + self.update_deadline(); + if result < 0 { + return Err(self.error(ctx, rquickjs::Error::Exception, context)); + } + if self.timed_out.replace(false) { + return Err(WireError::Timeout); + } + Ok(result > 0) + } + fn settle(&self, ctx: &Ctx<'js>) { + loop { + let item = { + let pending = self.pending.borrow(); + pending.iter().position(|p| { + p.settle_depth >= self.js_depth.get() + && (p.deadline.is_some_and(|d| Instant::now() >= d) + || p.promise.result::().is_some()) + }) + }; + let Some(index) = item else { break }; + let item = self.pending.borrow_mut().remove(index); + let result = if item.deadline.is_some_and(|d| Instant::now() >= d) { + Err(WireError::Timeout) + } else { + item.promise + .result::() + .unwrap() + .and_then(|v| js_to_middle(ctx, v)) + .map_err(|e| self.error(ctx, e, &item.context)) + }; + self.finish(item.id, result); + self.update_deadline(); + } + } + fn has_awaitable(&self) -> bool { + self.pending + .borrow() + .iter() + .any(|p| p.settle_depth >= self.js_depth.get()) + } + fn advance(&self, ctx: &Ctx<'js>) { + self.settle(ctx); + let target = self + .pending + .borrow() + .iter() + .find(|p| p.settle_depth >= self.js_depth.get()) + .map(|p| (p.id, p.context.clone())); + let Some((id, context)) = target else { + return; + }; + if let Err(e) = self.job(ctx, &context) { + let index = self.pending.borrow().iter().position(|p| p.id == id); + if let Some(index) = index { + self.pending.borrow_mut().remove(index); + self.finish(id, Err(e)); + self.update_deadline(); + } + } + self.settle(ctx); + } + fn ask_loop(&self) { + let ids = { + let mut pending = self.pending.borrow_mut(); + pending + .iter_mut() + .filter_map(|p| { + if !p.asked { + p.asked = true; + Some(p.id) + } else { + None + } + }) + .collect::>() + }; + for id in ids { + self.driver.emit(Event::NeedsLoop { id }) + } + } + fn host( + &self, + ctx: &Ctx<'js>, + target: HostTarget, + args: Value<'js>, + ) -> rquickjs::Result> { + let args = js_to_middle(ctx, args)?; + let Data::Array(args) = args else { + return Err(Exception::throw_type( + ctx, + "host arguments must be an array", + )); + }; + let token = self.driver.token.get() + 1; + self.driver.token.set(token); + self.host_waits.borrow_mut().insert(token); + self.driver.emit(Event::Host { + token, + target, + args, + }); + let result = (|| loop { + if self.driver.stop.load(Ordering::SeqCst) { + return Err(Exception::throw_internal(ctx, "QuickJS worker closed")); + } + if self.deadline.get().is_some_and(|d| Instant::now() >= d) { + self.timed_out.set(true); + return Err(Exception::throw_internal( + ctx, + "JavaScript execution timed out", + )); + } + let reply = self.replies.borrow_mut().remove(&token); + if let Some(reply) = reply { + return match reply { + Ok(value) => middle_to_js(ctx, &value), + Err(error) => Err(error::throw_host_error(ctx, &error)), + }; + } + self.advance(ctx); + let command = if jobs_pending(ctx) && self.has_awaitable() { + self.driver.commands.borrow().try_recv().ok() + } else { + match self + .driver + .commands + .borrow() + .recv_timeout(Duration::from_millis(5)) + { + Ok(c) => Some(c), + Err(mpsc::RecvTimeoutError::Timeout) => None, + Err(_) => return Err(Exception::throw_internal(ctx, "PHP receiver closed")), + } + }; + if let Some(command) = command { + if !self.command(ctx, command) { + return Err(Exception::throw_internal(ctx, "QuickJS worker closed")); + } + } + })(); + self.host_waits.borrow_mut().remove(&token); + self.replies.borrow_mut().remove(&token); + result + } +} +fn install<'js>(ctx: &Ctx<'js>, session: Rc>) -> rquickjs::Result<()> { + let host_session = session.clone(); + let host = Function::new( + ctx.clone(), + move |ctx: Ctx<'js>, name: String, args: Value<'js>| { + host_session.host(&ctx, HostTarget::Named(name), args) + }, + )?; + ctx.globals().set("__host", host)?; + let fn_session = session.clone(); + let invoke = Function::new( + ctx.clone(), + move |ctx: Ctx<'js>, id: f64, args: Value<'js>| { + fn_session.host(&ctx, HostTarget::Function(id as u64), args) + }, + )?; + ctx.globals().set("__php_invoke", invoke)?; + let queue = session.driver.messages.clone(); + let post = Function::new( + ctx.clone(), + move |ctx: Ctx<'js>, value: Value<'js>| -> rquickjs::Result<()> { + let capacity = queue.lock().unwrap_or_else(|e| e.into_inner()).remaining(); + let (data, bytes) = js_to_data(&ctx, value, capacity)?; + queue + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(data, bytes) + .map_err(|e| Exception::throw_type(&ctx, e)) + }, + )?; + let quickjs = rquickjs::Object::new(ctx.clone())?; + quickjs.set("postMessage", post)?; + ctx.globals().set("quickjs", quickjs)?; + ctx.eval::<(),_>("Object.freeze(globalThis.quickjs); Object.defineProperty(globalThis,'quickjs',{value:quickjs,writable:false,configurable:false})")?; + ctx.eval::<(), _>(RUNTIME_JS)?; + Ok(()) +} diff --git a/stubs/php_quickjs.stubs.php b/stubs/php_quickjs.stubs.php index c7b03a8..9bfb87f 100644 --- a/stubs/php_quickjs.stubs.php +++ b/stubs/php_quickjs.stubs.php @@ -44,6 +44,12 @@ public function eval(string $code, bool $typescript = true): mixed {} */ public function manifest(): mixed {} + /** JavaScript execution mode. */ + public static function executionMode(): string {} + + /** Stop this runtime; future entries fail. */ + public function close(): void {} + /** Whether Promise jobs are ready (shared mode only). Does not include host I/O. */ public function hasPendingJobs(): bool {} diff --git a/tests/php/13_messages.php b/tests/php/13_messages.php index 8d025ad..6e7525d 100644 --- a/tests/php/13_messages.php +++ b/tests/php/13_messages.php @@ -2,7 +2,8 @@ require __DIR__ . '/_harness.php'; $js = new QuickJS(maxQueuedMessageBytes: 1024); -eq('0.0.3', phpversion('php_quickjs'), 'extension reports its build version'); +preg_match('/^version = \"([^\"]+)\"/m', file_get_contents(__DIR__ . '/../../Cargo.toml'), $package); +eq($package[1], phpversion('php_quickjs'), 'extension reports its build version'); $send = $js->eval('(value) => quickjs.postMessage(value)'); foreach (['', "nul\0tail", 'Привет 🌍', "\xff\xfe"] as $value) { $send($value); diff --git a/tests/php/14_async.php b/tests/php/14_async.php index b113e14..1a15198 100644 --- a/tests/php/14_async.php +++ b/tests/php/14_async.php @@ -86,14 +86,16 @@ function spin() { if (stopped) resolve(1); else Promise.resolve().then(spin); } 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 { +// The worker may finish a JS callback while the PHP event loop is blocked. +// Its execution is no longer delayed until PHP returns from usleep(). +$blocked = new QuickJS(timeoutMs: 100); +$blocked->register('start', static function ($callback) use (&$future): void { $future = Amp\async(static fn() => $callback()); - Revolt\EventLoop::queue(static fn() => usleep(40000)); + Revolt\EventLoop::queue(static fn() => usleep(200000)); }); -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'); +throws(fn() => Amp\async(fn() => $blocked->eval('new Promise(() => php.start(() => { globalThis.completed = true; return 42; }))'))->await(), QuickJSTimeoutException::class, 'unresolved owner still times out while PHP is blocked'); +eq(42, $future->await(), 'worker callback completes independently of blocked PHP'); +eq(true, $blocked->eval('completed'), 'completed callback side effects are preserved'); // 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); diff --git a/tests/php/21_worker.php b/tests/php/21_worker.php new file mode 100644 index 0000000..6b97df7 --- /dev/null +++ b/tests/php/21_worker.php @@ -0,0 +1,112 @@ + new QuickJS(memoryLimit: 1), Throwable::class, 'failed initialization joins its worker and reports an error'); +$js = new QuickJS(timeoutMs: 2000); +$ticks = 0; +$timer = EventLoop::repeat(.002, static function () use (&$ticks): void { ++$ticks; }); +try { + eq(42, $js->eval('const end = Date.now()+100; while(Date.now()= 10, 'PHP timers run during CPU-bound JavaScript'); +} finally { EventLoop::cancel($timer); } +$js->register('echo', static fn($data) => $data); +eq("\xff\x00\xfe", $js->eval('php.echo(new Uint8Array([255,0,254]))', false), 'binary host callback stays typed'); +$js->register('close', static function () use ($js): void { $js->close(); }); +throws(fn() => $js->eval('php.close()', false), Throwable::class, 'close inside a host callback does not deadlock'); +$js->close(); +throws(fn() => $js->eval('42', false), Throwable::class, 'closed runtime rejects entries'); +throws(fn() => $js->register('x', fn()=>1), Throwable::class, 'closed runtime rejects registration'); +throws(fn() => $js->grant(new stdClass), Throwable::class, 'closed runtime rejects new handles'); + +$runtime = new QuickJS(); +$object = new stdClass(); +$weak = WeakReference::create($object); +$runtime->grant($object); +unset($object); +$runtime->close(); +eq(null, $weak->get(), 'close releases PHP handles'); + +$running = new QuickJS(); +$task = Amp\async(static fn() => $running->eval('while(true){}', false)); +Amp\delay(.02); +$start = hrtime(true); +$running->close(); +throws(fn() => $task->await(), Throwable::class, 'closing an executing runtime resumes its PHP caller'); +ok((hrtime(true)-$start)/1e6 < 1000, 'close interrupts a CPU loop promptly'); + +$budget = new QuickJS(timeoutMs: 20); +$budget->register('resolveThenBlock', static function ($resolve): void { + $resolve(42); + usleep(40000); +}); +throws(fn() => $budget->eval('new Promise(resolve => { Promise.resolve().then(() => php.resolveThenBlock(resolve)); })', false), QuickJSTimeoutException::class, 'resolving a Promise inside PHP does not disarm the active JS job deadline'); +eq(42, $budget->eval('42', false), 'runtime recovers after a resolved-but-blocked job timeout'); +$budget->close(); + +$independent = new QuickJS(timeoutMs: 100); +$waiting = $independent->eval('() => new Promise(() => {})', false); +$child = null; +$independent->register('start', static function ($resolve) use ($waiting, &$child): void { + $child = Amp\async(static fn() => $waiting()); + EventLoop::defer(static fn() => $resolve(1)); +}); +eq(1, $independent->eval('new Promise(resolve => php.start(resolve))', false), 'owner can finish before a callback Promise'); +Amp\delay(.07); +eq(42, $independent->eval('(() => {const end=Date.now()+50;while(Date.now() $child->await(), QuickJSTimeoutException::class, 'pending callback still observes its own timeout'); +$independent->close(); + +$reentrant = new QuickJS(); +$callable = new class($reentrant) { + public function __construct(private QuickJS $runtime) {} + public function __invoke(): int { return 1; } + public function __destruct() { $this->runtime->register('other', static fn() => 2); } +}; +$reentrant->register('value', $callable); +unset($callable); +$reentrant->register('value', static fn() => 3); +eq(5, $reentrant->eval('php.value()+php.other()', false), 'replacing capabilities releases PHP objects outside registry borrows'); +$resource = new class($reentrant) { + public function __construct(private QuickJS $runtime) {} + public function __destruct() { $this->runtime->grant('replacement'); } +}; +$handle = $reentrant->grant($resource); +unset($resource); +$reentrant->revoke($handle); +eq('replacement', $reentrant->resolve($handle+1), 'revoking handles permits PHP destructor reentry'); +$reentrant->close(); + +$marshal = new QuickJS(); +$called = false; +$value = $marshal->roundtrip(['then' => static function ($resolve) use (&$called): void { $called = true; $resolve(42); }]); +eq(true, is_array($value), 'roundtrip preserves records with a callable then property'); +eq(false, $called, 'roundtrip does not execute thenables'); +$marshal->close(); + +for($i=0;$i<20;$i++) { + $temporary = new QuickJS(); + $temporary->eval('42', false); + $temporary->close(); +} +eq([], EventLoop::getIdentifiers(), 'repeated create/close leaves no Revolt watchers'); +if (function_exists('pcntl_fork')) { + $parent = new QuickJS(); + $pid = pcntl_fork(); + if ($pid === 0) { + foreach ([fn() => $parent->eval('1', false), fn() => $parent->drainMessages(), fn() => $parent->close()] as $operation) { + try { $operation(); exit(2); } catch (Throwable $error) { + if (!str_contains($error->getMessage(), 'fork')) exit(3); + } + } + exit(0); + } + if ($pid < 0) throw new RuntimeException('fork failed'); + pcntl_waitpid($pid, $status); + eq(0, pcntl_wexitstatus($status), 'inherited runtimes fail clearly and child teardown does not deadlock'); + eq(42, $parent->eval('42', false), 'parent worker survives child exit'); + $parent->close(); +} +done(); From a133302893614d7623b6154ccc9a94453973a60b Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Mon, 26 Oct 2026 16:23:53 +0100 Subject: [PATCH 19/29] Update Rust dependencies to latest stable releases --- .github/workflows/release.yml | 4 +- .github/workflows/test.yml | 2 +- Cargo.lock | 1034 +++++++++++++++++---------------- Cargo.toml | 14 +- Dockerfile-dev | 2 +- README.md | 4 +- docker-compose.yml | 2 +- docs/install.md | 4 +- rust-toolchain.toml | 4 +- src/callback.rs | 7 +- src/engine.rs | 16 +- src/error.rs | 9 +- src/exceptions.rs | 2 +- src/lib.rs | 22 +- src/marshal.rs | 4 +- src/worker.rs | 2 +- 16 files changed, 585 insertions(+), 547 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09afc66..782ddea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: php-version: ${{ matrix.php }} env: phpts: nts - - uses: dtolnay/rust-toolchain@1.96 + - uses: dtolnay/rust-toolchain@1.99.1 # ubuntu-22.04 (kept for glibc 2.35 portability) defaults to clang 14, # whose emmintrin.h trips a bindgen SSE2 intrinsics bug when parsing the # PHP headers on x86_64. clang 15+ fixes it; aarch64 never hits it. @@ -162,7 +162,7 @@ jobs: - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - - uses: dtolnay/rust-toolchain@1.96 + - uses: dtolnay/rust-toolchain@1.99.1 - run: cargo build --release - name: Stage artifact run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 844870a..09b2e0e 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.1 + uses: dtolnay/rust-toolchain@1.99.1 with: components: rustfmt diff --git a/Cargo.lock b/Cargo.lock index fef2cba..92ea300 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,9 +10,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" -version = "0.9.1" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher", "cpubits", @@ -21,9 +21,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -36,9 +36,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "autocfg" @@ -48,9 +48,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base64" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "base64-simd" @@ -68,11 +68,29 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bindgen" +version = "0.73.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "787ef8ef523575546b106a58213d6e6b06198a05c2f757258c68a74273670cfa" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "log", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 3.0.9", +] + [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "bitvec" @@ -110,9 +128,9 @@ checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bzip2" @@ -125,9 +143,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.3" +version = "1.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ce8d3bd5823c7504d3f579f13e7b2f3da252fcb938c594d5680ee508bf846f" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" dependencies = [ "serde_core", ] @@ -143,15 +161,16 @@ dependencies = [ [[package]] name = "cargo_metadata" -version = "0.14.2" +version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" +checksum = "eee4243f1f26fc7a42710e7439c149e2b10b05472f88090acce52632f231a73a" dependencies = [ "camino", "cargo-platform", "semver", "serde", "serde_json", + "thiserror 1.0.69", ] [[package]] @@ -165,14 +184,14 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.65" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex 2.0.1", + "shlex", ] [[package]] @@ -186,9 +205,15 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "cipher" @@ -202,9 +227,9 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", @@ -223,21 +248,20 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror", + "thiserror 2.0.21", ] [[package]] name = "compact_str" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416" dependencies = [ "castaway", "cfg-if", "itoa", - "rustversion", - "ryu", "static_assertions", + "zmij", ] [[package]] @@ -248,15 +272,18 @@ checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] name = "constant_time_eq" -version = "0.4.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +checksum = "9eb6feb175df65d0a3ccd3e73484e940d0ac664e8034f0519fd49a93db3071d9" +dependencies = [ + "rustversion", +] [[package]] name = "convert_case" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +checksum = "1af709f1f33454bf52eadfc8c78b3b9ef9cb26fb54d16dc9cd9a7299f899fd1b" dependencies = [ "unicode-segmentation", ] @@ -291,18 +318,18 @@ checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] @@ -318,18 +345,18 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a" dependencies = [ "cmov", ] [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ "darling_core", "darling_macro", @@ -337,33 +364,33 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn", + "syn 3.0.9", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", - "syn", + "syn 3.0.9", ] [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "debugid" @@ -383,9 +410,9 @@ checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" [[package]] name = "der" -version = "0.8.0" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" dependencies = [ "pem-rfc7468", "zeroize", @@ -393,9 +420,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" [[package]] name = "digest" @@ -412,13 +439,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] @@ -429,9 +456,9 @@ checksum = "fd8e701084c37e7ef62d3f9e453b618130cbc0ef3573847785952a3ac3f746bf" [[package]] name = "either" -version = "1.16.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "embedded-io" @@ -447,9 +474,9 @@ checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -462,56 +489,35 @@ dependencies = [ ] [[package]] -name = "error-chain" -version = "0.12.4" +name = "escape-simd" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" -dependencies = [ - "version_check", -] +checksum = "f0440157235aa4badd45ea073af8de2ba44ffead74bf8a7833b215533c36f08e" [[package]] name = "ext-php-rs" -version = "0.15.15" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe62f25cd053f5f95dc7bbf94e5b41818ea3cf39b36cc25de8ca6b4de68a0eb" +checksum = "f1279870fe6585ac0ba42ed6af0c5c5477b2b7f44dc073d4a6586a5b4a9c4aa0" dependencies = [ "anyhow", + "bindgen", "bitflags", "cc", "cfg-if", - "ext-php-rs-bindgen", "ext-php-rs-build", "ext-php-rs-derive", + "ext-php-rs-introspection", "inventory", "native-tls", "once_cell", "parking_lot", "skeptic", + "thiserror 2.0.21", "ureq", "zip", ] -[[package]] -name = "ext-php-rs-bindgen" -version = "0.72.1-extphprs.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32ceb73da3b8b18a3424765dd6926dbc29464a0a7bc31ab491600bfa02adc4bc" -dependencies = [ - "bitflags", - "cexpr", - "clang-sys", - "itertools", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex 1.3.0", - "syn", -] - [[package]] name = "ext-php-rs-build" version = "0.1.1" @@ -523,53 +529,47 @@ dependencies = [ [[package]] name = "ext-php-rs-derive" -version = "0.11.14" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f57a075f878ad51bcc56745fb21efe601429a509204f8a385bbe526566597320" +checksum = "d31fcc898471e5ab9f4495a8772408f1219f2caa7e1dd36244598a76c0a868cf" dependencies = [ "convert_case", "darling", "itertools", "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] +[[package]] +name = "ext-php-rs-introspection" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa29f09b08e644d5dc1e76a318f758afc9dccbe92ccf84b4778e0433a78eecc" + [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", "miniz_oxide", "zlib-rs", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -608,21 +608,21 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-task", @@ -630,18 +630,6 @@ dependencies = [ "slab", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -651,26 +639,15 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 6.0.0", + "r-efi", "wasm-bindgen", ] [[package]] name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "hashbrown" -version = "0.15.5" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.1.5", -] +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "hashbrown" @@ -680,7 +657,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -694,15 +671,15 @@ dependencies = [ [[package]] name = "hmac-sha1-compact" -version = "1.1.7" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0b3ba31f6dc772cc8221ce81dbbbd64fa1e668255a6737d95eeace59b5a8823" +checksum = "7ed9b23855291da44e7e84616e19dc0d969c3580e14ffa6b759f28775e26afc1" [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -716,18 +693,18 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", @@ -739,22 +716,43 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "406e9ae5b95d8aa37db6293405931b766a6172d1848daee22d70eb44aa78a43c" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3632ebbd0c5c3637ba1a40bdf8288f2cb9d2268e73441a69d5fc73d7cfbaa87c" + [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -766,16 +764,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -786,18 +785,20 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -805,6 +806,29 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_segmenter" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61b195dc605f81aa181bec9b3bd14e26cc71b903e4644f6af578ca9772c7a710" +dependencies = [ + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913a7d294ad056474749152107309fdee3ec17d2482d1ab70fdc61a81c64b9a3" + [[package]] name = "ident_case" version = "1.0.1" @@ -840,37 +864,37 @@ checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown 0.17.1", + "hashbrown", ] [[package]] name = "inout" -version = "0.2.2" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" dependencies = [ "hybrid-array", ] [[package]] name = "inventory" -version = "0.3.24" +version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +checksum = "6928282826c822ad91bf1c9a1cb90a30ba1c26770749929b4656cd6be829cd7c" dependencies = [ "rustversion", ] [[package]] name = "itertools" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" dependencies = [ "either", ] @@ -883,19 +907,19 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom", "libc", ] [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -904,9 +928,35 @@ dependencies = [ [[package]] name = "json-escape-simd" -version = "3.0.2" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d302fe9761f19c4123edfe82b66be55a08e54012403cd7e27f8a176538737954" +dependencies = [ + "escape-simd", +] + +[[package]] +name = "lazy-regex" +version = "3.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35e770254dd7802184595b1d30da2a15cb72569e2aca2b177aef8d22eac8a693" +checksum = "f0a2cce8a22920a29fdcc8c9170aa5d29edd9fbcf8bea856e24c2cb1c2419f67" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", +] + +[[package]] +name = "lazy-regex-proc_macros" +version = "3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f1e4b1f1d8c71edb4ff2b2150ec4606445fd2ddf67ddc097655daf59286baa6" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 3.0.9", +] [[package]] name = "libbz2-rs-sys" @@ -916,9 +966,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libloading" @@ -938,9 +988,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -953,33 +1003,33 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru" -version = "0.12.5" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "4a9f71e3591c7ec44d3e563e87bf14ddc2bada8519dca046e5b0e560cd004e2d" dependencies = [ - "hashbrown 0.15.5", + "hashbrown", ] [[package]] name = "lzma-rust2" -version = "0.16.4" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce716bf1a316f47a280fc76295f6495b5bea4752bca01c3b3885e101b1c23c02" +checksum = "f1c46f3fcd2e888afdb01a07efc071cb21300e07c68d32c3e8b01b5c8cb1fcaa" dependencies = [ "sha2", ] [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "minimal-lexical" @@ -989,9 +1039,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.9" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ "adler2", "simd-adler32", @@ -1014,6 +1064,18 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -1032,9 +1094,9 @@ checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" dependencies = [ "num-integer", "num-traits", @@ -1048,9 +1110,9 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -1092,7 +1154,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1119,17 +1181,11 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" -[[package]] -name = "owo-colors" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" - [[package]] name = "oxc" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c837e177cfe9e45dc8b474b16565046b673e5e2e8c2a2aece798ff9a1e104602" +checksum = "d6e790cd34b555e135d0d656139676ae62a50ef9a6829f9aacf9602b7525c501" dependencies = [ "oxc_allocator", "oxc_ast", @@ -1143,6 +1199,7 @@ dependencies = [ "oxc_regular_expression", "oxc_semantic", "oxc_span", + "oxc_str", "oxc_syntax", "oxc_transformer", "oxc_transformer_plugins", @@ -1150,60 +1207,36 @@ dependencies = [ [[package]] name = "oxc-browserslist" -version = "3.0.6" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b87743c2f6963036971ca587539cd9235693d953dc018a734014241afb20d5b" +checksum = "456720daa870e4f69f8d29fded1c8deea82da4780a6b8553baa160eb0cd8d864" dependencies = [ - "flate2", + "js-sys", + "libc", + "miniz_oxide", "postcard", "rustc-hash", "serde", - "thiserror", -] - -[[package]] -name = "oxc-miette" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b776084bf11ad750806cb63f9ed2606a12ab374cf458f36a7731eba1f5d753fc" -dependencies = [ - "cfg-if", - "owo-colors", - "oxc-miette-derive", - "textwrap", - "thiserror", - "unicode-segmentation", - "unicode-width", -] - -[[package]] -name = "oxc-miette-derive" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66e0bafc397eee2f94c5bf89bb5a82ba6d522d1b79e2924c8169f053febb433f" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror 2.0.21", ] [[package]] name = "oxc_allocator" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b34ec71e68e73a0ed7d929e58ab2bb4f58752dea944c08f14ccc3b8272c77946" +checksum = "45c8f46c8923b4afb4dd588d4684d5dd32332f1341225c066cc6af31094e7e3d" dependencies = [ "allocator-api2", - "hashbrown 0.17.1", + "hashbrown", "oxc_data_structures", "rustc-hash", ] [[package]] name = "oxc_ast" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa406c62bb247767a6a051be6ac3be7db6c4f818129ccae570da6ee9eb96ead0" +checksum = "35465751cfc99739fee42d8345b57d5b5087b462bea7bc5bb17d1f248e1efa22" dependencies = [ "bitflags", "oxc_allocator", @@ -1219,21 +1252,21 @@ dependencies = [ [[package]] name = "oxc_ast_macros" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3706e193b659865e03155a44e443f0447abf6789f9a3eb436ae10e557afab899" +checksum = "e78e5d50ecf9fbf6c00afb29dbd670784b2ac407fd676f625d1f3515a1a2d577" dependencies = [ "phf", "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] name = "oxc_ast_visit" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5353724c6c835bfd37ed59db65ccd506b7721598bcbe4750eda165754d105463" +checksum = "6836d2fde033c44eae57ab1be3fcc7bd6a57465783bdfce654ca28064a334dbe" dependencies = [ "oxc_allocator", "oxc_ast", @@ -1243,17 +1276,16 @@ dependencies = [ [[package]] name = "oxc_codegen" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d57d63987a27e8e67bc40518a4133b96ad2c0475f4735b8110e577a25680a09e" +checksum = "75db0d260bb5493e31be0ab17651d428da5cd8c49ec2cf78d857e605d66385dd" dependencies = [ "bitflags", "cow-utils", - "dragonbox_ecma", - "itoa", "oxc_allocator", "oxc_ast", "oxc_data_structures", + "oxc_ecmascript", "oxc_index", "oxc_semantic", "oxc_sourcemap", @@ -1261,13 +1293,14 @@ dependencies = [ "oxc_str", "oxc_syntax", "rustc-hash", + "smallvec", ] [[package]] name = "oxc_compat" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a25f516a7f4c05f66da375d07b29a87fe0e6037c79374f409153cb9496f3e0" +checksum = "90809c31d39f49d2023b090ffcfd13b5f2e07b156abde430f1b1454ceef51bb1" dependencies = [ "cow-utils", "oxc-browserslist", @@ -1278,45 +1311,58 @@ dependencies = [ [[package]] name = "oxc_data_structures" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fde9ca13ef89018fb4c18502cefb9668baac6066905d27a914c29fb5fae1c8ac" +checksum = "a46b759a8cb13a1111649370f1d769322b082edabd625466254f238818bdc9e9" dependencies = [ "ropey", ] [[package]] name = "oxc_diagnostics" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2cbafa6cc3c38d72e35cd9337a38a4558e9fee4521fa5cabe321b90519e80aa" +checksum = "5b80f4b05a80879518ebe85a8df9368758ebc1e176327b659e1e97389f8561c7" dependencies = [ + "bytecount", "cow-utils", - "oxc-miette", + "itoa", + "memchr", + "oxc_span", "percent-encoding", + "smallvec", + "textwrap", + "unicode-segmentation", + "unicode-width", ] [[package]] name = "oxc_ecmascript" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55408e4f458ec4b9cd842f67364e9395b28bdf2201c7755ed04d6707c1084ea" +checksum = "f8c634665e0be5dde95549146734ab4939b8d1b5d2e7cc006d38b9f5d17a58a2" dependencies = [ "cow-utils", + "dragonbox_ecma", + "itoa", "num-bigint", "num-traits", "oxc_allocator", "oxc_ast", + "oxc_compat", + "oxc_data_structures", "oxc_regular_expression", "oxc_span", + "oxc_str", "oxc_syntax", + "smallvec", ] [[package]] name = "oxc_estree" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b6c062d52b9ff15d118b87679bdd0a05c43599c1d55fd1cab280da1cc822858" +checksum = "5dcdd8235762cc9305d619842b73c1e89a0ebec8ccf2f27142ef597521443c1f" [[package]] name = "oxc_index" @@ -1330,9 +1376,9 @@ dependencies = [ [[package]] name = "oxc_isolated_declarations" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589c261039e8530a6d374502265a976f5a4b0760753abffe56590d954c67607b" +checksum = "a6d9cef33b435f8a3cf25ac1b130654edf428aee5297602cc02f23d0aa57ffb1" dependencies = [ "bitflags", "oxc_allocator", @@ -1348,14 +1394,15 @@ dependencies = [ [[package]] name = "oxc_mangler" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf6fc583affb70dcc496a185bb4f6d47bc7d921a06a8c03baaa2c185082bd8e3" +checksum = "357d27012ddc6ec3f519e4190b3c41efef505e0581cc1ae951a72db7728f0ea4" dependencies = [ "itertools", "oxc_allocator", "oxc_ast", "oxc_data_structures", + "oxc_ecmascript", "oxc_index", "oxc_semantic", "oxc_span", @@ -1366,12 +1413,13 @@ dependencies = [ [[package]] name = "oxc_minifier" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a35d55fd306d180df685938f822f757007996929755e9223810be206538e86d9" +checksum = "5d2be59e492f818fb677c1d16a6677401ed9b35d7fe6c101646aba4e653a288f" dependencies = [ "cow-utils", "itoa", + "lazy-regex", "oxc_allocator", "oxc_ast", "oxc_ast_visit", @@ -1381,7 +1429,6 @@ dependencies = [ "oxc_index", "oxc_mangler", "oxc_parser", - "oxc_regular_expression", "oxc_semantic", "oxc_span", "oxc_str", @@ -1392,9 +1439,9 @@ dependencies = [ [[package]] name = "oxc_parser" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6efc796fc0c65a2a6bd40984b65b1052b5550b017af303f02b08794e304c14c8" +checksum = "635c4e1bb876a9d4ee0e338fa68547a1d75add58637e5c3921fb81ae10a60439" dependencies = [ "bitflags", "cow-utils", @@ -1416,9 +1463,9 @@ dependencies = [ [[package]] name = "oxc_regular_expression" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e533f1345534dceaee6c2c7102e5f3d2e7466269199eca9abeb871e02a7496a4" +checksum = "3ae3f2c42773d16336a75d5c61dae45a34aea399f0b5399a7a25c8509d79662f" dependencies = [ "bitflags", "oxc_allocator", @@ -1433,9 +1480,9 @@ dependencies = [ [[package]] name = "oxc_semantic" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de31d2015099e33ecdc23ad59730b768a141a0b2941d43970a26ba50de003e10" +checksum = "b24ee2095e2ddac60c2063b59693bbf2b80936c8a9e036463b153a203857831b" dependencies = [ "itertools", "memchr", @@ -1456,9 +1503,9 @@ dependencies = [ [[package]] name = "oxc_sourcemap" -version = "8.0.2" +version = "9.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac6db7d8c33f46a0b9ebb702c077364abcd6b64c3a3a97bb86b9f5b3554833c" +checksum = "1133371a5fadc14b9f66a8fec14be3564a77647c7ab21b1ef0ec973bd82613ce" dependencies = [ "base64-simd", "json-escape-simd", @@ -1469,12 +1516,11 @@ dependencies = [ [[package]] name = "oxc_span" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5245fa99a7c9405d57e2d9666afc9fd01ac644e37aff1cb3e1cedd50c17915" +checksum = "0cb5bee6f7a9829ddc4d8c05102fe1aa7e2ca31c7ff0bcfbb7069ca977334840" dependencies = [ "compact_str", - "oxc-miette", "oxc_allocator", "oxc_ast_macros", "oxc_estree", @@ -1483,21 +1529,22 @@ dependencies = [ [[package]] name = "oxc_str" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c61444d5681ce805fbbb5dc0498d980685a4c6a82dd57c2e17ff948041c1fb" +checksum = "c04d966af690d3b780ee4c47436174e558750eed6ca40b07d695fa4910ad8e33" dependencies = [ "compact_str", - "hashbrown 0.17.1", + "hashbrown", + "memchr", "oxc_allocator", "oxc_estree", ] [[package]] name = "oxc_syntax" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "296b94ecc1235a2b5ea2a47412d4bc573b7a25a93132a1a2f49c617d85b58995" +checksum = "684d5f6db25b6e3d026582b3926166c80eeda9aac8a879782292c6a33b7d13a0" dependencies = [ "bitflags", "cow-utils", @@ -1515,9 +1562,9 @@ dependencies = [ [[package]] name = "oxc_transformer" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494dcdb3e8c8dcb50e2ce3ba238030c9116a8b47b54f55c0f0211d68ae66b183" +checksum = "fec592c64f6455678cbdb76635144f76825fcaf85f9509d814bd6a9e2bfde160" dependencies = [ "base64", "compact_str", @@ -1545,9 +1592,9 @@ dependencies = [ [[package]] name = "oxc_transformer_plugins" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6ce80c16ee776e23a0bcb472153db9ec66c59b709fcdb63f43f7623cdfadce7" +checksum = "2470e565dc13884f9a1e1c08d8eaad3c927e807d867a118c0f0287534971327f" dependencies = [ "cow-utils", "itoa", @@ -1568,9 +1615,9 @@ dependencies = [ [[package]] name = "oxc_traverse" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da52a3734ef3ad4c10ebb6f4e768f63ea1597677d53785e3ceb26d9735f5c1f8" +checksum = "b2ba98ff0dc34cb2e45bcbe3cee395e792298fad77ca63c574766571f859cc56" dependencies = [ "itoa", "oxc_allocator", @@ -1635,9 +1682,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "phf" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", "phf_shared", @@ -1646,9 +1693,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -1656,22 +1703,22 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -1696,9 +1743,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "postcard" @@ -1714,34 +1761,26 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "ppmd-rust" -version = "1.4.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] +checksum = "cd4acc8d264ea385db64ccfdcdfd5909ee9eda0975666723d59f3bfec80b3611" [[package]] name = "proc-macro-crate" @@ -1754,9 +1793,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -1774,19 +1813,13 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" @@ -1810,9 +1843,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -1822,9 +1855,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1839,11 +1872,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "relative-path" -version = "2.0.1" +version = "2.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bca40a312222d8ba74837cb474edef44b37f561da5f773981007a10bbaa992b0" +checksum = "38540a682c6e9bca04a8bca9e5ec5a6ba168dfbb04c9998fe517a9befb92eb68" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -1858,9 +1891,9 @@ dependencies = [ [[package]] name = "rquickjs" -version = "0.12.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0688f8b0192998cca685adefdfad3483da295fa40a0ec406b4c14ecd729e858" +checksum = "a0a22bf72f515cc4189dd7c63d016f6de92a11eee98ddf7dd64794198f6eb34e" dependencies = [ "rquickjs-core", "rquickjs-macro", @@ -1868,52 +1901,50 @@ dependencies = [ [[package]] name = "rquickjs-core" -version = "0.12.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fee8c5383f0cfda3b980a80ca4520e726e09b593c59562f579daa51b6c20411" +checksum = "6657979a24d543e5fc66f32cef2f47ca82ce8eb55cec5f7d59a12b7b570bbf24" dependencies = [ - "hashbrown 0.17.1", + "hashbrown", "relative-path", "rquickjs-sys", ] [[package]] name = "rquickjs-macro" -version = "0.12.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e6cf4b6e695b526cb430a6f445d3ccb9908696b99f1c1f8a1480af38bed5e6" +checksum = "0a2ea9adb03b669c933482ff740b683cd36be7cef23891657a8ccb850401d286" dependencies = [ "convert_case", - "fnv", - "ident_case", "indexmap", "proc-macro-crate", "proc-macro2", "quote", "rquickjs-core", - "syn", + "syn 3.0.9", ] [[package]] name = "rquickjs-sys" -version = "0.12.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "698077537c286a169de8693b216672bcef148bf2e2e112ebf50758c68e9afa09" +checksum = "cee271d0eeba64f0915b846cb7ae02e16faf3dfdffdca91731101d9d30fe3423" dependencies = [ "cc", ] [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ "bitflags", "errno", @@ -1924,24 +1955,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "zeroize", ] [[package]] name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "same-file" @@ -1954,9 +1979,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" dependencies = [ "windows-sys", ] @@ -1992,9 +2017,9 @@ dependencies = [ [[package]] name = "self_cell" -version = "1.2.2" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" [[package]] name = "semver" @@ -2014,9 +2039,9 @@ checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -2024,29 +2049,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -2068,21 +2093,15 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures", "digest", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "shlex" version = "2.0.1" @@ -2091,28 +2110,30 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "skeptic" -version = "0.13.7" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8" +checksum = "d82adb9b794e23afb98bb1c89fd84b1971dc74d07b476a3967a8c95b13c2f678" dependencies = [ "bytecount", "cargo_metadata", - "error-chain", "glob", "pulldown-cmark", + "serde", + "serde_json", "tempfile", + "thiserror 1.0.69", "walkdir", ] @@ -2124,9 +2145,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" dependencies = [ "serde", ] @@ -2181,9 +2202,20 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -2192,13 +2224,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] @@ -2214,7 +2246,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom", "once_cell", "rustix", "windows-sys", @@ -2222,40 +2254,60 @@ dependencies = [ [[package]] name = "textwrap" -version = "0.16.2" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" dependencies = [ + "icu_segmenter", "smawk", - "unicode-linebreak", "unicode-width", ] [[package]] name = "thiserror" -version = "2.0.18" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ - "thiserror-impl", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] name = "time" -version = "0.3.51" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "js-sys", @@ -2273,28 +2325,29 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.17+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ "indexmap", "toml_datetime", @@ -2304,18 +2357,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow", ] [[package]] name = "typed-path" -version = "0.12.3" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" [[package]] name = "typenum" @@ -2325,27 +2378,21 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicase" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" [[package]] name = "unicode-id-start" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" +checksum = "0825d2e09dcae814a4273c1e1435979d414ede25b2ee235b1545da6682483765" [[package]] name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-linebreak" -version = "0.1.5" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-segmentation" @@ -2361,9 +2408,9 @@ checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "ureq" -version = "3.3.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" dependencies = [ "base64", "der", @@ -2379,9 +2426,9 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.0" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" dependencies = [ "base64", "http", @@ -2415,9 +2462,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.4" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ "js-sys", "wasm-bindgen", @@ -2429,12 +2476,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "vsimd" version = "0.8.0" @@ -2451,20 +2492,11 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -2475,9 +2507,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2485,31 +2517,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] [[package]] name = "webpki-root-certs" -version = "1.0.8" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" dependencies = [ "rustls-pki-types", ] @@ -2540,24 +2572,18 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wyz" @@ -2570,9 +2596,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2581,13 +2607,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", "synstructure", ] @@ -2602,39 +2628,41 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", "synstructure", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -2642,20 +2670,20 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] name = "zip" -version = "8.6.0" +version = "9.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +checksum = "ec2e3161de27091f18a831d9494e9c53378a34833af5c1dda35ad85849da18d6" dependencies = [ "aes", "bzip2", @@ -2663,16 +2691,18 @@ dependencies = [ "crc32fast", "deflate64", "flate2", - "getrandom 0.4.3", + "getrandom", "hmac", "indexmap", "lzma-rust2", "memchr", + "nix", "pbkdf2", "ppmd-rust", "sha1", "time", "typed-path", + "windows-sys", "zeroize", "zopfli", "zstd", @@ -2680,21 +2710,21 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.4" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "977347db8caa080403f6b6b7c1cda9479a8e869316f7e13a59b19076a40f94e3" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" dependencies = [ "bumpalo", "crc32fast", @@ -2704,27 +2734,27 @@ dependencies = [ [[package]] name = "zstd" -version = "0.13.3" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "7.2.4" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" +version = "2.1.2+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index abdc8dd..6f8bd01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "php_quickjs" version = "0.1.0" edition = "2021" -rust-version = "1.96" +rust-version = "1.97" description = "Embed a QuickJS sandbox in PHP with typed, bidirectional communication" license = "MIT" authors = ["Edd Mann "] @@ -17,19 +17,19 @@ name = "php_quickjs" crate-type = ["cdylib"] [dependencies] -ext-php-rs = { version = "0.15", features = ["closure"] } -rquickjs = { version = "0.12", default-features = false, features = [ +ext-php-rs = { version = "0.16.3", features = ["closure"] } +rquickjs = { version = "0.14.0", default-features = false, features = [ "std", "macro", "classes", "array-buffer", ] } -serde_json = "1" +serde_json = "1.0.154" # TypeScript fast path: transpile guest TS -> JS in-process, remap errors. -oxc = { version = "0.137", features = ["full"] } -sourcemap = "9" -lru = "0.12" +oxc = { version = "0.155.0", features = ["full"] } +sourcemap = "9.3.2" +lru = "0.19.0" [profile.release] opt-level = 3 diff --git a/Dockerfile-dev b/Dockerfile-dev index d67dbd4..42125e0 100644 --- a/Dockerfile-dev +++ b/Dockerfile-dev @@ -2,7 +2,7 @@ FROM composer:2 AS composer FROM php:8.5-cli-bookworm -ARG RUST_VERSION=1.96.1 +ARG RUST_VERSION=1.99.1 RUN apt-get update \ && apt-get install -y --no-install-recommends \ diff --git a/README.md b/README.md index 27e35d2..0a7cdb3 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ the one matching your platform: extension=/path/to/php-quickjs-...so ``` -Or build from source (Rust 1.96+, clang, PHP dev headers — a plain cargo `cdylib`, no +Or build from source (Rust 1.97+, clang, PHP dev headers — a plain cargo `cdylib`, no `phpize`): ```sh @@ -102,7 +102,7 @@ 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: +To build and test with PHP 8.5 + Rust 1.99.1 in Docker: ```sh docker compose run --rm --build dev diff --git a/docker-compose.yml b/docker-compose.yml index 9632373..3bfc223 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: build: context: . dockerfile: Dockerfile-dev - image: php-quickjs-dev:php85-rust196 + image: php-quickjs-dev:php85-rust199 working_dir: /workspace volumes: - .:/workspace diff --git a/docs/install.md b/docs/install.md index ebd7bcb..086435f 100644 --- a/docs/install.md +++ b/docs/install.md @@ -27,7 +27,7 @@ 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 +`Dockerfile-dev` pins PHP 8.5 and Rust 1.99.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. @@ -124,7 +124,7 @@ extension=/var/task/quickjs.so ## Build from source -Requires Rust 1.96+, clang, and PHP 8.4/8.5 dev headers (`php-config`). +Requires Rust 1.97+, clang, and PHP 8.4/8.5 dev headers (`php-config`). ```sh git clone https://github.com/eddmann/php-quickjs && cd php-quickjs diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 620e3ef..fc34a8a 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.1" +# Rust 1.97+ required by oxc 0.155. +channel = "1.99.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. diff --git a/src/callback.rs b/src/callback.rs index 4f2ff09..965ccc5 100644 --- a/src/callback.rs +++ b/src/callback.rs @@ -26,9 +26,10 @@ impl JsCallback { Ok(()) } fn invoke_inner(&self, args: &[&Zval]) -> PhpResult { - self.check_realm().map_err(PhpException::default)?; - let _guard = self.engine.enter().map_err(PhpException::default)?; - let args = arguments_to_middle(args, &self.engine.state).map_err(PhpException::default)?; + self.check_realm().map_err(PhpException::from_message)?; + let _guard = self.engine.enter().map_err(PhpException::from_message)?; + let args = + arguments_to_middle(args, &self.engine.state).map_err(PhpException::from_message)?; self.engine.callback(self.id, args) } } diff --git a/src/engine.rs b/src/engine.rs index 5f691b1..908a1c6 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -48,7 +48,7 @@ impl Waiter { pub(crate) fn call_php(class: &str, method: &str, args: Vec<&dyn IntoZvalDyn>) -> PhpResult { PhpFunction::try_from_method(class, method) .ok_or_else(|| { - PhpException::default(format!( + PhpException::from_message(format!( "{class}::{method} unavailable; autoload revolt/event-loop" )) })? @@ -57,7 +57,7 @@ pub(crate) fn call_php(class: &str, method: &str, args: Vec<&dyn IntoZvalDyn>) - } fn function(name: &str, args: Vec<&dyn IntoZvalDyn>) -> PhpResult { PhpFunction::try_from_function(name) - .ok_or_else(|| PhpException::default(format!("PHP function {name} unavailable")))? + .ok_or_else(|| PhpException::from_message(format!("PHP function {name} unavailable")))? .try_call(args) .map_err(Into::into) } @@ -135,7 +135,7 @@ impl Engine { } pub fn eval(&self, code: String, typescript: bool) -> PhpResult { if self.is_active() { - return Err(PhpException::default( + return Err(PhpException::from_message( "Cannot eval while JavaScript is executing; use a JS callback".into(), )); } @@ -149,7 +149,7 @@ impl Engine { } pub fn jobs(&self, max: i64) -> PhpResult { if self.is_active() { - return Err(PhpException::default( + return Err(PhpException::from_message( "Cannot run jobs while JavaScript is executing".into(), )); } @@ -170,7 +170,7 @@ impl Engine { fn request(&self, op: Operation) -> PhpResult { self.port.check().map_err(WireError::into_php)?; if self.waiters.borrow().len() >= 256 { - return Err(PhpException::default( + return Err(PhpException::from_message( "Too many pending QuickJS operations (256)".into(), )); } @@ -218,7 +218,7 @@ impl Engine { let result = self.results.borrow_mut().remove(&id); if let Some(result) = result { let data = result.map_err(WireError::into_php)?; - return middle_to_zval(&data, &self.state).map_err(PhpException::default); + return middle_to_zval(&data, &self.state).map_err(PhpException::from_message); } self.port.check().map_err(WireError::into_php)?; if let Some(w) = &waiter { @@ -276,7 +276,7 @@ impl Engine { })?; } } - Event::Stopped(message) => return Err(PhpException::default(message)), + Event::Stopped(message) => return Err(PhpException::from_message(message)), } Ok(()) } @@ -288,7 +288,7 @@ impl Engine { let filename = format!("php://fd/{}", self.port.wake.as_raw_fd()); let stream = function("fopen", vec![&filename, &"r"])?; if stream.is_false() { - return Err(PhpException::default( + return Err(PhpException::from_message( "Cannot open QuickJS notification descriptor".into(), )); } diff --git a/src/error.rs b/src/error.rs index 5176365..f0a0a0a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -8,7 +8,7 @@ use ext_php_rs::error::Error as PhpError; use ext_php_rs::exception::PhpException; -use ext_php_rs::zend::ClassEntry; +use ext_php_rs::zend::{ClassEntry, ExecutorGlobals}; use rquickjs::{Ctx, Error as JsError, Exception, Object}; /// A host-side failure carried back to JS: the originating class (a PHP @@ -32,7 +32,12 @@ impl HostError { /// `Display`, which debug-prints the raw object (NUL-laden memory). pub fn php_exception_info(err: PhpError) -> HostError { match err { - PhpError::Exception(obj) => { + PhpError::ExceptionPending { class } => { + let Some(obj) = ExecutorGlobals::take_exception() else { + return HostError::internal(format!( + "PHP exception of class {class} is no longer pending" + )); + }; let class = obj .get_class_name() .unwrap_or_else(|_| "Exception".to_owned()); diff --git a/src/exceptions.rs b/src/exceptions.rs index 9a90c04..24cb2c0 100644 --- a/src/exceptions.rs +++ b/src/exceptions.rs @@ -91,7 +91,7 @@ pub fn eval_exception( } match obj.into_zval(false) { Ok(zval) => { - let mut ex = PhpException::default(message); + let mut ex = PhpException::from_message(message); ex.set_object(Some(zval)); ex } diff --git a/src/lib.rs b/src/lib.rs index 0364c67..c54731e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,22 +54,22 @@ impl QuickJS { 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()) + PhpException::from_message("maxQueuedMessageBytes is too large".to_owned()) })?, _ => { - return Err(PhpException::default( + return Err(PhpException::from_message( "maxQueuedMessageBytes must be positive".to_owned(), )) } }; let transpile_cache_max_bytes = usize::try_from(transpileCacheMaxBytes).map_err(|_| { - PhpException::default( + PhpException::from_message( "transpileCacheMaxBytes must be a non-negative integer fitting usize".to_owned(), ) })?; let transpile_cache_max_entries = usize::try_from(transpileCacheMaxEntries).map_err(|_| { - PhpException::default( + PhpException::from_message( "transpileCacheMaxEntries must be a non-negative integer fitting usize" .to_owned(), ) @@ -94,7 +94,7 @@ impl QuickJS { self.engine .state .register(name, callable, types) - .map_err(PhpException::default) + .map_err(PhpException::from_message) } /// Evaluate source and marshal its result back to PHP. TypeScript is @@ -125,7 +125,7 @@ impl QuickJS { pub fn executePendingJobs(&self, maxJobs: i64) -> PhpResult { self.require_shared_jobs()?; if maxJobs <= 0 { - return Err(PhpException::default( + return Err(PhpException::from_message( "maxJobs must be greater than zero".to_owned(), )); } @@ -136,7 +136,7 @@ impl QuickJS { /// an awaiting driver yields; no JS entry or Promise job is executed. pub fn drainMessages(&self) -> PhpResult { let messages = marshal::MiddleValue::Array(self.engine.drain_messages()?); - middle_to_zval(&messages, &self.engine.state).map_err(PhpException::default) + middle_to_zval(&messages, &self.engine.state).map_err(PhpException::from_message) } /// Return the registration manifest as an array of `['name'=>..., 'types'=>...]`. @@ -159,7 +159,7 @@ impl QuickJS { }) .collect(), ); - marshal::middle_to_zval(&mv, state).map_err(PhpException::default) + marshal::middle_to_zval(&mv, state).map_err(PhpException::from_message) } /// Generate a TypeScript `.d.ts` declaration for the `php` global from the @@ -184,7 +184,7 @@ impl QuickJS { .state .handles .resolve(handle) - .ok_or_else(|| PhpException::default(format!("unknown handle: {handle}"))) + .ok_or_else(|| PhpException::from_message(format!("unknown handle: {handle}"))) } /// Revoke a handle, releasing the host-side reference. Returns whether the @@ -197,7 +197,7 @@ impl QuickJS { /// leg of the marshaling pipeline (PHP -> middle -> JS -> middle -> PHP). 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 middle = zval_to_middle(value, &state).map_err(PhpException::from_message)?; self.engine.roundtrip(middle) } } @@ -205,7 +205,7 @@ impl QuickJS { impl QuickJS { fn require_shared_jobs(&self) -> PhpResult<()> { if self.engine.isolated() { - return Err(PhpException::default( + return Err(PhpException::from_message( "Promise jobs require shared mode (isolated: false)".to_owned(), )); } diff --git a/src/marshal.rs b/src/marshal.rs index 18671c4..240cdb0 100644 --- a/src/marshal.rs +++ b/src/marshal.rs @@ -160,7 +160,9 @@ impl<'js> JsConversion<'_, 'js> { // 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() { + // SAFETY: copy the bytes before executing any JavaScript; budget accounting + // only updates Rust state and throws after the slice is no longer used. + if let Some(bytes) = unsafe { ta.as_bytes() } { self.budget .charge(bytes.len()) .map_err(|e| rquickjs::Exception::throw_type(ctx, e))?; diff --git a/src/worker.rs b/src/worker.rs index 5de5b9b..7836857 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -93,7 +93,7 @@ pub enum WireError { impl WireError { pub fn into_php(self) -> PhpException { match self { - Self::Internal(s) => PhpException::default(s), + Self::Internal(s) => PhpException::from_message(s), Self::Timeout => { PhpException::from_class::( "JavaScript execution timed out".into(), From f1284f24d57e293468f08ec106bfbabce03ba9af Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Mon, 26 Oct 2026 17:35:29 +0100 Subject: [PATCH 20/29] Simplify worker waits and harden asynchronous bridge lifecycle --- Cargo.lock | 1 + Cargo.toml | 3 + build.rs | 21 ++ docs/api.md | 8 + docs/architecture.md | 11 +- docs/async.md | 17 +- src/bridge.rs | 9 + src/callback.rs | 10 + src/engine.rs | 462 ++++++++++++++++++++-------- src/error.rs | 8 + src/lib.rs | 21 +- src/marshal.rs | 28 +- src/notification.c | 32 ++ src/worker.rs | 578 +++++++++++++++++++++++++----------- stubs/php_quickjs.stubs.php | 4 + tests/cgi/notification.php | 21 ++ tests/cgi/run.php | 18 ++ tests/php/14_async.php | 4 +- tests/php/21_worker.php | 69 +++++ 19 files changed, 1013 insertions(+), 312 deletions(-) create mode 100644 build.rs create mode 100644 src/notification.c create mode 100644 tests/cgi/notification.php create mode 100644 tests/cgi/run.php diff --git a/Cargo.lock b/Cargo.lock index 92ea300..fbf0a81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,6 +1727,7 @@ dependencies = [ name = "php_quickjs" version = "0.1.0" dependencies = [ + "cc", "ext-php-rs", "lru", "oxc", diff --git a/Cargo.toml b/Cargo.toml index 6f8bd01..7d6b6b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,9 @@ oxc = { version = "0.155.0", features = ["full"] } sourcemap = "9.3.2" lru = "0.19.0" +[build-dependencies] +cc = "1.10.0" + [profile.release] opt-level = 3 lto = "thin" diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..53dd6b9 --- /dev/null +++ b/build.rs @@ -0,0 +1,21 @@ +use std::{env, process::Command}; + +fn main() { + println!("cargo:rerun-if-changed=src/notification.c"); + println!("cargo:rerun-if-env-changed=PHP_CONFIG"); + let php_config = env::var("PHP_CONFIG").unwrap_or_else(|_| "php-config".into()); + let output = Command::new(php_config) + .arg("--includes") + .output() + .expect("php-config --includes"); + assert!(output.status.success(), "php-config --includes failed"); + let mut build = cc::Build::new(); + build.file("src/notification.c"); + for flag in String::from_utf8(output.stdout) + .expect("PHP include paths") + .split_whitespace() + { + build.flag(flag); + } + build.compile("quickjs_notification"); +} diff --git a/docs/api.md b/docs/api.md index 08bfa8d..5b24881 100644 --- a/docs/api.md +++ b/docs/api.md @@ -86,6 +86,14 @@ 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. +### `awaitMessages(): array` + +Enable automatic jobs in a shared runtime and await a nonempty message batch. +Only one consumer may wait at a time. Closing the runtime wakes the consumer +with an exception. `Js\Callback::post(...$args): void` provides bounded, +asynchronous dispatch for message-based bridges; failures stop the runtime and +are delivered to the consumer. See [asynchronous execution](async.md). + ## Separate resource limits `memoryLimit` applies to the QuickJS heap, not PHP allocations or the transpiler. diff --git a/docs/architecture.md b/docs/architecture.md index 13d10cd..752884a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -117,15 +117,18 @@ nested function requests, preventing PHP -> JS -> PHP deadlocks. A depth cap of 200 and inherited deadlines bound recursion. Isolated runtimes cancel remaining callback requests before releasing their context. -`engine.rs` owns PHP dispatch and Revolt suspensions. A notification socket wakes -waiting Fibers; host callbacks run on the calling PHP thread and may suspend. +`engine.rs` shares one wait loop for RPC results and native message batches. A +coalesced descriptor notification dispatches results to their waiting Fibers; +host callbacks run on the calling PHP thread and may suspend. Manifest updates +are sent only when registrations change, and a cached facade is reused in JS. Without an autoloaded Revolt driver, synchronous requests use a blocking receive loop and unresolved external waits fail clearly. See [asynchronous execution](async.md). The cached facade and pending Promise roots are explicitly released before context teardown, including panic unwinding, so native closure cycles cannot -retain a dead runtime. `close()` interrupts and joins the worker, then releases -PHP capabilities and handles. The engine validates its creator PID on entry. +retain a dead runtime. `close()` interrupts and joins the worker, wakes every +waiter, then releases PHP capabilities and handles. User cleanup cannot prevent +those wakeups. The engine validates its creator PID on entry. ## Capability handles diff --git a/docs/async.md b/docs/async.md index 6745b71..2a9b0fa 100644 --- a/docs/async.md +++ b/docs/async.md @@ -10,8 +10,9 @@ until `executePendingJobs()` or a returned Promise requires them. ## PHP scheduling Autoload `revolt/event-loop` to suspend the calling PHP Fiber during JS execution. -A nonblocking Unix notification socket wakes Revolt for completed requests and -PHP capability calls. The watcher is removed when no PHP callers remain. Without +A native descriptor wakes only the PHP callers whose results are ready. Linux +uses eventfd; macOS uses a socket pair. PHP streams wrap the descriptor directly, +so CGI and FPM are supported too. The watcher is unreferenced while idle. Without Revolt, simple synchronous calls use a blocking receive loop on the PHP thread; external Promise waits fail with an explicit autoload error. Guest code still runs on its worker in both cases. @@ -44,7 +45,7 @@ owning runtime until released or explicitly closed. PHP Future cancellation alone does not call `close()`; consumers decide whether to abort the whole runtime. Close live runtimes before `fork()`, then create new runtimes in the child. An inherited runtime cannot be used in a child. -Linux and macOS are supported; notification sockets use `php://fd`. +Linux and macOS are supported. ## Native messages @@ -53,6 +54,16 @@ Linux and macOS are supported; notification sockets use `php://fd`. 64 levels are rejected. The `quickjs` global and method are frozen. PHP retrieves and clears messages with `$js->drainMessages()` without entering JS. +`$js->awaitMessages()` opts a shared runtime into automatic job processing and +waits for a nonempty batch, yielding through Revolt. Use one message consumer per +runtime. `Js\Callback::post(...$args)` queues a call without awaiting its result; +admission is bounded to 128 outstanding calls and yields when full. A posted +call failure stops the worker and reaches the message consumer. Synchronous +`__invoke()` and `call()` keep their existing result and exception semantics. + +Guest bridges can use `quickjs.now()` for monotonic milliseconds and +`quickjs.reportError(error)` to stop the worker on a background failure. + The shared native queue has an aggregate accounted-byte limit, 32 MiB by default, configured through `maxQueuedMessageBytes`. Accounting includes containers and per-message overhead. Conversion runs outside the queue mutex: JS getters may diff --git a/src/bridge.rs b/src/bridge.rs index 87d1923..d51d136 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -23,6 +23,7 @@ pub struct BridgeState { dispatch: RefCell>, /// Ordered registration manifest (drives the facade and the `.d.ts`). manifest: RefCell>, + manifest_revision: Cell, /// Anonymous PHP callables handed to JS, keyed by id. php_funcs: RefCell>, next_php: Cell, @@ -78,6 +79,7 @@ impl BridgeState { manifest.push(ManifestEntry { name, types }); } drop(manifest); + self.manifest_revision.set(self.manifest_revision.get() + 1); drop(previous); Ok(()) } @@ -110,6 +112,10 @@ impl BridgeState { self.manifest.borrow().clone() } + pub fn manifest_revision(&self) -> u64 { + self.manifest_revision.get() + } + pub fn clear(&self) { let dispatch = std::mem::take(&mut *self.dispatch.borrow_mut()); let functions = std::mem::take(&mut *self.php_funcs.borrow_mut()); @@ -255,6 +261,9 @@ impl MessageQueue { self.bytes = 0; std::mem::take(&mut self.messages) } + pub(crate) fn is_empty(&self) -> bool { + self.messages.is_empty() + } pub(crate) fn remaining(&self) -> usize { self.limit diff --git a/src/callback.rs b/src/callback.rs index 965ccc5..19206f9 100644 --- a/src/callback.rs +++ b/src/callback.rs @@ -27,6 +27,7 @@ impl JsCallback { } fn invoke_inner(&self, args: &[&Zval]) -> PhpResult { self.check_realm().map_err(PhpException::from_message)?; + self.engine.ensure_open()?; let _guard = self.engine.enter().map_err(PhpException::from_message)?; let args = arguments_to_middle(args, &self.engine.state).map_err(PhpException::from_message)?; @@ -48,4 +49,13 @@ impl JsCallback { pub fn call(&self, args: &[&Zval]) -> PhpResult { self.invoke_inner(args) } + /// Queue a call, discard its value and report failures to awaitMessages(). + /// Admission is bounded and yields through Revolt when the queue is full. + pub fn post(&self, args: &[&Zval]) -> PhpResult<()> { + self.check_realm().map_err(PhpException::from_message)?; + self.engine.ensure_post_open()?; + let args = + arguments_to_middle(args, &self.engine.state).map_err(PhpException::from_message)?; + self.engine.post(self.id, args) + } } diff --git a/src/engine.rs b/src/engine.rs index 908a1c6..d60bb1a 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -9,11 +9,11 @@ use ext_php_rs::{ convert::{IntoZval, IntoZvalDyn}, prelude::*, types::Zval, - zend::Function as PhpFunction, + zend::{ExecutorGlobals, Function as PhpFunction}, }; use std::{ cell::{Cell, RefCell}, - collections::HashMap, + collections::{HashMap, VecDeque}, io::Read, rc::{Rc, Weak}, }; @@ -27,61 +27,66 @@ pub struct Engine { sequence: Cell, active_eval: Cell>, root_calls: Cell, + host_calls: Cell, depth: Cell, results: RefCell>>, waiters: RefCell>>, + events: RefCell>, watcher: RefCell>, + watcher_referenced: Cell, + manifest_revision: Cell>, + message_waiter: Cell>, + message_error: RefCell>, + loop_functions: RefCell>, weak: Weak, } struct Waiter { suspension: Zval, sleeping: Cell, } +unsafe extern "C" { + fn php_quickjs_notification_stream(fd: i32, result: *mut Zval) -> bool; +} impl Waiter { fn wake(&self) -> PhpResult<()> { if self.sleeping.replace(false) { - self.suspension.try_call_method("resume", vec![])?; + checked(self.suspension.try_call_method("resume", vec![]))?; } Ok(()) } } +fn checked(result: ext_php_rs::error::Result) -> PhpResult { + let value = result?; + // Leave Zend's exception in place, including uncatchable Fiber shutdown. + // PhpException::throw preserves it when control returns to PHP. + if ExecutorGlobals::has_exception() { + return Err(PhpException::from_message("PHP callback failed".into())); + } + Ok(value) +} pub(crate) fn call_php(class: &str, method: &str, args: Vec<&dyn IntoZvalDyn>) -> PhpResult { - PhpFunction::try_from_method(class, method) - .ok_or_else(|| { - PhpException::from_message(format!( - "{class}::{method} unavailable; autoload revolt/event-loop" - )) - })? - .try_call(args) - .map_err(Into::into) + checked( + PhpFunction::try_from_method(class, method) + .ok_or_else(|| { + PhpException::from_message(format!( + "{class}::{method} unavailable; autoload revolt/event-loop" + )) + })? + .try_call(args), + ) } fn function(name: &str, args: Vec<&dyn IntoZvalDyn>) -> PhpResult { - PhpFunction::try_from_function(name) - .ok_or_else(|| PhpException::from_message(format!("PHP function {name} unavailable")))? - .try_call(args) - .map_err(Into::into) + checked( + PhpFunction::try_from_function(name) + .ok_or_else(|| PhpException::from_message(format!("PHP function {name} unavailable")))? + .try_call(args), + ) } impl Engine { - pub fn new( - memory_limit: usize, - timeout_ms: u64, - max_stack: usize, - isolated: bool, - max_queued_message_bytes: usize, - cache_entries: usize, - cache_bytes: usize, - ) -> PhpResult> { + pub fn new(config: Config) -> PhpResult> { let state = BridgeState::new(); - let port = worker::Port::new(Config { - memory_limit, - timeout_ms, - max_stack, - isolated, - max_queued_message_bytes, - cache_entries, - cache_bytes, - }) - .map_err(WireError::into_php)?; + let isolated = config.isolated; + let port = worker::Port::new(config).map_err(WireError::into_php)?; let engine = Rc::new_cyclic(|weak| Self { state: state.clone(), port, @@ -90,10 +95,17 @@ impl Engine { sequence: Cell::new(0), active_eval: Cell::new(None), root_calls: Cell::new(0), + host_calls: Cell::new(0), depth: Cell::new(0), results: RefCell::new(HashMap::new()), waiters: RefCell::new(HashMap::new()), + events: RefCell::new(VecDeque::new()), watcher: RefCell::new(None), + watcher_referenced: Cell::new(false), + manifest_revision: Cell::new(None), + message_waiter: Cell::new(None), + message_error: RefCell::new(None), + loop_functions: RefCell::new(HashMap::new()), weak: weak.clone(), }); state.set_engine(Rc::downgrade(&engine)); @@ -102,18 +114,38 @@ impl Engine { pub fn close(&self) -> PhpResult<()> { self.port.check_pid().map_err(WireError::into_php)?; self.port.close(); + let result = self.wake_all(); + // No user destructor runs until every sleeping caller has been resumed. self.remove_watcher(); - self.state.clear(); + self.events.borrow_mut().clear(); + self.results.borrow_mut().clear(); let _ = self.port.drain_messages(); - let waiters = self.waiters.borrow().values().cloned().collect::>(); - for waiter in waiters { - waiter.wake()?; + self.state.clear(); + result + } + fn loop_function(&self, method: &'static str) -> Option { + if let Some(function) = self.loop_functions.borrow().get(method).copied() { + return Some(function); } - Ok(()) + let function = PhpFunction::try_from_method("Revolt\\EventLoop", method)?; + self.loop_functions.borrow_mut().insert(method, function); + Some(function) + } + fn loop_call(&self, method: &'static str, args: Vec<&dyn IntoZvalDyn>) -> PhpResult { + checked( + self.loop_function(method) + .ok_or_else(|| PhpException::from_message("Autoload revolt/event-loop".into()))? + .try_call(args), + ) } pub fn ensure_open(&self) -> PhpResult<()> { self.port.check().map_err(WireError::into_php) } + pub fn ensure_post_open(&self) -> PhpResult<()> { + self.port + .check() + .map_err(|error| self.error_after_stop(error)) + } pub fn isolated(&self) -> bool { self.isolated } @@ -121,17 +153,16 @@ impl Engine { self.realm.get() } pub fn is_active(&self) -> bool { - self.active_eval.get().is_some() || self.root_calls.get() > 0 + self.active_eval.get().is_some() || self.root_calls.get() > 0 || self.host_calls.get() > 0 } - pub fn enter(&self) -> Result, String> { + pub fn enter(&self) -> Result, String> { let d = self.depth.get(); if d >= MAX_DEPTH { return Err(format!( "maximum bridge re-entrancy depth ({MAX_DEPTH}) exceeded" )); } - self.depth.set(d + 1); - Ok(DepthGuard { depth: &self.depth }) + Ok(CounterGuard::enter(&self.depth)) } pub fn eval(&self, code: String, typescript: bool) -> PhpResult { if self.is_active() { @@ -147,6 +178,40 @@ impl Engine { pub fn callback(&self, id: u64, args: Vec) -> PhpResult { self.request(Operation::Callback { id, args }) } + pub fn post(&self, function: u64, args: Vec) -> PhpResult<()> { + if self.isolated { + return Err(PhpException::from_message( + "post requires a non-isolated runtime".into(), + )); + } + self.port + .start_pump() + .map_err(|error| self.error_after_stop(error))?; + while !self.port.reserve_post() { + if self.host_calls.get() > 0 { + return Err(PhpException::from_message( + "Post admission is full during a synchronous PHP host callback".into(), + )); + } + if let Err(error) = self.request(Operation::Capacity) { + if let Err(stopped) = self.port.check() { + return Err(self.error_after_stop(stopped)); + } + return Err(error); + } + self.port.check().map_err(WireError::into_php)?; + } + let id = self.next_id(); + let result = self.submit( + id, + Operation::Post { id: function, args }, + self.active_eval.get(), + ); + if result.is_err() { + self.port.release_post(); + } + result + } pub fn jobs(&self, max: i64) -> PhpResult { if self.is_active() { return Err(PhpException::from_message( @@ -164,100 +229,202 @@ impl Engine { pub fn drain_messages(&self) -> PhpResult> { self.port.drain_messages().map_err(WireError::into_php) } - fn send(&self, command: Command) -> PhpResult<()> { - self.port.send(command).map_err(WireError::into_php) - } - fn request(&self, op: Operation) -> PhpResult { - self.port.check().map_err(WireError::into_php)?; - if self.waiters.borrow().len() >= 256 { + pub fn await_messages(&self) -> PhpResult> { + if self.isolated || self.message_waiter.get().is_some() { return Err(PhpException::from_message( - "Too many pending QuickJS operations (256)".into(), + "awaitMessages requires one consumer on a non-isolated runtime".into(), )); } + self.port + .start_pump() + .map_err(|error| self.error_after_stop(error))?; + if let Some(error) = self.message_error.borrow_mut().take() { + return Err(error.into_php()); + } + let ready = self.drain_messages()?; + if !ready.is_empty() { + return Ok(ready); + } + let id = self.next_id(); + self.message_waiter.set(Some(id)); + let result = self.wait( + id, + || Ok(()), + || { + if let Some(error) = self.message_error.borrow_mut().take() { + return Err(error.into_php()); + } + let messages = self.drain_messages()?; + Ok((!messages.is_empty()).then_some(messages)) + }, + ); + self.message_waiter.set(None); + result + } + fn next_id(&self) -> u64 { let id = self.sequence.get() + 1; self.sequence.set(id); - let eval = matches!(&op, Operation::Eval { .. }); - let parent = self.active_eval.get(); - let root_call = !eval && parent.is_none() && !matches!(&op, Operation::HasJobs); - if root_call { - self.root_calls.set(self.root_calls.get() + 1) + id + } + fn wait( + &self, + id: u64, + start: impl FnOnce() -> PhpResult<()>, + mut ready: impl FnMut() -> PhpResult>, + ) -> PhpResult { + if self.waiters.borrow().len() >= 256 { + return Err(PhpException::from_message( + "Too many pending QuickJS operations (256)".into(), + )); } - if eval { - self.active_eval.set(Some(id)) + let suspension = self + .loop_function("getSuspension") + .map(|function| checked(function.try_call(vec![]))) + .transpose()?; + let waiter = suspension.map(|suspension| { + Rc::new(Waiter { + suspension, + sleeping: Cell::new(false), + }) + }); + if let Some(waiter) = &waiter { + self.waiters.borrow_mut().insert(id, waiter.clone()); } let result = (|| { - let suspension = PhpFunction::try_from_method("Revolt\\EventLoop", "getSuspension") - .map(|f| f.try_call(vec![]).map_err(PhpException::from)) - .transpose()?; - let waiter = suspension.map(|s| { - Rc::new(Waiter { - suspension: s, - sleeping: Cell::new(false), - }) - }); - if let Some(w) = &waiter { - self.waiters.borrow_mut().insert(id, w.clone()); - self.ensure_watcher()?; - } - self.send(Command::Request(Request { - id, - op, - names: self - .state - .manifest_snapshot() - .into_iter() - .map(|e| e.name) - .collect(), - deletions: self.state.take_pending_deletions(), - parent, - }))?; + start()?; loop { - while let Ok(event) = self.port.receive() { + loop { + let event = self + .events + .borrow_mut() + .pop_front() + .or_else(|| self.port.receive().ok()); + let Some(event) = event else { break }; self.event(event, waiter.is_some())?; } - let result = self.results.borrow_mut().remove(&id); - if let Some(result) = result { - let data = result.map_err(WireError::into_php)?; - return middle_to_zval(&data, &self.state).map_err(PhpException::from_message); + if let Some(value) = ready()? { + return Ok(value); } - self.port.check().map_err(WireError::into_php)?; - if let Some(w) = &waiter { - w.sleeping.set(true); - let result = w.suspension.try_call_method("suspend", vec![]); - w.sleeping.set(false); - result?; + self.ensure_open()?; + if let Some(waiter) = &waiter { + self.ensure_watcher()?; + waiter.sleeping.set(true); + let result = waiter.suspension.try_call_method("suspend", vec![]); + waiter.sleeping.set(false); + checked(result)?; } else { - let event = self.port.receive_blocking().map_err(WireError::into_php)?; - self.event(event, false)?; + self.event( + self.port.receive_blocking().map_err(WireError::into_php)?, + false, + )?; } } })(); self.waiters.borrow_mut().remove(&id); if self.waiters.borrow().is_empty() { - self.remove_watcher() + self.pause_watcher(); + } + result + } + fn wake(&self, id: Option) -> PhpResult<()> { + let waiter = id.and_then(|id| self.waiters.borrow().get(&id).cloned()); + waiter.map_or(Ok(()), |waiter| waiter.wake()) + } + fn wake_all(&self) -> PhpResult<()> { + let waiters = self.waiters.borrow().values().cloned().collect::>(); + let mut error = None; + for waiter in waiters { + if let Err(e) = waiter.wake() { + error.get_or_insert(e); + } } + error.map_or(Ok(()), Err) + } + fn send(&self, command: Command) -> PhpResult<()> { + self.port.send(command).map_err(WireError::into_php) + } + fn error_after_stop(&self, fallback: WireError) -> PhpException { + if self.port.check_pid().is_err() { + return fallback.into_php(); + } + while let Ok(event) = self.port.receive() { + match event { + Event::Host { .. } | Event::Stopped(_) => {} + event => { + let _ = self.event(event, true); + } + } + } + self.message_error + .borrow_mut() + .take() + .unwrap_or(fallback) + .into_php() + } + fn submit(&self, id: u64, op: Operation, parent: Option) -> PhpResult<()> { + let revision = self.state.manifest_revision(); + let names = (self.isolated || self.manifest_revision.get() != Some(revision)).then(|| { + self.state + .manifest_snapshot() + .into_iter() + .map(|entry| entry.name) + .collect() + }); + self.send(Command::Request(Request { + id, + op, + names, + deletions: self.state.take_pending_deletions(), + parent, + }))?; + self.manifest_revision.set(Some(revision)); + Ok(()) + } + fn request(&self, op: Operation) -> PhpResult { + self.ensure_open()?; + let id = self.next_id(); + let eval = matches!(&op, Operation::Eval { .. }); + let parent = self.active_eval.get(); + let _root = (!eval && parent.is_none() && !matches!(&op, Operation::HasJobs)) + .then(|| CounterGuard::enter(&self.root_calls)); if eval { - self.active_eval.set(None) + self.active_eval.set(Some(id)); } - if root_call { - self.root_calls.set(self.root_calls.get() - 1) + let result = self.wait( + id, + || self.submit(id, op, parent), + || { + let result = self.results.borrow_mut().remove(&id); + result + .map(|result| { + let data = result.map_err(WireError::into_php)?; + middle_to_zval(&data, &self.state).map_err(PhpException::from_message) + }) + .transpose() + }, + ); + if eval { + self.active_eval.set(None); } result } fn event(&self, event: Event, has_loop: bool) -> PhpResult<()> { match event { + Event::Messages => self.wake(self.message_waiter.get())?, + Event::AsyncError(error) | Event::Fatal(error) => { + *self.message_error.borrow_mut() = Some(error); + self.wake(self.message_waiter.get())?; + } Event::Done { id, result } => { self.results.borrow_mut().insert(id, result); - let waiter = self.waiters.borrow().get(&id).cloned(); - if let Some(w) = waiter { - w.wake()? - } + self.wake(Some(id))?; } Event::Host { token, target, args, } => { + let _host = CounterGuard::enter(&self.host_calls); let result = match target { worker::HostTarget::Named(name) => { crate::bridge::host_call(&self.state, &name, args) @@ -266,6 +433,10 @@ impl Engine { crate::bridge::php_fn_call(&self.state, id, args) } }; + if let Err(error) = checked(Ok(())) { + self.port.close(); + return Err(error); + } self.send(Command::HostReply { token, result })?; } Event::NeedsLoop { id } => { @@ -281,13 +452,21 @@ impl Engine { Ok(()) } fn ensure_watcher(&self) -> PhpResult<()> { - if self.watcher.borrow().is_some() { + let watcher = self + .watcher + .borrow() + .as_ref() + .map(|(id, _)| id.shallow_clone()); + if let Some(watcher) = watcher { + if !self.watcher_referenced.replace(true) { + self.loop_call("reference", vec![&watcher])?; + } return Ok(()); } use std::os::fd::AsRawFd; - let filename = format!("php://fd/{}", self.port.wake.as_raw_fd()); - let stream = function("fopen", vec![&filename, &"r"])?; - if stream.is_false() { + let mut stream = Zval::new(); + // The C shim uses the PHP streams API and owns a duplicate descriptor. + if !unsafe { php_quickjs_notification_stream(self.port.wake.as_raw_fd(), &mut stream) } { return Err(PhpException::from_message( "Cannot open QuickJS notification descriptor".into(), )); @@ -300,46 +479,85 @@ impl Engine { loop { match (&engine.port.wake).read(&mut buf) { Ok(0) | Err(_) => break, + Ok(count) if count < buf.len() => break, Ok(_) => {} } } - let waiters = engine - .waiters - .borrow() - .values() - .cloned() - .collect::>(); - for w in waiters { - w.wake()? + engine.port.acknowledge_notification(); + while let Ok(event) = engine.port.receive() { + match event { + Event::Done { .. } + | Event::Messages + | Event::AsyncError(_) + | Event::Fatal(_) => engine.event(event, true)?, + Event::NeedsLoop { .. } => {} + event => engine.events.borrow_mut().push_back(event), + } + } + if !engine.events.borrow().is_empty() { + let waiter = engine + .waiters + .borrow() + .values() + .find(|w| w.sleeping.get()) + .cloned(); + if let Some(waiter) = waiter { + waiter.wake()?; + } + } + if engine.port.check().is_err() { + engine.wake_all()?; } } Ok(()) }) as Box PhpResult<()>>) .into_zval(false)?; let callback = call_php("Closure", "fromCallable", vec![&native])?; - let watcher = call_php("Revolt\\EventLoop", "onReadable", vec![&stream, &callback])?; + let watcher = self.loop_call("onReadable", vec![&stream, &callback])?; *self.watcher.borrow_mut() = Some((watcher, stream)); + self.watcher_referenced.set(true); Ok(()) } + fn pause_watcher(&self) { + if self.watcher_referenced.replace(false) { + let watcher = self + .watcher + .borrow() + .as_ref() + .map(|(id, _)| id.shallow_clone()); + if let Some(watcher) = watcher { + let _ = self.loop_call("unreference", vec![&watcher]); + } + } + } fn remove_watcher(&self) { + self.watcher_referenced.set(false); let watcher = self.watcher.borrow_mut().take(); if let Some((watcher, stream)) = watcher { - let _ = call_php("Revolt\\EventLoop", "cancel", vec![&watcher]); + let _ = self.loop_call("cancel", vec![&watcher]); let _ = function("fclose", vec![&stream]); } } } impl Drop for Engine { fn drop(&mut self) { - self.remove_watcher(); + // PHP forbids calling userland after executor shutdown. Its driver and + // stream resources are already being destroyed at that point. + if ExecutorGlobals::get().active { + self.remove_watcher(); + } self.port.close(); } } -pub struct DepthGuard<'a> { - depth: &'a Cell, +pub struct CounterGuard<'a>(&'a Cell); +impl<'a> CounterGuard<'a> { + fn enter(depth: &'a Cell) -> Self { + depth.set(depth.get() + 1); + Self(depth) + } } -impl Drop for DepthGuard<'_> { +impl Drop for CounterGuard<'_> { fn drop(&mut self) { - self.depth.set(self.depth.get() - 1) + self.0.set(self.0.get() - 1); } } diff --git a/src/error.rs b/src/error.rs index f0a0a0a..0d76eea 100644 --- a/src/error.rs +++ b/src/error.rs @@ -33,6 +33,14 @@ impl HostError { pub fn php_exception_info(err: PhpError) -> HostError { match err { PhpError::ExceptionPending { class } => { + unsafe extern "C" { + fn php_quickjs_is_unwinding() -> bool; + } + // Fiber/request termination must propagate through PHP, not become + // a catchable JS exception or have its special Zend object inspected. + if unsafe { php_quickjs_is_unwinding() } { + return HostError::internal("PHP execution ended"); + } let Some(obj) = ExecutorGlobals::take_exception() else { return HostError::internal(format!( "PHP exception of class {class} is no longer pending" diff --git a/src/lib.rs b/src/lib.rs index c54731e..c9c946f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -74,15 +74,15 @@ impl QuickJS { .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, + let engine = Engine::new(worker::Config { + memory_limit: memoryLimit.unwrap_or(0).max(0) as usize, + timeout_ms: timeoutMs.unwrap_or(0).max(0) as u64, + max_stack: maxStack.unwrap_or(0).max(0) as usize, isolated, max_queued_message_bytes, - transpile_cache_max_entries, - transpile_cache_max_bytes, - )?; + cache_entries: transpile_cache_max_entries, + cache_bytes: transpile_cache_max_bytes, + })?; Ok(QuickJS { engine }) } @@ -139,6 +139,13 @@ impl QuickJS { middle_to_zval(&messages, &self.engine.state).map_err(PhpException::from_message) } + /// Enable automatic JS jobs and await the next non-empty message batch. + /// One consumer per non-isolated runtime; yields through Revolt when loaded. + pub fn awaitMessages(&self) -> PhpResult { + let messages = marshal::MiddleValue::Array(self.engine.await_messages()?); + middle_to_zval(&messages, &self.engine.state).map_err(PhpException::from_message) + } + /// Return the registration manifest as an array of `['name'=>..., 'types'=>...]`. pub fn manifest(&self) -> PhpResult { let state = &self.engine.state; diff --git a/src/marshal.rs b/src/marshal.rs index 240cdb0..b66eaf3 100644 --- a/src/marshal.rs +++ b/src/marshal.rs @@ -3,7 +3,7 @@ use crate::bridge::BridgeState; use crate::callback::JsCallback; use ext_php_rs::convert::IntoZval; -use ext_php_rs::types::{ArrayKey, ZendClassObject, ZendHashTable, Zval}; +use ext_php_rs::types::{ArrayKey, ZendClassObject, ZendHashTable, ZendStr, Zval}; use rquickjs::{Array, Ctx, Function, Object, TypedArray, Value}; /// The neutral value that bridges JS and PHP. #[derive(Debug, Clone)] @@ -222,7 +222,7 @@ pub fn middle_to_js<'js>(ctx: &Ctx<'js>, value: &MiddleValue) -> rquickjs::Resul } MiddleValue::Float(f) => Value::new_float(ctx.clone(), *f), MiddleValue::Str(s) => rquickjs::String::from_str(ctx.clone(), s)?.into_value(), - MiddleValue::Bytes(b) => TypedArray::new(ctx.clone(), b.clone())?.into_value(), + MiddleValue::Bytes(b) => TypedArray::new_copy(ctx.clone(), b)?.into_value(), MiddleValue::Array(items) => { let arr = Array::new(ctx.clone())?; for (i, it) in items.iter().enumerate() { @@ -363,6 +363,28 @@ impl Drop for PhpConversion<'_> { } } +/// Transfer owned buffers into QuickJS instead of cloning their backing Vec. +pub fn middle_into_js<'js>(ctx: &Ctx<'js>, value: MiddleValue) -> rquickjs::Result> { + match value { + MiddleValue::Bytes(bytes) => Ok(TypedArray::new(ctx.clone(), bytes)?.into_value()), + MiddleValue::Array(items) => { + let array = Array::new(ctx.clone())?; + for (index, item) in items.into_iter().enumerate() { + array.set(index, middle_into_js(ctx, item)?)?; + } + Ok(array.into_value()) + } + MiddleValue::Map(entries) => { + let object = Object::new(ctx.clone())?; + for (key, value) in entries { + object.set(key, middle_into_js(ctx, value)?)?; + } + Ok(object.into_value()) + } + value => middle_to_js(ctx, &value), + } +} + /// Convert the neutral representation into a PHP value. pub fn middle_to_zval(value: &MiddleValue, state: &BridgeState) -> Result { let mut zv = Zval::new(); @@ -374,7 +396,7 @@ pub fn middle_to_zval(value: &MiddleValue, state: &BridgeState) -> Result zv .set_string(s, false) .map_err(|e| format!("string conversion failed: {e}"))?, - MiddleValue::Bytes(b) => zv.set_binary(b.clone()), + MiddleValue::Bytes(b) => zv.set_zend_string(ZendStr::new(b, false)), MiddleValue::Array(items) => { let mut ht = ZendHashTable::new(); for it in items { diff --git a/src/notification.c b/src/notification.c new file mode 100644 index 0000000..524f572 --- /dev/null +++ b/src/notification.c @@ -0,0 +1,32 @@ +#include +#include +#include
+#include
+#include +#include + +bool php_quickjs_is_unwinding(void) +{ + return EG(exception) && (zend_is_unwind_exit(EG(exception)) || zend_is_graceful_exit(EG(exception))); +} +#ifdef __linux__ +#include +int php_quickjs_notification_fd(void) { + return eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); +} +#endif + +/* PHP owns a duplicate, so closing its stream cannot close the Rust socket. + * Using the streams API also supports FPM/CGI, unlike php://fd. */ +bool php_quickjs_notification_stream(int fd, zval *result) +{ + int owned = fcntl(fd, F_DUPFD_CLOEXEC, 0); + if (owned < 0) return false; + php_stream *stream = php_stream_fopen_from_fd(owned, "r", NULL); + if (stream == NULL) { + close(owned); + return false; + } + php_stream_to_zval(stream, result); + return true; +} diff --git a/src/worker.rs b/src/worker.rs index 7836857..8933942 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -1,8 +1,9 @@ -//! Runtime ownership and RPC. This module never calls Zend or retains PHP values. +//! Runtime ownership and RPC. Driver and Session retain only owned wire values; +//! PHP exception conversion is called by the PHP-thread facade. use crate::{ bridge::{build_facade, MessageQueue, RUNTIME_JS}, error::{self, HostError, JsErrorParts}, - marshal::{js_to_data, js_to_middle, middle_to_js, MiddleValue as Data}, + marshal::{js_to_data, js_to_middle, middle_into_js, MiddleValue as Data}, transpile::{TranspileCache, Transpiled}, }; use ext_php_rs::prelude::PhpException; @@ -10,17 +11,44 @@ use rquickjs::{Context, Ctx, Exception, Function, Promise, Runtime, Value}; use std::{ cell::{Cell, RefCell}, collections::{HashMap, HashSet, VecDeque}, + fs::File, io::Write, - os::unix::net::UnixStream, rc::Rc, sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, mpsc, Arc, Mutex, }, thread::{self, JoinHandle}, time::{Duration, Instant}, }; +#[cfg(target_os = "linux")] +fn notification_pair() -> std::io::Result<(File, File)> { + use std::os::fd::FromRawFd; + unsafe extern "C" { + fn php_quickjs_notification_fd() -> i32; + } + let fd = unsafe { php_quickjs_notification_fd() }; + if fd < 0 { + return Err(std::io::Error::last_os_error()); + } + // The PHP thread owns the reader until after joining the writer's thread. + let reader = unsafe { File::from_raw_fd(fd) }; + let writer = reader.try_clone()?; + Ok((reader, writer)) +} +#[cfg(not(target_os = "linux"))] +fn notification_pair() -> std::io::Result<(File, File)> { + use std::os::{fd::OwnedFd, unix::net::UnixStream}; + let (reader, writer) = UnixStream::pair()?; + reader.set_nonblocking(true)?; + writer.set_nonblocking(true)?; + Ok(( + File::from(OwnedFd::from(reader)), + File::from(OwnedFd::from(writer)), + )) +} + pub struct Config { pub memory_limit: usize, pub timeout_ms: u64, @@ -30,9 +58,28 @@ pub struct Config { pub cache_entries: usize, pub cache_bytes: usize, } +// Each direction writes different counters. Keep them off one cache line. +#[derive(Default)] +#[repr(align(128))] +struct Signal(T); +impl std::ops::Deref for Signal { + type Target = T; + fn deref(&self) -> &T { + &self.0 + } +} +#[derive(Default)] +struct Signals { + stop: Signal, + notified: Signal, + pump: Signal, + posts: Signal, +} pub enum Operation { Eval { code: String, typescript: bool }, Callback { id: u64, args: Vec }, + Post { id: u64, args: Vec }, + Capacity, Jobs(i64), HasJobs, Roundtrip(Data), @@ -40,7 +87,7 @@ pub enum Operation { pub struct Request { pub id: u64, pub op: Operation, - pub names: Vec, + pub names: Option>, pub deletions: Vec, pub parent: Option, } @@ -49,6 +96,7 @@ pub enum HostTarget { Function(u64), } pub enum Command { + Pump, Request(Request), HostReply { token: u64, @@ -61,6 +109,9 @@ pub enum Command { Stop, } pub enum Event { + Messages, + AsyncError(WireError), + Fatal(WireError), Done { id: u64, result: Result, @@ -152,9 +203,9 @@ pub struct Port { pub struct PortInner { tx: mpsc::SyncSender, rx: mpsc::Receiver, - pub wake: UnixStream, + pub wake: File, worker: RefCell>>, - stop: Arc, + signals: Arc, messages: Arc>, } impl std::ops::Deref for Port { @@ -165,19 +216,14 @@ impl std::ops::Deref for Port { } impl Port { pub fn new(config: Config) -> Result { - let (wake, notify) = UnixStream::pair().map_err(|e| WireError::Internal(e.to_string()))?; - wake.set_nonblocking(true) - .map_err(|e| WireError::Internal(e.to_string()))?; - notify - .set_nonblocking(true) - .map_err(|e| WireError::Internal(e.to_string()))?; + let (wake, notify) = notification_pair().map_err(|e| WireError::Internal(e.to_string()))?; let (tx, commands) = mpsc::sync_channel(256); let (events, rx) = mpsc::channel(); - let stop = Arc::new(AtomicBool::new(false)); + let signals = Arc::new(Signals::default()); + let worker_signals = signals.clone(); let messages = Arc::new(Mutex::new(MessageQueue::new( config.max_queued_message_bytes, ))); - let closed = stop.clone(); let queue = messages.clone(); let (ready_tx, ready) = mpsc::sync_channel(1); let worker = thread::Builder::new() @@ -193,15 +239,16 @@ impl Port { commands: RefCell::new(commands), events, notify, - stop: closed.clone(), + signals: worker_signals, messages: queue, cache: TranspileCache::new(config.cache_entries, config.cache_bytes), config, token: Cell::new(0), + notification_pending: Cell::new(false), }); let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| driver.run(ready_tx))); - closed.store(true, Ordering::SeqCst); + driver.signals.stop.store(true, Ordering::SeqCst); if !matches!(outcome, Ok(Ok(()))) { driver.emit(Event::Stopped( "QuickJS worker terminated unexpectedly".into(), @@ -216,7 +263,7 @@ impl Port { )) }); if let Err(error) = initialized { - stop.store(true, Ordering::SeqCst); + signals.stop.store(true, Ordering::SeqCst); let _ = tx.try_send(Command::Stop); let _ = worker.join(); return Err(error); @@ -227,7 +274,7 @@ impl Port { rx, wake, worker: RefCell::new(Some(worker)), - stop, + signals, messages, }), pid: std::process::id(), @@ -243,7 +290,7 @@ impl Port { } pub fn check(&self) -> Result<(), WireError> { self.check_pid()?; - if self.stop.load(Ordering::SeqCst) { + if self.signals.stop.load(Ordering::SeqCst) { return Err(WireError::Internal("QuickJS worker is closed".into())); } Ok(()) @@ -257,6 +304,31 @@ impl Port { pub fn receive(&self) -> Result { self.rx.try_recv() } + pub fn acknowledge_notification(&self) { + // Clear before draining events: a concurrent enqueue either appears in + // this drain or writes another byte. Neither ordering loses a wakeup. + self.signals.notified.store(false, Ordering::SeqCst); + } + pub fn start_pump(&self) -> Result<(), WireError> { + self.check()?; + if !self.signals.pump.load(Ordering::SeqCst) + && !self.signals.pump.swap(true, Ordering::SeqCst) + { + self.send(Command::Pump)?; + } + Ok(()) + } + pub fn reserve_post(&self) -> bool { + self.signals + .posts + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| { + (n < 128).then_some(n + 1) + }) + .is_ok() + } + pub fn release_post(&self) { + self.signals.posts.fetch_sub(1, Ordering::Relaxed); + } pub fn receive_blocking(&self) -> Result { self.rx .recv() @@ -274,7 +346,7 @@ impl Port { if self.pid != std::process::id() { return; } - self.stop.store(true, Ordering::SeqCst); + self.signals.stop.store(true, Ordering::SeqCst); let _ = self.tx.try_send(Command::Stop); if let Some(worker) = self.worker.borrow_mut().take() { let _ = worker.join(); @@ -297,27 +369,46 @@ impl Drop for Port { struct Driver { commands: RefCell>, events: mpsc::Sender, - notify: UnixStream, - stop: Arc, + notify: File, + signals: Arc, messages: Arc>, cache: TranspileCache, config: Config, token: Cell, + notification_pending: Cell, } impl Driver { fn notify(&self) { - let _ = (&self.notify).write(&[1]); + if !self.signals.notified.swap(true, Ordering::SeqCst) { + let _ = (&self.notify).write(&1u64.to_ne_bytes()); + } } fn emit(&self, event: Event) { + if matches!(&event, Event::Fatal(_)) && self.signals.stop.swap(true, Ordering::SeqCst) { + return; + } + let urgent = !self.signals.pump.load(Ordering::SeqCst) + || matches!( + &event, + Event::Host { .. } | Event::Stopped(_) | Event::AsyncError(_) | Event::Fatal(_) + ); let _ = self.events.send(event); - self.notify(); + self.notification_pending.set(true); + if urgent { + self.flush_notifications(); + } + } + fn flush_notifications(&self) { + if self.notification_pending.replace(false) { + self.notify(); + } } fn run(self: &Rc, ready: mpsc::SyncSender>) -> Result<(), String> { if self.config.isolated { let _ = ready.send(Ok(())); loop { let command = self.commands.borrow().recv().map_err(|e| e.to_string())?; - if self.stop.load(Ordering::SeqCst) { + if self.signals.stop.load(Ordering::SeqCst) { return Ok(()); } match command { @@ -354,11 +445,11 @@ impl Driver { crate::sandbox::apply_limits(&rt, self.config.memory_limit, self.config.max_stack); let deadline = Rc::new(Cell::new(None::)); let timed_out = Rc::new(Cell::new(false)); - let interrupted = self.stop.clone(); + let interrupted = self.signals.clone(); let dl = deadline.clone(); let expired = timed_out.clone(); rt.set_interrupt_handler(Some(Box::new(move || { - if interrupted.load(Ordering::SeqCst) { + if interrupted.stop.load(Ordering::SeqCst) { return true; } if dl.get().is_some_and(|d| Instant::now() >= d) { @@ -373,6 +464,8 @@ impl Driver { let session = Rc::new(Session { driver: self.clone(), pending: RefCell::new(Vec::new()), + posted: RefCell::new(HashSet::new()), + capacity: RefCell::new(VecDeque::new()), replies: RefCell::new(HashMap::new()), host_waits: RefCell::new(HashSet::new()), entries: RefCell::new(Vec::new()), @@ -383,6 +476,7 @@ impl Driver { root_finished: Cell::new(false), facade: RefCell::new(None), js_depth: Cell::new(0), + reported_failure: Cell::new(false), }); let _cleanup = SessionCleanup(session.clone()); install(&ctx, session.clone()).map_err(|e| error::js_error_message(&ctx, e))?; @@ -392,8 +486,43 @@ impl Driver { if let Some(first) = first { session.request(&ctx, first); } - while !self.stop.load(Ordering::SeqCst) && !session.root_finished.get() { - session.advance(&ctx); + 'running: while !self.signals.stop.load(Ordering::SeqCst) + && !session.root_finished.get() + { + let turn = (self.config.timeout_ms > 0 + && self.signals.pump.load(Ordering::SeqCst) + && jobs_pending(&ctx)) + .then(|| { + let deadline = session.deadline.get().or_else(|| { + (self.config.timeout_ms > 0) + .then(|| Instant::now() + Duration::from_millis(self.config.timeout_ms)) + }); + session.enter(deadline, false) + }); + // Drain runnable jobs, but admit PHP callbacks every 64 jobs so + // an unbounded microtask chain cannot starve external timers. + let mut jobs = 0; + loop { + session.advance(&ctx); + jobs += 1; + if jobs % 64 == 0 { + self.flush_notifications(); + let command = self.commands.borrow().try_recv().ok(); + if let Some(command) = command { + if !session.command(&ctx, command) { + break 'running; + } + } + } + if self.signals.stop.load(Ordering::SeqCst) + || !session.has_awaitable() + || !jobs_pending(&ctx) + { + break; + } + } + drop(turn); + self.flush_notifications(); if session.root_finished.get() { break; } @@ -424,7 +553,11 @@ impl Driver { self.emit(Event::Done { id: item.id, result: Err(WireError::Internal( - "JS callback canceled: owning isolated eval ended".into(), + if self.signals.stop.load(Ordering::SeqCst) { + "QuickJS worker is closed".into() + } else { + "JS callback canceled: owning isolated eval ended".into() + }, )), }); } @@ -450,6 +583,8 @@ struct Pending<'js> { struct Session<'js> { driver: Rc, pending: RefCell>>, + posted: RefCell>, + capacity: RefCell)>>, replies: RefCell>>, host_waits: RefCell>, entries: RefCell>>, @@ -458,12 +593,29 @@ struct Session<'js> { failed: RefCell>, root: Option, root_finished: Cell, - facade: RefCell, rquickjs::Object<'js>)>>, + facade: RefCell>>, js_depth: Cell, + reported_failure: Cell, } // Native closures retain Session. Break its JS roots before the context is freed, // including panic unwinding; otherwise a cached facade could retain the runtime. struct SessionCleanup<'js>(Rc>); +struct JsEntry<'a, 'js> { + session: &'a Session<'js>, + depth: bool, + budget: bool, +} +impl Drop for JsEntry<'_, '_> { + fn drop(&mut self) { + if self.depth { + self.session.js_depth.set(self.session.js_depth.get() - 1); + } + if self.budget { + self.session.entries.borrow_mut().pop(); + self.session.update_deadline(); + } + } +} impl Drop for SessionCleanup<'_> { fn drop(&mut self) { self.0.pending.borrow_mut().clear(); @@ -474,7 +626,43 @@ fn jobs_pending(ctx: &Ctx<'_>) -> bool { unsafe { rquickjs::qjs::JS_IsJobPending(rquickjs::qjs::JS_GetRuntime(ctx.as_raw().as_ptr())) } } impl<'js> Session<'js> { + fn enter(&self, deadline: Option, depth: bool) -> JsEntry<'_, 'js> { + let budget = self.driver.config.timeout_ms > 0; + if budget { + self.entries.borrow_mut().push(deadline); + self.update_deadline(); + } + if depth { + self.js_depth.set(self.js_depth.get() + 1); + } + JsEntry { + session: self, + depth, + budget, + } + } + fn finish_value( + &self, + id: u64, + ctx: &Ctx<'js>, + result: rquickjs::Result>, + context: &ErrorContext, + ) { + let result = result + .and_then(|value| { + if self.posted.borrow().contains(&id) { + Ok(Data::Null) + } else { + js_to_middle(ctx, value) + } + }) + .map_err(|e| self.error(ctx, e, context)); + self.finish(id, result); + } fn update_deadline(&self) { + if self.driver.config.timeout_ms == 0 { + return; + } let entries = self.entries.borrow(); // An independent pending Promise must not shorten a new synchronous entry. // Awaited jobs inherit their own active budget through the entry stack. @@ -520,22 +708,49 @@ impl<'js> Session<'js> { true } }); + self.capacity.borrow_mut().retain(|(child, parent)| { + if *parent == Some(id) { + canceled.push(*child); + false + } else { + true + } + }); for id in canceled { - self.driver.emit(Event::Done { + self.finish( id, - result: Err(WireError::Internal( + Err(WireError::Internal( "JS callback canceled: owning call ended".into(), )), - }); + ); + } + } + let posted = self.posted.borrow_mut().remove(&id); + if posted { + self.driver.signals.posts.fetch_sub(1, Ordering::Relaxed); + if let Err(error) = result { + self.driver.emit(Event::Fatal(error)); } + let available = + 128usize.saturating_sub(self.driver.signals.posts.load(Ordering::Relaxed)); + let ready = { + let mut capacity = self.capacity.borrow_mut(); + let n = available.min(capacity.len()); + capacity.drain(..n).map(|(id, _)| id).collect::>() + }; + for id in ready { + self.finish(id, Ok(Data::Null)); + } + } else { + self.driver.emit(Event::Done { id, result }); } - self.driver.emit(Event::Done { id, result }); if self.root == Some(id) { self.root_finished.set(true) } } fn command(&self, ctx: &Ctx<'js>, command: Command) -> bool { match command { + Command::Pump => {} Command::Stop => return false, Command::Request(request) => self.request(ctx, request), Command::HostReply { token, result } => { @@ -559,6 +774,9 @@ impl<'js> Session<'js> { deletions, parent, } = request; + if matches!(&op, Operation::Post { .. }) { + self.posted.borrow_mut().insert(id); + } if parent.is_some_and(|p| self.failed.borrow().contains(&p)) { self.finish( id, @@ -568,20 +786,31 @@ impl<'js> Session<'js> { ); return; } - let inherited = self - .entries - .borrow() - .iter() - .copied() - .flatten() - .chain( - self.pending - .borrow() - .iter() - .filter(|p| Some(p.id) == parent) - .filter_map(|p| p.deadline), - ) - .min(); + if matches!(&op, Operation::Capacity) { + if self.driver.signals.posts.load(Ordering::Relaxed) < 128 { + self.finish(id, Ok(Data::Null)); + } else { + self.capacity.borrow_mut().push_back((id, parent)); + } + return; + } + let inherited = if self.driver.config.timeout_ms == 0 { + None + } else { + self.entries + .borrow() + .iter() + .copied() + .flatten() + .chain( + self.pending + .borrow() + .iter() + .filter(|p| Some(p.id) == parent) + .filter_map(|p| p.deadline), + ) + .min() + }; let deadline = inherited.or_else(|| { (self.driver.config.timeout_ms > 0) .then(|| Instant::now() + Duration::from_millis(self.driver.config.timeout_ms)) @@ -590,7 +819,7 @@ impl<'js> Session<'js> { self.finish(id, Err(WireError::Timeout)); return; } - if self.entries.borrow().len() >= crate::engine::MAX_DEPTH { + if self.js_depth.get() >= crate::engine::MAX_DEPTH { self.finish( id, Err(WireError::Internal( @@ -599,72 +828,60 @@ impl<'js> Session<'js> { ); return; } - self.entries.borrow_mut().push(deadline); - self.update_deadline(); + let _entry = self.enter(deadline, true); self.timed_out.set(false); - let await_result = matches!(&op, Operation::Eval { .. } | Operation::Callback { .. }); + let await_result = matches!( + &op, + Operation::Eval { .. } | Operation::Callback { .. } | Operation::Post { .. } + ); let context = ErrorContext { eval: matches!(&op, Operation::Eval { .. }), module: None, }; - self.js_depth.set(self.js_depth.get() + 1); - let result = self.start(ctx, op, &names, &deletions, context); - match result { - Ok((value, context)) => { - if self.timed_out.get() || deadline.is_some_and(|d| Instant::now() >= d) { - self.timed_out.set(false); - self.finish(id, Err(WireError::Timeout)); - } else if !await_result { - self.finish( - id, - js_to_middle(ctx, value).map_err(|e| self.error(ctx, e, &context)), - ); - } else { - let promise_result = (|| { - if let Some(promise) = value.as_promise() { - return Ok(Some(promise.clone())); - } + let (value, context) = match self.start(ctx, op, names, &deletions, context) { + Ok(result) => result, + Err(error) => return self.finish(id, Err(error)), + }; + if self.timed_out.replace(false) || deadline.is_some_and(|d| Instant::now() >= d) { + return self.finish(id, Err(WireError::Timeout)); + } + let result = (|| { + if await_result { + let promise = match value.as_promise() { + Some(promise) => Some(promise.clone()), + None if value.is_object() => { let normalize: Function = ctx.globals().get("__asPromise")?; - normalize.call((value.clone(),)) - })(); - match promise_result { - Err(e) => self.finish(id, Err(self.error(ctx, e, &context))), - Ok(Some(promise)) => { - if let Some(result) = promise.result::() { - let result = result - .and_then(|v| js_to_middle(ctx, v)) - .map_err(|e| self.error(ctx, e, &context)); - self.finish(id, result) - } else { - self.pending.borrow_mut().push(Pending { - id, - parent, - promise, - context, - deadline, - asked: false, - settle_depth: self.js_depth.get() - 1, - }) - } - } - Ok(None) => self.finish( - id, - js_to_middle(ctx, value).map_err(|e| self.error(ctx, e, &context)), - ), + normalize.call((value.clone(),))? + } + None => None, + }; + if let Some(promise) = promise { + if let Some(result) = promise.result::() { + return result.map(Some); } + self.pending.borrow_mut().push(Pending { + id, + parent, + promise, + context: context.clone(), + deadline, + asked: false, + settle_depth: self.js_depth.get() - 1, + }); + return Ok(None); } } - Err(e) => self.finish(id, Err(e)), + Ok(Some(value)) + })(); + if let Some(result) = result.transpose() { + self.finish_value(id, ctx, result, &context); } - self.js_depth.set(self.js_depth.get() - 1); - self.entries.borrow_mut().pop(); - self.update_deadline(); } fn start( &self, ctx: &Ctx<'js>, op: Operation, - names: &[String], + names: Option>, deletions: &[u64], mut context: ErrorContext, ) -> Result<(Value<'js>, ErrorContext), WireError> { @@ -675,18 +892,22 @@ impl<'js> Session<'js> { delete.call::<_, ()>((*id as f64,))?; } } + if names.is_none() && !matches!(&op, Operation::Eval { .. }) { + return Ok(()); + } let cached = self .facade .borrow() .as_ref() - .filter(|(current, _)| current == names) - .map(|(_, object)| object.clone()); + .filter(|_| names.is_none()) + .cloned(); if let Some(object) = cached { ctx.globals().set("php", object)?; } else { - ctx.eval::<(), _>(build_facade(names))?; + let names = names.unwrap_or_default(); + ctx.eval::<(), _>(build_facade(&names))?; let object = ctx.globals().get("php")?; - *self.facade.borrow_mut() = Some((names.to_vec(), object)); + *self.facade.borrow_mut() = Some(object); } Ok(()) })(); @@ -715,25 +936,17 @@ impl<'js> Session<'js> { ctx.eval_with_options(module.js.as_bytes(), options) .map_err(|e| self.error(ctx, e, &context))? } - Operation::Callback { id, args } => { - let get: Function = ctx - .globals() - .get("__getJsFn") - .map_err(|e| self.error(ctx, e, &context))?; - let function: Function = get - .call((id as f64,)) - .map_err(|e| self.error(ctx, e, &context))?; - 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).map_err(|e| self.error(ctx, e, &context))?, - ) - .map_err(|e| self.error(ctx, e, &context))?; - } - function - .call_arg(call_args) - .map_err(|e| self.error(ctx, e, &context))? + Operation::Callback { id, args } | Operation::Post { id, args } => { + let result = (|| { + let get: Function = ctx.globals().get("__getJsFn")?; + let function: Function = get.call((id as f64,))?; + let mut call_args = rquickjs::function::Args::new(ctx.clone(), args.len()); + for arg in args { + call_args.push_arg(middle_into_js(ctx, arg)?)?; + } + function.call_arg(call_args) + })(); + result.map_err(|e| self.error(ctx, e, &context))? } Operation::Jobs(max) => { let mut n = 0; @@ -750,26 +963,23 @@ impl<'js> Session<'js> { Value::new_float(ctx.clone(), n as f64) } Operation::HasJobs => Value::new_bool(ctx.clone(), jobs_pending(ctx)), + Operation::Capacity => unreachable!("capacity requests do not enter JS"), Operation::Roundtrip(value) => { - middle_to_js(ctx, &value).map_err(|e| self.error(ctx, e, &context))? + middle_into_js(ctx, value).map_err(|e| self.error(ctx, e, &context))? } }; Ok((value, context)) } fn job(&self, ctx: &Ctx<'js>, context: &ErrorContext) -> Result { let mut job_ctx = std::ptr::null_mut(); - self.entries.borrow_mut().push(self.deadline.get()); - self.update_deadline(); - self.js_depth.set(self.js_depth.get() + 1); + let entry = self.enter(self.deadline.get(), true); let result = unsafe { rquickjs::qjs::JS_ExecutePendingJob( rquickjs::qjs::JS_GetRuntime(ctx.as_raw().as_ptr()), &mut job_ctx, ) }; - self.js_depth.set(self.js_depth.get() - 1); - self.entries.borrow_mut().pop(); - self.update_deadline(); + drop(entry); if result < 0 { return Err(self.error(ctx, rquickjs::Error::Exception, context)); } @@ -790,24 +1000,26 @@ impl<'js> Session<'js> { }; let Some(index) = item else { break }; let item = self.pending.borrow_mut().remove(index); - let result = if item.deadline.is_some_and(|d| Instant::now() >= d) { - Err(WireError::Timeout) + if item.deadline.is_some_and(|d| Instant::now() >= d) { + self.finish(item.id, Err(WireError::Timeout)); } else { - item.promise - .result::() - .unwrap() - .and_then(|v| js_to_middle(ctx, v)) - .map_err(|e| self.error(ctx, e, &item.context)) - }; - self.finish(item.id, result); + self.finish_value( + item.id, + ctx, + item.promise.result::().unwrap(), + &item.context, + ); + } self.update_deadline(); } } fn has_awaitable(&self) -> bool { - self.pending - .borrow() - .iter() - .any(|p| p.settle_depth >= self.js_depth.get()) + self.driver.signals.pump.load(Ordering::SeqCst) + || self + .pending + .borrow() + .iter() + .any(|p| p.settle_depth >= self.js_depth.get()) } fn advance(&self, ctx: &Ctx<'js>) { self.settle(ctx); @@ -817,36 +1029,30 @@ impl<'js> Session<'js> { .iter() .find(|p| p.settle_depth >= self.js_depth.get()) .map(|p| (p.id, p.context.clone())); - let Some((id, context)) = target else { - return; + let (id, context) = match target { + Some((id, context)) => (Some(id), context), + None if self.driver.signals.pump.load(Ordering::SeqCst) => { + (None, ErrorContext::default()) + } + None => return, }; if let Err(e) = self.job(ctx, &context) { - let index = self.pending.borrow().iter().position(|p| p.id == id); + let index = self.pending.borrow().iter().position(|p| Some(p.id) == id); if let Some(index) = index { self.pending.borrow_mut().remove(index); - self.finish(id, Err(e)); + self.finish(id.unwrap(), Err(e)); self.update_deadline(); + } else { + self.driver.signals.pump.store(false, Ordering::SeqCst); + self.driver.emit(Event::AsyncError(e)); } } self.settle(ctx); } fn ask_loop(&self) { - let ids = { - let mut pending = self.pending.borrow_mut(); - pending - .iter_mut() - .filter_map(|p| { - if !p.asked { - p.asked = true; - Some(p.id) - } else { - None - } - }) - .collect::>() - }; - for id in ids { - self.driver.emit(Event::NeedsLoop { id }) + for pending in self.pending.borrow_mut().iter_mut().filter(|p| !p.asked) { + pending.asked = true; + self.driver.emit(Event::NeedsLoop { id: pending.id }); } } fn host( @@ -871,7 +1077,7 @@ impl<'js> Session<'js> { args, }); let result = (|| loop { - if self.driver.stop.load(Ordering::SeqCst) { + if self.driver.signals.stop.load(Ordering::SeqCst) { return Err(Exception::throw_internal(ctx, "QuickJS worker closed")); } if self.deadline.get().is_some_and(|d| Instant::now() >= d) { @@ -884,11 +1090,12 @@ impl<'js> Session<'js> { let reply = self.replies.borrow_mut().remove(&token); if let Some(reply) = reply { return match reply { - Ok(value) => middle_to_js(ctx, &value), + Ok(value) => middle_into_js(ctx, value), Err(error) => Err(error::throw_host_error(ctx, &error)), }; } self.advance(ctx); + self.driver.flush_notifications(); let command = if jobs_pending(ctx) && self.has_awaitable() { self.driver.commands.borrow().try_recv().ok() } else { @@ -931,20 +1138,49 @@ fn install<'js>(ctx: &Ctx<'js>, session: Rc>) -> rquickjs::Result<( }, )?; ctx.globals().set("__php_invoke", invoke)?; - let queue = session.driver.messages.clone(); + let message_driver = session.driver.clone(); let post = Function::new( ctx.clone(), move |ctx: Ctx<'js>, value: Value<'js>| -> rquickjs::Result<()> { + let queue = &message_driver.messages; let capacity = queue.lock().unwrap_or_else(|e| e.into_inner()).remaining(); let (data, bytes) = js_to_data(&ctx, value, capacity)?; + let mut queue = queue.lock().unwrap_or_else(|e| e.into_inner()); + let empty = queue.is_empty(); queue - .lock() - .unwrap_or_else(|e| e.into_inner()) .push(data, bytes) - .map_err(|e| Exception::throw_type(&ctx, e)) + .map_err(|e| Exception::throw_type(&ctx, e))?; + drop(queue); + if empty && message_driver.signals.pump.load(Ordering::SeqCst) { + message_driver.emit(Event::Messages); + } + Ok(()) }, )?; let quickjs = rquickjs::Object::new(ctx.clone())?; + let failure_session = session.clone(); + quickjs.set( + "reportError", + Function::new(ctx.clone(), move |ctx: Ctx<'js>, value: Value<'js>| { + if !failure_session.reported_failure.replace(true) { + let thrown = ctx.throw(value); + let parts = error::js_error_parts(&ctx, thrown); + failure_session.driver.emit(Event::Fatal(WireError::Guest { + parts, + module: None, + map: None, + eval: false, + })); + } + })?, + )?; + let clock_origin = Instant::now(); + quickjs.set( + "now", + Function::new(ctx.clone(), move || { + clock_origin.elapsed().as_secs_f64() * 1000.0 + })?, + )?; quickjs.set("postMessage", post)?; ctx.globals().set("quickjs", quickjs)?; ctx.eval::<(),_>("Object.freeze(globalThis.quickjs); Object.defineProperty(globalThis,'quickjs',{value:quickjs,writable:false,configurable:false})")?; diff --git a/stubs/php_quickjs.stubs.php b/stubs/php_quickjs.stubs.php index 9bfb87f..7dc5aaa 100644 --- a/stubs/php_quickjs.stubs.php +++ b/stubs/php_quickjs.stubs.php @@ -61,6 +61,8 @@ public function executePendingJobs(int $maxJobs = 100): int {} * @return list */ public function drainMessages(): mixed {} + /** Await a message batch and enable automatic jobs; one consumer, non-isolated runtime. */ + public function awaitMessages(): mixed {} /** Generate TypeScript `.d.ts` declarations for the `php` and `quickjs` globals. */ public function dts(): string {} @@ -94,6 +96,8 @@ public function __invoke(mixed ...$args): mixed {} /** Invoke the JS function, awaiting a returned Promise. */ public function call(mixed ...$args): mixed {} + /** Queue a bounded call; discard its value, deliver failures to awaitMessages(). */ + public function post(mixed ...$args): void {} } } diff --git a/tests/cgi/notification.php b/tests/cgi/notification.php new file mode 100644 index 0000000..4edbcf1 --- /dev/null +++ b/tests/cgi/notification.php @@ -0,0 +1,21 @@ +register('echo', static fn($value) => $value); +for ($i = 0; $i < 10; ++$i) { + if ($js->eval('php.echo(42)', false) !== 42) throw new RuntimeException('CGI result mismatch'); +} +$batch = Amp\async(static fn() => $js->awaitMessages()); +Amp\delay(.001); +$js->eval('Promise.resolve().then(() => quickjs.postMessage(42)); null', false); +if ($batch->await() !== [42]) throw new RuntimeException('CGI message pump mismatch'); +$js->close(); +// Request shutdown force-closes suspended Fibers. Its PHP exception must escape +// the native wait loop instead of retrying suspend forever. +$unfinished = new QuickJS(); +Amp\async(static fn() => $unfinished->awaitMessages())->ignore(); +$hostWait = new QuickJS(); +$hostWait->register('wait', static fn() => Amp\delay(60)); +Amp\async(static fn() => $hostWait->eval('php.wait()', false))->ignore(); +Amp\delay(.001); +echo "CGI notifications PASS\n"; diff --git a/tests/cgi/run.php b/tests/cgi/run.php new file mode 100644 index 0000000..62bf090 --- /dev/null +++ b/tests/cgi/run.php @@ -0,0 +1,18 @@ + ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, + [...getenv(), 'REDIRECT_STATUS' => '1', 'REQUEST_METHOD' => 'GET', 'SCRIPT_FILENAME' => __DIR__ . '/notification.php']); +if (!is_resource($process)) throw new RuntimeException('Cannot start php-cgi'); +fclose($pipes[0]); +$output = stream_get_contents($pipes[1]); +$errors = stream_get_contents($pipes[2]); +fclose($pipes[1]); fclose($pipes[2]); +$status = proc_close($process); +if ($status !== 0 || !str_contains($output, "CGI notifications PASS\n") || str_contains($output, 'Fatal error')) { + throw new RuntimeException("CGI failed ($status): $output $errors"); +} +echo "CGI notifications PASS\n"; diff --git a/tests/php/14_async.php b/tests/php/14_async.php index 1a15198..e61c1cb 100644 --- a/tests/php/14_async.php +++ b/tests/php/14_async.php @@ -12,7 +12,7 @@ }); 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'); +eq([], array_values(array_filter(Revolt\EventLoop::getIdentifiers(), Revolt\EventLoop::isReferenced(...))), 'completion leaves no referenced watcher'); $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'); @@ -82,7 +82,7 @@ function spin() { if (stopped) resolve(1); else Promise.resolve().then(spin); } 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'); +eq([], array_values(array_filter(Revolt\EventLoop::getIdentifiers(), Revolt\EventLoop::isReferenced(...))), 'timeout leaves no referenced watcher'); 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'); diff --git a/tests/php/21_worker.php b/tests/php/21_worker.php index 6b97df7..bd0d095 100644 --- a/tests/php/21_worker.php +++ b/tests/php/21_worker.php @@ -109,4 +109,73 @@ public function __destruct() { $this->runtime->grant('replacement'); } eq(42, $parent->eval('42', false), 'parent worker survives child exit'); $parent->close(); } +$messages = new QuickJS(); +$clock = $messages->eval('[quickjs.now(), quickjs.now()]', false); +ok($clock[0] >= 0 && $clock[1] >= $clock[0], 'native clock is monotonic'); +$batch = Amp\async(static fn() => $messages->awaitMessages()); +Amp\delay(.001); +throws(fn() => $messages->awaitMessages(), Throwable::class, 'message pump rejects multiple consumers'); +$messages->eval('Promise.resolve().then(() => quickjs.postMessage(42)); null', false); +eq([42], $batch->await(), 'message pump drives ready jobs without a JS drain callback'); +$batch = Amp\async(static fn() => $messages->awaitMessages()); +Amp\delay(.001); +$messages->close(); +throws(fn() => $batch->await(), Throwable::class, 'close resumes a message consumer'); + +$timedMessages = new QuickJS(timeoutMs: 20); +$batch = Amp\async(static fn() => $timedMessages->awaitMessages()); +Amp\delay(.001); +$timedMessages->eval('Promise.resolve().then(() => { while(true) {} }); null', false); +throws(fn() => $batch->await(), QuickJSTimeoutException::class, 'automatic job turns retain the configured execution budget'); +eq(42, $timedMessages->eval('42', false), 'runtime recovers after an automatic job timeout'); +$timedMessages->close(); + +$hostJobs = new QuickJS(); +$hostJobs->register('reenter', static function () use ($hostJobs) { + try { $hostJobs->eval('42', false); } + catch (Throwable $error) { return $error->getMessage(); } + return 'incorrectly accepted'; +}); +$batch = Amp\async(static fn() => $hostJobs->awaitMessages()); +Amp\delay(.001); +$hostJobs->eval('Promise.resolve().then(() => quickjs.postMessage(php.reenter())); null', false); +ok(str_contains($batch->await()[0], 'Cannot eval'), 'PHP host calls from automatic jobs retain the reentry guard'); +$hostJobs->close(); + +$posted = new QuickJS(); +$push = $posted->eval('value => { quickjs.postMessage(value); }', false); +for ($i = 0; $i < 512; ++$i) $push->post($i); +$values = []; +while (count($values) < 512) array_push($values, ...$posted->awaitMessages()); +eq(range(0, 511), $values, 'bounded posts preserve FIFO and make progress under admission pressure'); +$posted->close(); + +$postedError = new QuickJS(); +$explode = $postedError->eval('async () => { throw new Error("posted failure"); }', false); +$explode->post(); +try { $postedError->awaitMessages(); ok(false, 'posted errors must surface'); } +catch (Throwable $error) { ok(str_contains($error->getMessage(), 'posted failure'), 'posted rejection reaches the message consumer'); } +$postedError->close(); + +foreach (['throw', 'reenter'] as $cleanup) { + $closing = new QuickJS(); + $never = $closing->eval('() => new Promise(() => {})', false); + $resource = new class($closing, $cleanup) { + public function __construct(private QuickJS $js, private string $cleanup) {} + public function __destruct() { + if ($this->cleanup === 'reenter') $this->js->grant(new stdClass()); + throw new RuntimeException('cleanup failed'); + } + }; + $closing->grant($resource); + unset($resource); + $waiters = [Amp\async(static fn() => $never()), Amp\async(static fn() => $never())]; + Amp\delay(.005); + throws(fn() => $closing->close(), Throwable::class, "$cleanup during close propagates cleanup failure"); + Amp\delay(.001); + foreach ($waiters as $waiter) { + ok($waiter->isComplete(), "$cleanup during close resumes every waiter"); + throws(fn() => $waiter->await(), Throwable::class, "$cleanup during close rejects without deadlock"); + } +} done(); From 5af9c2bad22aa94e162d327f5bcdd42dde2d42c5 Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Mon, 26 Oct 2026 18:21:32 +0100 Subject: [PATCH 21/29] Remove redundant execution mode diagnostic --- README.md | 2 +- docs/async.md | 2 +- src/lib.rs | 5 ----- stubs/php_quickjs.stubs.php | 3 --- tests/php/21_worker.php | 1 - 5 files changed, 2 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 0a7cdb3..e9414a1 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Since 0.1.0, each runtime and its TypeScript compiler run on a dedicated native thread. PHP callbacks, objects and closures remain on the PHP thread. With Revolt autoloaded, waiting suspends the calling Fiber rather than the PHP thread. Synchronous `php.*` callbacks and `Js\Callback` keep their existing API, including -nested calls. `QuickJS::executionMode()` returns `worker`; there is no inline mode. +nested calls. Call `$js->close()` to interrupt JS, join its worker and release PHP capabilities. Close live runtimes before `fork()` and create new runtimes in the child; inherited runtimes reject entries in the child. diff --git a/docs/async.md b/docs/async.md index 2a9b0fa..df1e814 100644 --- a/docs/async.md +++ b/docs/async.md @@ -2,7 +2,7 @@ Since 0.1.0, each `QuickJS` owns a native OS thread. The runtime, contexts, JavaScript values, Promise jobs, TypeScript compiler and transpile cache stay on -that thread. `QuickJS::executionMode()` returns `worker`; there is no inline mode. +that thread. The PHP API remains synchronous in shape: `eval()` and `Js\Callback` await their result, including returned Promises and thenables. Detached jobs remain queued until `executePendingJobs()` or a returned Promise requires them. diff --git a/src/lib.rs b/src/lib.rs index c9c946f..d98e0c4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -104,11 +104,6 @@ impl QuickJS { self.engine.eval(code, typescript) } - /// JavaScript always executes on a dedicated native thread. - pub fn executionMode() -> &'static str { - "worker" - } - /// Stop this runtime and release its native worker. Further entries fail. pub fn close(&self) -> PhpResult<()> { self.engine.close() diff --git a/stubs/php_quickjs.stubs.php b/stubs/php_quickjs.stubs.php index 7dc5aaa..4490eac 100644 --- a/stubs/php_quickjs.stubs.php +++ b/stubs/php_quickjs.stubs.php @@ -44,9 +44,6 @@ public function eval(string $code, bool $typescript = true): mixed {} */ public function manifest(): mixed {} - /** JavaScript execution mode. */ - public static function executionMode(): string {} - /** Stop this runtime; future entries fail. */ public function close(): void {} diff --git a/tests/php/21_worker.php b/tests/php/21_worker.php index bd0d095..da01b73 100644 --- a/tests/php/21_worker.php +++ b/tests/php/21_worker.php @@ -3,7 +3,6 @@ require __DIR__ . '/../../vendor/autoload.php'; use Revolt\EventLoop; -eq('worker', QuickJS::executionMode(), 'worker execution is the only runtime mode'); throws(fn() => new QuickJS(memoryLimit: 1), Throwable::class, 'failed initialization joins its worker and reports an error'); $js = new QuickJS(timeoutMs: 2000); $ticks = 0; From e3c5ea67a30415972a84077f066085eee19a8380 Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Mon, 26 Oct 2026 19:37:54 +0100 Subject: [PATCH 22/29] Unify owned JS conversion and simplify worker receiver --- src/callback.rs | 17 +++++------ src/marshal.rs | 48 +++++++++----------------------- src/worker.rs | 27 ++++++------------ tests/php/04_callbacks_bidir.php | 2 +- 4 files changed, 30 insertions(+), 64 deletions(-) diff --git a/src/callback.rs b/src/callback.rs index 19206f9..0b1715d 100644 --- a/src/callback.rs +++ b/src/callback.rs @@ -25,14 +25,6 @@ impl JsCallback { } Ok(()) } - fn invoke_inner(&self, args: &[&Zval]) -> PhpResult { - self.check_realm().map_err(PhpException::from_message)?; - self.engine.ensure_open()?; - let _guard = self.engine.enter().map_err(PhpException::from_message)?; - let args = - arguments_to_middle(args, &self.engine.state).map_err(PhpException::from_message)?; - self.engine.callback(self.id, args) - } } impl Drop for JsCallback { fn drop(&mut self) { @@ -44,10 +36,15 @@ impl Drop for JsCallback { #[php_impl] impl JsCallback { pub fn __invoke(&self, args: &[&Zval]) -> PhpResult { - self.invoke_inner(args) + self.check_realm().map_err(PhpException::from_message)?; + self.engine.ensure_open()?; + let _guard = self.engine.enter().map_err(PhpException::from_message)?; + let args = + arguments_to_middle(args, &self.engine.state).map_err(PhpException::from_message)?; + self.engine.callback(self.id, args) } pub fn call(&self, args: &[&Zval]) -> PhpResult { - self.invoke_inner(args) + self.__invoke(args) } /// Queue a call, discard its value and report failures to awaitMessages(). /// Admission is bounded and yields through Revolt when the queue is full. diff --git a/src/marshal.rs b/src/marshal.rs index b66eaf3..c85989a 100644 --- a/src/marshal.rs +++ b/src/marshal.rs @@ -207,44 +207,44 @@ impl<'js> JsConversion<'_, 'js> { } } -/// Convert the neutral representation into a JS value. -pub fn middle_to_js<'js>(ctx: &Ctx<'js>, value: &MiddleValue) -> rquickjs::Result> { +/// Convert owned values into JS, transferring binary buffers without copying. +pub fn middle_into_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), + MiddleValue::Bool(b) => Value::new_bool(ctx.clone(), b), MiddleValue::Int(i) => { - if let Ok(i32v) = i32::try_from(*i) { + if let Ok(i32v) = i32::try_from(i) { Value::new_int(ctx.clone(), i32v) } else { // Beyond i32: represent as a JS number (exact up to 2^53). - Value::new_float(ctx.clone(), *i as f64) + Value::new_float(ctx.clone(), i as f64) } } - MiddleValue::Float(f) => Value::new_float(ctx.clone(), *f), - MiddleValue::Str(s) => rquickjs::String::from_str(ctx.clone(), s)?.into_value(), - MiddleValue::Bytes(b) => TypedArray::new_copy(ctx.clone(), b)?.into_value(), + MiddleValue::Float(f) => Value::new_float(ctx.clone(), f), + MiddleValue::Str(s) => rquickjs::String::from_str(ctx.clone(), &s)?.into_value(), + MiddleValue::Bytes(b) => TypedArray::new(ctx.clone(), b)?.into_value(), MiddleValue::Array(items) => { let arr = Array::new(ctx.clone())?; - for (i, it) in items.iter().enumerate() { - arr.set(i, middle_to_js(ctx, it)?)?; + for (i, it) in items.into_iter().enumerate() { + arr.set(i, middle_into_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)?)?; + obj.set(k, middle_into_js(ctx, v)?)?; } obj.into_value() } // Reconstruct callables from the JS-side helpers. MiddleValue::JsFn(id) => { let get: Function = ctx.globals().get("__getJsFn")?; - get.call((*id as f64,))? + get.call((id as f64,))? } MiddleValue::PhpFn(id) => { let make: Function = ctx.globals().get("__makePhpFn")?; - make.call((*id as f64,))? + make.call((id as f64,))? } }) } @@ -363,28 +363,6 @@ impl Drop for PhpConversion<'_> { } } -/// Transfer owned buffers into QuickJS instead of cloning their backing Vec. -pub fn middle_into_js<'js>(ctx: &Ctx<'js>, value: MiddleValue) -> rquickjs::Result> { - match value { - MiddleValue::Bytes(bytes) => Ok(TypedArray::new(ctx.clone(), bytes)?.into_value()), - MiddleValue::Array(items) => { - let array = Array::new(ctx.clone())?; - for (index, item) in items.into_iter().enumerate() { - array.set(index, middle_into_js(ctx, item)?)?; - } - Ok(array.into_value()) - } - MiddleValue::Map(entries) => { - let object = Object::new(ctx.clone())?; - for (key, value) in entries { - object.set(key, middle_into_js(ctx, value)?)?; - } - Ok(object.into_value()) - } - value => middle_to_js(ctx, &value), - } -} - /// Convert the neutral representation into a PHP value. pub fn middle_to_zval(value: &MiddleValue, state: &BridgeState) -> Result { let mut zv = Zval::new(); diff --git a/src/worker.rs b/src/worker.rs index 8933942..dc076c3 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -236,7 +236,7 @@ impl Port { ) .spawn(move || { let driver = Rc::new(Driver { - commands: RefCell::new(commands), + commands, events, notify, signals: worker_signals, @@ -367,7 +367,7 @@ impl Drop for Port { } } struct Driver { - commands: RefCell>, + commands: mpsc::Receiver, events: mpsc::Sender, notify: File, signals: Arc, @@ -407,7 +407,7 @@ impl Driver { if self.config.isolated { let _ = ready.send(Ok(())); loop { - let command = self.commands.borrow().recv().map_err(|e| e.to_string())?; + let command = self.commands.recv().map_err(|e| e.to_string())?; if self.signals.stop.load(Ordering::SeqCst) { return Ok(()); } @@ -507,7 +507,7 @@ impl Driver { jobs += 1; if jobs % 64 == 0 { self.flush_notifications(); - let command = self.commands.borrow().try_recv().ok(); + let command = self.commands.try_recv().ok(); if let Some(command) = command { if !session.command(&ctx, command) { break 'running; @@ -527,16 +527,12 @@ impl Driver { break; } let command = if session.pending.borrow().is_empty() { - self.commands.borrow().recv().ok() + self.commands.recv().ok() } else if jobs_pending(&ctx) { - self.commands.borrow().try_recv().ok() + self.commands.try_recv().ok() } else { session.ask_loop(); - match self - .commands - .borrow() - .recv_timeout(Duration::from_millis(5)) - { + match self.commands.recv_timeout(Duration::from_millis(5)) { Ok(c) => Some(c), Err(mpsc::RecvTimeoutError::Timeout) => None, Err(_) => break, @@ -1097,14 +1093,9 @@ impl<'js> Session<'js> { self.advance(ctx); self.driver.flush_notifications(); let command = if jobs_pending(ctx) && self.has_awaitable() { - self.driver.commands.borrow().try_recv().ok() + self.driver.commands.try_recv().ok() } else { - match self - .driver - .commands - .borrow() - .recv_timeout(Duration::from_millis(5)) - { + match self.driver.commands.recv_timeout(Duration::from_millis(5)) { Ok(c) => Some(c), Err(mpsc::RecvTimeoutError::Timeout) => None, Err(_) => return Err(Exception::throw_internal(ctx, "PHP receiver closed")), diff --git a/tests/php/04_callbacks_bidir.php b/tests/php/04_callbacks_bidir.php index 09c509d..336b729 100644 --- a/tests/php/04_callbacks_bidir.php +++ b/tests/php/04_callbacks_bidir.php @@ -48,7 +48,7 @@ '), 'JS function survives a round trip through PHP'); // --- Mutual recursion / nesting (PHP -> JS -> PHP -> JS) ----------------- -$js->register('callTwice', fn(callable $fn, $x) => $fn($fn($x))); +$js->register('callTwice', fn(Js\Callback $fn, $x) => $fn->call($fn($x))); eq(12, $js->eval('php.callTwice(n => php.makeAdder(3)(n), 6)'), 'nested JS->PHP->JS->PHP callbacks'); From 7c61dd17c25d9a830c82e3ff414be1aad38f6128 Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Mon, 26 Oct 2026 22:17:47 +0100 Subject: [PATCH 23/29] Unify callback RPC and simplify nested request handling --- src/engine.rs | 12 +++- src/worker.rs | 131 +++++++++++++++++++++-------------------- tests/php/14_async.php | 15 +++++ 3 files changed, 91 insertions(+), 67 deletions(-) diff --git a/src/engine.rs b/src/engine.rs index d60bb1a..68da43b 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -176,7 +176,11 @@ impl Engine { self.request(Operation::Eval { code, typescript }) } pub fn callback(&self, id: u64, args: Vec) -> PhpResult { - self.request(Operation::Callback { id, args }) + self.request(Operation::Call { + id, + args, + posted: false, + }) } pub fn post(&self, function: u64, args: Vec) -> PhpResult<()> { if self.isolated { @@ -204,7 +208,11 @@ impl Engine { let id = self.next_id(); let result = self.submit( id, - Operation::Post { id: function, args }, + Operation::Call { + id: function, + args, + posted: true, + }, self.active_eval.get(), ); if result.is_err() { diff --git a/src/worker.rs b/src/worker.rs index dc076c3..e727269 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -76,9 +76,15 @@ struct Signals { posts: Signal, } pub enum Operation { - Eval { code: String, typescript: bool }, - Callback { id: u64, args: Vec }, - Post { id: u64, args: Vec }, + Eval { + code: String, + typescript: bool, + }, + Call { + id: u64, + args: Vec, + posted: bool, + }, Capacity, Jobs(i64), HasJobs, @@ -467,7 +473,6 @@ impl Driver { posted: RefCell::new(HashSet::new()), capacity: RefCell::new(VecDeque::new()), replies: RefCell::new(HashMap::new()), - host_waits: RefCell::new(HashSet::new()), entries: RefCell::new(Vec::new()), deadline, timed_out, @@ -522,26 +527,16 @@ impl Driver { } } drop(turn); - self.flush_notifications(); if session.root_finished.get() { + self.flush_notifications(); break; } - let command = if session.pending.borrow().is_empty() { - self.commands.recv().ok() - } else if jobs_pending(&ctx) { - self.commands.try_recv().ok() - } else { + let idle = session.pending.borrow().is_empty(); + if !idle && !jobs_pending(&ctx) { session.ask_loop(); - match self.commands.recv_timeout(Duration::from_millis(5)) { - Ok(c) => Some(c), - Err(mpsc::RecvTimeoutError::Timeout) => None, - Err(_) => break, - } - }; - if let Some(command) = command { - if !session.command(&ctx, command) { - break; - } + } + if !session.step(&ctx, idle).unwrap_or(false) { + break; } } let pending = std::mem::take(&mut *session.pending.borrow_mut()); @@ -581,8 +576,7 @@ struct Session<'js> { pending: RefCell>>, posted: RefCell>, capacity: RefCell)>>, - replies: RefCell>>, - host_waits: RefCell>, + replies: RefCell>>>, entries: RefCell>>, deadline: Rc>>, timed_out: Rc>, @@ -750,8 +744,8 @@ impl<'js> Session<'js> { Command::Stop => return false, Command::Request(request) => self.request(ctx, request), Command::HostReply { token, result } => { - if self.host_waits.borrow().contains(&token) { - self.replies.borrow_mut().insert(token, result); + if let Some(reply) = self.replies.borrow_mut().get_mut(&token) { + *reply = Some(result); } } Command::Abort { id, message } => { @@ -762,6 +756,21 @@ impl<'js> Session<'js> { } true } + fn step(&self, ctx: &Ctx<'js>, idle: bool) -> Result { + self.driver.flush_notifications(); + let command = if !idle && jobs_pending(ctx) && self.has_awaitable() { + self.driver.commands.try_recv().ok() + } else if idle { + Some(self.driver.commands.recv()?) + } else { + match self.driver.commands.recv_timeout(Duration::from_millis(5)) { + Ok(command) => Some(command), + Err(mpsc::RecvTimeoutError::Timeout) => None, + Err(mpsc::RecvTimeoutError::Disconnected) => return Err(mpsc::RecvError), + } + }; + Ok(command.is_none_or(|command| self.command(ctx, command))) + } fn request(&self, ctx: &Ctx<'js>, request: Request) { let Request { id, @@ -770,7 +779,7 @@ impl<'js> Session<'js> { deletions, parent, } = request; - if matches!(&op, Operation::Post { .. }) { + if matches!(&op, Operation::Call { posted: true, .. }) { self.posted.borrow_mut().insert(id); } if parent.is_some_and(|p| self.failed.borrow().contains(&p)) { @@ -826,10 +835,7 @@ impl<'js> Session<'js> { } let _entry = self.enter(deadline, true); self.timed_out.set(false); - let await_result = matches!( - &op, - Operation::Eval { .. } | Operation::Callback { .. } | Operation::Post { .. } - ); + let await_result = matches!(&op, Operation::Eval { .. } | Operation::Call { .. }); let context = ErrorContext { eval: matches!(&op, Operation::Eval { .. }), module: None, @@ -932,7 +938,7 @@ impl<'js> Session<'js> { ctx.eval_with_options(module.js.as_bytes(), options) .map_err(|e| self.error(ctx, e, &context))? } - Operation::Callback { id, args } | Operation::Post { id, args } => { + Operation::Call { id, args, .. } => { let result = (|| { let get: Function = ctx.globals().get("__getJsFn")?; let function: Function = get.call((id as f64,))?; @@ -986,25 +992,27 @@ impl<'js> Session<'js> { } fn settle(&self, ctx: &Ctx<'js>) { loop { - let item = { - let pending = self.pending.borrow(); - pending.iter().position(|p| { - p.settle_depth >= self.js_depth.get() - && (p.deadline.is_some_and(|d| Instant::now() >= d) - || p.promise.result::().is_some()) - }) - }; - let Some(index) = item else { break }; + let ready = self + .pending + .borrow() + .iter() + .enumerate() + .find_map(|(index, p)| { + if p.settle_depth < self.js_depth.get() { + return None; + } + let result = if p.deadline.is_some_and(|d| Instant::now() >= d) { + Err(WireError::Timeout) + } else { + Ok(p.promise.result::()?) + }; + Some((index, result)) + }); + let Some((index, result)) = ready else { break }; let item = self.pending.borrow_mut().remove(index); - if item.deadline.is_some_and(|d| Instant::now() >= d) { - self.finish(item.id, Err(WireError::Timeout)); - } else { - self.finish_value( - item.id, - ctx, - item.promise.result::().unwrap(), - &item.context, - ); + match result { + Ok(result) => self.finish_value(item.id, ctx, result, &item.context), + Err(error) => self.finish(item.id, Err(error)), } self.update_deadline(); } @@ -1066,7 +1074,7 @@ impl<'js> Session<'js> { }; let token = self.driver.token.get() + 1; self.driver.token.set(token); - self.host_waits.borrow_mut().insert(token); + self.replies.borrow_mut().insert(token, None); self.driver.emit(Event::Host { token, target, @@ -1083,7 +1091,11 @@ impl<'js> Session<'js> { "JavaScript execution timed out", )); } - let reply = self.replies.borrow_mut().remove(&token); + let reply = self + .replies + .borrow_mut() + .get_mut(&token) + .and_then(Option::take); if let Some(reply) = reply { return match reply { Ok(value) => middle_into_js(ctx, value), @@ -1091,23 +1103,12 @@ impl<'js> Session<'js> { }; } self.advance(ctx); - self.driver.flush_notifications(); - let command = if jobs_pending(ctx) && self.has_awaitable() { - self.driver.commands.try_recv().ok() - } else { - match self.driver.commands.recv_timeout(Duration::from_millis(5)) { - Ok(c) => Some(c), - Err(mpsc::RecvTimeoutError::Timeout) => None, - Err(_) => return Err(Exception::throw_internal(ctx, "PHP receiver closed")), - } - }; - if let Some(command) = command { - if !self.command(ctx, command) { - return Err(Exception::throw_internal(ctx, "QuickJS worker closed")); - } + match self.step(ctx, false) { + Ok(true) => {} + Ok(false) => return Err(Exception::throw_internal(ctx, "QuickJS worker closed")), + Err(_) => return Err(Exception::throw_internal(ctx, "PHP receiver closed")), } })(); - self.host_waits.borrow_mut().remove(&token); self.replies.borrow_mut().remove(&token); result } diff --git a/tests/php/14_async.php b/tests/php/14_async.php index e61c1cb..4b519c5 100644 --- a/tests/php/14_async.php +++ b/tests/php/14_async.php @@ -110,4 +110,19 @@ function spin() { if (stopped) resolve(1); else Promise.resolve().then(spin); } 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'); +// A pending Promise and a posted call must not interfere with synchronous reentry. +$mixed = new QuickJS(timeoutMs: 1000); +$mixed->register('map', static fn(array $values, callable $callback) => array_map($callback, $values)); +$wait = $mixed->eval('() => new Promise(resolve => { globalThis.finishMixed = resolve; })', false); +$pushMixed = $mixed->eval('async value => { await 0; quickjs.postMessage(value); }', false); +$runMixed = $mixed->eval('() => php.map([1, 2, 3], n => php.map([n, n + 1], m => m * 2))', false); +$finishMixed = $mixed->eval('() => finishMixed(43)', false); +$pendingMixed = Amp\async(static fn() => $wait()); +Amp\delay(.001); +$pushMixed->post(7); +eq([[2, 4], [4, 6], [6, 8]], $runMixed(), 'nested synchronous callbacks coexist with pending and posted calls'); +eq([7], $mixed->awaitMessages(), 'posted Promise delivers its message during mixed calls'); +$finishMixed(); +eq(43, $pendingMixed->await(), 'unrelated pending Promise completes after nested callbacks'); +$mixed->close(); done(); From d76d2b48d3585d5dd4b4669c3134bd07e75bc8c6 Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Mon, 26 Oct 2026 22:17:47 +0100 Subject: [PATCH 24/29] Drive QuickJS promises with rquickjs futures and service jobs --- Cargo.lock | 31 ++++ Cargo.toml | 1 + src/lib.rs | 1 - src/sandbox.rs | 18 -- src/worker.rs | 399 +++++++++++++++++++++++++---------------- tests/php/11_jobs.php | 6 +- tests/php/14_async.php | 12 ++ 7 files changed, 292 insertions(+), 176 deletions(-) delete mode 100644 src/sandbox.rs diff --git a/Cargo.lock b/Cargo.lock index fbf0a81..00b7209 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,6 +40,17 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -494,6 +505,25 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0440157235aa4badd45ea073af8de2ba44ffead74bf8a7833b215533c36f08e" +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "ext-php-rs" version = "0.16.3" @@ -1906,6 +1936,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6657979a24d543e5fc66f32cef2f47ca82ce8eb55cec5f7d59a12b7b570bbf24" dependencies = [ + "async-lock", "hashbrown", "relative-path", "rquickjs-sys", diff --git a/Cargo.toml b/Cargo.toml index 7d6b6b5..2d1ef45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ rquickjs = { version = "0.14.0", default-features = false, features = [ "macro", "classes", "array-buffer", + "futures", ] } serde_json = "1.0.154" diff --git a/src/lib.rs b/src/lib.rs index d98e0c4..e9f32a1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,7 +15,6 @@ mod exceptions; mod handles; mod manifest; mod marshal; -mod sandbox; mod transpile; mod worker; diff --git a/src/sandbox.rs b/src/sandbox.rs deleted file mode 100644 index 14b85a9..0000000 --- a/src/sandbox.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! In-engine resource containment: memory limit, native stack size, and a -//! wall-clock deadline enforced through QuickJS's interrupt handler. -//! -//! These are *resource* guards (infinite loops, alloc bombs), not a defence -//! against QuickJS memory-corruption bugs — for hostile code, nest the whole -//! extension inside an outer microVM/gVisor boundary. - -use rquickjs::Runtime; -/// Apply the static limits. A value of `0` means "leave unbounded" (QuickJS -/// treats a literal 0 limit as zero bytes, so we simply skip the call). -pub fn apply_limits(rt: &Runtime, memory_limit: usize, max_stack: usize) { - if memory_limit > 0 { - rt.set_memory_limit(memory_limit); - } - if max_stack > 0 { - rt.set_max_stack_size(max_stack); - } -} diff --git a/src/worker.rs b/src/worker.rs index e727269..784b9bd 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -7,21 +7,49 @@ use crate::{ transpile::{TranspileCache, Transpiled}, }; use ext_php_rs::prelude::PhpException; -use rquickjs::{Context, Ctx, Exception, Function, Promise, Runtime, Value}; +use rquickjs::{ + promise::PromiseFuture, AsyncContext, AsyncRuntime, Ctx, Exception, Function, Value, +}; use std::{ cell::{Cell, RefCell}, collections::{HashMap, HashSet, VecDeque}, fs::File, + future::{poll_fn, Future}, io::Write, + pin::{pin, Pin}, rc::Rc, sync::{ atomic::{AtomicBool, AtomicUsize, Ordering}, mpsc, Arc, Mutex, }, + task::{Context as TaskContext, Poll, Wake, Waker}, thread::{self, JoinHandle}, time::{Duration, Instant}, }; +struct WorkerWake(thread::Thread); +impl Wake for WorkerWake { + fn wake(self: Arc) { + self.0.unpark(); + } +} +fn block_on(deadline: &Cell>, future: impl Future) -> T { + let waker = Waker::from(Arc::new(WorkerWake(thread::current()))); + let mut context = TaskContext::from_waker(&waker); + let mut future = pin!(future); + loop { + match future.as_mut().poll(&mut context) { + Poll::Ready(result) => return result, + Poll::Pending => match deadline.get() { + Some(deadline) => { + thread::park_timeout(deadline.saturating_duration_since(Instant::now())) + } + None => thread::park(), + }, + } + } +} + #[cfg(target_os = "linux")] fn notification_pair() -> std::io::Result<(File, File)> { use std::os::fd::FromRawFd; @@ -305,7 +333,11 @@ impl Port { self.check()?; self.tx .try_send(command) - .map_err(|_| WireError::Internal("QuickJS command queue is full or closed".into())) + .map_err(|_| WireError::Internal("QuickJS command queue is full or closed".into()))?; + if let Some(worker) = self.worker.borrow().as_ref() { + worker.thread().unpark(); + } + Ok(()) } pub fn receive(&self) -> Result { self.rx.try_recv() @@ -355,6 +387,7 @@ impl Port { self.signals.stop.store(true, Ordering::SeqCst); let _ = self.tx.try_send(Command::Stop); if let Some(worker) = self.worker.borrow_mut().take() { + worker.thread().unpark(); let _ = worker.join(); } } @@ -447,113 +480,120 @@ impl Driver { first: Option, ready: Option>>, ) -> Result<(), String> { - let rt = Runtime::new().map_err(|e| e.to_string())?; - crate::sandbox::apply_limits(&rt, self.config.memory_limit, self.config.max_stack); let deadline = Rc::new(Cell::new(None::)); - let timed_out = Rc::new(Cell::new(false)); - let interrupted = self.signals.clone(); - let dl = deadline.clone(); - let expired = timed_out.clone(); - rt.set_interrupt_handler(Some(Box::new(move || { - if interrupted.stop.load(Ordering::SeqCst) { - return true; - } - if dl.get().is_some_and(|d| Instant::now() >= d) { - expired.set(true); - return true; + let wake_deadline = deadline.clone(); + block_on(&wake_deadline, async { + let rt = AsyncRuntime::new().map_err(|e| e.to_string())?; + if self.config.memory_limit > 0 { + rt.set_memory_limit(self.config.memory_limit).await; } - false - }))); - let context = Context::full(&rt).map_err(|e| e.to_string())?; - let root = first.as_ref().map(|r| r.id); - context.with(|ctx| { - let session = Rc::new(Session { - driver: self.clone(), - pending: RefCell::new(Vec::new()), - posted: RefCell::new(HashSet::new()), - capacity: RefCell::new(VecDeque::new()), - replies: RefCell::new(HashMap::new()), - entries: RefCell::new(Vec::new()), - deadline, - timed_out, - failed: RefCell::new(VecDeque::new()), - root, - root_finished: Cell::new(false), - facade: RefCell::new(None), - js_depth: Cell::new(0), - reported_failure: Cell::new(false), - }); - let _cleanup = SessionCleanup(session.clone()); - install(&ctx, session.clone()).map_err(|e| error::js_error_message(&ctx, e))?; - if let Some(ready) = ready { - let _ = ready.send(Ok(())); + if self.config.max_stack > 0 { + rt.set_max_stack_size(self.config.max_stack).await; } - if let Some(first) = first { - session.request(&ctx, first); - } - 'running: while !self.signals.stop.load(Ordering::SeqCst) - && !session.root_finished.get() - { - let turn = (self.config.timeout_ms > 0 - && self.signals.pump.load(Ordering::SeqCst) - && jobs_pending(&ctx)) - .then(|| { - let deadline = session.deadline.get().or_else(|| { - (self.config.timeout_ms > 0) - .then(|| Instant::now() + Duration::from_millis(self.config.timeout_ms)) + let timed_out = Rc::new(Cell::new(false)); + let interrupted = self.signals.clone(); + let dl = deadline.clone(); + let expired = timed_out.clone(); + rt.set_interrupt_handler(Some(Box::new(move || { + if interrupted.stop.load(Ordering::SeqCst) { + return true; + } + if dl.get().is_some_and(|d| Instant::now() >= d) { + expired.set(true); + return true; + } + false + }))) + .await; + let context = AsyncContext::full(&rt).await.map_err(|e| e.to_string())?; + let root = first.as_ref().map(|r| r.id); + context + .async_with(async move |ctx| { + let session = Rc::new(Session { + driver: self.clone(), + pending: RefCell::new(Vec::new()), + turn_deadline: Cell::new(None), + service: RefCell::new(None), + service_queued: Cell::new(false), + posted: RefCell::new(HashSet::new()), + capacity: RefCell::new(VecDeque::new()), + replies: RefCell::new(HashMap::new()), + entries: RefCell::new(Vec::new()), + deadline, + timed_out, + failed: RefCell::new(VecDeque::new()), + root, + root_finished: Cell::new(false), + facade: RefCell::new(None), + js_depth: Cell::new(0), + reported_failure: Cell::new(false), }); - session.enter(deadline, false) - }); - // Drain runnable jobs, but admit PHP callbacks every 64 jobs so - // an unbounded microtask chain cannot starve external timers. - let mut jobs = 0; - loop { - session.advance(&ctx); - jobs += 1; - if jobs % 64 == 0 { - self.flush_notifications(); - let command = self.commands.try_recv().ok(); - if let Some(command) = command { - if !session.command(&ctx, command) { - break 'running; + let _cleanup = SessionCleanup(session.clone()); + install(&ctx, session.clone()).map_err(|e| error::js_error_message(&ctx, e))?; + let service = session.clone(); + *session.service.borrow_mut() = Some( + Function::new(ctx.clone(), move || -> rquickjs::Result<()> { + let ctx = service.service.borrow().as_ref().unwrap().ctx().clone(); + service.service_queued.set(false); + service.tick(&ctx); + service.schedule(&ctx); + Ok(()) + }) + .map_err(|e| error::js_error_message(&ctx, e))?, + ); + if let Some(ready) = ready { + let _ = ready.send(Ok(())); + } + if let Some(first) = first { + session.request(&ctx, first); + } + let mut turn = None; + poll_fn(|_| { + drop(turn.take()); + loop { + if !session.tick(&ctx) { + return Poll::Ready(()); + } + if session.has_awaitable() { + session.schedule(&ctx); + if !jobs_pending(&ctx) { + session.ask_loop(); + } + self.flush_notifications(); + turn = Some(session.enter(session.deadline.get(), true)); + return Poll::Pending; + } + // No async work: block on the command channel without spinning + // or implicitly draining jobs requested through the manual API. + self.flush_notifications(); + match self.commands.recv() { + Ok(command) => { + if !session.command(&ctx, command) { + return Poll::Ready(()); + } + } + _ => return Poll::Ready(()), } } + }) + .await; + let pending = std::mem::take(&mut *session.pending.borrow_mut()); + for item in pending { + self.emit(Event::Done { + id: item.id, + result: Err(WireError::Internal( + if self.signals.stop.load(Ordering::SeqCst) { + "QuickJS worker is closed".into() + } else { + "JS callback canceled: owning isolated eval ended".into() + }, + )), + }); } - if self.signals.stop.load(Ordering::SeqCst) - || !session.has_awaitable() - || !jobs_pending(&ctx) - { - break; - } - } - drop(turn); - if session.root_finished.get() { - self.flush_notifications(); - break; - } - let idle = session.pending.borrow().is_empty(); - if !idle && !jobs_pending(&ctx) { - session.ask_loop(); - } - if !session.step(&ctx, idle).unwrap_or(false) { - break; - } - } - let pending = std::mem::take(&mut *session.pending.borrow_mut()); - for item in pending { - self.emit(Event::Done { - id: item.id, - result: Err(WireError::Internal( - if self.signals.stop.load(Ordering::SeqCst) { - "QuickJS worker is closed".into() - } else { - "JS callback canceled: owning isolated eval ended".into() - }, - )), - }); - } - session.replies.borrow_mut().clear(); - Ok(()) + session.replies.borrow_mut().clear(); + Ok(()) + }) + .await }) } } @@ -565,15 +605,18 @@ struct ErrorContext { struct Pending<'js> { id: u64, parent: Option, - promise: Promise<'js>, + future: RefCell>>, context: ErrorContext, deadline: Option, - asked: bool, + asked: Cell, settle_depth: usize, } struct Session<'js> { driver: Rc, - pending: RefCell>>, + pending: RefCell>>>, + turn_deadline: Cell>, + service: RefCell>>, + service_queued: Cell, posted: RefCell>, capacity: RefCell)>>, replies: RefCell>>>, @@ -610,6 +653,7 @@ impl Drop for SessionCleanup<'_> { fn drop(&mut self) { self.0.pending.borrow_mut().clear(); self.0.facade.borrow_mut().take(); + self.0.service.borrow_mut().take(); } } fn jobs_pending(ctx: &Ctx<'_>) -> bool { @@ -662,6 +706,7 @@ impl<'js> Session<'js> { .iter() .filter_map(|p| p.deadline) .min() + .or(self.turn_deadline.get()) } else { entries.iter().copied().flatten().min() }); @@ -682,6 +727,7 @@ impl<'js> Session<'js> { } } fn finish(&self, id: u64, result: Result) { + self.pending.borrow_mut().retain(|p| p.id != id); if result.is_err() { let mut failed = self.failed.borrow_mut(); failed.push_back(id); @@ -749,19 +795,16 @@ impl<'js> Session<'js> { } } Command::Abort { id, message } => { - self.pending.borrow_mut().retain(|p| p.id != id); self.finish(id, Err(WireError::Internal(message))); self.update_deadline(); } } true } - fn step(&self, ctx: &Ctx<'js>, idle: bool) -> Result { + fn step(&self, ctx: &Ctx<'js>) -> Result { self.driver.flush_notifications(); - let command = if !idle && jobs_pending(ctx) && self.has_awaitable() { + let command = if jobs_pending(ctx) && self.has_awaitable() { self.driver.commands.try_recv().ok() - } else if idle { - Some(self.driver.commands.recv()?) } else { match self.driver.commands.recv_timeout(Duration::from_millis(5)) { Ok(command) => Some(command), @@ -861,15 +904,15 @@ impl<'js> Session<'js> { if let Some(result) = promise.result::() { return result.map(Some); } - self.pending.borrow_mut().push(Pending { + self.pending.borrow_mut().push(Rc::new(Pending { id, parent, - promise, + future: RefCell::new(promise.into_future()), context: context.clone(), deadline, - asked: false, + asked: Cell::new(false), settle_depth: self.js_depth.get() - 1, - }); + })); return Ok(None); } } @@ -991,29 +1034,78 @@ impl<'js> Session<'js> { Ok(result > 0) } fn settle(&self, ctx: &Ctx<'js>) { - loop { - let ready = self - .pending - .borrow() - .iter() - .enumerate() - .find_map(|(index, p)| { - if p.settle_depth < self.js_depth.get() { - return None; - } - let result = if p.deadline.is_some_and(|d| Instant::now() >= d) { - Err(WireError::Timeout) - } else { - Ok(p.promise.result::()?) - }; - Some((index, result)) - }); - let Some((index, result)) = ready else { break }; - let item = self.pending.borrow_mut().remove(index); - match result { - Ok(result) => self.finish_value(item.id, ctx, result, &item.context), - Err(error) => self.finish(item.id, Err(error)), + // Release collection borrows before polling: Promise.then may enter PHP + // and recursively add, cancel or settle another callback. + let pending = self.pending.borrow().clone(); + for item in pending { + if item.settle_depth < self.js_depth.get() { + continue; + } + let Ok(mut future) = item.future.try_borrow_mut() else { + continue; + }; + let result = if item.deadline.is_some_and(|d| Instant::now() >= d) { + None + } else { + let Poll::Ready(result) = + Pin::new(&mut *future).poll(&mut TaskContext::from_waker(Waker::noop())) + else { + continue; + }; + Some(result) + }; + if self.pending.borrow().iter().any(|p| p.id == item.id) { + match result { + Some(result) => self.finish_value(item.id, ctx, result, &item.context), + None => self.finish(item.id, Err(WireError::Timeout)), + } } + } + self.update_deadline(); + } + fn tick(&self, ctx: &Ctx<'js>) -> bool { + if self.driver.signals.stop.load(Ordering::SeqCst) || self.root_finished.get() { + return false; + } + let had_pending = !self.pending.borrow().is_empty(); + self.settle(ctx); + if self.timed_out.replace(false) && !had_pending { + self.driver.signals.pump.store(false, Ordering::SeqCst); + self.driver.emit(Event::AsyncError(WireError::Timeout)); + } + for _ in 0..64 { + let command = self.driver.commands.try_recv().ok(); + let Some(command) = command else { break }; + if !self.command(ctx, command) { + return false; + } + } + self.driver.flush_notifications(); + !self.driver.signals.stop.load(Ordering::SeqCst) && !self.root_finished.get() + } + fn schedule(&self, ctx: &Ctx<'js>) { + if self.driver.signals.stop.load(Ordering::SeqCst) || self.root_finished.get() { + return; + } + if jobs_pending(ctx) { + if self.turn_deadline.get().is_none() && self.driver.config.timeout_ms > 0 { + self.turn_deadline.set(Some( + Instant::now() + Duration::from_millis(self.driver.config.timeout_ms), + )); + self.update_deadline(); + } + if !self.service_queued.replace(true) { + if let Err(error) = self.service.borrow().as_ref().unwrap().defer(()) { + self.service_queued.set(false); + self.driver.emit(Event::Fatal(self.error( + ctx, + error, + &ErrorContext::default(), + ))); + } + } + } else { + self.turn_deadline.set(None); self.update_deadline(); } } @@ -1027,35 +1119,34 @@ impl<'js> Session<'js> { } fn advance(&self, ctx: &Ctx<'js>) { self.settle(ctx); - let target = self + if !self.has_awaitable() { + return; + } + let (id, context) = self .pending .borrow() .iter() .find(|p| p.settle_depth >= self.js_depth.get()) - .map(|p| (p.id, p.context.clone())); - let (id, context) = match target { - Some((id, context)) => (Some(id), context), - None if self.driver.signals.pump.load(Ordering::SeqCst) => { - (None, ErrorContext::default()) - } - None => return, - }; - if let Err(e) = self.job(ctx, &context) { - let index = self.pending.borrow().iter().position(|p| Some(p.id) == id); - if let Some(index) = index { - self.pending.borrow_mut().remove(index); - self.finish(id.unwrap(), Err(e)); + .map(|p| (Some(p.id), p.context.clone())) + .unwrap_or_default(); + if let Err(error) = self.job(ctx, &context) { + if let Some(id) = id { + self.finish(id, Err(error)); self.update_deadline(); } else { self.driver.signals.pump.store(false, Ordering::SeqCst); - self.driver.emit(Event::AsyncError(e)); + self.driver.emit(Event::AsyncError(error)); } } self.settle(ctx); } fn ask_loop(&self) { - for pending in self.pending.borrow_mut().iter_mut().filter(|p| !p.asked) { - pending.asked = true; + for pending in self + .pending + .borrow() + .iter() + .filter(|p| !p.asked.replace(true)) + { self.driver.emit(Event::NeedsLoop { id: pending.id }); } } @@ -1103,7 +1194,7 @@ impl<'js> Session<'js> { }; } self.advance(ctx); - match self.step(ctx, false) { + match self.step(ctx) { Ok(true) => {} Ok(false) => return Err(Exception::throw_internal(ctx, "QuickJS worker closed")), Err(_) => return Err(Exception::throw_internal(ctx, "PHP receiver closed")), diff --git a/tests/php/11_jobs.php b/tests/php/11_jobs.php index 8a957b0..1d3890a 100644 --- a/tests/php/11_jobs.php +++ b/tests/php/11_jobs.php @@ -19,10 +19,10 @@ 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'); +eq(42, $stop->eval('globalThis.detached = 0; Promise.resolve().then(() => { Promise.resolve().then(() => { detached = 1; }); return 42; })'), 'await returns its fulfilled Promise value'); +eq(1, $stop->eval('detached'), 'await may also drain later detached microtasks'); $stop->executePendingJobs(); -eq(1, $stop->eval('detached'), 'detached jobs can still be drained explicitly'); +eq(1, $stop->eval('detached'), 'explicit drain remains valid after automatic microtasks'); 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;'); diff --git a/tests/php/14_async.php b/tests/php/14_async.php index 4b519c5..30c2a39 100644 --- a/tests/php/14_async.php +++ b/tests/php/14_async.php @@ -110,6 +110,18 @@ function spin() { if (stopped) resolve(1); else Promise.resolve().then(spin); } 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'); +// Polling a Promise future may itself enter PHP through an overridden then(). +$js->register('futureReentry', static fn(callable $callback) => $callback()); +eq(42, $js->eval('(() => { + const pending = new Promise(resolve => Promise.resolve().then(() => resolve(42))); + const then = pending.then; + pending.then = function (...args) { + php.futureReentry(async () => { await 0; return 7; }); + return then.apply(this, args); + }; + return pending; +})()', false), 'Promise future polling permits nested asynchronous PHP callbacks'); + // A pending Promise and a posted call must not interfere with synchronous reentry. $mixed = new QuickJS(timeoutMs: 1000); $mixed->register('map', static fn(array $values, callable $callback) => array_map($callback, $values)); From 44435fb9a57ebd0fe66ccad3996e5974a9fa0450 Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Mon, 26 Oct 2026 23:06:05 +0100 Subject: [PATCH 25/29] Keep queued requests progressing across worker turns --- src/worker.rs | 14 ++++++++++++-- tests/php/23_post_progress.php | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 tests/php/23_post_progress.php diff --git a/src/worker.rs b/src/worker.rs index 784b9bd..290376e 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -279,6 +279,7 @@ impl Port { config, token: Cell::new(0), notification_pending: Cell::new(false), + thread: thread::current(), }); let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| driver.run(ready_tx))); @@ -415,6 +416,7 @@ struct Driver { config: Config, token: Cell, notification_pending: Cell, + thread: thread::Thread, } impl Driver { fn notify(&self) { @@ -1069,16 +1071,24 @@ impl<'js> Session<'js> { } let had_pending = !self.pending.borrow().is_empty(); self.settle(ctx); + if self.root_finished.get() { + return false; + } if self.timed_out.replace(false) && !had_pending { self.driver.signals.pump.store(false, Ordering::SeqCst); self.driver.emit(Event::AsyncError(WireError::Timeout)); } - for _ in 0..64 { + for index in 0..64 { let command = self.driver.commands.try_recv().ok(); let Some(command) = command else { break }; - if !self.command(ctx, command) { + if !self.command(ctx, command) || self.root_finished.get() { return false; } + if index == 63 { + // Notifications coalesce. A full quantum may leave commands + // queued even when no JS jobs can wake the executor again. + self.driver.thread.unpark(); + } } self.driver.flush_notifications(); !self.driver.signals.stop.load(Ordering::SeqCst) && !self.root_finished.get() diff --git a/tests/php/23_post_progress.php b/tests/php/23_post_progress.php new file mode 100644 index 0000000..4a80a1c --- /dev/null +++ b/tests/php/23_post_progress.php @@ -0,0 +1,16 @@ +eval('value => quickjs.postMessage(value)', false); + for ($i = 0; $i < 512; ++$i) $push->post($i); + $values = []; + while (count($values) < 512) array_push($values, ...$js->awaitMessages()); + eq(range(0, 511), $values, "coalesced posts make progress and preserve FIFO ($trial)"); + $js->close(); +} +done(); From 082381dcae9183e2353711b92504e1e3c04fa1c4 Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Mon, 26 Oct 2026 23:25:06 +0100 Subject: [PATCH 26/29] Route reentrant PHP callbacks while keeping JavaScript on its worker --- README.md | 3 + src/engine.rs | 111 ++++++++++++++++++++------ src/worker.rs | 14 ++++ tests/php/22_reentrant_rpc.php | 131 +++++++++++++++++++++++++++++++ tests/php/24_worker_progress.php | 21 +++++ 5 files changed, 257 insertions(+), 23 deletions(-) create mode 100644 tests/php/22_reentrant_rpc.php create mode 100644 tests/php/24_worker_progress.php diff --git a/README.md b/README.md index e9414a1..728c585 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,9 @@ thread. PHP callbacks, objects and closures remain on the PHP thread. With Revolt autoloaded, waiting suspends the calling Fiber rather than the PHP thread. Synchronous `php.*` callbacks and `Js\Callback` keep their existing API, including nested calls. +JavaScript always executes on its dedicated worker, including callbacks invoked +from PHP. A synchronous PHP host call pauses its calling JS frame; the worker +can continue processing ready jobs and nested callback requests while PHP runs. Call `$js->close()` to interrupt JS, join its worker and release PHP capabilities. Close live runtimes before `fork()` and create new runtimes in the child; inherited runtimes reject entries in the child. diff --git a/src/engine.rs b/src/engine.rs index 68da43b..c8e0c45 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -302,11 +302,7 @@ impl Engine { start()?; loop { loop { - let event = self - .events - .borrow_mut() - .pop_front() - .or_else(|| self.port.receive().ok()); + let event = self.next_event(id)?; let Some(event) = event else { break }; self.event(event, waiter.is_some())?; } @@ -338,6 +334,72 @@ impl Engine { let waiter = id.and_then(|id| self.waiters.borrow().get(&id).cloned()); waiter.map_or(Ok(()), |waiter| waiter.wake()) } + fn host_waiter(&self, event: &Event) -> Option { + match event { + Event::Host { owner, .. } => owner + .filter(|id| self.waiters.borrow().contains_key(id)) + .or(self.message_waiter.get()), + _ => None, + } + } + fn next_event(&self, caller: u64) -> PhpResult> { + let index = self + .events + .borrow() + .iter() + .position(|event| self.host_waiter(event).is_none_or(|owner| owner == caller)); + if let Some(index) = index { + return Ok(self.events.borrow_mut().remove(index)); + } + while let Ok(event) = self.port.receive() { + if let Some(owner) = self.host_waiter(&event).filter(|owner| *owner != caller) { + self.events.borrow_mut().push_back(event); + self.wake(Some(owner))?; + } else { + return Ok(Some(event)); + } + } + Ok(None) + } + fn finish_host( + &self, + token: u64, + target: worker::HostTarget, + args: Vec, + ) -> PhpResult<()> { + let _host = CounterGuard::enter(&self.host_calls); + let result = match target { + worker::HostTarget::Named(name) => crate::bridge::host_call(&self.state, &name, args), + worker::HostTarget::Function(id) => crate::bridge::php_fn_call(&self.state, id, args), + }; + if let Err(error) = checked(Ok(())) { + self.port.close(); + return Err(error); + } + self.send(Command::HostReply { token, result }) + } + fn queue_host( + &self, + token: u64, + target: worker::HostTarget, + args: Vec, + ) -> PhpResult<()> { + let engine = self.weak.upgrade().expect("active engine"); + let call = RefCell::new(Some((target, args))); + let callback = Closure::wrap(Box::new(move || -> PhpResult<()> { + let Some((target, args)) = call.borrow_mut().take() else { + return Ok(()); + }; + if engine.ensure_open().is_err() { + return Ok(()); + } + engine.finish_host(token, target, args) + }) as Box PhpResult<()>>) + .into_zval(false)?; + let callback = call_php("Closure", "fromCallable", vec![&callback])?; + self.loop_call("queue", vec![&callback])?; + Ok(()) + } fn wake_all(&self) -> PhpResult<()> { let waiters = self.waiters.borrow().values().cloned().collect::>(); let mut error = None; @@ -428,24 +490,18 @@ impl Engine { self.wake(Some(id))?; } Event::Host { + owner, token, target, args, } => { - let _host = CounterGuard::enter(&self.host_calls); - let result = match target { - worker::HostTarget::Named(name) => { - crate::bridge::host_call(&self.state, &name, args) - } - worker::HostTarget::Function(id) => { - crate::bridge::php_fn_call(&self.state, id, args) - } - }; - if let Err(error) = checked(Ok(())) { - self.port.close(); - return Err(error); + if has_loop && !owner.is_some_and(|id| self.waiters.borrow().contains_key(&id)) { + // Background jobs must not borrow a message consumer's + // Fiber: the PHP callback may itself await that consumer. + self.queue_host(token, target, args)?; + } else { + self.finish_host(token, target, args)?; } - self.send(Command::HostReply { token, result })?; } Event::NeedsLoop { id } => { if !has_loop { @@ -503,12 +559,21 @@ impl Engine { } } if !engine.events.borrow().is_empty() { - let waiter = engine - .waiters + let owner = engine + .events .borrow() - .values() - .find(|w| w.sleeping.get()) - .cloned(); + .iter() + .find_map(|event| engine.host_waiter(event)); + let waiter = owner + .and_then(|id| engine.waiters.borrow().get(&id).cloned()) + .or_else(|| { + engine + .waiters + .borrow() + .values() + .find(|w| w.sleeping.get()) + .cloned() + }); if let Some(waiter) = waiter { waiter.wake()?; } diff --git a/src/worker.rs b/src/worker.rs index 290376e..826242f 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -151,6 +151,7 @@ pub enum Event { result: Result, }, Host { + owner: Option, token: u64, target: HostTarget, args: Vec, @@ -529,6 +530,7 @@ impl Driver { facade: RefCell::new(None), js_depth: Cell::new(0), reported_failure: Cell::new(false), + active_request: Cell::new(None), }); let _cleanup = SessionCleanup(session.clone()); install(&ctx, session.clone()).map_err(|e| error::js_error_message(&ctx, e))?; @@ -631,6 +633,13 @@ struct Session<'js> { facade: RefCell>>, js_depth: Cell, reported_failure: Cell, + active_request: Cell>, +} +struct RequestScope<'a>(&'a Cell>, Option); +impl Drop for RequestScope<'_> { + fn drop(&mut self) { + self.0.set(self.1); + } } // Native closures retain Session. Break its JS roots before the context is freed, // including panic unwinding; otherwise a cached facade could retain the runtime. @@ -817,6 +826,10 @@ impl<'js> Session<'js> { Ok(command.is_none_or(|command| self.command(ctx, command))) } fn request(&self, ctx: &Ctx<'js>, request: Request) { + let _owner = RequestScope( + &self.active_request, + self.active_request.replace(Some(request.id)), + ); let Request { id, op, @@ -1177,6 +1190,7 @@ impl<'js> Session<'js> { self.driver.token.set(token); self.replies.borrow_mut().insert(token, None); self.driver.emit(Event::Host { + owner: self.active_request.get(), token, target, args, diff --git a/tests/php/22_reentrant_rpc.php b/tests/php/22_reentrant_rpc.php new file mode 100644 index 0000000..0da9c02 --- /dev/null +++ b/tests/php/22_reentrant_rpc.php @@ -0,0 +1,131 @@ +register('map', static function (array $values, callable $callback) use (&$maps): array { + ++$maps; + return array_map($callback, $values); +}); +eq([2, 4, 6], $js->eval('php.map([1, 2, 3], n => n * 2)'), 'ordinary API supports synchronous nested callbacks'); +eq([[2, 4], [4, 6]], $js->eval('php.map([1, 2], n => php.map([n, n+1], m => m*2))'), 'nested PHP calls retain RPC semantics'); +eq(4, $maps, 'every PHP map is called exactly once'); +$js->register('identity', static fn($value) => $value); +eq([['text' => 'hello', 'bytes' => "\0\xff", 'flag' => true, 'nil' => null]], $js->eval('php.map([1], () => php.identity({text:"hello", bytes:new Uint8Array([0,255]), flag:true, nil:null}))'), 'nested RPC retains generic typed marshaling'); + +$js->register('recover', static function (callable $callback): string { + try { $callback(); } catch (Throwable $error) { return $error->getMessage(); } + return 'missing error'; +}); +eq('nested failure', $js->eval('php.recover(() => { throw new Error("nested failure"); })'), 'JS exceptions cross the RPC callback boundary'); +$js->register('throwPhp', static fn() => throw new LogicException('php failure')); +eq('LogicException:php failure', $js->eval('php.map([1], () => { try { php.throwPhp(); } catch(e) { return e.phpClass+":"+e.message; } })[0]'), 'nested PHP exceptions retain their class'); +eq(42, $js->eval('42'), 'worker resumes after nested exceptions'); + +$js->register('apply', static fn(callable $callback) => $callback()); +eq(42, $js->eval('php.apply(async () => { await 0; await 0; return 42; })'), 'nested Promise executes ready jobs'); +$js->register('later', static function (callable $resolve): void { + EventLoop::delay(.002, static fn() => $resolve(42)); +}); +eq(42, $js->eval('php.apply(() => new Promise(resolve => php.later(resolve)))'), 'nested Promise resumes through external I/O'); + +$js->register('sleep', static function (int $n): int { Amp\delay(.001); return $n * 2; }); +eq([2,4,6], Amp\async(static fn() => $js->eval('php.map([1,2,3], n => php.sleep(n))'))->await(), 'a nested PHP callback may suspend its Fiber'); + +$saved = $js->eval('n => php.map([n], m => m+1)[0]'); +$js->register('otherFiber', static function () use ($saved): int { + $future = Amp\async(static fn() => $saved(41)); + return $future->await(); +}); +eq(42, $js->eval('php.otherFiber()'), 'another Fiber can enter the exclusively borrowed session'); + +$post = $js->eval('value => quickjs.postMessage(value)'); +$consumer = Amp\async(static fn() => $js->awaitMessages()); +Amp\delay(.001); +$js->register('postAndWait', static function () use ($post, $consumer): int { + $post->post(42); + return $consumer->await()[0]; +}); +eq(42, $js->eval('php.postAndWait()'), 'posted calls wake a separate consumer before the PHP host call returns'); + +$sync = $js->eval('n => n'); +$push = $js->eval('n => quickjs.postMessage(n)'); +$js->register('finishQueued', static fn() => $sync(42)); +$busy = $js->eval('() => { const end=Date.now()+20; while(Date.now() $busy()); +Amp\delay(.001); +$push->post(7); +unset($push); +eq(42, $inFlight->await(), 'nested callbacks preserve earlier post admission'); +eq([7], $js->awaitMessages(), 'queued posts run before their deferred function deletion'); + +$emitBackground = $js->eval('value => quickjs.postMessage(value)'); +$backgroundConsumer = Amp\async(static fn() => $js->awaitMessages()); +Amp\delay(.001); +$backgroundDone = false; +$js->register('backgroundHost', static function () use ($emitBackground, $backgroundConsumer, &$backgroundDone): int { + $emitBackground->post(11); + $result = $backgroundConsumer->await()[0]; + $backgroundDone = true; + return $result; +}); +$js->eval('Promise.resolve().then(() => { globalThis.backgroundResult = php.backgroundHost(); }); null'); +eq([11], $backgroundConsumer->await(), 'background host calls can await an independent message consumer'); +while (!$backgroundDone) Amp\delay(.001); +eq(11, $js->eval('backgroundResult'), 'background host replies resume their suspended JS job'); + +$close = new QuickJS(); +$close->register('apply', static fn(callable $callback) => $callback()); +$close->register('close', static fn() => $close->close()); +throws(static fn() => $close->eval('php.apply(() => php.close())'), Throwable::class, 'close from a nested host call does not deadlock'); +$close->close(); + +$queued = new QuickJS(); +$called = false; +$queued->register('queued', static function () use (&$called): void { $called = true; }); +$queuedCall = $queued->eval('() => php.queued()'); +$queuedCall->post(); +usleep(5000); // Let the worker enqueue its host request without running Revolt. +$start = hrtime(true); +$queued->close(); +eq(false, $called, 'close discards an unconsumed host request'); +ok((hrtime(true)-$start)/1e6 < 1000, 'close joins a worker parked on an unconsumed host request'); + +for ($i = 0; $i < 20; ++$i) { + $abandoned = new QuickJS(); + $abandoned->register('noop', static fn() => null); + $abandonedCall = $abandoned->eval('() => php.noop()'); + $abandonedCall->post(); + unset($abandonedCall, $abandoned); +} +ok(true, 'destruction joins workers racing with unconsumed host requests'); + +$doomed = new QuickJS(); +$doomed->register('apply', static fn(callable $callback) => $callback()); +$paused = false; +$doomed->register('pause', static function () use (&$paused): void { + $paused = true; + Fiber::suspend(); +}); +$dying = new Fiber(static fn() => $doomed->eval('php.apply(() => php.pause())')); +$dying->start(); +Amp\delay(.01); +eq(true, $paused, 'nested host callback is suspended before Fiber destruction'); +unset($dying); +gc_collect_cycles(); +throws(static fn() => $doomed->eval('42'), Throwable::class, 'destroying a host Fiber closes the runtime without retaining its pending RPC'); +$doomed->close(); + +$budget = new QuickJS(timeoutMs: 20); +$budget->register('apply', static fn(callable $callback) => $callback()); +throws(static fn() => $budget->eval('php.apply(() => { while(true) {} })'), QuickJSTimeoutException::class, 'nested JS callbacks retain execution deadlines'); +eq(42, $budget->eval('42'), 'runtime recovers after nested callback timeout'); +throws(static fn() => $budget->eval('php.apply(() => new Promise(() => {}))'), QuickJSTimeoutException::class, 'nested external wait expires during a PHP host call'); +eq(42, $budget->eval('42'), 'runtime recovers after nested Promise timeout'); +$budget->close(); +$js->close(); +eq([], EventLoop::getIdentifiers(), 'nested RPC waits leave no watchers'); +done(); diff --git a/tests/php/24_worker_progress.php b/tests/php/24_worker_progress.php new file mode 100644 index 0000000..f663a33 --- /dev/null +++ b/tests/php/24_worker_progress.php @@ -0,0 +1,21 @@ +eval('() => { Promise.resolve().then(() => quickjs.postMessage("worker progressed")); }', false); +$js->register('blockPhp', static function () use ($js, $kick): array { + $kick->post(); + // No event-loop turns or JS entries after post(): only the independent + // worker can execute the queued microtask while this PHP call is active. + for ($i = 0; $i < 200; ++$i) { + usleep(1000); + $messages = $js->drainMessages(); + if ($messages !== []) return $messages; + } + return []; +}); +eq(['worker progressed'], $js->eval('php.blockPhp()', false), 'worker executes JS jobs while its PHP host callback blocks'); +eq(42, $js->eval('42', false), 'runtime remains usable after concurrent PHP and JS progress'); +$js->close(); +done(); From 45e1617e667afcaf258dbd13b2d4618b734db480 Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Tue, 27 Oct 2026 00:17:06 +0100 Subject: [PATCH 27/29] Drive worker RPC and Promise completion through one event loop Replace periodic polling and the second async executor with command-driven waits and bounded microtask checkpoints. Complete only ready Promises, preserve nested synchronous RPC, wake every notified PHP owner, and avoid redundant callback lookups and suspensions. --- Cargo.lock | 31 --- Cargo.toml | 1 - docs/architecture.md | 9 +- src/engine.rs | 73 ++++-- src/js/runtime.js | 5 +- src/marshal.rs | 8 +- src/worker.rs | 452 ++++++++++++++++------------------ tests/php/25_event_driven.php | 57 +++++ 8 files changed, 323 insertions(+), 313 deletions(-) create mode 100644 tests/php/25_event_driven.php diff --git a/Cargo.lock b/Cargo.lock index 00b7209..fbf0a81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,17 +40,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - [[package]] name = "autocfg" version = "1.5.1" @@ -505,25 +494,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0440157235aa4badd45ea073af8de2ba44ffead74bf8a7833b215533c36f08e" -[[package]] -name = "event-listener" -version = "5.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" -dependencies = [ - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - [[package]] name = "ext-php-rs" version = "0.16.3" @@ -1936,7 +1906,6 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6657979a24d543e5fc66f32cef2f47ca82ce8eb55cec5f7d59a12b7b570bbf24" dependencies = [ - "async-lock", "hashbrown", "relative-path", "rquickjs-sys", diff --git a/Cargo.toml b/Cargo.toml index 2d1ef45..7d6b6b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,6 @@ rquickjs = { version = "0.14.0", default-features = false, features = [ "macro", "classes", "array-buffer", - "futures", ] } serde_json = "1.0.154" diff --git a/docs/architecture.md b/docs/architecture.md index 752884a..fa73201 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -103,7 +103,7 @@ Rust converts these references without JS-side wrapping or decoding. ### Invoking a JS function from PHP -`Js\Callback::__invoke` (`callback.rs`) looks up the function with `__getJsFn`, +`Js\Callback::__invoke` (`callback.rs`) looks up the function directly in the JS registry, converts its arguments, invokes it, and awaits any returned Promise. The PHP wrapper holds an integer function ID and an owning engine reference; @@ -112,8 +112,11 @@ stay on the PHP side. Owned `MiddleValue` data is `Send`; no unsafe `Send` shim used to move a runtime, Zend value, context or function between threads. `worker.rs` owns the runtime and processes requests, host replies and ready jobs. -Returned Promises are tracked independently. A synchronous host wait can process -nested function requests, preventing PHP -> JS -> PHP deadlocks. A depth cap of +Returned Promises are tracked by request ID; their intrinsic completion handlers +append only ready IDs to a local queue. There is no second async executor or +periodic polling. The worker blocks on the command channel until a command or +the nearest execution deadline. Synchronous host waits use the same dispatcher +and service nested function requests, preventing PHP -> JS -> PHP deadlocks. A depth cap of 200 and inherited deadlines bound recursion. Isolated runtimes cancel remaining callback requests before releasing their context. diff --git a/src/engine.rs b/src/engine.rs index c8e0c45..82a14d1 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -12,7 +12,7 @@ use ext_php_rs::{ zend::{ExecutorGlobals, Function as PhpFunction}, }; use std::{ - cell::{Cell, RefCell}, + cell::{Cell, OnceCell, RefCell}, collections::{HashMap, VecDeque}, io::Read, rc::{Rc, Weak}, @@ -41,7 +41,7 @@ pub struct Engine { weak: Weak, } struct Waiter { - suspension: Zval, + suspension: OnceCell, sleeping: Cell, } unsafe extern "C" { @@ -50,7 +50,12 @@ unsafe extern "C" { impl Waiter { fn wake(&self) -> PhpResult<()> { if self.sleeping.replace(false) { - checked(self.suspension.try_call_method("resume", vec![]))?; + checked( + self.suspension + .get() + .expect("sleeping waiter") + .try_call_method("resume", vec![]), + )?; } Ok(()) } @@ -285,13 +290,10 @@ impl Engine { "Too many pending QuickJS operations (256)".into(), )); } - let suspension = self - .loop_function("getSuspension") - .map(|function| checked(function.try_call(vec![]))) - .transpose()?; - let waiter = suspension.map(|suspension| { + let get_suspension = self.loop_function("getSuspension"); + let waiter = get_suspension.map(|_| { Rc::new(Waiter { - suspension, + suspension: OnceCell::new(), sleeping: Cell::new(false), }) }); @@ -311,9 +313,24 @@ impl Engine { } self.ensure_open()?; if let Some(waiter) = &waiter { + if waiter.suspension.get().is_none() { + let suspension = + checked(get_suspension.expect("event loop").try_call(vec![]))?; + let _ = waiter.suspension.set(suspension); + } self.ensure_watcher()?; + // Setup may outlive a short worker call. Drain once more before + // entering Revolt; the descriptor covers a later completion. + if let Some(event) = self.next_event(id)? { + self.event(event, true)?; + continue; + } waiter.sleeping.set(true); - let result = waiter.suspension.try_call_method("suspend", vec![]); + let result = waiter + .suspension + .get() + .expect("initialized suspension") + .try_call_method("suspend", vec![]); waiter.sleeping.set(false); checked(result)?; } else { @@ -451,7 +468,6 @@ impl Engine { Ok(()) } fn request(&self, op: Operation) -> PhpResult { - self.ensure_open()?; let id = self.next_id(); let eval = matches!(&op, Operation::Eval { .. }); let parent = self.active_eval.get(); @@ -558,22 +574,31 @@ impl Engine { event => engine.events.borrow_mut().push_back(event), } } - if !engine.events.borrow().is_empty() { - let owner = engine + // A single native notification can carry host calls for several + // Fibers. Wake every owner, otherwise the last nested host call + // may wait forever behind an earlier owner's queued event. + let owners = engine + .events + .borrow() + .iter() + .filter_map(|event| engine.host_waiter(event)) + .collect::>(); + for owner in owners { + engine.wake(Some(owner))?; + } + if !engine.events.borrow().is_empty() + && engine .events .borrow() .iter() - .find_map(|event| engine.host_waiter(event)); - let waiter = owner - .and_then(|id| engine.waiters.borrow().get(&id).cloned()) - .or_else(|| { - engine - .waiters - .borrow() - .values() - .find(|w| w.sleeping.get()) - .cloned() - }); + .all(|event| engine.host_waiter(event).is_none()) + { + let waiter = engine + .waiters + .borrow() + .values() + .find(|w| w.sleeping.get()) + .cloned(); if let Some(waiter) = waiter { waiter.wake()?; } diff --git a/src/js/runtime.js b/src/js/runtime.js index 3b7ed1c..805b1d1 100644 --- a/src/js/runtime.js +++ b/src/js/runtime.js @@ -12,8 +12,6 @@ if (!globalThis.__registerJsFn) { jsFns[id] = fn; return id; } - 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)); @@ -21,9 +19,8 @@ if (!globalThis.__registerJsFn) { } globalThis.__registerJsFn = registerFn; - globalThis.__getJsFn = getFn; + Object.defineProperty(globalThis, "__jsFunctions", {value: jsFns}); globalThis.__makePhpFn = makePhpFn; - globalThis.__deleteJsFn = deleteFn; globalThis.__asPromise = function (value) { return value !== null && (typeof value === "object" || typeof value === "function") diff --git a/src/marshal.rs b/src/marshal.rs index c85989a..285645a 100644 --- a/src/marshal.rs +++ b/src/marshal.rs @@ -89,8 +89,8 @@ pub fn js_to_middle<'js>(ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result< let result = conversion.convert(value, 0); if result.is_err() { for id in conversion.registered { - if let Ok(del) = ctx.globals().get::<_, Function>("__deleteJsFn") { - let _ = del.call::<_, ()>((id as f64,)); + if let Ok(functions) = ctx.globals().get::<_, Object>("__jsFunctions") { + let _ = functions.remove(id as f64); } } } @@ -239,8 +239,8 @@ pub fn middle_into_js<'js>(ctx: &Ctx<'js>, value: MiddleValue) -> rquickjs::Resu } // Reconstruct callables from the JS-side helpers. MiddleValue::JsFn(id) => { - let get: Function = ctx.globals().get("__getJsFn")?; - get.call((id as f64,))? + let functions: Object = ctx.globals().get("__jsFunctions")?; + functions.get(id as f64)? } MiddleValue::PhpFn(id) => { let make: Function = ctx.globals().get("__makePhpFn")?; diff --git a/src/worker.rs b/src/worker.rs index 826242f..be69716 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -7,49 +7,21 @@ use crate::{ transpile::{TranspileCache, Transpiled}, }; use ext_php_rs::prelude::PhpException; -use rquickjs::{ - promise::PromiseFuture, AsyncContext, AsyncRuntime, Ctx, Exception, Function, Value, -}; +use rquickjs::{function::This, Context, Ctx, Exception, Function, Promise, Runtime, Value}; use std::{ cell::{Cell, RefCell}, collections::{HashMap, HashSet, VecDeque}, fs::File, - future::{poll_fn, Future}, io::Write, - pin::{pin, Pin}, rc::Rc, sync::{ atomic::{AtomicBool, AtomicUsize, Ordering}, mpsc, Arc, Mutex, }, - task::{Context as TaskContext, Poll, Wake, Waker}, thread::{self, JoinHandle}, time::{Duration, Instant}, }; -struct WorkerWake(thread::Thread); -impl Wake for WorkerWake { - fn wake(self: Arc) { - self.0.unpark(); - } -} -fn block_on(deadline: &Cell>, future: impl Future) -> T { - let waker = Waker::from(Arc::new(WorkerWake(thread::current()))); - let mut context = TaskContext::from_waker(&waker); - let mut future = pin!(future); - loop { - match future.as_mut().poll(&mut context) { - Poll::Ready(result) => return result, - Poll::Pending => match deadline.get() { - Some(deadline) => { - thread::park_timeout(deadline.saturating_duration_since(Instant::now())) - } - None => thread::park(), - }, - } - } -} - #[cfg(target_os = "linux")] fn notification_pair() -> std::io::Result<(File, File)> { use std::os::fd::FromRawFd; @@ -280,7 +252,6 @@ impl Port { config, token: Cell::new(0), notification_pending: Cell::new(false), - thread: thread::current(), }); let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| driver.run(ready_tx))); @@ -336,9 +307,6 @@ impl Port { self.tx .try_send(command) .map_err(|_| WireError::Internal("QuickJS command queue is full or closed".into()))?; - if let Some(worker) = self.worker.borrow().as_ref() { - worker.thread().unpark(); - } Ok(()) } pub fn receive(&self) -> Result { @@ -389,7 +357,6 @@ impl Port { self.signals.stop.store(true, Ordering::SeqCst); let _ = self.tx.try_send(Command::Stop); if let Some(worker) = self.worker.borrow_mut().take() { - worker.thread().unpark(); let _ = worker.join(); } } @@ -417,7 +384,6 @@ struct Driver { config: Config, token: Cell, notification_pending: Cell, - thread: thread::Thread, } impl Driver { fn notify(&self) { @@ -483,121 +449,82 @@ impl Driver { first: Option, ready: Option>>, ) -> Result<(), String> { + let rt = Runtime::new().map_err(|e| e.to_string())?; + if self.config.memory_limit > 0 { + rt.set_memory_limit(self.config.memory_limit); + } + if self.config.max_stack > 0 { + rt.set_max_stack_size(self.config.max_stack); + } let deadline = Rc::new(Cell::new(None::)); - let wake_deadline = deadline.clone(); - block_on(&wake_deadline, async { - let rt = AsyncRuntime::new().map_err(|e| e.to_string())?; - if self.config.memory_limit > 0 { - rt.set_memory_limit(self.config.memory_limit).await; + let timed_out = Rc::new(Cell::new(false)); + let interrupted = self.signals.clone(); + let dl = deadline.clone(); + let expired = timed_out.clone(); + rt.set_interrupt_handler(Some(Box::new(move || { + if interrupted.stop.load(Ordering::SeqCst) { + return true; } - if self.config.max_stack > 0 { - rt.set_max_stack_size(self.config.max_stack).await; + if dl.get().is_some_and(|d| Instant::now() >= d) { + expired.set(true); + return true; } - let timed_out = Rc::new(Cell::new(false)); - let interrupted = self.signals.clone(); - let dl = deadline.clone(); - let expired = timed_out.clone(); - rt.set_interrupt_handler(Some(Box::new(move || { - if interrupted.stop.load(Ordering::SeqCst) { - return true; - } - if dl.get().is_some_and(|d| Instant::now() >= d) { - expired.set(true); - return true; - } - false - }))) - .await; - let context = AsyncContext::full(&rt).await.map_err(|e| e.to_string())?; - let root = first.as_ref().map(|r| r.id); - context - .async_with(async move |ctx| { - let session = Rc::new(Session { - driver: self.clone(), - pending: RefCell::new(Vec::new()), - turn_deadline: Cell::new(None), - service: RefCell::new(None), - service_queued: Cell::new(false), - posted: RefCell::new(HashSet::new()), - capacity: RefCell::new(VecDeque::new()), - replies: RefCell::new(HashMap::new()), - entries: RefCell::new(Vec::new()), - deadline, - timed_out, - failed: RefCell::new(VecDeque::new()), - root, - root_finished: Cell::new(false), - facade: RefCell::new(None), - js_depth: Cell::new(0), - reported_failure: Cell::new(false), - active_request: Cell::new(None), - }); - let _cleanup = SessionCleanup(session.clone()); - install(&ctx, session.clone()).map_err(|e| error::js_error_message(&ctx, e))?; - let service = session.clone(); - *session.service.borrow_mut() = Some( - Function::new(ctx.clone(), move || -> rquickjs::Result<()> { - let ctx = service.service.borrow().as_ref().unwrap().ctx().clone(); - service.service_queued.set(false); - service.tick(&ctx); - service.schedule(&ctx); - Ok(()) - }) - .map_err(|e| error::js_error_message(&ctx, e))?, - ); - if let Some(ready) = ready { - let _ = ready.send(Ok(())); - } - if let Some(first) = first { - session.request(&ctx, first); - } - let mut turn = None; - poll_fn(|_| { - drop(turn.take()); - loop { - if !session.tick(&ctx) { - return Poll::Ready(()); - } - if session.has_awaitable() { - session.schedule(&ctx); - if !jobs_pending(&ctx) { - session.ask_loop(); - } - self.flush_notifications(); - turn = Some(session.enter(session.deadline.get(), true)); - return Poll::Pending; - } - // No async work: block on the command channel without spinning - // or implicitly draining jobs requested through the manual API. - self.flush_notifications(); - match self.commands.recv() { - Ok(command) => { - if !session.command(&ctx, command) { - return Poll::Ready(()); - } - } - _ => return Poll::Ready(()), - } - } - }) - .await; - let pending = std::mem::take(&mut *session.pending.borrow_mut()); - for item in pending { - self.emit(Event::Done { - id: item.id, - result: Err(WireError::Internal( - if self.signals.stop.load(Ordering::SeqCst) { - "QuickJS worker is closed".into() - } else { - "JS callback canceled: owning isolated eval ended".into() - }, - )), - }); - } - session.replies.borrow_mut().clear(); - Ok(()) - }) - .await + false + }))); + let context = Context::full(&rt).map_err(|e| e.to_string())?; + let root = first.as_ref().map(|r| r.id); + context.with(|ctx| { + let promise: rquickjs::Object = + ctx.globals().get("Promise").map_err(|e| e.to_string())?; + let prototype: rquickjs::Object = + promise.get("prototype").map_err(|e| e.to_string())?; + let then: Function = prototype.get("then").map_err(|e| e.to_string())?; + let session = Rc::new(Session { + driver: self.clone(), + pending: RefCell::new(HashMap::new()), + ready: Rc::new(RefCell::new(VecDeque::new())), + then: RefCell::new(Some(then)), + deferred_ready: RefCell::new(Vec::new()), + turn_deadline: Cell::new(None), + posted: RefCell::new(HashSet::new()), + capacity: RefCell::new(VecDeque::new()), + replies: RefCell::new(HashMap::new()), + entries: RefCell::new(Vec::new()), + deadline, + timed_out, + failed: RefCell::new(VecDeque::new()), + root, + root_finished: Cell::new(false), + facade: RefCell::new(None), + functions: RefCell::new(None), + js_depth: Cell::new(0), + reported_failure: Cell::new(false), + active_request: Cell::new(None), + }); + let _cleanup = SessionCleanup(session.clone()); + install(&ctx, session.clone()).map_err(|e| error::js_error_message(&ctx, e))?; + if let Some(ready) = ready { + let _ = ready.send(Ok(())); + } + if let Some(first) = first { + session.request(&ctx, first); + } + while session.step(&ctx, None) {} + let pending = std::mem::take(&mut *session.pending.borrow_mut()); + for (id, _) in pending { + self.emit(Event::Done { + id, + result: Err(WireError::Internal( + if self.signals.stop.load(Ordering::SeqCst) { + "QuickJS worker is closed".into() + } else { + "JS callback canceled: owning isolated eval ended".into() + }, + )), + }); + } + session.replies.borrow_mut().clear(); + Ok(()) }) } } @@ -609,7 +536,7 @@ struct ErrorContext { struct Pending<'js> { id: u64, parent: Option, - future: RefCell>>, + promise: Promise<'js>, context: ErrorContext, deadline: Option, asked: Cell, @@ -617,10 +544,11 @@ struct Pending<'js> { } struct Session<'js> { driver: Rc, - pending: RefCell>>>, + pending: RefCell>>>, + ready: Rc>>, + then: RefCell>>, + deferred_ready: RefCell>, turn_deadline: Cell>, - service: RefCell>>, - service_queued: Cell, posted: RefCell>, capacity: RefCell)>>, replies: RefCell>>>, @@ -631,6 +559,7 @@ struct Session<'js> { root: Option, root_finished: Cell, facade: RefCell>>, + functions: RefCell>>, js_depth: Cell, reported_failure: Cell, active_request: Cell>, @@ -653,6 +582,10 @@ impl Drop for JsEntry<'_, '_> { fn drop(&mut self) { if self.depth { self.session.js_depth.set(self.session.js_depth.get() - 1); + let deferred = std::mem::take(&mut *self.session.deferred_ready.borrow_mut()); + if !deferred.is_empty() { + self.session.ready.borrow_mut().extend(deferred); + } } if self.budget { self.session.entries.borrow_mut().pop(); @@ -664,7 +597,10 @@ impl Drop for SessionCleanup<'_> { fn drop(&mut self) { self.0.pending.borrow_mut().clear(); self.0.facade.borrow_mut().take(); - self.0.service.borrow_mut().take(); + self.0.functions.borrow_mut().take(); + self.0.then.borrow_mut().take(); + self.0.ready.borrow_mut().clear(); + self.0.deferred_ready.borrow_mut().clear(); } } fn jobs_pending(ctx: &Ctx<'_>) -> bool { @@ -714,7 +650,7 @@ impl<'js> Session<'js> { self.deadline.set(if entries.is_empty() { self.pending .borrow() - .iter() + .values() .filter_map(|p| p.deadline) .min() .or(self.turn_deadline.get()) @@ -738,7 +674,7 @@ impl<'js> Session<'js> { } } fn finish(&self, id: u64, result: Result) { - self.pending.borrow_mut().retain(|p| p.id != id); + self.pending.borrow_mut().remove(&id); if result.is_err() { let mut failed = self.failed.borrow_mut(); failed.push_back(id); @@ -747,7 +683,7 @@ impl<'js> Session<'js> { } drop(failed); let mut canceled = Vec::new(); - self.pending.borrow_mut().retain(|p| { + self.pending.borrow_mut().retain(|_, p| { if p.parent == Some(id) { canceled.push(p.id); false @@ -812,18 +748,27 @@ impl<'js> Session<'js> { } true } - fn step(&self, ctx: &Ctx<'js>) -> Result { + fn wait(&self, ctx: &Ctx<'js>) -> bool { self.driver.flush_notifications(); - let command = if jobs_pending(ctx) && self.has_awaitable() { - self.driver.commands.try_recv().ok() - } else { - match self.driver.commands.recv_timeout(Duration::from_millis(5)) { - Ok(command) => Some(command), - Err(mpsc::RecvTimeoutError::Timeout) => None, - Err(mpsc::RecvTimeoutError::Disconnected) => return Err(mpsc::RecvError), - } + if !self.ready.borrow().is_empty() { + return true; + } + let command = match self.deadline.get() { + Some(deadline) => match self + .driver + .commands + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + { + Ok(command) => command, + Err(mpsc::RecvTimeoutError::Timeout) => return true, + Err(mpsc::RecvTimeoutError::Disconnected) => return false, + }, + None => match self.driver.commands.recv() { + Ok(command) => command, + Err(_) => return false, + }, }; - Ok(command.is_none_or(|command| self.command(ctx, command))) + self.command(ctx, command) } fn request(&self, ctx: &Ctx<'js>, request: Request) { let _owner = RequestScope( @@ -868,7 +813,7 @@ impl<'js> Session<'js> { .chain( self.pending .borrow() - .iter() + .values() .filter(|p| Some(p.id) == parent) .filter_map(|p| p.deadline), ) @@ -919,15 +864,26 @@ impl<'js> Session<'js> { if let Some(result) = promise.result::() { return result.map(Some); } - self.pending.borrow_mut().push(Rc::new(Pending { + self.pending.borrow_mut().insert( id, - parent, - future: RefCell::new(promise.into_future()), - context: context.clone(), - deadline, - asked: Cell::new(false), - settle_depth: self.js_depth.get() - 1, - })); + Rc::new(Pending { + id, + parent, + promise: promise.clone(), + context: context.clone(), + deadline, + asked: Cell::new(false), + settle_depth: self.js_depth.get() - 1, + }), + ); + let ready = self.ready.clone(); + let notify = Function::new(ctx.clone(), move || { + ready.borrow_mut().push_back(id); + })?; + // Observe the native Promise with the pristine intrinsic, + // without a Rust executor or guest-overridable .then lookup. + let then = self.then.borrow().as_ref().unwrap().clone(); + then.call::<_, ()>((This(promise), notify.clone(), notify))?; return Ok(None); } } @@ -947,9 +903,9 @@ impl<'js> Session<'js> { ) -> Result<(Value<'js>, ErrorContext), WireError> { let prep = (|| -> rquickjs::Result<()> { if !deletions.is_empty() { - let delete: Function = ctx.globals().get("__deleteJsFn")?; + let functions = self.functions.borrow().as_ref().unwrap().clone(); for id in deletions { - delete.call::<_, ()>((*id as f64,))?; + functions.remove(*id as f64)?; } } if names.is_none() && !matches!(&op, Operation::Eval { .. }) { @@ -998,8 +954,8 @@ impl<'js> Session<'js> { } Operation::Call { id, args, .. } => { let result = (|| { - let get: Function = ctx.globals().get("__getJsFn")?; - let function: Function = get.call((id as f64,))?; + let function: Function = + self.functions.borrow().as_ref().unwrap().get(id as f64)?; let mut call_args = rquickjs::function::Args::new(ctx.clone(), args.len()); for arg in args { call_args.push_arg(middle_into_js(ctx, arg)?)?; @@ -1049,87 +1005,79 @@ impl<'js> Session<'js> { Ok(result > 0) } fn settle(&self, ctx: &Ctx<'js>) { - // Release collection borrows before polling: Promise.then may enter PHP - // and recursively add, cancel or settle another callback. - let pending = self.pending.borrow().clone(); - for item in pending { + if self.pending.borrow().is_empty() { + self.ready.borrow_mut().clear(); + return; + } + let ready = std::mem::take(&mut *self.ready.borrow_mut()); + for id in ready { + let item = self.pending.borrow().get(&id).cloned(); + let Some(item) = item else { continue }; if item.settle_depth < self.js_depth.get() { + self.deferred_ready.borrow_mut().push(id); continue; } - let Ok(mut future) = item.future.try_borrow_mut() else { - continue; - }; - let result = if item.deadline.is_some_and(|d| Instant::now() >= d) { - None - } else { - let Poll::Ready(result) = - Pin::new(&mut *future).poll(&mut TaskContext::from_waker(Waker::noop())) - else { - continue; - }; - Some(result) - }; - if self.pending.borrow().iter().any(|p| p.id == item.id) { - match result { - Some(result) => self.finish_value(item.id, ctx, result, &item.context), - None => self.finish(item.id, Err(WireError::Timeout)), - } + if let Some(result) = item.promise.result::() { + self.finish_value(id, ctx, result, &item.context); + } + } + if self.driver.config.timeout_ms > 0 { + let expired = self + .pending + .borrow() + .values() + .filter(|p| p.deadline.is_some_and(|d| Instant::now() >= d)) + .map(|p| p.id) + .collect::>(); + for id in expired { + self.finish(id, Err(WireError::Timeout)); } } self.update_deadline(); } - fn tick(&self, ctx: &Ctx<'js>) -> bool { + fn step(&self, ctx: &Ctx<'js>, host: Option) -> bool { if self.driver.signals.stop.load(Ordering::SeqCst) || self.root_finished.get() { return false; } let had_pending = !self.pending.borrow().is_empty(); self.settle(ctx); - if self.root_finished.get() { - return false; - } if self.timed_out.replace(false) && !had_pending { self.driver.signals.pump.store(false, Ordering::SeqCst); self.driver.emit(Event::AsyncError(WireError::Timeout)); } - for index in 0..64 { - let command = self.driver.commands.try_recv().ok(); - let Some(command) = command else { break }; - if !self.command(ctx, command) || self.root_finished.get() { - return false; - } - if index == 63 { - // Notifications coalesce. A full quantum may leave commands - // queued even when no JS jobs can wake the executor again. - self.driver.thread.unpark(); + // Commands are JS turns. Checkpoint their microtasks before admitting + // another command, but bound the batch so an infinite chain cannot + // starve PHP replies, cancellation or close. + for _ in 0..64 { + if !self.has_awaitable() + || !jobs_pending(ctx) + || self.root_finished.get() + || self.driver.signals.stop.load(Ordering::SeqCst) + { + break; } + self.advance(ctx); } - self.driver.flush_notifications(); - !self.driver.signals.stop.load(Ordering::SeqCst) && !self.root_finished.get() - } - fn schedule(&self, ctx: &Ctx<'js>) { - if self.driver.signals.stop.load(Ordering::SeqCst) || self.root_finished.get() { - return; + if self.root_finished.get() || self.driver.signals.stop.load(Ordering::SeqCst) { + return false; } - if jobs_pending(ctx) { - if self.turn_deadline.get().is_none() && self.driver.config.timeout_ms > 0 { - self.turn_deadline.set(Some( - Instant::now() + Duration::from_millis(self.driver.config.timeout_ms), - )); - self.update_deadline(); - } - if !self.service_queued.replace(true) { - if let Err(error) = self.service.borrow().as_ref().unwrap().defer(()) { - self.service_queued.set(false); - self.driver.emit(Event::Fatal(self.error( - ctx, - error, - &ErrorContext::default(), - ))); + self.settle(ctx); + // A nested job may consume an ancestor's reply. Do not sleep after + // that reply has become ready on this same worker thread. + if host.is_some_and(|id| self.replies.borrow().get(&id).is_some_and(Option::is_some)) { + return true; + } + self.driver.flush_notifications(); + match self.driver.commands.try_recv() { + Ok(command) => self.command(ctx, command), + Err(mpsc::TryRecvError::Disconnected) => false, + Err(mpsc::TryRecvError::Empty) => { + if self.has_awaitable() && jobs_pending(ctx) { + return true; } + self.ask_loop(); + self.wait(ctx) } - } else { - self.turn_deadline.set(None); - self.update_deadline(); } } fn has_awaitable(&self) -> bool { @@ -1137,7 +1085,7 @@ impl<'js> Session<'js> { || self .pending .borrow() - .iter() + .values() .any(|p| p.settle_depth >= self.js_depth.get()) } fn advance(&self, ctx: &Ctx<'js>) { @@ -1145,10 +1093,20 @@ impl<'js> Session<'js> { if !self.has_awaitable() { return; } + if !jobs_pending(ctx) { + self.turn_deadline.set(None); + return; + } + if self.turn_deadline.get().is_none() && self.driver.config.timeout_ms > 0 { + self.turn_deadline.set(Some( + Instant::now() + Duration::from_millis(self.driver.config.timeout_ms), + )); + self.update_deadline(); + } let (id, context) = self .pending .borrow() - .iter() + .values() .find(|p| p.settle_depth >= self.js_depth.get()) .map(|p| (Some(p.id), p.context.clone())) .unwrap_or_default(); @@ -1161,13 +1119,17 @@ impl<'js> Session<'js> { self.driver.emit(Event::AsyncError(error)); } } + if !jobs_pending(ctx) { + self.turn_deadline.set(None); + } self.settle(ctx); } fn ask_loop(&self) { for pending in self .pending .borrow() - .iter() + .values() + .filter(|p| p.settle_depth >= self.js_depth.get()) .filter(|p| !p.asked.replace(true)) { self.driver.emit(Event::NeedsLoop { id: pending.id }); @@ -1217,11 +1179,8 @@ impl<'js> Session<'js> { Err(error) => Err(error::throw_host_error(ctx, &error)), }; } - self.advance(ctx); - match self.step(ctx) { - Ok(true) => {} - Ok(false) => return Err(Exception::throw_internal(ctx, "QuickJS worker closed")), - Err(_) => return Err(Exception::throw_internal(ctx, "PHP receiver closed")), + if !self.step(ctx, Some(token)) { + return Err(Exception::throw_internal(ctx, "QuickJS worker closed")); } })(); self.replies.borrow_mut().remove(&token); @@ -1292,5 +1251,6 @@ fn install<'js>(ctx: &Ctx<'js>, session: Rc>) -> rquickjs::Result<( ctx.globals().set("quickjs", quickjs)?; ctx.eval::<(),_>("Object.freeze(globalThis.quickjs); Object.defineProperty(globalThis,'quickjs',{value:quickjs,writable:false,configurable:false})")?; ctx.eval::<(), _>(RUNTIME_JS)?; + *session.functions.borrow_mut() = Some(ctx.globals().get("__jsFunctions")?); Ok(()) } diff --git a/tests/php/25_event_driven.php b/tests/php/25_event_driven.php new file mode 100644 index 0000000..5d165a5 --- /dev/null +++ b/tests/php/25_event_driven.php @@ -0,0 +1,57 @@ +register('hold', static function ($resolve) use (&$callbacks, &$ready): void { + $callbacks[] = $resolve; + if (count($callbacks) === 16) $ready->complete(); +}); +$wait = $js->eval('() => new Promise(resolve => php.hold(resolve))', false); +try { + for ($round = 0; $round < 30; ++$round) { + $ready = new Amp\DeferredFuture(); + $tasks = []; + for ($i = 0; $i < 16; ++$i) $tasks[] = Amp\async(static fn() => $wait()); + $ready->getFuture()->await(new Amp\TimeoutCancellation(2)); + foreach ($callbacks as $callback) $callback(42); + $results = Amp\Future\await($tasks); + ksort($results); + eq(array_fill(0, 16, 42), $results, 'all concurrent host calls and Promise waiters progress'); + $callbacks = []; + } +} finally { + $js->close(); +} +eq([], Revolt\EventLoop::getIdentifiers(), 'concurrent RPC cleanup leaves no watchers'); +$shared = new QuickJS(); +$entered = 0; +$ready = new Amp\DeferredFuture(); +$shared->register('entered', static function () use (&$entered, $ready): void { + if (++$entered === 16) $ready->complete(); +}); +$shared->eval('globalThis.finish = null; globalThis.sharedPromise = new Promise(resolve => finish = resolve); null', false); +$wait = $shared->eval('() => { php.entered(); return sharedPromise; }', false); +$finish = $shared->eval('() => finish(42)', false); +$tasks = []; +try { + for ($i = 0; $i < 16; ++$i) $tasks[] = Amp\async(static fn() => $wait()); + $ready->getFuture()->await(new Amp\TimeoutCancellation(2)); + $finish(); + $results = Amp\Future\await($tasks); + ksort($results); + eq(array_fill(0, 16, 42), $results, 'one Promise can wake several independent requests'); +} finally { + $shared->close(); +} +$turns = new QuickJS(); +$dispatch = $turns->eval('globalThis.value = 0; start => { if (start) Promise.resolve().then(() => value = 42); else quickjs.postMessage(value); }', false); +$dispatch->post(true); +$dispatch->post(false); +eq([42], $turns->awaitMessages(), 'ready microtasks run before the next independent posted command'); +$turns->close(); +done(); From 48ae0eae4a0e1f01918d86e86aceaca71515963e Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Tue, 27 Oct 2026 13:51:39 +0100 Subject: [PATCH 28/29] Add schema in readme --- README.md | 77 +++++++++++++++++++++++++++++++++++++++++-------- docs/install.md | 3 +- 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 728c585..c5ee934 100644 --- a/README.md +++ b/README.md @@ -87,10 +87,10 @@ typed capabilities, `dts()`, isolated realms — in [`examples/`](examples): ## Installation -Prebuilt binaries are attached to each -[release](https://github.com/eddmann/php-quickjs/releases) for PHP 8.4 / 8.5 — -self-hosted Linux, AWS Lambda (a ready Bref layer), and macOS (Apple Silicon). Enable -the one matching your platform: +Upstream [releases](https://github.com/eddmann/php-quickjs/releases) provide binaries +for PHP 8.4 / 8.5 on Linux, AWS Lambda and macOS (Apple Silicon). Those binaries may +not include the worker described here; build this fork at a worker revision when it is +required. Enable the binary matching your platform: ```ini ; php.ini @@ -101,7 +101,8 @@ Or build from source (Rust 1.97+, clang, PHP dev headers — a plain cargo `cdyl `phpize`): ```sh -git clone https://github.com/eddmann/php-quickjs && cd php-quickjs +git clone https://github.com/xtrime-ru/php-quickjs && cd php-quickjs +# Check out a revision containing the worker before building. make release ``` @@ -119,11 +120,63 @@ The repository is mounted at `/workspace`; Cargo output stays in ## How it works +```mermaid +%%{init: {"theme":"base","themeCSS":".cluster-label text { fill:#c4c7c5 !important; } .edgeLabel .background { fill:#121212 !important; opacity:1 !important; }","htmlLabels":false,"markdownAutoWrap":false,"themeVariables":{"darkMode":true,"primaryTextColor":"#e6e1e5","textColor":"#e6e1e5","fontFamily":"system-ui, sans-serif","fontSize":"15px","lineColor":"#9aa0ab","edgeLabelBackground":"#121212","clusterBkg":"#1a1c20","clusterBorder":"#35383e"},"flowchart":{"htmlLabels":false,"curve":"linear","rankSpacing":45,"nodeSpacing":32}}}%% +flowchart TB + subgraph Canvas[" "] + direction TB + subgraph PHP["PHP THREAD"] + direction TB + API("PHP API") + Loop("Revolt event loop") + Fiber("Waiting Fiber") + end + Cmd["Command queue"] + subgraph Worker["QUICKJS WORKER THREAD"] + direction TB + Driver("Worker loop") + Compiler("Oxc compiler") + JS("QuickJS runtime") + Jobs("Promise jobs") + Driver -->|"TypeScript"| Compiler + Compiler -->|"JavaScript"| JS + Driver -->|"execute"| JS + JS -->|"microtasks"| Jobs + end + Events["Event queue"] + Messages["Message queue"] + API -->|"eval · JS callback"| Cmd + Cmd -->|"Request"| Driver + JS -->|"Done · Messages"| Events + Jobs -->|"Promise settled"| Events + JS -->|"postMessage"| Messages + Events -->|"native notification"| Loop + Loop -->|"result / error"| Fiber + Messages -->|"await / drain"| Fiber + end + style Canvas fill:#121212,stroke:#121212,stroke-width:80px,rx:12,ry:12 + style PHP fill:#171e2b,stroke:#486185,rx:8,ry:8 + style Worker fill:#1a1c20,stroke:#35383e,rx:8,ry:8 + classDef host fill:#1e293b,stroke:#486185,color:#dbeafe,stroke-width:1.5px + classDef worker fill:#1c302c,stroke:#41685a,color:#d1fae5,stroke-width:1.5px + classDef queue fill:#302b23,stroke:#786746,color:#fef3c7,stroke-width:1.5px + class API,Loop,Fiber host + class Driver,Compiler,JS,Jobs worker + class Cmd,Events,Messages queue + ``` -PHP (trusted) ──ext-php-rs──► Rust bridge ──rquickjs──► QuickJS (untrusted) - register() dispatch table php.module.fn() - eval() __host(name, args) frozen php.* facade -``` + +1. PHP sends an evaluation, JS callback or PHP callback reply through the **command + queue**. Only owned, typed values cross threads; PHP objects and closures remain + on the PHP thread. +2. The worker executes JS and ready Promise jobs. Pending Promises stay associated + with their request IDs. A synchronous PHP call pauses its JS frame while the + worker can process ready jobs and nested JS callbacks. +3. Results, errors and PHP call requests travel through the **event queue**. A native + readiness notification wakes Revolt, which resumes the appropriate waiting Fiber. + Without Revolt, PHP waits synchronously. Synchronization uses no periodic polling. +4. `quickjs.postMessage()` writes snapshots to a separate, bounded **message queue**; + PHP consumes them with `drainMessages()` or `awaitMessages()`. 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 @@ -136,9 +189,9 @@ ways with TS source locations. ## Asynchronous execution -`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 +`eval()` and `Js\Callback` automatically await returned Promises. With Revolt +autoloaded, waits suspend the calling Fiber and worker notifications resume it; +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)` diff --git a/docs/install.md b/docs/install.md index 086435f..8ab2f5c 100644 --- a/docs/install.md +++ b/docs/install.md @@ -127,7 +127,8 @@ extension=/var/task/quickjs.so Requires Rust 1.97+, clang, and PHP 8.4/8.5 dev headers (`php-config`). ```sh -git clone https://github.com/eddmann/php-quickjs && cd php-quickjs +git clone https://github.com/xtrime-ru/php-quickjs && cd php-quickjs +# Check out a revision containing the worker before building. 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 From 8150c4b11a1df2fdd7d0892852bbce075827ed9a Mon Sep 17 00:00:00 2001 From: Alexander Pankratov Date: Tue, 27 Oct 2026 13:51:50 +0100 Subject: [PATCH 29/29] Update dev docker image --- Dockerfile-dev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile-dev b/Dockerfile-dev index 42125e0..04f2f64 100644 --- a/Dockerfile-dev +++ b/Dockerfile-dev @@ -1,6 +1,6 @@ FROM composer:2 AS composer -FROM php:8.5-cli-bookworm +FROM php:8.5-cli-trixie ARG RUST_VERSION=1.99.1