diff options
Diffstat (limited to 'crates/shirabe')
8 files changed, 617 insertions, 44 deletions
diff --git a/crates/shirabe/src/event_dispatcher/event_dispatcher.rs b/crates/shirabe/src/event_dispatcher/event_dispatcher.rs index a5a238bd..7b620e5b 100644 --- a/crates/shirabe/src/event_dispatcher/event_dispatcher.rs +++ b/crates/shirabe/src/event_dispatcher/event_dispatcher.rs @@ -1682,6 +1682,7 @@ impl RustMethodDispatcher for ScriptRpcDispatcher<'_> { method_name: &str, args: Vec<PluginValue>, _out_param_positions: &[u32], + out_params: &mut IndexMap<u32, PluginValue>, ) -> Result<PluginValue, PhpThrow> { if rhandle == 0 { if method_name == "__shirabe_find_file" { @@ -1707,6 +1708,9 @@ impl RustMethodDispatcher for ScriptRpcDispatcher<'_> { if method_name == "__shirabeConstruct" { return crate::plugin::php_plugin_proxy::construct_entity(&args); } + if method_name == "__shirabeCallStatic" { + return crate::plugin::php_plugin_proxy::call_static_entity(&args); + } return Err(runtime_throw(format!( "unknown runtime service method `{method_name}`" ))); @@ -1719,6 +1723,7 @@ impl RustMethodDispatcher for ScriptRpcDispatcher<'_> { rhandle, method_name, &args, + out_params, ), } } diff --git a/crates/shirabe/src/plugin/php_plugin_proxy.rs b/crates/shirabe/src/plugin/php_plugin_proxy.rs index 8469593f..84bf195f 100644 --- a/crates/shirabe/src/plugin/php_plugin_proxy.rs +++ b/crates/shirabe/src/plugin/php_plugin_proxy.rs @@ -57,6 +57,7 @@ enum RustEntity { ), Operation(std::rc::Rc<AnyOperation>), Plugin(std::rc::Rc<std::cell::RefCell<dyn PluginInterface>>), + ProcessExecutor(std::rc::Rc<std::cell::RefCell<crate::util::ProcessExecutor>>), } /// The pointer identity backing R-table interning: the same shared instance must always cross @@ -79,6 +80,7 @@ fn entity_ptr_id(entity: &RustEntity) -> usize { } RustEntity::Operation(operation) => std::rc::Rc::as_ptr(operation) as *const () as usize, RustEntity::Plugin(plugin) => std::rc::Rc::as_ptr(plugin) as *const () as usize, + RustEntity::ProcessExecutor(process) => std::rc::Rc::as_ptr(process) as *const () as usize, } } @@ -288,6 +290,7 @@ impl RustMethodDispatcher for PluginRpcDispatcher<'_> { method_name: &str, args: Vec<PluginValue>, _out_param_positions: &[u32], + out_params: &mut IndexMap<u32, PluginValue>, ) -> Result<PluginValue, PhpThrow> { if rhandle == 0 { if method_name == "__shirabe_find_file" { @@ -308,6 +311,9 @@ impl RustMethodDispatcher for PluginRpcDispatcher<'_> { if method_name == "__shirabeConstruct" { return construct_entity(&args); } + if method_name == "__shirabeCallStatic" { + return call_static_entity(&args); + } return Err(runtime_throw(format!( "unknown runtime service method `{method_name}`" ))); @@ -319,7 +325,7 @@ impl RustMethodDispatcher for PluginRpcDispatcher<'_> { return dispatch_event_method(event, method_name); } - dispatch_r_table_method(rhandle, method_name, &args) + dispatch_r_table_method(rhandle, method_name, &args, out_params) } } @@ -330,6 +336,7 @@ pub(crate) fn dispatch_r_table_method( rhandle: u64, method_name: &str, args: &[PluginValue], + out_params: &mut IndexMap<u32, PluginValue>, ) -> Result<PluginValue, PhpThrow> { // The entity is cloned out so no table borrow is held while the handler runs (a // handler that re-enters register_*_entity would otherwise panic on the RefCell). @@ -371,6 +378,9 @@ pub(crate) fn dispatch_r_table_method( dispatch_operation_method(&operation, method_name, args) } Some(RustEntity::Plugin(plugin)) => dispatch_plugin_method(&plugin, method_name), + Some(RustEntity::ProcessExecutor(process)) => { + dispatch_process_executor_method(&process, method_name, args, out_params) + } None => Err(runtime_throw(format!("unknown Rust handle {rhandle}"))), } } @@ -397,6 +407,14 @@ pub(crate) fn construct_entity(args: &[PluginValue]) -> Result<PluginValue, PhpT }; let string_arg = |position: usize| arg::<String>(&class, ctor_args, position); let package_arg = |position: usize| arg::<PackageInterfaceHandle>(&class, ctor_args, position); + let io_arg = |position: usize| { + arg::<std::rc::Rc<std::cell::RefCell<dyn IOInterface>>>(&class, ctor_args, position) + }; + let process_executor_arg = |position: usize| { + arg::<std::rc::Rc<std::cell::RefCell<crate::util::ProcessExecutor>>>( + &class, ctor_args, position, + ) + }; let alias_package_arg = |position: usize| -> Result<crate::package::AliasPackageHandle, PhpThrow> { package_arg(position)? @@ -486,18 +504,25 @@ pub(crate) fn construct_entity(args: &[PluginValue]) -> Result<PluginValue, PhpT // runs subprocesses through, so a plugin-built one is a complete instance rather than // a second view on a Rust-side service. "Composer\\Util\\Filesystem" => { - // TODO(plugin): `ProcessExecutor` has no proxy stub, so an executor argument could - // only be a second instance the Rust side never sees. - match ctor_args.first() { - None | Some(PluginValue::Null) => {} - other => { - return Err(runtime_throw(format!( - "{class} cannot take a ProcessExecutor over RPC yet, got {other:?}" - ))); - } - } + let executor = match ctor_args.first() { + None | Some(PluginValue::Null) => None, + _ => Some(process_executor_arg(0)?), + }; let rhandle = register_entity(RustEntity::Filesystem(std::rc::Rc::new( - std::cell::RefCell::new(crate::util::Filesystem::new(None)), + std::cell::RefCell::new(crate::util::Filesystem::new(executor)), + ))); + return Ok(construction_result(rhandle)); + } + // The job queue and the async permits of a process executor are its own state, and the + // timeout it runs children under is the Rust side's; a plugin-built one is a complete + // instance of the former holding the latter, not a second view on the graph's executor. + "Composer\\Util\\ProcessExecutor" => { + let io = match ctor_args.first() { + None | Some(PluginValue::Null) => None, + _ => Some(io_arg(0)?), + }; + let rhandle = register_entity(RustEntity::ProcessExecutor(std::rc::Rc::new( + std::cell::RefCell::new(crate::util::ProcessExecutor::new(io)), ))); return Ok(construction_result(rhandle)); } @@ -551,7 +576,8 @@ fn clone_entity(entity: &RustEntity) -> Result<PluginValue, PhpThrow> { | RustEntity::Repository(_) | RustEntity::EventDispatcher(_) | RustEntity::Operation(_) - | RustEntity::Plugin(_) => { + | RustEntity::Plugin(_) + | RustEntity::ProcessExecutor(_) => { return Err(runtime_throw( "cloning this Rust-side entity over RPC is not supported".to_string(), )); @@ -993,6 +1019,268 @@ fn dispatch_filesystem_method( } } +/// Serves the `ProcessExecutor` proxy stub, whether the executor behind it belongs to the object +/// graph or was built by plugin code writing `new ProcessExecutor(...)`. Both run their children in +/// the Rust process, which is what keeps the timeout, the executable path cache and the job budget +/// single-sourced across the boundary instead of forking a copy per world. +fn dispatch_process_executor_method( + process: &std::rc::Rc<std::cell::RefCell<crate::util::ProcessExecutor>>, + method_name: &str, + args: &[PluginValue], + out_params: &mut IndexMap<u32, PluginValue>, +) -> Result<PluginValue, PhpThrow> { + let failed = |error: anyhow::Error| runtime_throw(format!("{method_name} failed: {error:#}")); + let cwd_arg = |position: usize| -> Result<Option<String>, PhpThrow> { + match args.get(position) { + None | Some(PluginValue::Null) => Ok(None), + _ => arg::<String>(method_name, args, position).map(Some), + } + }; + match method_name { + "execute" => { + let command = exec_command_arg(method_name, args, 0)?; + let cwd = cwd_arg(2)?; + // PHP selects the output handling with `func_num_args() > 1` and `is_callable($output)`; + // the stub reproduces the caller's arity, so an absent argument is the forwarding case. + let Some(output) = args.get(1) else { + return process + .borrow_mut() + .execute( + command, + crate::util::ProcessExecutor::FORWARD_OUTPUT, + cwd.as_deref(), + ) + .map(|code| code.to_plugin_value()) + .map_err(failed); + }; + if is_php_callable(output).map_err(failed)? { + // TODO(plugin): the executor stays mutably borrowed while the callback runs, so a + // callback re-entering this same executor panics instead of nesting the way PHP + // would. + let callable = output.clone(); + let callback: Box<dyn FnMut(&str, &str) -> bool> = + Box::new(move |r#type: &str, buffer: &str| { + // TODO(error-model): `Process::run`'s callback cannot fail, so a throw + // raised by the plugin's handler is dropped here rather than unwinding + // out of the command the way PHP would. + let _ = call_php_callable( + &callable, + vec![PluginValue::string(r#type), PluginValue::string(buffer)], + ); + false + }); + return process + .borrow_mut() + .execute(command, callback, cwd.as_deref()) + .map(|code| code.to_plugin_value()) + .map_err(failed); + } + let mut captured: Option<String> = None; + let code = process + .borrow_mut() + .execute(command, &mut captured, cwd.as_deref()) + .map_err(failed)?; + // PHP leaves `$output` alone when the child was signaled before the assignment, which + // is what an absent out-param position reproduces. + if let Some(captured) = captured { + out_params.insert(1, PluginValue::string(captured)); + } + Ok(code.to_plugin_value()) + } + "executeTty" => Ok(process + .borrow_mut() + .execute_tty( + exec_command_arg(method_name, args, 0)?, + cwd_arg(1)?.as_deref(), + ) + .map_err(failed)? + .to_plugin_value()), + "splitLines" => { + let output = match args.first() { + None | Some(PluginValue::Null) => String::new(), + _ => arg::<String>(method_name, args, 0)?, + }; + Ok(PluginValue::List( + process + .borrow() + .split_lines(&output) + .into_iter() + .map(PluginValue::string) + .collect(), + )) + } + "getErrorOutput" => Ok(PluginValue::string(process.borrow().get_error_output())), + "requiresGitDirEnv" => Ok(process + .borrow() + .requires_git_dir_env(&exec_command_arg(method_name, args, 0)?) + .to_plugin_value()), + "setMaxJobs" => { + process + .borrow_mut() + .set_max_jobs(arg::<i64>(method_name, args, 0)?); + Ok(PluginValue::Null) + } + "resetMaxJobs" => { + process.borrow_mut().reset_max_jobs(); + Ok(PluginValue::Null) + } + // TODO(plugin,async): the async surface needs an execution model that can hand a plugin a + // live `Symfony\Component\Process\Process`. That object's state is the `proc_open()` + // resource and the OS pipes of whichever process called `start()`, so a Rust-side spawn + // has none to give; the real `start()` has to run in the worker, driven by a promise + // representation that crosses the boundary unresolved. Neither exists yet. + "executeAsync" | "wait" | "enableAsync" | "countActiveJobs" => Err(runtime_throw(format!( + "Shirabe does not support ProcessExecutor::{method_name}() from a plugin yet" + ))), + other => Err(runtime_throw(format!( + "unknown ProcessExecutor method `{other}`" + ))), + } +} + +/// Decodes the `string|non-empty-list<string>` a process executor takes as its command. +fn exec_command_arg( + method: &str, + args: &[PluginValue], + position: usize, +) -> Result<crate::util::process_executor::CommandLine, PhpThrow> { + match args.get(position) { + // TODO(bytes): lossy UTF-8; every PHP string crossing the boundary is bytes. + Some(PluginValue::String(bytes)) => Ok(crate::util::process_executor::CommandLine::Shell( + String::from_utf8_lossy(bytes).into_owned(), + )), + Some(PluginValue::List(items)) => { + let mut parts = Vec::with_capacity(items.len()); + for (index, item) in items.iter().enumerate() { + match item { + PluginValue::String(bytes) => { + parts.push(String::from_utf8_lossy(bytes).into_owned()); + } + other => { + return Err(runtime_throw(format!( + "{method} expects a list of strings at position {position}, \ + but element {index} is {other:?}" + ))); + } + } + } + Ok(crate::util::process_executor::CommandLine::Args(parts)) + } + other => Err(arg_throw( + method, + position, + "a command string or argument list", + other, + )), + } +} + +/// PHP's `is_callable($value)`, answered by the worker because only its own function and class +/// tables can say whether a string or a `[$object, 'method']` pair names something callable. +/// Values that cannot name a callable at all are answered here rather than over a round trip. +fn is_php_callable(value: &PluginValue) -> anyhow::Result<bool> { + match value { + PluginValue::String(_) + | PluginValue::List(_) + | PluginValue::Array(_) + | PluginValue::PhpHandle(_) + | PluginValue::PhpClass(_) => { + let answer = unwrap_php_result(call_function_with_dispatcher( + "is_callable", + vec![value.clone()], + Some(&mut PluginRpcDispatcher::default()), + ))?; + match answer { + PluginValue::Bool(answer) => Ok(answer), + other => Err(anyhow::anyhow!( + "is_callable did not return a bool over RPC: {other:?}" + )), + } + } + _ => Ok(false), + } +} + +/// Calls a PHP callable value in the worker, whatever form it takes: `call_user_func` resolves a +/// closure, a function name and an `[$object, 'method']` pair exactly as the original call site +/// would have. +fn call_php_callable( + callable: &PluginValue, + args: Vec<PluginValue>, +) -> anyhow::Result<PluginValue> { + let mut call_args = Vec::with_capacity(args.len() + 1); + call_args.push(callable.clone()); + call_args.extend(args); + unwrap_php_result(call_function_with_dispatcher( + "call_user_func", + call_args, + Some(&mut PluginRpcDispatcher::default()), + )) +} + +/// Serves the static methods a proxy stub forwards instead of running locally, because their real +/// bodies reach state the Rust side owns or classes the worker has no code for. +pub(crate) fn call_static_entity(args: &[PluginValue]) -> Result<PluginValue, PhpThrow> { + let (class, method, call_args) = match (args.first(), args.get(1), args.get(2)) { + // TODO(bytes): lossy UTF-8; class and method names are bytes in PHP. + ( + Some(PluginValue::String(class)), + Some(PluginValue::String(method)), + Some(PluginValue::List(call_args)), + ) => ( + String::from_utf8_lossy(class).into_owned(), + String::from_utf8_lossy(method).into_owned(), + call_args.clone(), + ), + ( + Some(PluginValue::String(class)), + Some(PluginValue::String(method)), + None | Some(PluginValue::Array(_)), + ) => ( + String::from_utf8_lossy(class).into_owned(), + String::from_utf8_lossy(method).into_owned(), + Vec::new(), + ), + other => { + return Err(runtime_throw(format!( + "__shirabeCallStatic expects a class name, a method name and an argument list, \ + got {other:?}" + ))); + } + }; + let name = format!("{class}::{method}"); + let string_arg = |position: usize| arg::<String>(&name, &call_args, position); + match (class.as_str(), method.as_str()) { + ("Composer\\Util\\Filesystem", "isLocalPath") => { + Ok(crate::util::Filesystem::is_local_path(&string_arg(0)?).to_plugin_value()) + } + ("Composer\\Util\\Filesystem", "getPlatformPath") => Ok(PluginValue::string( + crate::util::Filesystem::get_platform_path(&string_arg(0)?), + )), + ("Composer\\Util\\ProcessExecutor", "getTimeout") => { + Ok(crate::util::ProcessExecutor::get_timeout().to_plugin_value()) + } + ("Composer\\Util\\ProcessExecutor", "setTimeout") => { + crate::util::ProcessExecutor::set_timeout(arg::<i64>(&name, &call_args, 0)?); + Ok(PluginValue::Null) + } + // `escape(string|false|null $argument)` casts its argument to string first, which turns + // both of the non-string forms into the empty string. + ("Composer\\Util\\ProcessExecutor", "escape") => { + let argument = match call_args.first() { + None | Some(PluginValue::Null) | Some(PluginValue::Bool(false)) => String::new(), + _ => string_arg(0)?, + }; + Ok(PluginValue::string(crate::util::ProcessExecutor::escape( + &argument, + ))) + } + _ => Err(runtime_throw(format!( + "Shirabe does not support calling {name} from the plugin process yet" + ))), + } +} + fn dispatch_event_dispatcher_method( dispatcher: &std::rc::Rc< std::cell::RefCell<dyn crate::event_dispatcher::EventDispatcherInterface>, @@ -1326,6 +1614,50 @@ impl FromPluginArg for PackageInterfaceHandle { } } +/// Resolves an IO argument back to the Rust-side entity its proxy stub stands for. +impl FromPluginArg for std::rc::Rc<std::cell::RefCell<dyn IOInterface>> { + fn from_arg( + method: &str, + position: usize, + value: Option<&PluginValue>, + ) -> Result<Self, PhpThrow> { + match value { + Some(PluginValue::RustHandle(handle)) => { + match R_TABLE.with(|table| table.borrow().get(&handle.rhandle).cloned()) { + Some(RustEntity::Io(io)) => Ok(io), + _ => Err(runtime_throw(format!( + "{method} expects an IO handle, got Rust handle {}", + handle.rhandle + ))), + } + } + other => Err(arg_throw(method, position, "an IOInterface", other)), + } + } +} + +/// Resolves a process executor argument back to the Rust-side entity its proxy stub stands for. +impl FromPluginArg for std::rc::Rc<std::cell::RefCell<crate::util::ProcessExecutor>> { + fn from_arg( + method: &str, + position: usize, + value: Option<&PluginValue>, + ) -> Result<Self, PhpThrow> { + match value { + Some(PluginValue::RustHandle(handle)) => { + match R_TABLE.with(|table| table.borrow().get(&handle.rhandle).cloned()) { + Some(RustEntity::ProcessExecutor(process)) => Ok(process), + _ => Err(runtime_throw(format!( + "{method} expects a ProcessExecutor handle, got Rust handle {}", + handle.rhandle + ))), + } + } + other => Err(arg_throw(method, position, "a ProcessExecutor", other)), + } + } +} + /// Resolves a repository argument back to the Rust-side entity its proxy stub stands for. impl FromPluginArg for RepositoryInterfaceHandle { fn from_arg( diff --git a/crates/shirabe/src/util/process_executor.rs b/crates/shirabe/src/util/process_executor.rs index eae6692b..e1e7a8d8 100644 --- a/crates/shirabe/src/util/process_executor.rs +++ b/crates/shirabe/src/util/process_executor.rs @@ -618,37 +618,6 @@ impl ProcessExecutor { }) } - /// Plugin-facing counterpart of `execute_async`. Reached only when the RPC dispatcher - /// relays a plugin's `executeAsync()` call made on the `rust-proxy` `ProcessExecutor` stub - /// (a plugin obtained the handle via `Loop::getProcessExecutor()`) — see - /// docs/dev/plugin-class-classification.md, "Process: dual instantiation split by caller". - /// - /// A `Symfony\Component\Process\Process` cannot be reconstructed on the Rust side: its state - /// (the `proc_open()` resource, the OS pipes) belongs to whichever process calls `start()`, - /// and it refuses serialization outright. So unlike `execute_async`, this must not - /// spawn in Rust: the real `Process::start()` has to run in the PHP child, and the plugin's - /// `.then()` callback must receive that genuine PHP-side object. - // TODO(plugin): once the plugin RPC channel exists, acquire a permit from `self.semaphore` - // (shared with `execute_async`, so the combined job budget — including any shared cap - // with HttpDownloader — stays correct regardless of which path runs a given job), then send - // the spawn request to the PHP child over that channel instead of calling `Process::start()` - // here. Release the permit on the child's completion notification, not by polling a - // Rust-owned process handle. The return type below is provisional: the real deliverable is a - // handle to the live PHP-side Process object, not a `shirabe_symfony_process` `Process` - // value, so this signature will need to change once the RPC plumbing exists. - pub fn execute_async_php<C>( - &mut self, - _command: C, - _cwd: Option<&str>, - ) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>>>> - where - C: IntoExecCommand, - { - todo!( - "forward the spawn to the PHP child over the plugin RPC channel and await its completion notification" - ) - } - fn output_handler( capture_output: bool, io: &mut Option<std::rc::Rc<std::cell::RefCell<dyn IOInterface>>>, @@ -855,6 +824,11 @@ impl ProcessExecutor { return false; } + // TODO(php-semantics): the shim's array_intersect returns a Vec and drops the keys PHP + // keeps from its first argument, so this compares values where PHP's `===` compares keys + // as well. PHP answers false for every pattern below, the early return above leaving `git` + // at index 0 so the intersection can never start where the pattern does; this answers + // true, which is what the patterns were written for. for git_cmd in Self::GIT_CMDS_NEED_GIT_DIR.iter() { let cmd_strs: Vec<String> = cmd.clone(); let git_cmd_strs: Vec<String> = git_cmd.iter().map(|s| s.to_string()).collect(); @@ -973,6 +947,7 @@ impl<const N: usize> IntoExecCommand for &[String; N] { /// | `execute($cmd)` | forward child output to STDOUT/STDERR (or the IO) | [`ProcessForwardOutput`] | `false` | /// | `execute($cmd, $out)` | assign captured output back to `$out` | `&mut String` / `&mut PhpMixed` | `true` | /// | `execute($cmd, $out)` where `$out` is unused | capture (suppress output) but discard it | `()` | `true` | +/// | `execute($cmd, $out)` from a plugin | capture, recording whether it was assigned | `&mut Option<String>` | `true` | /// | `execute($cmd, $cb)` | drive the child through the callback | `Box<dyn FnMut(&str, &str) -> bool>` | `false` | /// /// `capture_output` maps to PHP's `$this->captureOutput` (`func_num_args() > 3` in `doExecute`): when @@ -1058,6 +1033,23 @@ impl<'a> IntoExecOutput<'a> for &'a mut String { } } +/// `execute($cmd, $out)` reached over the plugin RPC boundary, where the caller has to know whether +/// the output was assigned at all rather than just what it is: PHP leaves `$output` untouched when +/// the child is signaled, and `None` reproduces that by sending no value back across the boundary. +impl<'a> IntoExecOutput<'a> for &'a mut Option<String> { + fn capture_output(&self) -> bool { + true + } + + fn to_callback(self) -> anyhow::Result<Box<dyn FnMut(&str, &str) -> bool>, Self> { + Err(self) + } + + fn write_back(&mut self, value: String) { + **self = Some(value); + } +} + /// `execute($cmd, $cb)` where `$cb` is callable: the callback is passed straight to `Process::run` /// as the output handler, so the caller drives the child's output itself (e.g. `Svn`'s streaming /// handler). The `bool` return mirrors Symfony's ignored callback return value. diff --git a/crates/shirabe/tests/plugin/e2e_process_executor_test.rs b/crates/shirabe/tests/plugin/e2e_process_executor_test.rs new file mode 100644 index 00000000..e1bb2aa6 --- /dev/null +++ b/crates/shirabe/tests/plugin/e2e_process_executor_test.rs @@ -0,0 +1,84 @@ +//! ProcessExecutor E2E compatibility check: upstream Composer and Shirabe each install a fixture +//! project whose plugin builds its own `ProcessExecutor` and writes what every call on it reports +//! to a trace file. Upstream has no test that drives a process executor from plugin code, so the +//! whole fixture is Shirabe-authored (`fixtures/e2e-process-executor/`) and nothing has to be +//! fetched; the test skips only while the PHP runtime or the Composer checkout is missing. + +use crate::e2e_extension_installer_test::{copy_dir, upstream_composer_bin}; +use crate::php_worker::{lock_php_worker, php_runtime_available}; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +fn fixture_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/plugin/fixtures/e2e-process-executor") +} + +struct Run { + exit_code: i32, + trace: String, +} + +/// Runs `install` in a fresh copy of the fixture and returns the exit code with the plugin's trace. +fn install(program: &str, prefix_args: &[&str]) -> Run { + let work = TempDir::new().unwrap(); + copy_dir(&fixture_dir(), work.path()); + let project = work.path().join("project"); + let output = std::process::Command::new(program) + .args(prefix_args) + .arg("install") + .current_dir(&project) + .env("COMPOSER_HOME", work.path().join("home")) + .env("COMPOSER_CACHE_DIR", work.path().join("cache")) + .env("COMPOSER_NO_INTERACTION", "1") + .env("COLUMNS", "120") + .env("LINES", "30") + .output() + .unwrap(); + Run { + exit_code: output.status.code().unwrap_or(-1), + trace: std::fs::read_to_string(project.join("process-executor-trace.txt")) + .unwrap_or_default(), + } +} + +#[test] +fn test_plugin_owned_process_executor_matches_upstream_composer() { + if !php_runtime_available() { + return; + } + let Some(composer_bin) = upstream_composer_bin() else { + return; + }; + let _worker = lock_php_worker(); + let composer_bin = composer_bin.to_str().unwrap().to_string(); + + let upstream = install("php", &[composer_bin.as_str()]); + let shirabe = install(env!("CARGO_BIN_EXE_shirabe"), &[]); + + assert_eq!(0, upstream.exit_code, "upstream install must succeed"); + assert_eq!(upstream.exit_code, shirabe.exit_code); + assert_eq!(upstream.trace, shirabe.trace); + + // Pinned as well as compared, so a run where neither side wrote a trace cannot pass. The + // timeout is the project's `process-timeout`, which is what makes it evidence that both + // worlds read one value rather than each holding its own default. + assert_eq!( + "\ +event=post-update-cmd +timeout=42 +timeout-after-set=7 +capture code=0 output=\"captured\\n\" error=\"\" +list code=0 output=\"from a list\\n\" +failing code=3 output=\"out\\n\" error=\"err\\n\" +forwarded code=0 file=\"forwarded\" +callback code=0 seen=[\"out:through-a-callback\"] argument=true +cwd code=0 basename=\"vendor\" +splitLines code=0 lines=[\"x\",\"y\"] empty=[] +escape=\"'a b'\\\\''c'\" +requiresGitDirEnv status=false +maxJobs=ok +filesystem normalizePath=\"\\/a\\/c\" isLocalPath=true getPlatformPath=\"\\/a\\/b\" +", + upstream.trace + ); +} diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-process-executor/plugin/composer.json b/crates/shirabe/tests/plugin/fixtures/e2e-process-executor/plugin/composer.json new file mode 100644 index 00000000..5d5609c1 --- /dev/null +++ b/crates/shirabe/tests/plugin/fixtures/e2e-process-executor/plugin/composer.json @@ -0,0 +1,17 @@ +{ + "name": "shirabe-test/process-executor-probe", + "version": "1.0.0", + "type": "composer-plugin", + "description": "Fixture plugin driving a ProcessExecutor it constructs itself.", + "autoload": { + "psr-4": { + "ShirabeTest\\ProcessExecutor\\": "src/" + } + }, + "require": { + "composer-plugin-api": "^2.0" + }, + "extra": { + "class": "ShirabeTest\\ProcessExecutor\\Plugin" + } +} diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-process-executor/plugin/src/Plugin.php b/crates/shirabe/tests/plugin/fixtures/e2e-process-executor/plugin/src/Plugin.php new file mode 100644 index 00000000..1f58a11d --- /dev/null +++ b/crates/shirabe/tests/plugin/fixtures/e2e-process-executor/plugin/src/Plugin.php @@ -0,0 +1,117 @@ +<?php + +namespace ShirabeTest\ProcessExecutor; + +use Composer\Composer; +use Composer\EventDispatcher\EventSubscriberInterface; +use Composer\IO\IOInterface; +use Composer\Plugin\PluginInterface; +use Composer\Script\Event; +use Composer\Script\ScriptEvents; +use Composer\Util\Filesystem; +use Composer\Util\ProcessExecutor; + +/** + * Drives a ProcessExecutor the plugin constructs itself and appends what every call reports to + * process-executor-trace.txt, so the whole surface can be compared line by line between + * implementations: the timeout shared with the rest of the run, both command forms, the three + * ways the second argument is treated, the error output, and a Filesystem built on the executor. + */ +class Plugin implements PluginInterface, EventSubscriberInterface +{ + /** @var IOInterface */ + private $io; + + public function activate(Composer $composer, IOInterface $io): void + { + $this->io = $io; + } + + public function deactivate(Composer $composer, IOInterface $io): void + { + } + + public function uninstall(Composer $composer, IOInterface $io): void + { + } + + public static function getSubscribedEvents() + { + // Whether an install resolves or replays a lock file decides which of the two fires, so + // both are subscribed and the trace records the one that ran. + return [ + ScriptEvents::POST_INSTALL_CMD => 'onPostCommand', + ScriptEvents::POST_UPDATE_CMD => 'onPostCommand', + ]; + } + + public function onPostCommand(Event $event): void + { + $process = new ProcessExecutor($this->io); + $lines = ['event=' . $event->getName()]; + + // The timeout is process-wide state Composer seeds from the config, so both worlds have + // to report the value this project asked for and to observe each other's writes. + $original = ProcessExecutor::getTimeout(); + $lines[] = 'timeout=' . $original; + ProcessExecutor::setTimeout(7); + $lines[] = 'timeout-after-set=' . ProcessExecutor::getTimeout(); + ProcessExecutor::setTimeout($original); + + $code = $process->execute('echo captured', $captured); + $lines[] = 'capture code=' . $code . ' output=' . json_encode($captured) + . ' error=' . json_encode($process->getErrorOutput()); + + $code = $process->execute(['echo', 'from', 'a', 'list'], $listed); + $lines[] = 'list code=' . $code . ' output=' . json_encode($listed); + + $code = $process->execute('echo out; echo err 1>&2; exit 3', $failed); + $lines[] = 'failing code=' . $code . ' output=' . json_encode($failed) + . ' error=' . json_encode($process->getErrorOutput()); + + // Without a second argument the child's output is forwarded rather than captured, which + // is a different branch of the same method; the redirection keeps it out of the terminal + // so the trace stays the only thing under comparison. + $code = $process->execute('echo forwarded > forwarded.txt'); + $lines[] = 'forwarded code=' . $code + . ' file=' . json_encode(trim((string) @file_get_contents('forwarded.txt'))); + + // A callable second argument drives the child's output itself and is never assigned to. + $seen = []; + $callback = static function (string $type, string $buffer) use (&$seen): void { + $seen[] = $type . ':' . trim($buffer); + }; + $code = $process->execute('echo through-a-callback', $callback); + $lines[] = 'callback code=' . $code . ' seen=' . json_encode($seen) + . ' argument=' . json_encode(\is_callable($callback)); + + $code = $process->execute('pwd', $cwdOutput, 'vendor'); + $lines[] = 'cwd code=' . $code . ' basename=' . json_encode(basename(trim((string) $cwdOutput))); + + $code = $process->execute('echo x; echo y', $multiline); + $lines[] = 'splitLines code=' . $code + . ' lines=' . json_encode($process->splitLines($multiline)) + . ' empty=' . json_encode($process->splitLines(null)); + + $lines[] = 'escape=' . json_encode(ProcessExecutor::escape("a b'c")); + // TODO(php-semantics): a command matching GIT_CMDS_NEED_GIT_DIR has no agreed value to + // compare. array_intersect() keeps its first argument's keys and `===` compares an + // array's keys too, so Composer answers false for those patterns as well; the shim's + // array_intersect drops the keys and Shirabe answers true. + $lines[] = 'requiresGitDirEnv status=' + . json_encode($process->requiresGitDirEnv('git status')); + + $process->setMaxJobs(4); + $process->resetMaxJobs(); + $lines[] = 'maxJobs=ok'; + + // The executor is a constructor argument of other Composer utilities, so a plugin-built + // one has to be accepted wherever the real class is. + $filesystem = new Filesystem($process); + $lines[] = 'filesystem normalizePath=' . json_encode($filesystem->normalizePath('/a/b/../c')) + . ' isLocalPath=' . json_encode(Filesystem::isLocalPath('/a/b')) + . ' getPlatformPath=' . json_encode(Filesystem::getPlatformPath('file:///a/b')); + + file_put_contents('process-executor-trace.txt', implode("\n", $lines) . "\n"); + } +} diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-process-executor/project/composer.json b/crates/shirabe/tests/plugin/fixtures/e2e-process-executor/project/composer.json new file mode 100644 index 00000000..61ca9a5a --- /dev/null +++ b/crates/shirabe/tests/plugin/fixtures/e2e-process-executor/project/composer.json @@ -0,0 +1,25 @@ +{ + "name": "shirabe/e2e-process-executor", + "description": "E2E fixture project: record what a plugin's own ProcessExecutor reports.", + "repositories": [ + { + "type": "path", + "url": "../plugin", + "options": { + "symlink": false + } + }, + { + "packagist.org": false + } + ], + "require": { + "shirabe-test/process-executor-probe": "1.0.0" + }, + "config": { + "process-timeout": 42, + "allow-plugins": { + "shirabe-test/process-executor-probe": true + } + } +} diff --git a/crates/shirabe/tests/plugin/main.rs b/crates/shirabe/tests/plugin/main.rs index 000ca3a9..35f5beb5 100644 --- a/crates/shirabe/tests/plugin/main.rs +++ b/crates/shirabe/tests/plugin/main.rs @@ -12,6 +12,7 @@ mod e2e_installer_test; mod e2e_installers_test; mod e2e_normalize_test; mod e2e_package_event_test; +mod e2e_process_executor_test; mod e2e_script_command_test; mod e2e_script_event_test; mod plugin_installer_test; |
