aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe-php-rpc/src/lib.rs
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-08-03 01:05:57 +0900
committernsfisis <nsfisis@gmail.com>2026-08-03 01:05:57 +0900
commit2f28d8112970960dbb9b6b582a3c6cd259337d21 (patch)
treeaf8bc223b3af9098e6925cbf6c47e198459ac818 /crates/shirabe-php-rpc/src/lib.rs
parentbf2c6fa58ae51f44fa0ec65c615f8e62de87812c (diff)
downloadphp-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 'crates/shirabe-php-rpc/src/lib.rs')
-rw-r--r--crates/shirabe-php-rpc/src/lib.rs547
1 files changed, 256 insertions, 291 deletions
diff --git a/crates/shirabe-php-rpc/src/lib.rs b/crates/shirabe-php-rpc/src/lib.rs
index 9b9c9222..491af072 100644
--- a/crates/shirabe-php-rpc/src/lib.rs
+++ b/crates/shirabe-php-rpc/src/lib.rs
@@ -1,11 +1,17 @@
//! Rust-to-PHP RPC over a Unix domain socket. See `docs/dev/php-rpc.md`.
-use anyhow::Context as _;
+pub mod frame;
+pub mod session;
+pub mod value;
+
+pub use value::{PhpClassHandle, PhpObjHandle, PluginValue, RustObjHandle};
+
+use frame::Frame;
use indexmap::IndexMap;
use shirabe_external_packages::symfony::process::PhpExecutableFinder;
use shirabe_php_shim::PhpMixed;
-use std::io::{Read as _, Write as _};
use std::os::unix::net::{UnixListener, UnixStream};
+use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{LazyLock, Mutex, OnceLock};
use std::time::{Duration, Instant};
@@ -294,8 +300,207 @@ fn string_list(value: PhpMixed, name: &str) -> Vec<String> {
}
}
+/// A PHP exception that crossed the RPC boundary (the recoverable failure lane, as opposed to
+/// the fatal `anyhow::Error` lane used for a dead worker or a broken channel).
+#[derive(Debug, Clone)]
+pub struct PhpThrow {
+ pub exception_class: String,
+ pub message: String,
+ pub code: i64,
+}
+
+impl PhpThrow {
+ fn runtime(message: String) -> PhpThrow {
+ PhpThrow {
+ exception_class: "RuntimeException".to_string(),
+ message,
+ code: 0,
+ }
+ }
+}
+
+impl std::fmt::Display for PhpThrow {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}: {}", self.exception_class, self.message)
+ }
+}
+
+impl std::error::Error for PhpThrow {}
+
+/// Handles `CallRustMethod` requests arriving while a Rust-initiated call is waiting for its
+/// `Return` (the cooperative reentrancy loop). A handler may itself issue nested RPC calls.
+pub trait RustMethodDispatcher {
+ fn dispatch(
+ &mut self,
+ rhandle: u64,
+ method_name: &str,
+ args: Vec<PluginValue>,
+ out_param_positions: &[u32],
+ ) -> Result<PluginValue, PhpThrow>;
+}
+
+/// The Rust side allocates odd correlation ids; the PHP side allocates even ones. Calls nest
+/// strictly, so this split is not needed for disambiguation — it just makes any captured frame
+/// attributable to its initiator.
+static NEXT_CORR_ID: AtomicU64 = AtomicU64::new(1);
+
+/// Allocates a Rust-side object handle. Handle 0 is reserved for the runtime service endpoint
+/// (e.g. `__shirabe_find_file` autoload queries), so ids start at 1.
+static NEXT_RHANDLE: AtomicU64 = AtomicU64::new(1);
+
+pub fn alloc_rhandle() -> u64 {
+ NEXT_RHANDLE.fetch_add(1, Ordering::Relaxed)
+}
+
+/// Calls a PHP function in the worker. The outer `Result` is the fatal lane (dead worker, broken
+/// framing); the inner one carries a PHP exception if the call threw.
+pub fn call_function(
+ name: &str,
+ args: Vec<PluginValue>,
+) -> anyhow::Result<Result<PluginValue, PhpThrow>> {
+ call_function_with_dispatcher(name, args, None)
+}
+
+pub fn call_function_with_dispatcher(
+ name: &str,
+ args: Vec<PluginValue>,
+ dispatcher: Option<&mut dyn RustMethodDispatcher>,
+) -> anyhow::Result<Result<PluginValue, PhpThrow>> {
+ rpc_call(
+ |corr_id| Frame::CallFunction {
+ corr_id,
+ function_name: name.to_string(),
+ args,
+ out_param_positions: Vec::new(),
+ },
+ dispatcher,
+ )
+}
+
+/// Calls `$class::$method(...$args)` in the worker (autoloading the class if needed).
+pub fn call_static_method(
+ class: &str,
+ method: &str,
+ args: Vec<PluginValue>,
+ dispatcher: Option<&mut dyn RustMethodDispatcher>,
+) -> anyhow::Result<Result<PluginValue, PhpThrow>> {
+ rpc_call(
+ |corr_id| Frame::CallStaticMethod {
+ corr_id,
+ pclass: class.to_string(),
+ method_name: method.to_string(),
+ args,
+ out_param_positions: Vec::new(),
+ },
+ dispatcher,
+ )
+}
+
+fn rpc_call(
+ request: impl FnOnce(u64) -> Frame,
+ mut dispatcher: Option<&mut dyn RustMethodDispatcher>,
+) -> anyhow::Result<Result<PluginValue, PhpThrow>> {
+ // Held for the whole logical call session; nested calls from the same thread (issued by a
+ // dispatcher handler) re-enter immediately, other threads are serialized.
+ let _session = session::SessionGuard::enter();
+ let my_id = NEXT_CORR_ID.fetch_add(2, Ordering::Relaxed);
+ send_frame(&request(my_id))?;
+ loop {
+ let incoming = recv_frame()?;
+ match incoming {
+ Frame::Return { corr_id, value, .. } if corr_id == my_id => {
+ return Ok(Ok(value));
+ }
+ Frame::Throw {
+ corr_id,
+ exception_class,
+ message,
+ code,
+ } if corr_id == my_id => {
+ return Ok(Err(PhpThrow {
+ exception_class,
+ message,
+ code,
+ }));
+ }
+ Frame::CallRustMethod {
+ corr_id,
+ rhandle,
+ method_name,
+ args,
+ out_param_positions,
+ } => {
+ let outcome = match dispatcher.as_deref_mut() {
+ Some(dispatcher) => {
+ dispatcher.dispatch(rhandle, &method_name, args, &out_param_positions)
+ }
+ // Never fall back to a silent null: an unroutable callback is reported as an
+ // explicit error on the PHP side.
+ None => Err(PhpThrow::runtime(format!(
+ "no Rust method dispatcher is active for this call \
+ (rhandle {rhandle}, method `{method_name}`)"
+ ))),
+ };
+ let reply = match outcome {
+ Ok(value) => Frame::Return {
+ corr_id,
+ value,
+ out_params: IndexMap::new(),
+ },
+ Err(throw) => Frame::Throw {
+ corr_id,
+ exception_class: throw.exception_class,
+ message: throw.message,
+ code: throw.code,
+ },
+ };
+ send_frame(&reply)?;
+ }
+ Frame::ReleaseRustHandle { .. } => {
+ // TODO(plugin): there is no persistent R table yet (script-event handles are
+ // scoped to a single dispatched call), so stub destructor notifications carry no
+ // state to clean up.
+ continue;
+ }
+ Frame::EpochBump { .. } => {
+ // Rust is the sender of epoch bumps; tolerate the symmetric direction.
+ continue;
+ }
+ other => panic!(
+ "PHP RPC: protocol violation — unexpected frame while waiting for corr_id \
+ {my_id}: {other:?}"
+ ),
+ }
+ }
+}
+
+fn call(name: &str, arg: &str) -> PhpMixed {
+ let outcome = call_function(name, vec![PluginValue::string(arg)])
+ .unwrap_or_else(|e| panic!("PHP RPC: request `{name}` failed: {e:#}"));
+ let value = match outcome {
+ Ok(value) => value,
+ Err(throw) => panic!("PHP RPC: request `{name}` threw {throw}"),
+ };
+ value
+ .to_php_mixed()
+ .unwrap_or_else(|e| panic!("PHP RPC: request `{name}` returned an unusable value: {e:#}"))
+}
+
const GLUE_SCRIPT: &str = include_str!("../php/worker.php");
+/// Hand-written proxy stub classes made autoloadable inside the worker, written in the shape the
+/// future stub generator will output.
+const STUB_FILES: &[(&str, &str)] = &[
+ (
+ "Composer/EventDispatcher/Event.php",
+ include_str!("../php/stubs/Composer/EventDispatcher/Event.php"),
+ ),
+ (
+ "Composer/Script/Event.php",
+ include_str!("../php/stubs/Composer/Script/Event.php"),
+ ),
+];
+
struct Worker {
stream: UnixStream,
// Also queried for its exit status when a socket read/write fails, to tell a dead worker
@@ -307,14 +512,6 @@ struct Worker {
}
impl Worker {
- fn request(&mut self, name: &str, arg: &str) -> anyhow::Result<Vec<u8>> {
- let mut payload = name.as_bytes().to_vec();
- payload.push(0);
- payload.extend_from_slice(arg.as_bytes());
- write_frame(&mut self.stream, &payload).with_context(|| self.worker_state())?;
- read_frame(&mut self.stream).with_context(|| self.worker_state())
- }
-
/// Describes the PHP worker's current process state, to be attached as `anyhow::Context` to
/// an I/O error so a dead worker (crash, OOM kill, ...) can be told apart from a live one
/// hitting a framing bug.
@@ -330,7 +527,7 @@ impl Worker {
}
}
-// TODO(phase-c): every failure here panics rather than propagating a `Result`; this is an interim
+// TODO(phase-c): a failed spawn panics rather than propagating a `Result`; this is an interim
// step until PHP RPC gets proper error handling (see docs/dev/php-rpc.md).
static WORKER: LazyLock<Mutex<Worker>> = LazyLock::new(|| {
Mutex::new(
@@ -338,16 +535,23 @@ static WORKER: LazyLock<Mutex<Worker>> = LazyLock::new(|| {
)
});
-fn call(name: &str, arg: &str) -> PhpMixed {
+/// Writes one frame while holding the worker mutex only for the duration of the write, so the
+/// session owner (see `session`) can interleave sends and blocking reads without keeping the
+/// worker locked across a whole call.
+fn send_frame(frame: &Frame) -> anyhow::Result<()> {
let mut guard = WORKER
.lock()
.unwrap_or_else(|e| panic!("PHP RPC: worker mutex poisoned: {e}"));
- let payload = guard
- .request(name, arg)
- .unwrap_or_else(|e| panic!("PHP RPC: request `{name}` failed: {e:#}"));
- parse_serialized_value(&payload).unwrap_or_else(|| {
- panic!("PHP RPC: request `{name}` returned an unparseable payload: {payload:?}")
- })
+ let result = frame::write_frame(&mut guard.stream, frame);
+ result.map_err(|e| anyhow::Error::new(e).context(guard.worker_state()))
+}
+
+fn recv_frame() -> anyhow::Result<Frame> {
+ let mut guard = WORKER
+ .lock()
+ .unwrap_or_else(|e| panic!("PHP RPC: worker mutex poisoned: {e}"));
+ let result = frame::read_frame(&mut guard.stream);
+ result.map_err(|e| anyhow::Error::new(e).context(guard.worker_state()))
}
fn spawn_worker() -> anyhow::Result<Worker> {
@@ -360,13 +564,33 @@ fn spawn_worker() -> anyhow::Result<Worker> {
let script_path = tempdir.path().join("worker.php");
std::fs::write(&script_path, GLUE_SCRIPT)?;
+ let stubs_dir = tempdir.path().join("stubs");
+ for (relative_path, contents) in STUB_FILES {
+ let path = stubs_dir.join(relative_path);
+ std::fs::create_dir_all(path.parent().expect("stub paths have a parent"))?;
+ std::fs::write(&path, contents)?;
+ }
+
// Bind before spawning so the socket exists when the child connects.
let listener = UnixListener::bind(&socket_path)?;
listener.set_nonblocking(true)?;
+ // The socket lives in a 0700 temp dir already; restricting the socket file itself makes the
+ // protection independent of the directory permission.
+ std::fs::set_permissions(
+ &socket_path,
+ std::os::unix::fs::PermissionsExt::from_mode(0o600),
+ )?;
+
let child = std::process::Command::new(&php)
+ // The Rust-side codec produces the byte representation of the default (and only
+ // supported) serialize_precision; pin the child to it in case a distro php.ini overrides
+ // the default.
+ .arg("-d")
+ .arg("serialize_precision=-1")
.arg(&script_path)
.arg(&socket_path)
+ .arg(&stubs_dir)
.spawn()?;
// Poll for the child's connection with a bounded deadline so a child that never connects does
@@ -393,280 +617,35 @@ fn spawn_worker() -> anyhow::Result<Worker> {
})
}
-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 a whole `serialize()` payload, rejecting trailing garbage.
-fn parse_serialized_value(payload: &[u8]) -> Option<PhpMixed> {
- let mut pos = 0;
- let value = parse_value(payload, &mut pos)?;
- (pos == payload.len()).then_some(value)
-}
-
-/// Parse one `serialize()` value starting at `pos`, advancing it past the value: `N;`, `b:0/1;`,
-/// `i:<n>;`, `d:<f>;`, `s:<len>:"<bytes>";`, `a:<count>:{<key><value>...}`.
-fn parse_value(payload: &[u8], pos: &mut usize) -> Option<PhpMixed> {
- let tag = payload.get(*pos..*pos + 2)?;
- *pos += 2;
- match tag {
- b"N;" => Some(PhpMixed::Null),
- b"b:" => match take_until(payload, pos, b';')? {
- b"0" => Some(PhpMixed::Bool(false)),
- b"1" => Some(PhpMixed::Bool(true)),
- _ => None,
- },
- b"i:" => parse_int(take_until(payload, pos, b';')?).map(PhpMixed::Int),
- b"d:" => std::str::from_utf8(take_until(payload, pos, b';')?)
- .ok()?
- .parse()
- .ok()
- .map(PhpMixed::Float),
- b"s:" => parse_string_body(payload, pos).map(PhpMixed::String),
- b"a:" => parse_array_body(payload, pos),
- _ => None,
- }
-}
-
-/// Parse the `<len>:"<bytes>";` tail of a serialized string.
-fn parse_string_body(payload: &[u8], pos: &mut usize) -> Option<String> {
- let len: usize = std::str::from_utf8(take_until(payload, pos, b':')?)
- .ok()?
- .parse()
- .ok()?;
- if payload.get(*pos) != Some(&b'"') {
- return None;
- }
- *pos += 1;
- let bytes = payload.get(*pos..*pos + len)?;
- *pos += len;
- if payload.get(*pos..*pos + 2) != Some(b"\";") {
- return None;
- }
- *pos += 2;
- Some(String::from_utf8_lossy(bytes).into_owned())
-}
-
-/// Parse the `<count>:{<key><value>...}` tail of a serialized array. An array whose keys are
-/// exactly `0..count` maps to `PhpMixed::List`, matching how PHP renders such an array as a JSON
-/// list; anything else maps to `PhpMixed::Array` with the keys stringified.
-fn parse_array_body(payload: &[u8], pos: &mut usize) -> Option<PhpMixed> {
- let count: usize = std::str::from_utf8(take_until(payload, pos, b':')?)
- .ok()?
- .parse()
- .ok()?;
- if payload.get(*pos) != Some(&b'{') {
- return None;
- }
- *pos += 1;
-
- let mut entries: IndexMap<String, PhpMixed> = IndexMap::new();
- let mut is_list = true;
- for index in 0..count {
- let key = match parse_value(payload, pos)? {
- PhpMixed::Int(n) => {
- is_list &= n == index as i64;
- n.to_string()
- }
- PhpMixed::String(s) => {
- is_list = false;
- s
- }
- _ => return None,
- };
- entries.insert(key, parse_value(payload, pos)?);
- }
-
- if payload.get(*pos) != Some(&b'}') {
- return None;
- }
- *pos += 1;
-
- Some(if is_list {
- PhpMixed::List(entries.into_values().collect())
- } else {
- PhpMixed::Array(entries)
- })
-}
-
-/// Return the bytes from `pos` up to the next `terminator`, advancing `pos` past it.
-fn take_until<'a>(payload: &'a [u8], pos: &mut usize, terminator: u8) -> Option<&'a [u8]> {
- let end = *pos + payload.get(*pos..)?.iter().position(|&b| b == terminator)?;
- let bytes = &payload[*pos..end];
- *pos = end + 1;
- Some(bytes)
-}
-
-fn parse_int(bytes: &[u8]) -> Option<i64> {
- std::str::from_utf8(bytes).ok()?.parse().ok()
-}
-
#[cfg(test)]
mod tests {
use super::*;
#[test]
- fn parses_string_scalar() {
- assert_eq!(
- parse_serialized_value(b"s:5:\"8.5.7\";"),
- Some(PhpMixed::String("8.5.7".to_string())),
- );
- }
-
- #[test]
- fn parses_empty_string() {
- assert_eq!(
- parse_serialized_value(b"s:0:\"\";"),
- Some(PhpMixed::String(String::new())),
- );
- }
-
- #[test]
- fn parses_string_with_embedded_quote() {
- assert_eq!(
- parse_serialized_value(b"s:3:\"a\"b\";"),
- Some(PhpMixed::String("a\"b".to_string())),
- );
- }
-
- #[test]
- fn rejects_truncated_string() {
- assert_eq!(parse_serialized_value(b"s:5:\"ab\";"), None);
- }
-
- #[test]
- fn rejects_trailing_garbage() {
- assert_eq!(parse_serialized_value(b"i:42;i:43;"), None);
- }
-
- #[test]
fn request_error_reports_dead_worker_exit_status() {
let mut worker = spawn_worker().expect("failed to spawn PHP worker");
worker.child.kill().expect("failed to kill PHP worker");
worker.child.wait().expect("failed to reap PHP worker");
- let err = worker
- .request("defined", "PHP_VERSION")
- .expect_err("request against a dead worker should fail");
- let message = format!("{err:#}");
- assert!(
- message.contains("PHP worker process already exited"),
- "unexpected error message: {message}"
+ // Writing may still succeed into the socket buffer; the read is what must fail.
+ let _ = frame::write_frame(
+ &mut worker.stream,
+ &Frame::CallFunction {
+ corr_id: 1,
+ function_name: "defined".to_string(),
+ args: vec![PluginValue::string("PHP_VERSION")],
+ out_param_positions: Vec::new(),
+ },
);
- }
-
- #[test]
- fn rejects_non_numeric_length() {
- assert_eq!(parse_serialized_value(b"s:x:\"ab\";"), None);
- }
-
- #[test]
- fn parses_scalar_null() {
- assert_eq!(parse_serialized_value(b"N;"), Some(PhpMixed::Null));
- }
-
- #[test]
- fn parses_scalar_bool() {
- assert_eq!(parse_serialized_value(b"b:0;"), Some(PhpMixed::Bool(false)));
- assert_eq!(parse_serialized_value(b"b:1;"), Some(PhpMixed::Bool(true)));
- }
-
- #[test]
- fn parses_scalar_int() {
- assert_eq!(parse_serialized_value(b"i:8;"), Some(PhpMixed::Int(8)));
- assert_eq!(parse_serialized_value(b"i:-1;"), Some(PhpMixed::Int(-1)));
- }
-
- #[test]
- fn parses_scalar_float() {
- assert_eq!(
- parse_serialized_value(b"d:1.5;"),
- Some(PhpMixed::Float(1.5))
- );
- }
-
- #[test]
- fn rejects_malformed_scalar() {
- assert_eq!(parse_serialized_value(b"b:2;"), None);
- assert_eq!(parse_serialized_value(b"i:x;"), None);
- assert_eq!(parse_serialized_value(b"d:x;"), None);
- assert_eq!(parse_serialized_value(b"garbage"), None);
- }
-
- #[test]
- fn parses_list_array() {
- assert_eq!(
- parse_serialized_value(b"a:2:{i:0;s:1:\"a\";i:1;i:7;}"),
- Some(PhpMixed::List(vec![
- PhpMixed::String("a".to_string()),
- PhpMixed::Int(7),
- ])),
- );
- }
-
- #[test]
- fn parses_empty_array_as_list() {
- assert_eq!(
- parse_serialized_value(b"a:0:{}"),
- Some(PhpMixed::List(vec![]))
- );
- }
-
- #[test]
- fn parses_keyed_array() {
- let expected: IndexMap<String, PhpMixed> = [
- ("zip".to_string(), PhpMixed::Bool(true)),
- ("apcu".to_string(), PhpMixed::Null),
- ]
- .into_iter()
- .collect();
- assert_eq!(
- parse_serialized_value(b"a:2:{s:3:\"zip\";b:1;s:4:\"apcu\";N;}"),
- Some(PhpMixed::Array(expected)),
- );
- }
-
- #[test]
- fn parses_nested_array() {
- let inner: IndexMap<String, PhpMixed> = [("curl".to_string(), PhpMixed::Bool(false))]
- .into_iter()
- .collect();
- let expected: IndexMap<String, PhpMixed> = [
- ("extensions".to_string(), PhpMixed::Array(inner)),
- ("php_version_id".to_string(), PhpMixed::Int(80500)),
- ]
- .into_iter()
- .collect();
- assert_eq!(
- parse_serialized_value(
- b"a:2:{s:10:\"extensions\";a:1:{s:4:\"curl\";b:0;}s:14:\"php_version_id\";i:80500;}"
- ),
- Some(PhpMixed::Array(expected)),
+ frame::read_frame(&mut worker.stream).expect_err("reading from a dead worker should fail");
+ let state = worker.worker_state();
+ assert!(
+ state.contains("PHP worker process already exited"),
+ "unexpected worker state: {state}"
);
}
#[test]
- fn rejects_malformed_array() {
- // Count larger than the number of entries.
- assert_eq!(parse_serialized_value(b"a:2:{i:0;i:1;}"), None);
- // Missing closing brace.
- assert_eq!(parse_serialized_value(b"a:1:{i:0;i:1;"), None);
- // Non-scalar key.
- assert_eq!(parse_serialized_value(b"a:1:{N;i:1;}"), None);
- }
-
- #[test]
fn queries_string_lists_when_php_available() {
if PhpExecutableFinder::new().find(false).is_none() {
// No PHP in this environment; the worker cannot start.
@@ -706,20 +685,6 @@ mod tests {
}
#[test]
- fn frame_roundtrip() {
- let (mut a, mut b) = UnixStream::pair().unwrap();
- write_frame(&mut a, b"get_php_version").unwrap();
- assert_eq!(read_frame(&mut b).unwrap(), b"get_php_version");
- }
-
- #[test]
- fn frame_roundtrip_empty_payload() {
- let (mut a, mut b) = UnixStream::pair().unwrap();
- write_frame(&mut a, b"").unwrap();
- assert_eq!(read_frame(&mut b).unwrap(), b"");
- }
-
- #[test]
fn queries_real_php_when_available() {
if PhpExecutableFinder::new().find(false).is_none() {
// No PHP in this environment; the worker cannot start.