diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-08-03 01:05:57 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-08-03 01:05:57 +0900 |
| commit | 2f28d8112970960dbb9b6b582a3c6cd259337d21 (patch) | |
| tree | af8bc223b3af9098e6925cbf6c47e198459ac818 /docs/dev | |
| parent | bf2c6fa58ae51f44fa0ec65c615f8e62de87812c (diff) | |
| download | php-shirabe-2f28d8112970960dbb9b6b582a3c6cd259337d21.tar.gz php-shirabe-2f28d8112970960dbb9b6b582a3c6cd259337d21.tar.zst php-shirabe-2f28d8112970960dbb9b6b582a3c6cd259337d21.zip | |
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 <noreply@anthropic.com>
Diffstat (limited to 'docs/dev')
| -rw-r--r-- | docs/dev/php-rpc.md | 160 |
1 files changed, 113 insertions, 47 deletions
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. +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. -## Scope +## Locating PHP -The crate supports exactly one interaction pattern, and nothing else: +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. -> Rust calls a named PHP function, passing a single string argument, and receives a single value -> back. +## Transport -- 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. +- 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. -## Locating PHP +### Frame layout -Reuse the existing `PhpExecutableFinder` class to resolve the PHP binary. +``` +[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 +``` -## Transport +`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<u8>` — 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`. -- 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. +## Failure model -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)`. +- 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<PluginValue, PhpThrow>` 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. -## Global state and public API +## Worker dispatch table -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: +`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: -* get_php_version() -* has_constant() -* get_constant() +- `__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. -The connection is a process-global `static` (e.g. `OnceLock<Mutex<Worker>>`), 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). +## Proxy stubs -A future revision threads this runtime information through arguments or embeds it in structs; for now -callers just reach for the global getter. +`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 +## Out of scope (deferred) -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. +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. |
