diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-07-01 01:33:38 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-07-01 01:33:40 +0900 |
| commit | 1de6ba5c6735e42b5f462c0b7489bcf172ccdc08 (patch) | |
| tree | 3a26a8cfe640d10c0d028f0c5087b89e4c8eb167 | |
| parent | 2b1c6c58a8c9e9afa8ba54214bc0bee48b06142f (diff) | |
| download | php-shirabe-1de6ba5c6735e42b5f462c0b7489bcf172ccdc08.tar.gz php-shirabe-1de6ba5c6735e42b5f462c0b7489bcf172ccdc08.tar.zst php-shirabe-1de6ba5c6735e42b5f462c0b7489bcf172ccdc08.zip | |
feat(php-rpc): query real PHP version and binary for --version
Add a minimal shirabe-php-rpc crate that spawns the system PHP as a
child process and asks it for runtime information over a Unix domain
socket, then use it to fill the `--version` PHP line with the real
\PHP_VERSION and \PHP_BINARY instead of fixed placeholder values.
See docs/dev/php-rpc.md for the design and scope.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| -rw-r--r-- | Cargo.lock | 9 | ||||
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/shirabe-php-rpc/Cargo.toml | 11 | ||||
| -rw-r--r-- | crates/shirabe-php-rpc/php/worker.php | 37 | ||||
| -rw-r--r-- | crates/shirabe-php-rpc/src/lib.rs | 109 | ||||
| -rw-r--r-- | crates/shirabe/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/shirabe/src/console/application.rs | 18 | ||||
| -rw-r--r-- | docs/dev/php-rpc.md | 60 |
8 files changed, 237 insertions, 9 deletions
@@ -2011,6 +2011,7 @@ dependencies = [ "shirabe-class-map-generator", "shirabe-external-packages", "shirabe-metadata-minifier", + "shirabe-php-rpc", "shirabe-php-shim", "shirabe-semver", "shirabe-spdx-licenses", @@ -2050,6 +2051,14 @@ dependencies = [ ] [[package]] +name = "shirabe-php-rpc" +version = "0.0.1" +dependencies = [ + "shirabe-external-packages", + "tempfile", +] + +[[package]] name = "shirabe-php-shim" version = "0.0.1" dependencies = [ @@ -11,6 +11,7 @@ shirabe = { path = "crates/shirabe" } shirabe-class-map-generator = { path = "crates/shirabe-class-map-generator" } shirabe-external-packages = { path = "crates/shirabe-external-packages" } shirabe-metadata-minifier = { path = "crates/shirabe-metadata-minifier" } +shirabe-php-rpc = { path = "crates/shirabe-php-rpc" } shirabe-php-shim = { path = "crates/shirabe-php-shim" } shirabe-semver = { path = "crates/shirabe-semver" } shirabe-spdx-licenses = { path = "crates/shirabe-spdx-licenses" } diff --git a/crates/shirabe-php-rpc/Cargo.toml b/crates/shirabe-php-rpc/Cargo.toml new file mode 100644 index 0000000..692dda9 --- /dev/null +++ b/crates/shirabe-php-rpc/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "shirabe-php-rpc" +version.workspace = true +edition.workspace = true + +[dependencies] +shirabe-external-packages.workspace = true +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/shirabe-php-rpc/php/worker.php b/crates/shirabe-php-rpc/php/worker.php new file mode 100644 index 0000000..214817a --- /dev/null +++ b/crates/shirabe-php-rpc/php/worker.php @@ -0,0 +1,37 @@ +<?php + +// PHP glue worker. See docs/dev/php-rpc.md. + +$client = @stream_socket_client('unix://' . $argv[1], $errno, $errstr); +if ($client === false) { + exit(1); +} +$dispatch = [ + 'get_php_version' => static fn() => PHP_VERSION, + 'get_php_binary' => static fn() => PHP_BINARY, +]; +$read_exact = static function ($conn, int $len): ?string { + $buf = ''; + while (strlen($buf) < $len) { + $chunk = fread($conn, $len - strlen($buf)); + if ($chunk === false || $chunk === '') { + return null; + } + $buf .= $chunk; + } + return $buf; +}; +while (true) { + $header = $read_exact($client, 8); + if ($header === null) { + break; + } + $len = unpack('P', $header)[1]; + $name = $len === 0 ? '' : $read_exact($client, $len); + if ($name === null) { + break; + } + $result = isset($dispatch[$name]) ? ($dispatch[$name])() : null; + $payload = serialize($result); + fwrite($client, pack('P', strlen($payload)) . $payload); +} diff --git a/crates/shirabe-php-rpc/src/lib.rs b/crates/shirabe-php-rpc/src/lib.rs new file mode 100644 index 0000000..f08a79a --- /dev/null +++ b/crates/shirabe-php-rpc/src/lib.rs @@ -0,0 +1,109 @@ +//! Rust-to-PHP RPC over a Unix domain socket. See `docs/dev/php-rpc.md`. + +use shirabe_external_packages::symfony::process::PhpExecutableFinder; +use std::io::{Read, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::sync::{LazyLock, Mutex}; +use std::time::{Duration, Instant}; + +/// PHP `\PHP_VERSION`. +pub fn get_php_version() -> String { + call("get_php_version").unwrap_or_default() +} + +/// PHP `\PHP_BINARY`. +pub fn get_php_binary() -> String { + call("get_php_binary").unwrap_or_default() +} + +const GLUE_SCRIPT: &str = include_str!("../php/worker.php"); + +struct Worker { + stream: UnixStream, + // Kept alive for the process lifetime: the child interpreter and the temp dir holding the socket + // and glue script. Neither is dropped because the worker lives in a never-dropped static. + _child: std::process::Child, + _tempdir: tempfile::TempDir, +} + +impl Worker { + fn request(&mut self, name: &str) -> Option<String> { + write_frame(&mut self.stream, name.as_bytes()).ok()?; + let payload = read_frame(&mut self.stream).ok()?; + parse_serialized_string(&payload) + } +} + +static WORKER: LazyLock<Mutex<Option<Worker>>> = LazyLock::new(|| Mutex::new(spawn_worker())); + +fn call(name: &str) -> Option<String> { + let mut guard = WORKER.lock().ok()?; + guard.as_mut()?.request(name) +} + +fn spawn_worker() -> Option<Worker> { + let php = PhpExecutableFinder::new().find(false)?; + + let tempdir = tempfile::tempdir().ok()?; + let socket_path = tempdir.path().join("rpc.sock"); + let script_path = tempdir.path().join("worker.php"); + std::fs::write(&script_path, GLUE_SCRIPT).ok()?; + + // Bind before spawning so the socket exists when the child connects. + let listener = UnixListener::bind(&socket_path).ok()?; + listener.set_nonblocking(true).ok()?; + + let child = std::process::Command::new(&php) + .arg(&script_path) + .arg(&socket_path) + .spawn() + .ok()?; + + // Poll for the child's connection with a bounded deadline so a child that never connects does + // not hang the caller. + let deadline = Instant::now() + Duration::from_secs(10); + let stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + return None; + } + std::thread::sleep(Duration::from_millis(5)); + } + Err(_) => return None, + } + }; + stream.set_nonblocking(false).ok()?; + + Some(Worker { + stream, + _child: child, + _tempdir: tempdir, + }) +} + +fn write_frame(stream: &mut UnixStream, payload: &[u8]) -> std::io::Result<()> { + stream.write_all(&(payload.len() as u64).to_le_bytes())?; + stream.write_all(payload)?; + stream.flush() +} + +fn read_frame(stream: &mut UnixStream) -> std::io::Result<Vec<u8>> { + let mut header = [0u8; 8]; + stream.read_exact(&mut header)?; + let len = u64::from_le_bytes(header) as usize; + let mut payload = vec![0u8; len]; + stream.read_exact(&mut payload)?; + Ok(payload) +} + +/// Parse the `s:<len>:"<bytes>";` form; only strings are needed here, so other forms are rejected. +fn parse_serialized_string(payload: &[u8]) -> Option<String> { + let rest = payload.strip_prefix(b"s:")?; + let colon = rest.iter().position(|&b| b == b':')?; + let len: usize = std::str::from_utf8(&rest[..colon]).ok()?.parse().ok()?; + let after = rest.get(colon + 1..)?; + let bytes = after.strip_prefix(b"\"")?.get(..len)?; + Some(String::from_utf8_lossy(bytes).into_owned()) +} diff --git a/crates/shirabe/Cargo.toml b/crates/shirabe/Cargo.toml index 632b659..7ec9db8 100644 --- a/crates/shirabe/Cargo.toml +++ b/crates/shirabe/Cargo.toml @@ -7,6 +7,7 @@ edition.workspace = true shirabe-class-map-generator.workspace = true shirabe-external-packages.workspace = true shirabe-metadata-minifier.workspace = true +shirabe-php-rpc.workspace = true shirabe-php-shim.workspace = true shirabe-semver.workspace = true shirabe-spdx-licenses.workspace = true diff --git a/crates/shirabe/src/console/application.rs b/crates/shirabe/src/console/application.rs index 077ea0a..a3b4df7 100644 --- a/crates/shirabe/src/console/application.rs +++ b/crates/shirabe/src/console/application.rs @@ -93,14 +93,13 @@ use shirabe_external_packages::symfony::console::style::symfony_style::SymfonySt use shirabe_external_packages::symfony::console::terminal::Terminal; use shirabe_external_packages::symfony::process::exception::ProcessTimedOutException; use shirabe_php_shim::{ - LogicException as ShimLogicException, PHP_BINARY, PHP_VERSION, PHP_VERSION_ID, PhpMixed, - RuntimeException, bin2hex, chdir, date_default_timezone_get, date_default_timezone_set, - defined, dirname, disk_free_space, extension_loaded, file_exists, file_get_contents, - file_put_contents, function_exists, getcwd, getmypid, glob, in_array, ini_set, is_array, - is_dir, is_file, is_string, is_subclass_of, json_decode, memory_get_peak_usage, - memory_get_usage, microtime, php_uname, posix_getuid, random_bytes, realpath, - restore_error_handler, round, str_contains, str_replace, strpos, strtoupper, sys_get_temp_dir, - time, unlink, + LogicException as ShimLogicException, PHP_VERSION, PHP_VERSION_ID, PhpMixed, RuntimeException, + bin2hex, chdir, date_default_timezone_get, date_default_timezone_set, defined, dirname, + disk_free_space, extension_loaded, file_exists, file_get_contents, file_put_contents, + function_exists, getcwd, getmypid, glob, in_array, ini_set, is_array, is_dir, is_file, + is_string, is_subclass_of, json_decode, memory_get_peak_usage, memory_get_usage, microtime, + php_uname, posix_getuid, random_bytes, realpath, restore_error_handler, round, str_contains, + str_replace, strpos, strtoupper, sys_get_temp_dir, time, unlink, }; /// The PHP `Composer\Console\Application` and `Symfony\Component\Console\Application` are @@ -2492,7 +2491,8 @@ impl ApplicationHandle { { io.write_error(&format!( "<info>PHP</info> version <comment>{}</comment> ({})", - PHP_VERSION, PHP_BINARY, + shirabe_php_rpc::get_php_version(), + shirabe_php_rpc::get_php_binary(), )); io.write_error( "Run the \"diagnose\" command to get more detailed diagnostics output.", diff --git a/docs/dev/php-rpc.md b/docs/dev/php-rpc.md new file mode 100644 index 0000000..9fc6ca3 --- /dev/null +++ b/docs/dev/php-rpc.md @@ -0,0 +1,60 @@ +# 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. + +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, argument-less PHP function and receives a single fixed-type scalar back. + +- Rust to PHP only. PHP never calls back into Rust. +- No arguments. +- Scalar return values only (string / int / float / bool / null). +- Every failure is ignored: a PHP exception, serialization/deserialization + failure, a missing PHP function, a crashed child, etc. None are handled. + If anything goes wrong, behavior is undefined. +- Windows is unsupported and `panic!`s for now. + +## Locating PHP + +Reuse the existing `PhpExecutableFinder` class to resolve the PHP binary. + +## 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 bare PHP function name as raw bytes. + - Response payload: `serialize()` of the function's return value. + +The PHP worker is a single read-eval-respond loop: read a framed function name, +call the matching entry in a fixed dispatch table, 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: + +```rust +shirabe_php_rpc::get_php_version() -> String +``` + +The connection is a process-global `static` (e.g. `OnceLock<Mutex<Worker>>`), lazily initialized on +the first call: the first `get_php_version()` 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: arguments and non-scalar return values, PHP to Rust callbacks +and re-entrancy, object handles / proxies / identity, stub generation, error +propagation, GC / lifecycle, and Windows support. |
