aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe-symfony-process/src/process.rs
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-08-09 11:14:42 +0900
committernsfisis <nsfisis@gmail.com>2026-08-09 11:14:42 +0900
commit446f719f6c34453f027d5ccdbf81b16e63f4c982 (patch)
tree9c0443adbb99da5891a81d0131fc414d9a04c8ef /crates/shirabe-symfony-process/src/process.rs
parent880ba0fd1d05faf98588cc326b3d9fbe625ebf2b (diff)
downloadphp-shirabe-446f719f6c34453f027d5ccdbf81b16e63f4c982.tar.gz
php-shirabe-446f719f6c34453f027d5ccdbf81b16e63f4c982.tar.zst
php-shirabe-446f719f6c34453f027d5ccdbf81b16e63f4c982.zip
refactor(symfony-process): extract symfony/process into the shirabe-symfony-process crate
Move `Symfony\Component\Process` out of shirabe-external-packages and into its own crate, so the path is `shirabe_symfony_process::Process` instead of `shirabe_external_packages::symfony::process::Process`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe-symfony-process/src/process.rs')
-rw-r--r--crates/shirabe-symfony-process/src/process.rs1293
1 files changed, 1293 insertions, 0 deletions
diff --git a/crates/shirabe-symfony-process/src/process.rs b/crates/shirabe-symfony-process/src/process.rs
new file mode 100644
index 00000000..6a078b9e
--- /dev/null
+++ b/crates/shirabe-symfony-process/src/process.rs
@@ -0,0 +1,1293 @@
+//! ref: composer/vendor/symfony/process/Process.php
+
+use crate::exception::invalid_argument_exception::InvalidArgumentException;
+use crate::exception::logic_exception::LogicException;
+use crate::exception::process_signaled_exception::ProcessSignaledException;
+use crate::exception::process_timed_out_exception::ProcessTimedOutException;
+use crate::exception::runtime_exception::RuntimeException;
+use crate::executable_finder::ExecutableFinder;
+use crate::pipes::pipes_interface::PipesInterface;
+use crate::pipes::unix_pipes::UnixPipes;
+use crate::pipes::windows_pipes::WindowsPipes;
+use crate::process_utils::ProcessUtils;
+use indexmap::IndexMap;
+use shirabe_php_shim::{Descriptor, PhpMixed, PhpResource, php_regex};
+use std::sync::OnceLock;
+
+/// A user-supplied callback invoked with the output type ("out"/"err") and a chunk of output.
+pub type UserCallback = Box<dyn FnMut(&str, &str) -> bool>;
+
+/// The callback built by `build_callback`. It receives the owning Process so it can append output
+/// to the internal buffers, mirroring the `$this`-capturing closure produced in PHP.
+type ProcessCallback = Box<dyn FnMut(&mut Process, &str, &str) -> bool>;
+
+/// PHP `$this->commandline` is `array|string`.
+#[derive(Debug, Clone)]
+enum CommandLine {
+ Array(Vec<String>),
+ String(String),
+}
+
+/// Test-only behaviour for a Process fabricated via [`Process::__mock`]: `getOutput`/
+/// `getErrorOutput`/`getExitCode`/`isSuccessful` return these fixed values instead of reading a
+/// real subprocess. Mirrors PHPUnit's `getMockBuilder(Process::class)->disableOriginalConstructor()`
+/// mocks used by the Composer test suite (e.g. `ZipDownloaderTest`). Held in [`Process::mock`];
+/// always `None` in production.
+#[derive(Debug, Clone)]
+pub struct ProcessMock {
+ pub exit_code: i64,
+ pub stdout: String,
+ pub stderr: String,
+}
+
+/// Process is a thin wrapper around proc_* functions to easily
+/// start independent PHP processes.
+pub struct Process {
+ callback: Option<ProcessCallback>,
+ commandline: CommandLine,
+ cwd: Option<String>,
+ env: IndexMap<String, PhpMixed>,
+ input: PhpMixed,
+ starttime: Option<f64>,
+ timeout: Option<f64>,
+ exitcode: Option<i64>,
+ fallback_status: IndexMap<String, PhpMixed>,
+ process_information: Option<IndexMap<String, PhpMixed>>,
+ stdout: Option<PhpResource>,
+ stderr: Option<PhpResource>,
+ process: Option<PhpResource>,
+ status: String,
+ incremental_output_offset: i64,
+ incremental_error_output_offset: i64,
+ tty: bool,
+ options: IndexMap<String, PhpMixed>,
+ use_file_handles: bool,
+ process_pipes: Option<Box<dyn PipesInterface>>,
+ latest_signal: Option<i64>,
+ cached_exit_code: Option<i64>,
+ /// Test-only mock state. `None` in production; set via [`Process::__mock`] in tests.
+ mock: Option<ProcessMock>,
+}
+
+impl std::fmt::Debug for Process {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("Process")
+ .field("commandline", &self.commandline)
+ .field("cwd", &self.cwd)
+ .field("status", &self.status)
+ .field("exitcode", &self.exitcode)
+ .finish_non_exhaustive()
+ }
+}
+
+fn descriptor(items: &[&str]) -> Descriptor {
+ match items {
+ ["pipe", mode] => Descriptor::Pipe(mode.to_string()),
+ ["file", path, mode] => Descriptor::File(path.to_string(), mode.to_string()),
+ _ => panic!("unsupported descriptor spec: {:?}", items),
+ }
+}
+
+/// PHP `(string)` cast for an environment value or stream payload.
+fn to_php_string(value: &PhpMixed) -> String {
+ match value {
+ PhpMixed::String(s) => s.clone(),
+ PhpMixed::Int(i) => i.to_string(),
+ PhpMixed::Float(f) => f.to_string(),
+ PhpMixed::Bool(b) => {
+ if *b {
+ "1".to_string()
+ } else {
+ String::new()
+ }
+ }
+ _ => String::new(),
+ }
+}
+
+impl Process {
+ pub const ERR: &'static str = "err";
+ pub const OUT: &'static str = "out";
+
+ pub const STATUS_READY: &'static str = "ready";
+ pub const STATUS_STARTED: &'static str = "started";
+ pub const STATUS_TERMINATED: &'static str = "terminated";
+
+ pub const STDOUT: i64 = 1;
+ pub const STDERR: i64 = 2;
+
+ // Timeout Precision in seconds.
+ pub const TIMEOUT_PRECISION: f64 = 0.2;
+
+ /// Exit codes translation table.
+ fn exit_code_text(code: i64) -> Option<&'static str> {
+ Some(match code {
+ 0 => "OK",
+ 1 => "General error",
+ 2 => "Misuse of shell builtins",
+
+ 126 => "Invoked command cannot execute",
+ 127 => "Command not found",
+ 128 => "Invalid exit argument",
+
+ // signals
+ 129 => "Hangup",
+ 130 => "Interrupt",
+ 131 => "Quit and dump core",
+ 132 => "Illegal instruction",
+ 133 => "Trace/breakpoint trap",
+ 134 => "Process aborted",
+ 135 => "Bus error: \"access to undefined portion of memory object\"",
+ 136 => "Floating point exception: \"erroneous arithmetic operation\"",
+ 137 => "Kill (terminate immediately)",
+ 138 => "User-defined 1",
+ 139 => "Segmentation violation",
+ 140 => "User-defined 2",
+ 141 => "Write to pipe with no one reading",
+ 142 => "Signal raised by alarm",
+ 143 => "Termination (request to terminate)",
+ // 144 - not defined
+ 145 => "Child process terminated, stopped (or continued*)",
+ 146 => "Continue if stopped",
+ 147 => "Stop executing temporarily",
+ 148 => "Terminal stop signal",
+ 149 => "Background process attempting to read from tty (\"in\")",
+ 150 => "Background process attempting to write to tty (\"out\")",
+ 151 => "Urgent data available on socket",
+ 152 => "CPU time limit exceeded",
+ 153 => "File size limit exceeded",
+ 154 => "Signal raised by timer counting virtual time: \"virtual timer expired\"",
+ 155 => "Profiling timer expired",
+ // 156 - not defined
+ 157 => "Pollable event",
+ // 158 - not defined
+ 159 => "Bad syscall",
+ _ => return None,
+ })
+ }
+
+ fn empty() -> Self {
+ let mut options = IndexMap::new();
+ options.insert("suppress_errors".to_string(), PhpMixed::Bool(true));
+ options.insert("bypass_shell".to_string(), PhpMixed::Bool(true));
+
+ Self {
+ callback: None,
+ commandline: CommandLine::Array(Vec::new()),
+ cwd: None,
+ env: IndexMap::new(),
+ input: PhpMixed::Null,
+ starttime: None,
+ timeout: None,
+ exitcode: None,
+ fallback_status: IndexMap::new(),
+ process_information: None,
+ stdout: None,
+ stderr: None,
+ process: None,
+ status: Self::STATUS_READY.to_string(),
+ incremental_output_offset: 0,
+ incremental_error_output_offset: 0,
+ tty: false,
+ options,
+ use_file_handles: false,
+ process_pipes: None,
+ latest_signal: None,
+ cached_exit_code: None,
+ mock: None,
+ }
+ }
+
+ /// For testing only. Builds an already-terminated mock Process whose getOutput/
+ /// getErrorOutput/getExitCode/isSuccessful return the configured values, without spawning a
+ /// real subprocess.
+ pub fn __mock(mock: ProcessMock) -> Self {
+ let mut this = Self::empty();
+ this.status = Self::STATUS_TERMINATED.to_string();
+ this.exitcode = Some(mock.exit_code);
+ this.mock = Some(mock);
+ this
+ }
+
+ pub fn new(
+ command: Vec<String>,
+ cwd: Option<String>,
+ env: Option<IndexMap<String, String>>,
+ input: PhpMixed,
+ timeout: Option<f64>,
+ ) -> anyhow::Result<Self> {
+ if !shirabe_php_shim::function_exists("proc_open") {
+ return Err(LogicException::new(
+ "The Process class relies on proc_open, which is not available on your PHP installation.".to_string(),
+ )
+ .into());
+ }
+
+ let mut this = Self::empty();
+ this.commandline = CommandLine::Array(command);
+ this.cwd = cwd;
+
+ // on Windows, if the cwd changed via chdir(), proc_open defaults to the dir where PHP was started
+ // 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 = shirabe_php_shim::getcwd();
+ }
+ if let Some(env) = env {
+ this.set_env(
+ env.into_iter()
+ .map(|(k, v)| (k, PhpMixed::String(v)))
+ .collect(),
+ );
+ }
+
+ this.set_input(input)?;
+ this.set_timeout(timeout)?;
+ this.use_file_handles = cfg!(windows);
+
+ Ok(this)
+ }
+
+ /// Creates a Process instance as a command-line to be run in a shell wrapper.
+ pub fn from_shell_commandline(
+ command: &str,
+ cwd: Option<&str>,
+ env: Option<IndexMap<String, String>>,
+ input: PhpMixed,
+ timeout: Option<f64>,
+ ) -> anyhow::Result<Self> {
+ let mut process = Self::new(Vec::new(), cwd.map(String::from), env, input, timeout)?;
+ process.commandline = CommandLine::String(command.to_string());
+
+ Ok(process)
+ }
+
+ /// Runs the process.
+ pub fn run(
+ &mut self,
+ callback: Option<UserCallback>,
+ env: IndexMap<String, PhpMixed>,
+ ) -> anyhow::Result<i64> {
+ self.start(callback, env)?;
+
+ self.wait(None)
+ }
+
+ /// Starts the process and returns after writing the input to STDIN.
+ pub fn start(
+ &mut self,
+ callback: Option<UserCallback>,
+ mut env: IndexMap<String, PhpMixed>,
+ ) -> anyhow::Result<()> {
+ if self.is_running() {
+ return Err(RuntimeException::new("Process is already running.".to_string()).into());
+ }
+
+ self.reset_process_data();
+ self.starttime = Some(shirabe_php_shim::microtime());
+ self.callback = Some(self.build_callback(callback));
+ let mut descriptors = self.get_descriptors();
+
+ if !self.env.is_empty() {
+ // non-Windows: $env += $this->env;
+ for (k, v) in &self.env {
+ env.entry(k.clone()).or_insert_with(|| v.clone());
+ }
+ }
+
+ for (k, v) in self.get_default_env() {
+ env.entry(k).or_insert(v);
+ }
+
+ let mut commandline = match &self.commandline {
+ CommandLine::Array(args) => {
+ let mut cmd = args
+ .iter()
+ .map(|a| self.escape_argument(Some(a)))
+ .collect::<Vec<_>>()
+ .join(" ");
+
+ if !cfg!(windows) {
+ // exec is mandatory to deal with sending a signal to the process
+ cmd = format!("exec {}", cmd);
+ }
+ cmd
+ }
+ CommandLine::String(s) => self.replace_placeholders(s, &env)?,
+ };
+
+ if cfg!(windows) {
+ commandline = self.prepare_windows_command_line(&commandline, &mut env)?;
+ } else if !self.use_file_handles && self.is_sigchild_enabled() {
+ // last exit code is output on the fourth pipe and caught to work around --enable-sigchild
+ descriptors.push(descriptor(&["pipe", "w"]));
+
+ commandline = format!("{{ ({}) <&3 3<&- 3>/dev/null & }} 3<&0;", commandline);
+ commandline.push_str(
+ "pid=$!; echo $pid >&3; wait $pid 2>/dev/null; code=$?; echo $code >&3; exit $code",
+ );
+
+ // Workaround for the bug, when PTS functionality is enabled.
+ let _pts_workaround = shirabe_php_shim::fopen("Process.php", "r");
+ }
+
+ let mut env_pairs: Vec<String> = Vec::new();
+ for (k, v) in &env {
+ let is_false = matches!(v, PhpMixed::Bool(false));
+ if !is_false && !["argc", "argv", "ARGC", "ARGV"].contains(&k.as_str()) {
+ env_pairs.push(format!("{}={}", k, to_php_string(v)));
+ }
+ }
+
+ if !self
+ .cwd
+ .as_deref()
+ .map(shirabe_php_shim::is_dir)
+ .unwrap_or(false)
+ {
+ return Err(RuntimeException::new(format!(
+ "The provided cwd \"{}\" does not exist.",
+ self.cwd.as_deref().unwrap_or("")
+ ))
+ .into());
+ }
+
+ let cwd = self.cwd.clone();
+ let options = self.options.clone();
+ let process = {
+ let pipes = self.process_pipes.as_mut().unwrap().pipes_mut();
+ shirabe_php_shim::proc_open(
+ &commandline,
+ &descriptors,
+ pipes,
+ cwd.as_deref().map(std::path::Path::new),
+ Some(&env_pairs),
+ Some(&options),
+ )
+ };
+ self.process = process.ok();
+
+ if self.process.is_none() {
+ return Err(
+ RuntimeException::new("Unable to launch a new process.".to_string()).into(),
+ );
+ }
+ self.status = Self::STATUS_STARTED.to_string();
+
+ if descriptors.len() > 3 {
+ let pipe3 = self
+ .process_pipes
+ .as_ref()
+ .unwrap()
+ .pipes()
+ .get(&3)
+ .cloned();
+ let pid = pipe3
+ .and_then(|p| shirabe_php_shim::fgets(&p, None))
+ .map(|s| s.trim().parse::<i64>().unwrap_or(0))
+ .unwrap_or(0);
+ self.fallback_status
+ .insert("pid".to_string(), PhpMixed::Int(pid));
+ }
+
+ if self.tty {
+ return Ok(());
+ }
+
+ self.update_status(false);
+ self.check_timeout()?;
+ Ok(())
+ }
+
+ /// Waits for the process to terminate.
+ pub fn wait(&mut self, callback: Option<UserCallback>) -> anyhow::Result<i64> {
+ self.require_process_is_started("wait")?;
+
+ self.update_status(false);
+
+ if let Some(callback) = callback {
+ self.callback = Some(self.build_callback(Some(callback)));
+ }
+
+ loop {
+ self.check_timeout()?;
+ let running = self.is_running()
+ && (cfg!(windows) || self.process_pipes.as_ref().unwrap().are_open());
+ self.read_pipes(running, !cfg!(windows) || !running);
+ if !running {
+ break;
+ }
+ }
+
+ while self.is_running() {
+ self.check_timeout()?;
+ shirabe_php_shim::usleep(1000);
+ }
+
+ let signaled = self
+ .process_information
+ .as_ref()
+ .and_then(|i| i.get("signaled"))
+ .map(shirabe_php_shim::php_truthy)
+ .unwrap_or(false);
+ let termsig = self
+ .process_information
+ .as_ref()
+ .and_then(|i| i.get("termsig"))
+ .and_then(|v| v.as_int());
+ if signaled && termsig != self.latest_signal {
+ return Err(ProcessSignaledException::new(self)?.into());
+ }
+
+ Ok(self.exitcode.unwrap_or(0))
+ }
+
+ /// Returns the Pid (process identifier), if applicable.
+ pub fn get_pid(&mut self) -> Option<i64> {
+ if self.is_running() {
+ self.process_information
+ .as_ref()
+ .and_then(|i| i.get("pid"))
+ .and_then(|v| v.as_int())
+ } else {
+ None
+ }
+ }
+
+ /// Returns the current output of the process (STDOUT).
+ pub fn get_output(&mut self) -> anyhow::Result<String> {
+ if let Some(mock) = &self.mock {
+ return Ok(mock.stdout.clone());
+ }
+
+ self.read_pipes_for_output("getOutput", false)?;
+
+ Ok(
+ shirabe_php_shim::stream_get_contents3(self.stdout.as_ref().unwrap(), -1, 0)
+ .unwrap_or_default(),
+ )
+ }
+
+ /// Returns the current error output of the process (STDERR).
+ pub fn get_error_output(&mut self) -> anyhow::Result<String> {
+ if let Some(mock) = &self.mock {
+ return Ok(mock.stderr.clone());
+ }
+
+ self.read_pipes_for_output("getErrorOutput", false)?;
+
+ Ok(
+ shirabe_php_shim::stream_get_contents3(self.stderr.as_ref().unwrap(), -1, 0)
+ .unwrap_or_default(),
+ )
+ }
+
+ /// Returns the exit code returned by the process.
+ pub fn get_exit_code(&mut self) -> Option<i64> {
+ if self.mock.is_some() {
+ return self.exitcode;
+ }
+
+ self.update_status(false);
+
+ self.exitcode
+ }
+
+ /// Returns a string representation for the exit code returned by the process.
+ pub fn get_exit_code_text(&mut self) -> Option<String> {
+ let exitcode = self.get_exit_code()?;
+
+ Some(
+ Self::exit_code_text(exitcode)
+ .unwrap_or("Unknown error")
+ .to_string(),
+ )
+ }
+
+ /// Checks if the process ended successfully.
+ pub fn is_successful(&mut self) -> bool {
+ self.get_exit_code() == Some(0)
+ }
+
+ /// Returns the number of the signal that caused the child process to terminate.
+ pub fn get_term_signal(&mut self) -> anyhow::Result<i64> {
+ self.require_process_is_terminated("getTermSignal")?;
+
+ let termsig = self
+ .process_information
+ .as_ref()
+ .and_then(|i| i.get("termsig"))
+ .and_then(|v| v.as_int());
+ if self.is_sigchild_enabled() && termsig == Some(-1) {
+ return Err(RuntimeException::new(
+ "This PHP has been compiled with --enable-sigchild. Term signal cannot be retrieved.".to_string(),
+ )
+ .into());
+ }
+
+ Ok(termsig.unwrap_or(0))
+ }
+
+ /// Checks if the process is currently running.
+ pub fn is_running(&mut self) -> bool {
+ if Self::STATUS_STARTED != self.status {
+ return false;
+ }
+
+ self.update_status(false);
+
+ self.process_information
+ .as_ref()
+ .and_then(|i| i.get("running"))
+ .map(shirabe_php_shim::php_truthy)
+ .unwrap_or(false)
+ }
+
+ /// Checks if the process has been started with no regard to the current state.
+ pub fn is_started(&self) -> bool {
+ Self::STATUS_READY != self.status
+ }
+
+ /// Checks if the process is terminated.
+ pub fn is_terminated(&mut self) -> bool {
+ self.update_status(false);
+
+ Self::STATUS_TERMINATED == self.status
+ }
+
+ /// Stops the process.
+ pub fn stop(&mut self, timeout: f64, signal: Option<i64>) -> Option<i64> {
+ let timeout_micro = shirabe_php_shim::microtime() + timeout;
+ if self.is_running() {
+ // given SIGTERM may not be defined and that "proc_terminate" uses the constant value
+ // and not the constant itself, we use the same here
+ let _ = self.do_signal(15, false);
+ loop {
+ shirabe_php_shim::usleep(1000);
+ if !(self.is_running() && shirabe_php_shim::microtime() < timeout_micro) {
+ break;
+ }
+ }
+
+ if self.is_running() {
+ // Avoid exception here: process is supposed to be running, but it might have
+ // stopped just after this line. Silently discard the error.
+ let _ = self.do_signal(signal.filter(|&s| s != 0).unwrap_or(9), false);
+ }
+ }
+
+ if self.is_running() {
+ if self.fallback_status.contains_key("pid") {
+ self.fallback_status.shift_remove("pid");
+
+ return self.stop(0.0, signal);
+ }
+ self.close();
+ }
+
+ self.exitcode
+ }
+
+ /// Adds a line to the STDOUT stream.
+ pub fn add_output(&mut self, line: &str) {
+ let stdout = self.stdout.as_ref().unwrap();
+ shirabe_php_shim::fseek(stdout, 0, shirabe_php_shim::SEEK_END);
+ shirabe_php_shim::fwrite(stdout, line, Some(line.len() as i64));
+ shirabe_php_shim::fseek(
+ stdout,
+ self.incremental_output_offset,
+ shirabe_php_shim::SEEK_SET,
+ );
+ }
+
+ /// Adds a line to the STDERR stream.
+ pub fn add_error_output(&mut self, line: &str) {
+ let stderr = self.stderr.as_ref().unwrap();
+ shirabe_php_shim::fseek(stderr, 0, shirabe_php_shim::SEEK_END);
+ shirabe_php_shim::fwrite(stderr, line, Some(line.len() as i64));
+ shirabe_php_shim::fseek(
+ stderr,
+ self.incremental_error_output_offset,
+ shirabe_php_shim::SEEK_SET,
+ );
+ }
+
+ /// Gets the command line to be executed.
+ pub fn get_command_line(&self) -> String {
+ match &self.commandline {
+ CommandLine::Array(args) => args
+ .iter()
+ .map(|a| self.escape_argument(Some(a)))
+ .collect::<Vec<_>>()
+ .join(" "),
+ CommandLine::String(s) => s.clone(),
+ }
+ }
+
+ /// Gets the process timeout in seconds (max. runtime).
+ pub fn get_timeout(&self) -> Option<f64> {
+ self.timeout
+ }
+
+ /// Sets the process timeout (max. runtime) in seconds.
+ pub fn set_timeout(&mut self, timeout: Option<f64>) -> anyhow::Result<&mut Self> {
+ self.timeout = self.validate_timeout(timeout)?;
+
+ Ok(self)
+ }
+
+ /// Enables or disables the TTY mode.
+ pub fn set_tty(&mut self, tty: bool) -> anyhow::Result<&mut Self> {
+ if cfg!(windows) && tty {
+ return Err(RuntimeException::new(
+ "TTY mode is not supported on Windows platform.".to_string(),
+ )
+ .into());
+ }
+
+ if tty && !Self::is_tty_supported() {
+ return Err(RuntimeException::new(
+ "TTY mode requires /dev/tty to be read/writable.".to_string(),
+ )
+ .into());
+ }
+
+ self.tty = tty;
+
+ Ok(self)
+ }
+
+ /// Checks if the TTY mode is enabled.
+ pub fn is_tty(&self) -> bool {
+ self.tty
+ }
+
+ /// Gets the working directory.
+ pub fn get_working_directory(&self) -> Option<String> {
+ if self.cwd.is_none() {
+ // getcwd() will return false if any one of the parent directories does not have
+ // the readable or search mode set, even if the current directory does
+ return shirabe_php_shim::getcwd().filter(|s| !s.is_empty());
+ }
+
+ self.cwd.clone()
+ }
+
+ /// Sets the environment variables.
+ pub fn set_env(&mut self, env: IndexMap<String, PhpMixed>) -> &mut Self {
+ self.env = env;
+
+ self
+ }
+
+ /// Sets the input.
+ pub fn set_input(&mut self, input: PhpMixed) -> anyhow::Result<&mut Self> {
+ if self.is_running() {
+ return Err(LogicException::new(
+ "Input cannot be set while the process is running.".to_string(),
+ )
+ .into());
+ }
+
+ self.input =
+ ProcessUtils::validate_input("Symfony\\Component\\Process\\Process::setInput", input)?;
+
+ Ok(self)
+ }
+
+ /// Performs a check between the timeout definition and the time the process started.
+ pub fn check_timeout(&mut self) -> anyhow::Result<()> {
+ if Self::STATUS_STARTED != self.status {
+ return Ok(());
+ }
+
+ if let Some(timeout) = self.timeout
+ && timeout < shirabe_php_shim::microtime() - self.starttime.unwrap_or(0.0)
+ {
+ self.stop(0.0, None);
+
+ return Err(ProcessTimedOutException::new(self).into());
+ }
+
+ Ok(())
+ }
+
+ /// Returns whether TTY is supported on the current operating system.
+ pub fn is_tty_supported() -> bool {
+ static IS_TTY_SUPPORTED: OnceLock<bool> = OnceLock::new();
+
+ *IS_TTY_SUPPORTED.get_or_init(|| {
+ let mut pipes = IndexMap::new();
+ shirabe_php_shim::proc_open(
+ "echo 1 >/dev/null",
+ &[
+ descriptor(&["file", "/dev/tty", "r"]),
+ descriptor(&["file", "/dev/tty", "w"]),
+ descriptor(&["file", "/dev/tty", "w"]),
+ ],
+ &mut pipes,
+ None,
+ None,
+ None,
+ )
+ .is_ok()
+ })
+ }
+
+ /// Creates the descriptors needed by the proc_open.
+ fn get_descriptors(&mut self) -> Vec<Descriptor> {
+ // TODO(plugin): $this->input instanceof \Iterator -> rewind() is not modeled.
+ if cfg!(windows) {
+ self.process_pipes = Some(Box::new(WindowsPipes::new(self.input.clone())));
+ } else {
+ self.process_pipes = Some(Box::new(UnixPipes::new(
+ Some(self.is_tty()),
+ self.input.clone(),
+ )));
+ }
+
+ self.process_pipes.as_mut().unwrap().get_descriptors()
+ }
+
+ /// Builds up the callback used by wait().
+ fn build_callback(&self, callback: Option<UserCallback>) -> ProcessCallback {
+ let mut callback = callback;
+ let out = Self::OUT;
+
+ Box::new(
+ move |this: &mut Process, r#type: &str, data: &str| -> bool {
+ if out == r#type {
+ this.add_output(data);
+ } else {
+ this.add_error_output(data);
+ }
+
+ match callback.as_mut() {
+ Some(cb) => cb(r#type, data),
+ None => false,
+ }
+ },
+ )
+ }
+
+ /// Updates the status of the process, reads pipes.
+ fn update_status(&mut self, blocking: bool) {
+ if Self::STATUS_STARTED != self.status {
+ return;
+ }
+
+ self.process_information = Some(shirabe_php_shim::proc_get_status(
+ self.process.as_ref().unwrap(),
+ ));
+ let running = self
+ .process_information
+ .as_ref()
+ .unwrap()
+ .get("running")
+ .map(shirabe_php_shim::php_truthy)
+ .unwrap_or(false);
+
+ // In PHP < 8.3, "proc_get_status" only returns the correct exit status on the first call.
+ if shirabe_php_shim::PHP_VERSION_ID < 80300 {
+ let exitcode = self
+ .process_information
+ .as_ref()
+ .unwrap()
+ .get("exitcode")
+ .and_then(|v| v.as_int());
+ if self.cached_exit_code.is_none() && !running && exitcode != Some(-1) {
+ self.cached_exit_code = exitcode;
+ }
+
+ if let Some(cached) = self.cached_exit_code
+ && !running
+ && exitcode == Some(-1)
+ {
+ self.process_information
+ .as_mut()
+ .unwrap()
+ .insert("exitcode".to_string(), PhpMixed::Int(cached));
+ }
+ }
+
+ self.read_pipes(running && blocking, !cfg!(windows) || !running);
+
+ if !self.fallback_status.is_empty() && self.is_sigchild_enabled() {
+ // processInformation = fallbackStatus + processInformation (fallback keys win)
+ let mut merged = self.fallback_status.clone();
+ for (k, v) in self.process_information.take().unwrap() {
+ merged.entry(k).or_insert(v);
+ }
+ self.process_information = Some(merged);
+ }
+
+ if !running {
+ self.close();
+ }
+ }
+
+ /// Returns whether PHP has been compiled with the '--enable-sigchild' option or not.
+ fn is_sigchild_enabled(&self) -> bool {
+ static SIGCHILD: OnceLock<bool> = OnceLock::new();
+
+ if let Some(v) = SIGCHILD.get() {
+ return *v;
+ }
+
+ if !shirabe_php_shim::function_exists("phpinfo") {
+ return *SIGCHILD.get_or_init(|| false);
+ }
+
+ shirabe_php_shim::ob_start();
+ shirabe_php_shim::phpinfo(shirabe_php_shim::INFO_GENERAL);
+
+ *SIGCHILD.get_or_init(|| {
+ shirabe_php_shim::str_contains(
+ &shirabe_php_shim::ob_get_clean().unwrap_or_default(),
+ "--enable-sigchild",
+ )
+ })
+ }
+
+ /// Reads pipes for the freshest output.
+ fn read_pipes_for_output(&mut self, caller: &str, blocking: bool) -> anyhow::Result<()> {
+ self.require_process_is_started(caller)?;
+
+ self.update_status(blocking);
+ Ok(())
+ }
+
+ /// Validates and returns the filtered timeout.
+ fn validate_timeout(&self, timeout: Option<f64>) -> anyhow::Result<Option<f64>> {
+ let timeout = timeout.unwrap_or(0.0);
+
+ if timeout == 0.0 {
+ Ok(None)
+ } else if timeout < 0.0 {
+ Err(InvalidArgumentException::new(
+ "The timeout value must be a valid positive integer or float number.".to_string(),
+ )
+ .into())
+ } else {
+ Ok(Some(timeout))
+ }
+ }
+
+ /// Reads pipes, executes callback.
+ fn read_pipes(&mut self, blocking: bool, close: bool) {
+ let result = self
+ .process_pipes
+ .as_mut()
+ .unwrap()
+ .read_and_write(blocking, close);
+
+ let mut callback = self.callback.take();
+ for (r#type, data) in result {
+ if r#type != 3 {
+ if let Some(cb) = callback.as_mut() {
+ cb(
+ self,
+ if Self::STDOUT == r#type {
+ Self::OUT
+ } else {
+ Self::ERR
+ },
+ &data,
+ );
+ }
+ } else if !self.fallback_status.contains_key("signaled") {
+ self.fallback_status.insert(
+ "exitcode".to_string(),
+ PhpMixed::Int(data.trim().parse().unwrap_or(0)),
+ );
+ }
+ }
+ self.callback = callback;
+ }
+
+ /// Closes process resource, closes file handles, sets the exitcode.
+ fn close(&mut self) -> i64 {
+ if let Some(p) = self.process_pipes.as_mut() {
+ p.close();
+ }
+ if self.process.is_some() {
+ shirabe_php_shim::proc_close(self.process.as_ref().unwrap());
+ self.process = None;
+ }
+ self.exitcode = self
+ .process_information
+ .as_ref()
+ .and_then(|i| i.get("exitcode"))
+ .and_then(|v| v.as_int());
+ self.status = Self::STATUS_TERMINATED.to_string();
+
+ if self.exitcode == Some(-1) {
+ let signaled = self
+ .process_information
+ .as_ref()
+ .and_then(|i| i.get("signaled"))
+ .map(shirabe_php_shim::php_truthy)
+ .unwrap_or(false);
+ let termsig = self
+ .process_information
+ .as_ref()
+ .and_then(|i| i.get("termsig"))
+ .and_then(|v| v.as_int())
+ .unwrap_or(0);
+ if signaled && termsig > 0 {
+ // if process has been signaled, no exitcode but a valid termsig, apply Unix convention
+ self.exitcode = Some(128 + termsig);
+ } else if self.is_sigchild_enabled()
+ && let Some(i) = self.process_information.as_mut()
+ {
+ i.insert("signaled".to_string(), PhpMixed::Bool(true));
+ i.insert("termsig".to_string(), PhpMixed::Int(-1));
+ }
+ }
+
+ // Free memory from self-reference callback created by buildCallback
+ self.callback = None;
+
+ self.exitcode.unwrap_or(-1)
+ }
+
+ /// Resets data related to the latest run of the process.
+ fn reset_process_data(&mut self) {
+ self.starttime = None;
+ self.callback = None;
+ self.exitcode = None;
+ self.fallback_status = IndexMap::new();
+ self.process_information = None;
+ // php://temp is an in-memory stream; fopen never fails for it.
+ self.stdout = Some(
+ shirabe_php_shim::fopen(&format!("php://temp/maxmemory:{}", 1024 * 1024), "w+")
+ .unwrap(),
+ );
+ self.stderr = Some(
+ shirabe_php_shim::fopen(&format!("php://temp/maxmemory:{}", 1024 * 1024), "w+")
+ .unwrap(),
+ );
+ self.process = None;
+ self.latest_signal = None;
+ self.status = Self::STATUS_READY.to_string();
+ self.incremental_output_offset = 0;
+ self.incremental_error_output_offset = 0;
+ }
+
+ /// Sends a POSIX signal to the process.
+ fn do_signal(&mut self, signal: i64, throw_exception: bool) -> anyhow::Result<bool> {
+ let pid = match self.get_pid() {
+ None => {
+ if throw_exception {
+ return Err(LogicException::new(
+ "Cannot send signal on a non running process.".to_string(),
+ )
+ .into());
+ }
+
+ return Ok(false);
+ }
+ Some(pid) => pid,
+ };
+
+ if cfg!(windows) {
+ let mut output: Vec<String> = Vec::new();
+ let mut exit_code: i64 = 0;
+ shirabe_php_shim::exec(
+ &format!("taskkill /F /T /PID {} 2>&1", pid),
+ Some(&mut output),
+ Some(&mut exit_code),
+ );
+ if exit_code != 0 && self.is_running() {
+ if throw_exception {
+ return Err(RuntimeException::new(format!(
+ "Unable to kill the process ({}).",
+ output.join(" ")
+ ))
+ .into());
+ }
+
+ return Ok(false);
+ }
+ } else {
+ let ok;
+ if !self.is_sigchild_enabled() {
+ ok = shirabe_php_shim::proc_terminate(self.process.as_ref().unwrap(), signal);
+ } else if shirabe_php_shim::function_exists("posix_kill") {
+ ok = shirabe_php_shim::posix_kill(pid, signal);
+ } else {
+ let mut pipes = IndexMap::new();
+ let opened = shirabe_php_shim::proc_open(
+ &format!("kill -{} {}", signal, pid),
+ &[
+ Descriptor::Inherit,
+ Descriptor::Inherit,
+ descriptor(&["pipe", "w"]),
+ ],
+ &mut pipes,
+ None,
+ None,
+ None,
+ );
+ ok = match opened {
+ Ok(_) => pipes
+ .get(&2)
+ .and_then(|p| shirabe_php_shim::fgets(p, None))
+ .is_none(),
+ Err(_) => false,
+ };
+ }
+ if !ok {
+ if throw_exception {
+ return Err(RuntimeException::new(format!(
+ "Error while sending signal \"{}\".",
+ signal
+ ))
+ .into());
+ }
+
+ return Ok(false);
+ }
+ }
+
+ self.latest_signal = Some(signal);
+ self.fallback_status
+ .insert("signaled".to_string(), PhpMixed::Bool(true));
+ self.fallback_status
+ .insert("exitcode".to_string(), PhpMixed::Int(-1));
+ self.fallback_status.insert(
+ "termsig".to_string(),
+ PhpMixed::Int(self.latest_signal.unwrap()),
+ );
+
+ Ok(true)
+ }
+
+ fn prepare_windows_command_line(
+ &mut self,
+ cmd: &str,
+ env: &mut IndexMap<String, PhpMixed>,
+ ) -> anyhow::Result<String> {
+ let uid = shirabe_php_shim::uniqid("", true);
+ let mut var_count = 0;
+ let mut var_cache: IndexMap<String, String> = IndexMap::new();
+ let cmd = shirabe_php_shim::preg_replace_callback(
+ php_regex!(
+ r#"/"(?:(
+ [^"%!^]*+
+ (?:
+ (?: !LF! | "(?:\^[%!^])?+" )
+ [^"%!^]*+
+ )++
+ ) | [^"]*+ )"/x"#
+ ),
+ |m: &[Option<String>]| -> anyhow::Result<String> {
+ let m0 = m.first().cloned().flatten().unwrap_or_default();
+ let m1 = m.get(1).cloned().flatten();
+ if m1.is_none() {
+ return Ok(m0);
+ }
+ if let Some(cached) = var_cache.get(&m0) {
+ return Ok(cached.clone());
+ }
+ let mut value = m1.unwrap();
+ if value.contains('\0') {
+ value = value.replace('\0', "?");
+ }
+ if shirabe_php_shim::strpbrk(&value, "\"%!\n").is_none() {
+ return Ok(format!("\"{}\"", value));
+ }
+
+ for (from, to) in [
+ ("!LF!", "\n"),
+ ("\"^!\"", "!"),
+ ("\"^%\"", "%"),
+ ("\"^^\"", "^"),
+ ("\"\"", "\""),
+ ] {
+ value = value.replace(from, to);
+ }
+ value = format!(
+ "\"{}\"",
+ shirabe_php_shim::preg_replace(php_regex!(r#"/(\\*)"/"#), "$1$1\\\"", &value)
+ );
+ var_count += 1;
+ let var = format!("{}{}", uid, var_count);
+
+ env.insert(var.clone(), PhpMixed::String(value));
+
+ let replacement = format!("!{}!", var);
+ var_cache.insert(m0, replacement.clone());
+ Ok(replacement)
+ },
+ cmd,
+ )?;
+
+ static COM_SPEC: OnceLock<Option<String>> = OnceLock::new();
+ let com_spec = COM_SPEC
+ .get_or_init(|| {
+ ExecutableFinder::new()
+ .find("cmd.exe", None, &[])
+ .map(|spec| {
+ format!(
+ "\"{}\"",
+ shirabe_php_shim::preg_replace(
+ php_regex!(r#"{(\\*+)"}"#),
+ "$1$1\\\"",
+ &spec,
+ )
+ )
+ })
+ })
+ .clone();
+
+ let mut cmd = format!(
+ "{} /V:ON /E:ON /D /C ({})",
+ com_spec.unwrap_or_else(|| "cmd".to_string()),
+ cmd.replace('\n', " ")
+ );
+ for (offset, filename) in self.process_pipes.as_ref().unwrap().get_files() {
+ cmd.push_str(&format!(" {}>\"{}\"", offset, filename));
+ }
+
+ Ok(cmd)
+ }
+
+ /// Ensures the process is running or terminated.
+ fn require_process_is_started(&self, function_name: &str) -> anyhow::Result<()> {
+ if !self.is_started() {
+ return Err(LogicException::new(format!(
+ "Process must be started before calling \"{}()\".",
+ function_name
+ ))
+ .into());
+ }
+ Ok(())
+ }
+
+ /// Ensures the process is terminated.
+ fn require_process_is_terminated(&mut self, function_name: &str) -> anyhow::Result<()> {
+ if !self.is_terminated() {
+ return Err(LogicException::new(format!(
+ "Process must be terminated before calling \"{}()\".",
+ function_name
+ ))
+ .into());
+ }
+ Ok(())
+ }
+
+ /// Escapes a string to be used as a shell argument.
+ fn escape_argument(&self, argument: Option<&str>) -> String {
+ let argument = match argument {
+ None | Some("") => return "\"\"".to_string(),
+ Some(a) => a,
+ };
+ if !cfg!(windows) {
+ return format!("'{}'", argument.replace('\'', "'\\''"));
+ }
+ let mut argument = argument.to_string();
+ if argument.contains('\0') {
+ argument = argument.replace('\0', "?");
+ }
+ if !shirabe_php_shim::preg_match(
+ php_regex!(r#"/[()%!^"<>&|\s\[\]=;*?'$]/"#),
+ &argument,
+ &mut Vec::new(),
+ ) {
+ return argument;
+ }
+ argument = shirabe_php_shim::preg_replace(php_regex!(r"/(\\+)$/"), "$1$1", &argument);
+
+ let mut result = argument;
+ for (from, to) in [
+ ("\"", "\"\""),
+ ("^", "\"^^\""),
+ ("%", "\"^%\""),
+ ("!", "\"^!\""),
+ ("\n", "!LF!"),
+ ] {
+ result = result.replace(from, to);
+ }
+ format!("\"{}\"", result)
+ }
+
+ fn replace_placeholders(
+ &self,
+ commandline: &str,
+ env: &IndexMap<String, PhpMixed>,
+ ) -> anyhow::Result<String> {
+ shirabe_php_shim::preg_replace_callback(
+ php_regex!(r#"/"\$\{:([_a-zA-Z]+[_a-zA-Z0-9]*)\}"/"#),
+ |matches: &[Option<String>]| -> anyhow::Result<String> {
+ let key = matches.get(1).cloned().flatten().unwrap_or_default();
+ match env.get(&key) {
+ None => Err(InvalidArgumentException::new(format!(
+ "Command line is missing a value for parameter \"{}\": {}",
+ key, commandline
+ ))
+ .into()),
+ Some(PhpMixed::Bool(false)) => Err(InvalidArgumentException::new(format!(
+ "Command line is missing a value for parameter \"{}\": {}",
+ key, commandline
+ ))
+ .into()),
+ Some(v) => Ok(self.escape_argument(Some(&to_php_string(v)))),
+ }
+ },
+ commandline,
+ )
+ }
+
+ fn get_default_env(&self) -> IndexMap<String, PhpMixed> {
+ let env: IndexMap<String, String> = shirabe_php_shim::getenv_all()
+ .map(|(k, v)| {
+ (
+ k.to_string_lossy().into_owned(),
+ v.to_string_lossy().into_owned(),
+ )
+ })
+ .collect();
+ let server = shirabe_php_shim::PHP_SERVER.lock().unwrap();
+
+ // non-Windows: array_intersect_key($env, $_SERVER) ?: $env
+ let mut intersect: IndexMap<String, PhpMixed> = IndexMap::new();
+ for (k, v) in &env {
+ if server.get(k).is_some() {
+ intersect.insert(k.clone(), PhpMixed::String(v.clone()));
+ }
+ }
+ let env_map: IndexMap<String, PhpMixed> = if intersect.is_empty() {
+ env.into_iter()
+ .map(|(k, v)| (k, PhpMixed::String(v)))
+ .collect()
+ } else {
+ intersect
+ };
+
+ // $_ENV + env_map
+ let mut result: IndexMap<String, PhpMixed> = shirabe_php_shim::PHP_ENV
+ .lock()
+ .unwrap()
+ .get_all()
+ .map(|(k, v)| {
+ (
+ k.to_string_lossy().into_owned(),
+ PhpMixed::String(v.to_string_lossy().into_owned()),
+ )
+ })
+ .collect();
+ for (k, v) in env_map {
+ result.entry(k).or_insert(v);
+ }
+ result
+ }
+}
+
+impl Drop for Process {
+ fn drop(&mut self) {
+ self.stop(0.0, None);
+ }
+}