diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-06-25 17:14:07 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-06-26 00:20:05 +0900 |
| commit | 0dd510dbc3a5b6e888abcc104ae8c4a4eeb1e42b (patch) | |
| tree | 8b6e7663c82bd7c4bbe91f4594940aa780376d77 | |
| parent | 6c137a28151f5ef05c9e22d3232e9ba7b81f6abe (diff) | |
| download | php-shirabe-0dd510dbc3a5b6e888abcc104ae8c4a4eeb1e42b.tar.gz php-shirabe-0dd510dbc3a5b6e888abcc104ae8c4a4eeb1e42b.tar.zst php-shirabe-0dd510dbc3a5b6e888abcc104ae8c4a4eeb1e42b.zip | |
feat(shim): implement stream_select/stream_set_blocking via libc
Add fcntl/select extern "C" declarations and a PhpResource::raw_fd seam so the
symfony Process pipe loop can read a live child's stdout/stderr. Fix fread on a
non-blocking fd (WouldBlock -> "") and the Process null-cwd default. Real
subprocess output capture now works; un-ignore the process_executor tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| -rw-r--r-- | crates/shirabe-external-packages/src/symfony/process/process.rs | 6 | ||||
| -rw-r--r-- | crates/shirabe-php-shim/src/fs.rs | 8 | ||||
| -rw-r--r-- | crates/shirabe-php-shim/src/lib.rs | 36 | ||||
| -rw-r--r-- | crates/shirabe-php-shim/src/stream.rs | 141 | ||||
| -rw-r--r-- | crates/shirabe/tests/util/process_executor_test.rs | 6 |
5 files changed, 177 insertions, 20 deletions
diff --git a/crates/shirabe-external-packages/src/symfony/process/process.rs b/crates/shirabe-external-packages/src/symfony/process/process.rs index 3550f09..1b58972 100644 --- a/crates/shirabe-external-packages/src/symfony/process/process.rs +++ b/crates/shirabe-external-packages/src/symfony/process/process.rs @@ -222,7 +222,11 @@ impl Process { this.cwd = cwd; // on Windows, if the cwd changed via chdir(), proc_open defaults to the dir where PHP was started - if this.cwd.is_none() && php::DIRECTORY_SEPARATOR == "\\" { + // PHP: null === $this->cwd && (\defined('ZEND_THREAD_SAFE') || '\\' === \DIRECTORY_SEPARATOR) + // `\defined('ZEND_THREAD_SAFE')` is unconditionally true in modern PHP (the constant always + // exists; its value merely reflects the NTS/ZTS build), so the disjunction is always true and + // the cwd is defaulted to getcwd() whenever it was null. + if this.cwd.is_none() { this.cwd = php::getcwd(); } if let Some(env) = env { diff --git a/crates/shirabe-php-shim/src/fs.rs b/crates/shirabe-php-shim/src/fs.rs index 5558f58..2fb0dc5 100644 --- a/crates/shirabe-php-shim/src/fs.rs +++ b/crates/shirabe-php-shim/src/fs.rs @@ -343,7 +343,13 @@ pub fn fread(stream: &PhpResource, length: i64) -> Option<String> { return None; } let mut buf = vec![0u8; cap]; - let n = state.backing.as_rws().read(&mut buf).ok()?; + let n = match state.backing.as_rws().read(&mut buf) { + Ok(n) => n, + // A non-blocking stream with no data ready is not an error in PHP: fread() returns + // the empty string and feof() stays false. std surfaces this as WouldBlock. + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => return Some(String::new()), + Err(_) => return None, + }; if cap > 0 && n == 0 { state.eof = true; } diff --git a/crates/shirabe-php-shim/src/lib.rs b/crates/shirabe-php-shim/src/lib.rs index 113c9d7..8c8073f 100644 --- a/crates/shirabe-php-shim/src/lib.rs +++ b/crates/shirabe-php-shim/src/lib.rs @@ -349,6 +349,32 @@ pub enum PhpResource { Process(std::rc::Rc<std::cell::RefCell<process::ProcessState>>), } +impl PhpResource { + /// Returns the underlying OS file descriptor backing this resource, when it has one. + /// Used by `stream_set_blocking`/`stream_select` to drive `fcntl(2)`/`select(2)`. In-memory + /// streams (`php://memory`/`php://temp`) and process handles have no fd and return `None`. + pub(crate) fn raw_fd(&self) -> Option<std::os::unix::io::RawFd> { + use std::os::unix::io::AsRawFd; + match self { + PhpResource::Stdin => Some(std::io::stdin().as_raw_fd()), + PhpResource::Stdout => Some(std::io::stdout().as_raw_fd()), + PhpResource::Stderr => Some(std::io::stderr().as_raw_fd()), + PhpResource::Process(_) => None, + PhpResource::Stream(state) => { + let state = state.borrow(); + if state.closed { + return None; + } + match &state.backing { + StreamBacking::File(f) => Some(f.as_raw_fd()), + StreamBacking::Pipe(p) => Some(p.as_raw_fd()), + StreamBacking::Memory(_) => None, + } + } + } + } +} + /// Combined capability of every seekable byte stream backing. Both `std::fs::File` /// and `std::io::Cursor<Vec<u8>>` satisfy it, so a stream can be driven uniformly. pub trait ReadWriteSeek: std::io::Read + std::io::Write + std::io::Seek {} @@ -420,6 +446,16 @@ impl std::io::Seek for ChildPipe { } } +impl std::os::unix::io::AsRawFd for ChildPipe { + fn as_raw_fd(&self) -> std::os::unix::io::RawFd { + match self { + ChildPipe::In(i) => i.as_raw_fd(), + ChildPipe::Out(o) => o.as_raw_fd(), + ChildPipe::Err(e) => e.as_raw_fd(), + } + } +} + #[derive(Debug)] pub struct StreamState { pub(crate) backing: StreamBacking, diff --git a/crates/shirabe-php-shim/src/stream.rs b/crates/shirabe-php-shim/src/stream.rs index a8ccbe5..606b202 100644 --- a/crates/shirabe-php-shim/src/stream.rs +++ b/crates/shirabe-php-shim/src/stream.rs @@ -259,24 +259,139 @@ fn build_meta_data( map } -pub fn stream_set_blocking(_resource: &PhpResource, _enable: bool) -> bool { - // TODO(phase-d): toggling O_NONBLOCK requires fcntl(2); a syscall crate is intentionally not - // introduced here. - todo!("stream_set_blocking requires fcntl(2) (syscall crate not available)") +// libc is already linked into every binary, so these can be declared directly without an extra +// crate (mirrors the `getuid`/`geteuid` declarations in process.rs). +const F_GETFL: i32 = 3; +const F_SETFL: i32 = 4; +const O_NONBLOCK: i32 = 0o4000; +const FD_SETSIZE: usize = 1024; + +unsafe extern "C" { + fn fcntl(fd: i32, cmd: i32, ...) -> i32; + fn select( + nfds: i32, + readfds: *mut FdSet, + writefds: *mut FdSet, + exceptfds: *mut FdSet, + timeout: *mut TimeVal, + ) -> i32; +} + +#[repr(C)] +struct TimeVal { + tv_sec: i64, + tv_usec: i64, +} + +// `fd_set` is a bitmap of `FD_SETSIZE` bits laid out as an array of `long` words. +#[repr(C)] +struct FdSet { + fds_bits: [i64; FD_SETSIZE / (8 * std::mem::size_of::<i64>())], +} + +impl FdSet { + fn zero() -> Self { + FdSet { + fds_bits: [0; FD_SETSIZE / (8 * std::mem::size_of::<i64>())], + } + } + + fn set(&mut self, fd: i32) { + let bits = 8 * std::mem::size_of::<i64>(); + self.fds_bits[fd as usize / bits] |= 1i64 << (fd as usize % bits); + } + + fn is_set(&self, fd: i32) -> bool { + let bits = 8 * std::mem::size_of::<i64>(); + (self.fds_bits[fd as usize / bits] & (1i64 << (fd as usize % bits))) != 0 + } +} + +/// PHP `stream_set_blocking()`: toggle `O_NONBLOCK` on the resource's underlying fd via `fcntl(2)`. +/// Returns `false` (PHP failure) when the resource has no fd or the syscall fails. +pub fn stream_set_blocking(resource: &PhpResource, enable: bool) -> bool { + let Some(fd) = resource.raw_fd() else { + return false; + }; + let flags = unsafe { fcntl(fd, F_GETFL) }; + if flags < 0 { + return false; + } + // `enable` means blocking, i.e. clear O_NONBLOCK. + let new_flags = if enable { + flags & !O_NONBLOCK + } else { + flags | O_NONBLOCK + }; + unsafe { fcntl(fd, F_SETFL, new_flags) >= 0 } } /// PHP `stream_select`. Returns the number of changed streams, or `None` for the PHP `false` -/// returned when the underlying `select` is interrupted/fails. +/// returned when the underlying `select` is interrupted/fails. On success the `read`/`write`/ +/// `except` vectors are narrowed to only the resources that became ready, matching PHP's +/// in-place rewrite of the passed arrays. pub fn stream_select( - _read: &mut Vec<PhpResource>, - _write: &mut Vec<PhpResource>, - _except: &mut Vec<PhpResource>, - _seconds: i64, - _microseconds: Option<i64>, + read: &mut Vec<PhpResource>, + write: &mut Vec<PhpResource>, + except: &mut Vec<PhpResource>, + seconds: i64, + microseconds: Option<i64>, ) -> Option<i64> { - // TODO(phase-d): multiplexing readiness requires select(2)/poll(2); a syscall crate is - // intentionally not introduced here. - todo!("stream_select requires select(2)/poll(2) (syscall crate not available)") + let mut readfds = FdSet::zero(); + let mut writefds = FdSet::zero(); + let mut exceptfds = FdSet::zero(); + let mut nfds = 0i32; + + // Resources without an fd (in-memory streams, process handles) cannot be waited on; PHP would + // emit a warning for them. We skip them here, leaving them out of the ready set. + let mut prepare = |set: &mut FdSet, resources: &[PhpResource]| { + for resource in resources { + if let Some(fd) = resource.raw_fd() { + if (fd as usize) < FD_SETSIZE { + set.set(fd); + if fd + 1 > nfds { + nfds = fd + 1; + } + } + } + } + }; + prepare(&mut readfds, read); + prepare(&mut writefds, write); + prepare(&mut exceptfds, except); + + let mut timeout = TimeVal { + tv_sec: seconds, + tv_usec: microseconds.unwrap_or(0), + }; + + let ret = unsafe { + select( + nfds, + &mut readfds, + &mut writefds, + &mut exceptfds, + &mut timeout, + ) + }; + + if ret < 0 { + // select(2) failed (e.g. EINTR). PHP returns false. + return None; + } + + // Narrow each array in place to the resources whose fd is still set in the result bitmap. + let narrow = |set: &FdSet, resources: &mut Vec<PhpResource>| { + resources.retain(|resource| match resource.raw_fd() { + Some(fd) if (fd as usize) < FD_SETSIZE => set.is_set(fd), + _ => false, + }); + }; + narrow(&readfds, read); + narrow(&writefds, write); + narrow(&exceptfds, except); + + Some(ret as i64) } /// PHP `stream_get_contents($stream, $maxlength, $offset)`. A non-negative `offset` seeks there diff --git a/crates/shirabe/tests/util/process_executor_test.rs b/crates/shirabe/tests/util/process_executor_test.rs index 342c7ff..01e37ed 100644 --- a/crates/shirabe/tests/util/process_executor_test.rs +++ b/crates/shirabe/tests/util/process_executor_test.rs @@ -20,7 +20,6 @@ use shirabe_external_packages::symfony::console::output::output_interface::{ }; use shirabe_php_shim::{PHP_EOL, trim}; -#[ignore] #[test] fn test_execute_captures_output() { let mut process = ProcessExecutor::new(None); @@ -41,7 +40,7 @@ fn test_use_io_is_not_null_and_if_not_captured() { todo!() } -#[ignore] +#[ignore = "stderr capture works, but the test cwd (crates/shirabe) contains a `foo/` fixture dir, so `cat foo` reports \"Is a directory\" instead of \"No such file or directory\""] #[test] fn test_execute_captures_stderr() { let mut process = ProcessExecutor::new(None); @@ -89,7 +88,6 @@ fn hide_password_provider() -> Vec<(&'static str, &'static str)> { ] } -#[ignore] #[test] fn test_hide_passwords() { for (command, expected_command_output) in hide_password_provider() { @@ -107,7 +105,6 @@ fn test_hide_passwords() { } } -#[ignore] #[test] fn test_doesnt_hide_ports() { let buffer = Rc::new(RefCell::new( @@ -130,7 +127,6 @@ fn test_split_lines() { todo!() } -#[ignore] #[test] fn test_console_io_does_not_format_symfony_console_style() { let output = Rc::new(RefCell::new(BufferedOutput::new( |
