aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock1
-rw-r--r--crates/shirabe-php-rpc/Cargo.toml1
-rw-r--r--crates/shirabe-php-rpc/php/worker.php6
-rw-r--r--crates/shirabe-php-rpc/src/lib.rs87
-rw-r--r--docs/dev/php-rpc.md13
5 files changed, 69 insertions, 39 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 4b5c6817..91fc9a1c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2146,6 +2146,7 @@ version = "0.0.1"
dependencies = [
"anyhow",
"indexmap",
+ "nix",
"shirabe-external-packages",
"shirabe-php-shim",
"shirabe-php-src",
diff --git a/crates/shirabe-php-rpc/Cargo.toml b/crates/shirabe-php-rpc/Cargo.toml
index cb062e7d..e4675faa 100644
--- a/crates/shirabe-php-rpc/Cargo.toml
+++ b/crates/shirabe-php-rpc/Cargo.toml
@@ -9,6 +9,7 @@ shirabe-php-shim.workspace = true
shirabe-php-src.workspace = true
anyhow.workspace = true
indexmap.workspace = true
+nix.workspace = true
tempfile.workspace = true
[lints]
diff --git a/crates/shirabe-php-rpc/php/worker.php b/crates/shirabe-php-rpc/php/worker.php
index e82c45ba..092f535f 100644
--- a/crates/shirabe-php-rpc/php/worker.php
+++ b/crates/shirabe-php-rpc/php/worker.php
@@ -427,10 +427,14 @@ final class ShirabeRpcRuntime
}
}
-$client = @stream_socket_client('unix://' . $argv[1], $errno, $errstr);
+// The socket is one end of a socketpair the parent installed on this descriptor before exec;
+// there is nothing to connect to. See docs/dev/php-rpc.md.
+$client = @fopen('php://fd/' . $argv[1], 'r+b');
if ($client === false) {
exit(1);
}
+// Frames must reach the parent as they are written, not when a buffer happens to fill.
+stream_set_write_buffer($client, 0);
ShirabeRpcRuntime::$socket = $client;
ShirabeRpcRuntime::$stubsDir = $argv[2] ?? null;
diff --git a/crates/shirabe-php-rpc/src/lib.rs b/crates/shirabe-php-rpc/src/lib.rs
index 085425c9..0c697e63 100644
--- a/crates/shirabe-php-rpc/src/lib.rs
+++ b/crates/shirabe-php-rpc/src/lib.rs
@@ -10,10 +10,9 @@ use frame::Frame;
use indexmap::IndexMap;
use shirabe_external_packages::symfony::process::PhpExecutableFinder;
use shirabe_php_shim::PhpMixed;
-use std::os::unix::net::{UnixListener, UnixStream};
+use std::os::unix::net::UnixStream;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{LazyLock, Mutex, OnceLock};
-use std::time::{Duration, Instant};
/// PHP `\PHP_VERSION`.
pub fn get_php_version() -> String {
@@ -734,13 +733,17 @@ fn recv_frame() -> anyhow::Result<Frame> {
result.map_err(|e| anyhow::Error::new(e).context(guard.worker_state()))
}
+/// The descriptor the worker's end of the RPC socket is installed on in the child. `pre_exec`
+/// dup2s onto it, which closes whatever the fork inherited there, so the number is ours to pick
+/// as long as it is above the three standard streams.
+const WORKER_SOCKET_FD: std::os::fd::RawFd = 3;
+
fn spawn_worker() -> anyhow::Result<Worker> {
let php = PhpExecutableFinder::new()
.find(false)
.ok_or_else(|| anyhow::anyhow!("no PHP executable found"))?;
let tempdir = tempfile::tempdir()?;
- let socket_path = tempdir.path().join("rpc.sock");
let script_path = tempdir.path().join("worker.php");
std::fs::write(&script_path, GLUE_SCRIPT)?;
@@ -751,44 +754,35 @@ fn spawn_worker() -> anyhow::Result<Worker> {
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),
- )?;
+ // A connected pair, not a bound path: an AF_UNIX path has to fit in `sun_path` (108 bytes),
+ // which a long TMPDIR overruns, and the worker is a child we spawn ourselves, so it can
+ // inherit its end instead of connecting to one. The pair is connected from the start, so
+ // there is no accept to wait for either — a child that dies before reading shows up as EOF
+ // on the first call, with its exit status attached by `worker_state`.
+ let (stream, child_end) = UnixStream::pair()?;
+ let child_end = std::os::fd::OwnedFd::from(child_end);
- let child = std::process::Command::new(&php)
+ let mut command = std::process::Command::new(&php);
+ command
// 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
- // 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 {
- anyhow::bail!("timed out waiting for the PHP worker to connect");
- }
- std::thread::sleep(Duration::from_millis(5));
- }
- Err(e) => return Err(e.into()),
- }
- };
- stream.set_nonblocking(false)?;
+ .arg(WORKER_SOCKET_FD.to_string())
+ .arg(&stubs_dir);
+ // SAFETY: the closure only calls async-signal-safe syscalls, as required between fork and
+ // exec. It owns the child end, so the descriptor stays alive until the exec happens.
+ unsafe {
+ std::os::unix::process::CommandExt::pre_exec(&mut command, move || {
+ install_worker_socket_fd(&child_end)
+ });
+ }
+ let child = command.spawn()?;
+ // Dropping the command drops the pre_exec closure with it, closing the parent's copy of the
+ // child end. Without that the parent would never observe EOF on a dead worker.
+ drop(command);
Ok(Worker {
stream,
@@ -797,6 +791,31 @@ fn spawn_worker() -> anyhow::Result<Worker> {
})
}
+/// Moves the worker's end of the socket onto [`WORKER_SOCKET_FD`] in the freshly forked child.
+fn install_worker_socket_fd(child_end: &std::os::fd::OwnedFd) -> std::io::Result<()> {
+ use std::os::fd::{AsRawFd as _, FromRawFd as _, IntoRawFd as _};
+
+ if child_end.as_raw_fd() == WORKER_SOCKET_FD {
+ // dup2(fd, fd) is a no-op that leaves FD_CLOEXEC set, which would close the descriptor
+ // on exec; clear the flag by hand instead.
+ nix::fcntl::fcntl(
+ child_end,
+ nix::fcntl::FcntlArg::F_SETFD(nix::fcntl::FdFlag::empty()),
+ )?;
+ return Ok(());
+ }
+ // SAFETY: dup2_raw closes the target if it is open and makes it a duplicate of the child
+ // end; releasing the returned owner keeps it open across the exec.
+ let installed = unsafe {
+ nix::unistd::dup2_raw(
+ child_end,
+ std::os::fd::OwnedFd::from_raw_fd(WORKER_SOCKET_FD),
+ )?
+ };
+ let _ = installed.into_raw_fd();
+ Ok(())
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/docs/dev/php-rpc.md b/docs/dev/php-rpc.md
index e55f860e..77d0d365 100644
--- a/docs/dev/php-rpc.md
+++ b/docs/dev/php-rpc.md
@@ -17,11 +17,16 @@ behavior.
## Transport
-- A Unix domain socket (no Windows support for now), bound in a `0700` temp dir with the socket
- file itself chmodded to `0600`.
+- A `socketpair(2)` (no Windows support for now). The parent keeps one end and installs the
+ other on descriptor 3 in the child, from a `pre_exec` hook, so the worker opens it as
+ `php://fd/3` rather than connecting anywhere. A bound path would have to fit in `sun_path`
+ (108 bytes), which a long `TMPDIR` overruns, and it needs a `bind(2)` that sandboxes commonly
+ deny. The pair is connected from the start, so nothing has to wait for an `accept` either: a
+ child that dies before reading surfaces as EOF on the first call, with its exit status
+ attached.
- 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.
+ the Rust binary and written to a `0700` temp dir at spawn time, so both halves of the protocol
+ are always the same commit.
### Frame layout