From d3bc3354c9705dfc6dc5e9b9adb5eb64d41e4c49 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 30 Aug 2026 23:01:25 +0900 Subject: feat(plugin): serve ProcessExecutor as a proxy stub Composer reaches this class two ways: the object graph hands one out through Composer::getLoop()->getProcessExecutor(), and plugins write `new ProcessExecutor($io)` freely. Both bind to a Rust-side entity, so the timeout the run shares -- seeded from process-timeout and rewritten while the run is in flight -- has one value instead of one per world, and the executor can still be passed to the classes that take one (`new Filesystem($process)`). Three things the stub generator was missing came with it: - By-ref parameters. The call carries their positions and the answer carries what each holds afterwards; a position the answer omits was never assigned to, which is what PHP does with an untouched by-ref parameter. ProcessExecutor::execute is the only one on a proxied class. - Argument arity, reproduced where the real body reads func_num_args(). execute($cmd) forwards the child's output and execute($cmd, $out) captures it, and nothing but the argument count separates the two. - Static methods that cannot run in the worker. One that reads a static property the Rust side owns, or that reaches a guarded class, forwards through __shirabeCallStatic instead of being materialized. That also fixes Filesystem::isLocalPath and getPlatformPath, whose materialized bodies called the guarded Composer\Util\Platform. The async surface stays an explicit error. executeAsync resolves its promise with a Symfony Process, whose proc_open() resource and pipes belong to whichever process called start(), so a Rust-side spawn has none to hand back; running the real start() in the worker needs a promise representation that crosses the boundary unresolved. The fixture project drives the whole synchronous surface from plugin code and compares the trace against upstream Composer byte for byte. Co-Authored-By: Claude Opus 5 (1M context) --- crates/shirabe/src/util/process_executor.rs | 54 ++++++++++++----------------- 1 file changed, 23 insertions(+), 31 deletions(-) (limited to 'crates/shirabe/src/util/process_executor.rs') 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( - &mut self, - _command: C, - _cwd: Option<&str>, - ) -> std::pin::Pin>>> - 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>>, @@ -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 = cmd.clone(); let git_cmd_strs: Vec = git_cmd.iter().map(|s| s.to_string()).collect(); @@ -973,6 +947,7 @@ impl 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` | `true` | /// | `execute($cmd, $cb)` | drive the child through the callback | `Box 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 { + fn capture_output(&self) -> bool { + true + } + + fn to_callback(self) -> anyhow::Result 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. -- cgit v1.3.1-4-g156e