From 2f28d8112970960dbb9b6b582a3c6cd259337d21 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Mon, 3 Aug 2026 01:05:57 +0900 Subject: feat(php-rpc): rework the RPC channel into the tagged plugin protocol Replace the name\0arg framing with the plugin wire protocol: tagged frames with corr_id multiplexing, a MAX_FRAME_LEN bound, a thread-ID based reentrant SessionLock, and the PluginValue codec (encoder plus the first recursive decoder, iterative with a 512-level depth cap). Float formatting is ported from php-src into shirabe-php-src so the encoder is byte-compatible with serialize() under serialize_precision=-1, which the spawned worker now pins. The worker gains a standing dispatch loop, CallRustMethod reentrancy, hand-written Event proxy stubs, and explicit-error answers for everything not implemented yet. The public query API (get_php_version and friends) is unchanged and now rides the new protocol; the codec is verified against real PHP by roundtrip oracle tests covering floats, non-UTF-8 bytes and deep nesting. Co-Authored-By: Claude Fable 5 --- docs/dev/php-rpc.md | 180 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 123 insertions(+), 57 deletions(-) (limited to 'docs/dev/php-rpc.md') diff --git a/docs/dev/php-rpc.md b/docs/dev/php-rpc.md index 6e0bc40c..b24f72d9 100644 --- a/docs/dev/php-rpc.md +++ b/docs/dev/php-rpc.md @@ -1,66 +1,132 @@ # PHP RPC -Composer can require a specific PHP version or loaded extensions, i.e., platform requirements. -To mimic this behavior needs a real PHP runtime. +Composer can require a specific PHP version or loaded extensions, i.e., platform requirements, +and its plugin/script machinery executes real PHP code. To mimic this behavior needs a real PHP +runtime. -This document describes a first design of PHP runtime: a `shirabe-php-rpc` crate that spawns the -system PHP as a child process and asks it for runtime information over a Unix domain socket. - -## Scope - -The crate supports exactly one interaction pattern, and nothing else: - -> Rust calls a named PHP function, passing a single string argument, and receives a single value -> back. - -- Rust to PHP only. PHP never calls back into Rust. -- Exactly one argument, and it must be a string. -- Return values are scalars (string / int / float / bool / null) or arrays of them. -- Every failure `panic!`s: a PHP exception (not currently possible — the glue script never lets - one escape), a serialization/deserialization failure, a missing PHP function, a crashed child, - etc. None are handled as recoverable errors. This is not the final design — a future revision - will replace these panics with proper `Result` propagation — but panicking beats silently - returning a plausible-looking default. -- Windows is unsupported and `panic!`s for now. +The `shirabe-php-rpc` crate spawns the system PHP as a child process and talks to it over a Unix +domain socket. There is exactly one child process per Shirabe process, shared by every caller; +it hosts both the simple runtime queries (`get_php_version`, `has_constant`, ...) and the plugin +protocol. ## Locating PHP -Reuse the existing `PhpExecutableFinder` class to resolve the PHP binary. +The existing `PhpExecutableFinder` class resolves the PHP binary. The child is started with +`-d serialize_precision=-1` so the wire codec's float formatting is pinned to the default PHP +behavior. ## Transport -- A Unix domain socket. (No Windows support for now) -- The PHP glue code is a small script written to a temporary file. -- Message frame: `[usize length (little-endian)][payload]`. - - Request payload: the PHP function name as raw bytes, followed by a `\0` byte and the string - argument (function names are static literals and never contain `\0`, so the first `\0` - unambiguously separates name from argument). - - Response payload: `serialize()` of the function's return value. - -The PHP worker is a single read-eval-respond loop: read a framed function name and argument, call -the matching entry in a fixed dispatch table (`defined`, `constant`), send back -`serialize($result)`. - -## Global state and public API - -PHP runtime information (e.g., process handle) is held as process-global state -rather than threaded through call sites for now. -The crate exposes plain free functions. For example: - -* get_php_version() -* has_constant() -* get_constant() - -The connection is a process-global `static` (e.g. `OnceLock>`), lazily initialized on -the first call: the first call spawns the child, performs the handshake, and caches the connection. -Commands that never query PHP never start it. The child lives for the rest of the process and is -left to be reaped at exit (no explicit shutdown message). - -A future revision threads this runtime information through arguments or embeds it in structs; for now -callers just reach for the global getter. - -## Out of scope - -Deferred things: multiple/non-string arguments, non-scalar return values, PHP to Rust callbacks -and re-entrancy, object handles / proxies / identity, stub generation, error -propagation, GC / lifecycle, and Windows support. +- A Unix domain socket (no Windows support for now), bound in a `0700` temp dir with the socket + file itself chmodded to `0600`. +- The PHP glue code (`php/worker.php`) and the proxy stub classes (`php/stubs/`) are embedded in + the Rust binary and written to the temp dir at spawn time, so both halves of the protocol are + always the same commit. + +### Frame layout + +``` +[u64 length LE] -- number of bytes that follow (1 + 8 + payload length) +[u8 tag] -- message tag, see below +[u64 corr_id LE] -- correlation id; 0 for one-way notifications +[payload] -- the remaining fields as one PHP-serialize()d list +``` + +`length` is validated against `MAX_FRAME_LEN` (256 MiB) before any allocation; an oversized +frame is a fatal channel error, not an allocation attempt. + +### Message tags + +| tag | name | direction | payload fields | +|---|---|---|---| +| `0x00` | `CallFunction` | Rust→PHP | `function_name`, `args`, `out_param_positions` | +| `0x01` | `CallStaticMethod` | Rust→PHP | `pclass`, `method_name`, `args`, `out_param_positions` | +| `0x02` | `NewObject` | Rust→PHP | `pclass`, `ctor_args` | +| `0x03` | `CallPhpMethod` | Rust→PHP | `phandle`, `method_name`, `args`, `out_param_positions` | +| `0x04` | `CallRustMethod` | PHP→Rust | `rhandle`, `method_name`, `args`, `out_param_positions` | +| `0x05` | `Return` | both | `value`, `out_params` | +| `0x06` | `Throw` | both | `exception_class`, `message`, `code` | +| `0x07` | `ReleaseRustHandle` | PHP→Rust | `rhandle` | +| `0x08` | `ReleasePhpHandle` | Rust→PHP | `phandle` | +| `0x09` | `EpochBump` | Rust→PHP | `rhandle`, `epoch` | + +The Rust side allocates odd correlation ids, the PHP side even ones. `NewObject` and +`CallPhpMethod` are protocol receptacles: the worker currently answers them with an explicit +`Throw` (the P table is not implemented yet). + +### Values: `PluginValue` and the codec + +Payloads are encoded with a Rust reimplementation of the PHP `serialize()` grammar +(`src/value.rs`), byte-compatible with the PHP core implementation under +`serialize_precision=-1` (the float formatting itself is ported in `shirabe-php-src`). The value +model is `PluginValue`: PHP scalars, byte strings (`Vec` — non-UTF-8 round-trips +losslessly), lists, ordered maps, and three handle descriptor kinds encoded as reserved arrays: + +- `{__rhandle, __class, __epoch[, __snapshot]}` — entity lives on the Rust side +- `{__phandle, __class, __implements}` — entity lives in the PHP child +- `{__pclass}` — a PHP class name + +`PluginValue::Object` is encode-only: the wire erases the array/object distinction and object +revival is banned (`unserialize(..., ['allowed_classes' => false])` is enforced on the PHP +side), so the decoder only produces `List` (contiguous 0-based int keys) or `Array`. The +decoder is iterative (input nesting never becomes call-stack depth) and additionally rejects +payloads nested deeper than 512 levels. + +The codec is verified against the real PHP `serialize()`/`unserialize()` by oracle tests +(`tests/oracle.rs`), with floats, non-UTF-8 byte strings and deep nesting as focus areas. + +## Concurrency and reentrancy + +A logical call session is serialized by a thread-ID based reentrant session lock +(`src/session.rs`): the owning thread may nest calls freely (a `CallRustMethod` handler can +itself call back into PHP), while other OS threads block until the whole outer session +completes. This keeps the single-child-process invariant under `cargo test`'s parallel harness. +The worker mutex itself is only held per frame, not across a call. + +While a Rust-initiated call waits for its `Return`, incoming `CallRustMethod` frames are +dispatched to the caller-supplied `RustMethodDispatcher` (the cooperative loop); with no +dispatcher active they are answered with an explicit `Throw`, never a silent null. Rust handle +0 is reserved for the runtime service endpoint (e.g. `__shirabe_find_file`, which the worker's +script-class autoloader uses to ask the Rust-side `ClassLoader` where a class file lives). + +The PHP side mirrors this: its top level is a standing serve loop, and `callRust` drives the +same dispatch while waiting for its own `Return`. + +## Failure model + +- The outer `anyhow::Result` of `call_function`/`call_static_method` is the fatal lane: dead + worker (EOF, with the child's exit status attached as context), broken framing, oversized + frames. +- The inner `Result` is the recoverable lane: a PHP exception crossing + the boundary as a `Throw` frame. +- A frame that decodes to something protocol-invalid is a bug in Shirabe itself (both halves + ship in the same commit) and panics; the PHP side symmetrically dies so Rust observes EOF. +- The legacy scalar query API (`get_php_version` etc.) keeps its historical contract: every + failure panics. + +## Worker dispatch table + +`CallFunction` first consults the worker's dispatch table (composite queries like `diagnose`, +Shirabe-internal helpers prefixed `__shirabe_`), then falls back to calling the named PHP +function; an unknown name is an explicit error. Notable internal helpers: + +- `__shirabe_eval` — runs a Rust-generated PHP snippet and returns its `return` value (used by + the `scripts` Command-class execution path). +- `__shirabe_require` — `require_once` a file (e.g. an autoloader) into the worker. +- `__shirabe_enable_script_autoloader` — registers the autoloader that resolves classes through + the Rust-side `ClassLoader` via handle 0. +- `__shirabe_oracle_roundtrip` — codec oracle support for tests. + +## Proxy stubs + +`php/stubs/` holds hand-written proxy stub classes (currently `Composer\EventDispatcher\Event` +and `Composer\Script\Event`), written in the shape the future stub generator will output. They +are autoloaded with highest priority so a proxied FQCN can never be shadowed by the real +implementation. Stubs are interned per rhandle (`WeakReference`-based registry) so identity +(`===`) holds, and their destructors send `ReleaseRustHandle`. + +## Out of scope (deferred) + +The P table (PHP-owned objects crossing to Rust), `NewObject`/`CallPhpMethod` execution, +out-parameter write-back at the call sites, epoch-based cache invalidation on the PHP side, +error-class reconstruction across the boundary, and Windows support. -- cgit v1.3.1