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 4fd98b0..d40bf14 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /target +/vendor **/*.rs.bk *.so +.* diff --git a/Cargo.lock b/Cargo.lock index 1dcf29b..effd0fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1678,15 +1678,12 @@ dependencies = [ [[package]] name = "php_quickjs" -version = "0.0.2" +version = "0.0.3" 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 18ac07a..a066024 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" @@ -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/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 e9ca43b..8fd1f73 100644 --- a/Makefile +++ b/Makefile @@ -10,10 +10,12 @@ else CARGO_FLAGS := endif -EXT := $(CURDIR)/target/$(PROFILE)/libphp_quickjs.so +EXT_SUFFIX := $(if $(filter Darwin,$(shell uname -s)),dylib,so) +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 @@ -26,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 @@ -40,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 076d37b..b920d43 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 @@ -90,9 +91,18 @@ 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 ``` +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)**. @@ -101,16 +111,40 @@ make build ``` 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 ``` -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 -both ways — remapping to TS coordinates on the way out. +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, 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. +## 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 +[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. + +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 defaults to +at most 256 entries and 32 MiB of source, generated JavaScript and source-map +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 This is an *embedder*, not a standalone defence against hostile code. The capability @@ -130,6 +164,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/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/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 d87532a..08bfa8d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,22 +4,34 @@ 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, int $transpileCacheMaxBytes = 33554432, int $transpileCacheMaxEntries = 256)` -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` +bounds the complete call, including Promise waits; `null` disables it. ### `register(string $name, callable $fn, ?string $types = null): void` 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` +### `eval(string $code, bool $typescript = true): mixed` -Run TypeScript or JavaScript and marshal the result back 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 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. 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` @@ -35,11 +47,50 @@ $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` 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` 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. + +See [asynchronous execution](async.md) for host event loop integration and PHP +Fiber boundaries. + +### `drainMessages(): array` + +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/docs/architecture.md b/docs/architecture.md index 2a0a24b..4f4ec39 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,8 +5,8 @@ ``` ┌─ 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->grant($obj) │ │ msgpack marshal │ │ guest TS-as-JS │ +│ $js->eval(tsCode) │◄─►│ host capabilities│◄─►│ frozen php.* facade │ +│ $js->grant($obj) │ │ native 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 @@ -33,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 **single** native function Rust injects into the realm — the - entire JS→host entry point. In `bridge.rs` it: - - decodes the msgpack payload to a `MiddleValue` list, +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`. -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 — 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 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 @@ -66,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 | @@ -93,40 +88,39 @@ 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`. -`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. +Rust converts these references without JS-side wrapping or decoding. ### 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. - -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. +`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). ## Capability handles diff --git a/docs/async.md b/docs/async.md new file mode 100644 index 0000000..3da3dff --- /dev/null +++ b/docs/async.md @@ -0,0 +1,60 @@ +# Async functions, 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. + +`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. + +## Native messages + +JavaScript can send a data-only value to PHP: + +```js +quickjs.postMessage({type: 'result', id: 7, value: 42}); +``` + +`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. + +The queue has a configurable aggregate accounted-byte limit (32 MiB by default): + +```php +$js = new QuickJS(maxQueuedMessageBytes: 32 * 1024 * 1024); +``` + +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/docs/errors.md b/docs/errors.md index f027be4..33faedb 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,23 +54,34 @@ 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 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 @@ -84,15 +97,17 @@ 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 -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/docs/execution-modes.md b/docs/execution-modes.md index 0d7570f..a3f38c7 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 @@ -98,7 +102,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 +110,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. @@ -123,5 +127,14 @@ 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. + +## 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/docs/install.md b/docs/install.md index 3afc52c..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 @@ -108,8 +128,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 diff --git a/docs/transpile-cache.md b/docs/transpile-cache.md new file mode 100644 index 0000000..9e21f10 --- /dev/null +++ b/docs/transpile-cache.md @@ -0,0 +1,17 @@ +# Transpile cache + +TypeScript evaluation caches Oxc output in a content-addressed LRU. Each engine +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 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. 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/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. diff --git a/src/bridge.rs b/src/bridge.rs index d483ccb..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. - -use crate::engine::{push_ctx, Engine}; +//! 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::{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 @@ -41,11 +36,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>, + 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. @@ -97,6 +96,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); + } + + 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) } @@ -163,48 +184,58 @@ 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(); - // The single JS -> host capability entry point. + // 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, - 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 = { - let _guard = push_ctx(&ctx); + move |ctx: Ctx<'js>, name: String, payload: Value<'js>| -> rquickjs::Result> { + invoke_host(&ctx, &host_state, payload, |args| { host_call(&host_state, &name, args) - }; - match result { - Ok(r) => encode_result(&ctx, r), - Err(err) => Err(throw_host_error(&ctx, &err)), - } + }) }, )?; globals.set("__host", host)?; @@ -213,38 +244,30 @@ 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 = { - let _guard = push_ctx(&ctx); + 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) - }; - match result { - Ok(r) => encode_result(&ctx, r), - Err(err) => Err(throw_host_error(&ctx, &err)), - } + }) }, )?; 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()))?; - // 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(()) @@ -267,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) )); } @@ -294,7 +317,51 @@ 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")); } } + +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 MessageQueue { + messages: Vec, + bytes: usize, + limit: usize, +} +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(MESSAGE_OVERHEAD); + if total > self.limit { + return Err("message queue limit exceeded"); + } + self.bytes = total; + self.messages.push(value); + Ok(()) + } + + 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 de644e5..61b8e0f 100644 --- a/src/callback.rs +++ b/src/callback.rs @@ -5,23 +5,39 @@ //! 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::marshal::{middle_to_zval, zval_to_middle, MiddleValue}; +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, TypedArray, Value}; +use rquickjs::{Ctx, Function, Value}; use std::rc::Rc; #[php_class] #[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(()) } } @@ -32,65 +48,78 @@ 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 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 payload = MiddleValue::Array(middle_args) - .to_msgpack() - .map_err(|e| PhpException::default(e.to_string()))?; + 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 { - 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| crate::error::js_error_to_php(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) }; - // 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) + } +} + +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)?; } + function.call_arg(call_args).map_err(map_error) } #[php_impl] diff --git a/src/engine.rs b/src/engine.rs index 2ca53af..4053beb 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,10 +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 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, 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}; @@ -15,49 +24,340 @@ 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, + /// 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, depth: Cell, - /// Per-eval wall-clock deadline; `None` when no eval is in flight. + 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-eval timeout; `None` disables the wall-clock guard. + /// Per-entry timeout; `None` disables the wall-clock guard. timeout: Option, } +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, +} + +impl Waiter { + fn wake(&self) -> PhpResult<()> { + if !self.woken.replace(true) { + 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 { + 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<()> { + 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 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(); + let state = BridgeState::new(max_queued_message_bytes); let engine = Rc::new(Engine { - rt, state: state.clone(), - transpile: TranspileCache::new(256), + memory_limit, + max_stack, + realm_id: Cell::new(0), + transpile: TranspileCache::new(transpile_cache_max_entries, transpile_cache_max_bytes), shared_ctx, 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)), @@ -89,26 +389,85 @@ 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 eval_in(&self, f: impl FnOnce(&Ctx<'_>) -> R) -> rquickjs::Result { - fn run(ctx: &Context, f: impl FnOnce(&Ctx<'_>) -> R) -> R { + pub fn realm_id(&self) -> u64 { + self.realm_id.get() + } + + 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<'_>) -> 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| { - let _guard = push_ctx(&c); - f(&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) => Ok(run(ctx, f)), + Some(ctx) => run(ctx), None => { - let ctx = Context::full(&self.rt)?; - Ok(run(&ctx, f)) + // 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(); @@ -133,40 +492,34 @@ 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()) }; +struct ExecutionGuard<'a> { + engine: &'a Engine, } -/// 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 -} - -/// 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.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(), + ))); + } + 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(), + ))); + } + } } } -/// 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()) +impl Drop for Engine { + fn drop(&mut self) { + self.pending_callbacks.get_mut().clear(); + } } diff --git a/src/error.rs b/src/error.rs index 7fcb09d..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); @@ -158,7 +175,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 7545539..3b7ed1c 100644 --- a/src/js/runtime.js +++ b/src/js/runtime.js @@ -1,129 +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))))); - } - - // 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)); - } - - 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; - // 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; - // 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, - invokeJs: invokeJs, - }; + globalThis.__jsFnCount = function () { return Object.keys(jsFns).length; }; })(); } diff --git a/src/lib.rs b/src/lib.rs index 49232a3..6d80367 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,26 +31,55 @@ 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` (infinite-loop 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))] + #[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, + 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 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 }) @@ -71,30 +100,40 @@ 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 { - // 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, - ) - })?; + /// 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(), + )); + } + 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.arm_deadline(); - 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)?; @@ -104,14 +143,46 @@ 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) - }); - self.engine.disarm_deadline(); - 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. + Ok(unsafe { + rquickjs::qjs::JS_IsJobPending(rquickjs::qjs::JS_GetRuntime(ctx.as_raw().as_ptr())) + }) + }) + } + + /// 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()?; + 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)) + } + + /// 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'=>...]`. @@ -172,22 +243,27 @@ 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 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) - }); - match outcome { - Ok(r) => r, - Err(e) => Err(to_php_err(e)), - } + }) } } 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 @@ -201,19 +277,22 @@ 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. - 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)) @@ -235,6 +314,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 beaf7bb..33b587c 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. @@ -65,211 +35,180 @@ fn int_or_float(f: f64) -> MiddleValue { } // --------------------------------------------------------------------------- -// native-msgpack serde +// JS <-> MiddleValue // --------------------------------------------------------------------------- -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() - } +pub const MAX_VALUE_DEPTH: usize = 64; +pub const VALUE_OVERHEAD: usize = 64; + +/// 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 with_limit(limit: usize) -> Self { + Self { + limit: Some(limit), + used: 0, } } -} - -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)) - } + 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"); } - d.deserialize_any(V) + self.used = used; + Ok(()) } -} - -/// 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())) - } + fn node(&mut self, depth: usize) -> Result<(), &'static str> { + if depth > MAX_VALUE_DEPTH { + return Err("bridge value exceeds maximum depth (64)"); } - d.deserialize_any(K) + self.charge(VALUE_OVERHEAD) } } -// --------------------------------------------------------------------------- -// 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. +/// 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 { - 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() { - return Ok(MiddleValue::Str(s.to_string()?)); - } - if value.is_function() { - // 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)); + 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); + } } - // 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() { - return Ok(MiddleValue::Bytes(bytes.to_vec())); + result +} + +/// Return the accounted size together with data, without traversing it twice. +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::with_limit(max_bytes), + functions: false, + registered: Vec::new(), + }; + let data = conversion.convert(value, 0)?; + Ok((data, conversion.budget.used)) +} + +struct JsConversion<'a, 'js> { + ctx: &'a Ctx<'js>, + budget: ConversionBudget, + functions: bool, + 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)); } - } - if value.is_array() { - let arr = value.into_array().unwrap(); - let mut out = Vec::with_capacity(arr.len()); - for i in 0..arr.len() { - out.push(js_to_middle(ctx, arr.get(i)?, _state)?); + // 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())); + } + } } - 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?; - out.push((k, js_to_middle(ctx, v, _state)?)); - } - return Ok(MiddleValue::Map(out)); + 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", + )) } - Err(rquickjs::Exception::throw_type( - ctx, - "unsupported JS value type", - )) } /// 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), @@ -287,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() } @@ -314,70 +253,113 @@ 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 { - 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()), - }); - } - if zv.is_array() { - let ht = zv.array().unwrap(); - return hashtable_to_middle(ht, state); + let mut conversion = PhpConversion::new(state); + let value = conversion.convert(zv, 0)?; + conversion.registered.clear(); + Ok(value) +} + +pub fn arguments_to_middle( + args: &[&Zval], + state: &BridgeState, +) -> Result, String> { + let mut conversion = PhpConversion::new(state); + let result = args + .iter() + .map(|arg| conversion.convert(arg, 0)) + .collect::, _>>()?; + conversion.registered.clear(); + Ok(result) +} + +struct PhpConversion<'a> { + state: &'a BridgeState, + budget: ConversionBudget, + registered: Vec, +} +impl<'a> PhpConversion<'a> { + fn new(state: &'a BridgeState) -> Self { + Self { + state, + budget: ConversionBudget::default(), + 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 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()); + } + cb.check_realm()?; 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) -> 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)?); - } - 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(v, state)?)); - } - Ok(MiddleValue::Map(out)) +impl Drop for PhpConversion<'_> { + fn drop(&mut self) { + self.state.release_php_fns(&self.registered); } } @@ -428,54 +410,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/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/src/transpile.rs b/src/transpile.rs index fa1578f..e11a54b 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -130,22 +130,65 @@ impl CompilerInterface for TsCompiler { // content-addressed cache // --------------------------------------------------------------------------- +#[cfg(test)] +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, + 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 { - inner: RefCell::new(LruCache::new(cap)), + byte_budget: if capacity == 0 { 0 } else { byte_budget }, + inner: RefCell::new(CacheState { + entries: LruCache::new(cap), + bytes: 0, + }), } } @@ -154,7 +197,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,13 +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().put( - key, - CachedModule { - source: source.to_owned(), - transpiled: transpiled.clone(), - }, - ); + if self.byte_budget > 0 { + self.inner.borrow_mut().insert( + key, + CachedModule { + source: source.to_owned(), + transpiled: transpiled.clone(), + }, + self.byte_budget, + ); + } Ok(transpiled) } } @@ -218,10 +264,137 @@ 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); + 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); } + + #[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(), + 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, CACHE_BYTE_BUDGET); + 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()); + } } diff --git a/stubs/php_quickjs.stubs.php b/stubs/php_quickjs.stubs.php index 1cbc4ca..c7b03a8 100644 --- a/stubs/php_quickjs.stubs.php +++ b/stubs/php_quickjs.stubs.php @@ -1,5 +1,7 @@ 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 {} + + /** 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. + * @return list + */ + public function drainMessages(): mixed {} - /** Generate a TypeScript `.d.ts` declaration for the `php` global. */ + /** 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. */ @@ -49,37 +72,61 @@ public function revoke(int $handle): bool {} public function roundtrip(mixed $value): mixed {} } +} + namespace Js { /** * A JS function handed to PHP. Invoke it like any callable: `$cb(...$args)`. */ 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 {} + + /** Invoke the JS function, awaiting a returned Promise. */ 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 - * 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 { + /** 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 {} - /** 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 {} } /** 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/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/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(); 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/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/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 new file mode 100644 index 0000000..8a957b0 --- /dev/null +++ b/tests/php/11_jobs.php @@ -0,0 +1,113 @@ +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('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(); +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'); +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'); +// 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/12_fibers.php b/tests/php/12_fibers.php new file mode 100644 index 0000000..0cdfb8e --- /dev/null +++ b/tests/php/12_fibers.php @@ -0,0 +1,60 @@ +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'); + $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; +(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('suspendValue', fn() => Fiber::suspend('inside JS')); +$suspending = new Fiber(function () use ($js) { + eq(42, $js->eval('php.suspendValue(); 42'), 'host callback resumes on its owning Fiber'); +}); +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(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) { + 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'); + +$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_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 new file mode 100644 index 0000000..b113e14 --- /dev/null +++ b/tests/php/14_async.php @@ -0,0 +1,111 @@ +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'); + +$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'); +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'); +// 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(); 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(); 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(); 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(); 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(); 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();