diff options
| -rw-r--r-- | crates/shirabe-php-rpc/php/guards/Composer/Util/Loop.php | 39 | ||||
| -rw-r--r-- | crates/shirabe-php-rpc/php/stubs/Composer/Util/Loop.php | 96 | ||||
| -rw-r--r-- | crates/shirabe-php-rpc/src/lib.rs | 4 | ||||
| -rw-r--r-- | crates/shirabe/src/plugin/php_plugin_proxy.rs | 140 | ||||
| -rw-r--r-- | crates/shirabe/tests/plugin/e2e_http_downloader_test.rs | 5 | ||||
| -rw-r--r-- | crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/plugin/src/Plugin.php | 42 | ||||
| -rw-r--r-- | docs/dev/plugin-class-classification.md | 12 | ||||
| -rw-r--r-- | scripts/plugin-stub-generator/targets.list | 1 |
8 files changed, 295 insertions, 44 deletions
diff --git a/crates/shirabe-php-rpc/php/guards/Composer/Util/Loop.php b/crates/shirabe-php-rpc/php/guards/Composer/Util/Loop.php deleted file mode 100644 index 32648ece..00000000 --- a/crates/shirabe-php-rpc/php/guards/Composer/Util/Loop.php +++ /dev/null @@ -1,39 +0,0 @@ -<?php - -// Generated by scripts/plugin-stub-generator; do not edit by hand. -// Guard for Composer\Util\Loop. -// The Rust side owns this class and the worker has no proxy for it, so this -// declaration shadows the real one: the constants and the hierarchy stay, while -// constructing it or calling anything on it raises an explicit error. - -namespace Composer\Util; - -use Symfony\Component\Console\Helper\ProgressBar; - -class Loop -{ - public function __construct(HttpDownloader $httpDownloader, ?ProcessExecutor $processExecutor = null) - { - \ShirabeUnsupportedClass::fail(self::class, '__construct'); - } - - public function getHttpDownloader(): HttpDownloader - { - \ShirabeUnsupportedClass::fail(self::class, 'getHttpDownloader'); - } - - public function getProcessExecutor(): ?ProcessExecutor - { - \ShirabeUnsupportedClass::fail(self::class, 'getProcessExecutor'); - } - - public function wait(array $promises, ?ProgressBar $progress = null): void - { - \ShirabeUnsupportedClass::fail(self::class, 'wait'); - } - - public function abortJobs(): void - { - \ShirabeUnsupportedClass::fail(self::class, 'abortJobs'); - } -} diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Util/Loop.php b/crates/shirabe-php-rpc/php/stubs/Composer/Util/Loop.php new file mode 100644 index 00000000..61e6edfe --- /dev/null +++ b/crates/shirabe-php-rpc/php/stubs/Composer/Util/Loop.php @@ -0,0 +1,96 @@ +<?php + +// Generated by scripts/plugin-stub-generator; do not edit by hand. +// Proxy stub for Composer\Util\Loop: the public surface forwards to the Rust-side entity over RPC. + +namespace Composer\Util; + +use Symfony\Component\Console\Helper\ProgressBar; + +class Loop implements \ShirabeRustStub +{ + /** @var int */ + protected $__rhandle; + /** @var int */ + protected $__epoch; + + /** + * Binds a stub the registry built for an existing entity. Proxy instantiation bypasses + * the constructor, which belongs to plugin code building a new entity instead. + */ + public function __shirabeBind(int $rhandle, int $epoch): void + { + $this->__rhandle = $rhandle; + $this->__epoch = $epoch; + } + + public function __destruct() + { + \ShirabeRustObjectRegistry::release($this->__rhandle); + } + + public function __shirabeRustHandleDescriptor(): array + { + return [ + '__rhandle' => $this->__rhandle, + '__class' => static::class, + '__epoch' => $this->__epoch, + ]; + } + + public function __clone() + { + // PHP has already shallow-copied this stub, so both copies would point at one + // entity and release it twice. The Rust side clones the entity instead, applying + // whatever __clone semantics the real class defines, and this copy rebinds to the + // fresh handle. Entities without clone semantics answer with an explicit error. + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust($this->__rhandle, '__shirabeClone', []); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + + public function __get($name) + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, '__get', [$name]); + } + + public function __set($name, $value): void + { + \ShirabeRpcRuntime::callRust($this->__rhandle, '__set', [$name, $value]); + } + + public function __isset($name): bool + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, '__isset', [$name]); + } + + public function __unset($name): void + { + \ShirabeRpcRuntime::callRust($this->__rhandle, '__unset', [$name]); + } + + public function __construct(HttpDownloader $httpDownloader, ?ProcessExecutor $processExecutor = null) + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$httpDownloader, $processExecutor]]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + + public function getHttpDownloader(): HttpDownloader + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getHttpDownloader', []); + } + + public function getProcessExecutor(): ?ProcessExecutor + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getProcessExecutor', []); + } + + public function wait(array $promises, ?ProgressBar $progress = null): void + { + \ShirabeRpcRuntime::callRust($this->__rhandle, 'wait', [$promises, $progress]); + } + + public function abortJobs(): void + { + \ShirabeRpcRuntime::callRust($this->__rhandle, 'abortJobs', []); + } +} diff --git a/crates/shirabe-php-rpc/src/lib.rs b/crates/shirabe-php-rpc/src/lib.rs index 3731c691..14e17e27 100644 --- a/crates/shirabe-php-rpc/src/lib.rs +++ b/crates/shirabe-php-rpc/src/lib.rs @@ -1044,6 +1044,10 @@ const STUB_FILES: &[(&str, &str)] = &[ "Composer/Util/HttpDownloader.php", include_str!("../php/stubs/Composer/Util/HttpDownloader.php"), ), + ( + "Composer/Util/Loop.php", + include_str!("../php/stubs/Composer/Util/Loop.php"), + ), ]; /// Hand-written worker-side classes (two-world implementations with behavior of their own, not diff --git a/crates/shirabe/src/plugin/php_plugin_proxy.rs b/crates/shirabe/src/plugin/php_plugin_proxy.rs index 0465eb6d..b070930b 100644 --- a/crates/shirabe/src/plugin/php_plugin_proxy.rs +++ b/crates/shirabe/src/plugin/php_plugin_proxy.rs @@ -60,6 +60,7 @@ enum RustEntity { Plugin(std::rc::Rc<std::cell::RefCell<dyn PluginInterface>>), ProcessExecutor(std::rc::Rc<std::cell::RefCell<crate::util::ProcessExecutor>>), HttpDownloader(std::rc::Rc<std::cell::RefCell<crate::util::HttpDownloader>>), + Loop(std::rc::Rc<std::cell::RefCell<crate::util::r#loop::Loop>>), } /// The pointer identity backing R-table interning: the same shared instance must always cross @@ -86,6 +87,7 @@ fn entity_ptr_id(entity: &RustEntity) -> usize { RustEntity::HttpDownloader(downloader) => { std::rc::Rc::as_ptr(downloader) as *const () as usize } + RustEntity::Loop(r#loop) => std::rc::Rc::as_ptr(r#loop) as *const () as usize, } } @@ -389,6 +391,7 @@ pub(crate) fn dispatch_r_table_method( Some(RustEntity::HttpDownloader(downloader)) => { dispatch_http_downloader_method(&downloader, method_name, args) } + Some(RustEntity::Loop(r#loop)) => dispatch_loop_method(&r#loop, method_name, args), None => Err(runtime_throw(format!("unknown Rust handle {rhandle}"))), } } @@ -426,6 +429,11 @@ pub(crate) fn construct_entity(args: &[PluginValue]) -> Result<PluginValue, PhpT let config_arg = |position: usize| { arg::<std::rc::Rc<std::cell::RefCell<crate::config::Config>>>(&class, ctor_args, position) }; + let http_downloader_arg = |position: usize| { + arg::<std::rc::Rc<std::cell::RefCell<crate::util::HttpDownloader>>>( + &class, ctor_args, position, + ) + }; let alias_package_arg = |position: usize| -> Result<crate::package::AliasPackageHandle, PhpThrow> { package_arg(position)? @@ -551,6 +559,20 @@ pub(crate) fn construct_entity(args: &[PluginValue]) -> Result<PluginValue, PhpT ))); return Ok(construction_result(rhandle)); } + // A loop owns nothing of its own: it holds the two services it drives and enables the + // async surface on each, so a plugin-built one is a second driver over whichever + // instances it was handed rather than a second copy of them. + "Composer\\Util\\Loop" => { + let process = match ctor_args.get(1) { + None | Some(PluginValue::Null) => None, + _ => Some(process_executor_arg(1)?), + }; + let rhandle = + register_entity(RustEntity::Loop(std::rc::Rc::new(std::cell::RefCell::new( + crate::util::r#loop::Loop::new(http_downloader_arg(0)?, process), + )))); + return Ok(construction_result(rhandle)); + } // TODO(plugin): the remaining proxied classes get a construction story on demand, // driven by explicit errors from real plugins. Each one has to decide what a // plugin-built instance means for the Rust-side graph, which is why none of them is @@ -603,7 +625,8 @@ fn clone_entity(entity: &RustEntity) -> Result<PluginValue, PhpThrow> { | RustEntity::Operation(_) | RustEntity::Plugin(_) | RustEntity::ProcessExecutor(_) - | RustEntity::HttpDownloader(_) => { + | RustEntity::HttpDownloader(_) + | RustEntity::Loop(_) => { return Err(runtime_throw( "cloning this Rust-side entity over RPC is not supported".to_string(), )); @@ -724,6 +747,11 @@ fn dispatch_composer_method( "Composer\\Downloader\\DownloadManager", )) } + "getLoop" => { + let r#loop = composer.borrow().get_loop(); + let rhandle = register_entity(RustEntity::Loop(r#loop)); + Ok(rust_handle_value(rhandle, "Composer\\Util\\Loop")) + } // TODO(plugin): the remaining Composer object graph (getLocker, getPluginManager, ...) // becomes reachable over RPC on demand, driven by explicit errors from real plugins. other => Err(runtime_throw(format!( @@ -915,6 +943,94 @@ fn rejected_promise(throw: PhpThrow) -> Result<PluginValue, PhpThrow> { } } +/// Drains one promise a plugin handed to `Loop::wait()`. React settles synchronously, so an +/// already-settled promise yields its value here and a rejected one yields its reason as the +/// throw; one that is still pending is an explicit error. +fn settle_promise(method_name: &str, promise: PluginValue) -> Result<PluginValue, PhpThrow> { + let handle = match promise { + PluginValue::PhpHandle(handle) => handle, + other => return Err(arg_throw(method_name, 0, "a promise", Some(&other))), + }; + let phandle = handle.phandle; + let settled = call_function_with_dispatcher( + "__shirabe_settle_promise", + vec![PluginValue::PhpHandle(handle)], + Some(&mut PluginRpcDispatcher::default()), + ); + // The promise entity was interned in the worker's P table when it crossed; the plugin's own + // reference keeps the object alive, and nothing on this side owns it past this call. + let _ = release_php_handle(phandle); + match settled { + Ok(outcome) => outcome, + Err(error) => Err(runtime_throw(format!( + "draining a promise in the plugin process failed: {error:#}" + ))), + } +} + +/// Serves the `Loop` proxy stub. The two services it hands out are the ones it was built over, +/// and `wait()` drains the promises it is given. +fn dispatch_loop_method( + r#loop: &std::rc::Rc<std::cell::RefCell<crate::util::r#loop::Loop>>, + method_name: &str, + args: &[PluginValue], +) -> Result<PluginValue, PhpThrow> { + match method_name { + "getHttpDownloader" => { + let downloader = r#loop.borrow().get_http_downloader().clone(); + let rhandle = register_entity(RustEntity::HttpDownloader(downloader)); + Ok(rust_handle_value(rhandle, "Composer\\Util\\HttpDownloader")) + } + "getProcessExecutor" => { + let process = r#loop.borrow().get_process_executor().cloned(); + match process { + Some(process) => { + let rhandle = register_entity(RustEntity::ProcessExecutor(process)); + Ok(rust_handle_value( + rhandle, + "Composer\\Util\\ProcessExecutor", + )) + } + None => Ok(PluginValue::Null), + } + } + "wait" => { + // TODO(symfony,plugin): the progress bar is a symfony/console object each world runs + // its own implementation of, so there is none to hand to the Rust side. + if !matches!(args.get(1), None | Some(PluginValue::Null)) { + return Err(runtime_throw( + "Shirabe does not support passing a ProgressBar to Loop::wait() from a plugin yet" + .to_string(), + )); + } + let promises = match args.first() { + Some(PluginValue::List(promises)) => promises.clone(), + Some(PluginValue::Array(promises)) => promises.values().cloned().collect(), + other => return Err(arg_throw(method_name, 0, "an array of promises", other)), + }; + // PHP waits on every promise of the group and rethrows the first rejection once the + // group is done, which is what `React\Promise\all()` hands it. + let mut uncaught: Option<PhpThrow> = None; + for promise in promises { + if let Err(throw) = settle_promise(method_name, promise) + && uncaught.is_none() + { + uncaught = Some(throw); + } + } + match uncaught { + Some(throw) => Err(throw), + None => Ok(PluginValue::Null), + } + } + "abortJobs" => { + r#loop.borrow().abort_jobs(); + Ok(PluginValue::Null) + } + other => Err(runtime_throw(format!("unknown Loop method `{other}`"))), + } +} + fn dispatch_filesystem_method( fs: &std::rc::Rc<std::cell::RefCell<crate::util::Filesystem>>, method_name: &str, @@ -1787,6 +1903,28 @@ impl FromPluginArg for std::rc::Rc<std::cell::RefCell<crate::config::Config>> { } } +/// Resolves a downloader argument back to the Rust-side entity its proxy stub stands for. +impl FromPluginArg for std::rc::Rc<std::cell::RefCell<crate::util::HttpDownloader>> { + 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::HttpDownloader(downloader)) => Ok(downloader), + _ => Err(runtime_throw(format!( + "{method} expects an HttpDownloader handle, got Rust handle {}", + handle.rhandle + ))), + } + } + other => Err(arg_throw(method, position, "an HttpDownloader", 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( diff --git a/crates/shirabe/tests/plugin/e2e_http_downloader_test.rs b/crates/shirabe/tests/plugin/e2e_http_downloader_test.rs index f5fdfa97..f19b992a 100644 --- a/crates/shirabe/tests/plugin/e2e_http_downloader_test.rs +++ b/crates/shirabe/tests/plugin/e2e_http_downloader_test.rs @@ -82,6 +82,11 @@ add-missing=ok rejected=\"Composer\\\\Downloader\\\\TransportException\" addCopy=ok file=\"{\\\"probe\\\":true,\\\"n\\\":42}\" countActiveJobs=0 wait=ok collect=ok +loop class=\"Composer\\\\Util\\\\Loop\" downloader=\"Composer\\\\Util\\\\HttpDownloader\" same=true executor=\"Composer\\\\Util\\\\ProcessExecutor\" +loop wait=ok bodies=[\"{\\\"probe\\\":true,\\\"n\\\":42}\",\"{\\\"probe\\\":true,\\\"n\\\":42}\"] +loop wait-rejected=\"Composer\\\\Downloader\\\\TransportException\" +abortJobs=ok +own loop=ok ", upstream.trace ); diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/plugin/src/Plugin.php b/crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/plugin/src/Plugin.php index 6e91ad00..b60c8df0 100644 --- a/crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/plugin/src/Plugin.php +++ b/crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/plugin/src/Plugin.php @@ -137,6 +137,48 @@ class Plugin implements PluginInterface, EventSubscriberInterface $response->collect(); }); + // The route Composer's own docblocks point plugin authors at: the loop the run built + // hands out the downloader and the executor the rest of the run uses. + $loop = $event->getComposer()->getLoop(); + $shared = $loop->getHttpDownloader(); + $lines[] = 'loop class=' . json_encode(\get_class($loop)) + . ' downloader=' . json_encode(\get_class($shared)) + . ' same=' . json_encode($shared === $loop->getHttpDownloader()) + . ' executor=' . json_encode($loop->getProcessExecutor() === null ? null : \get_class($loop->getProcessExecutor())); + + // The shared downloader is the one the run enabled async on, so it takes a request group + // without the plugin having to enable anything. + $waited = []; + $lines[] = 'loop wait=' . $this->describe(static function () use ($loop, $shared, $payload, &$waited): void { + $loop->wait([ + $shared->add('file://' . $payload)->then(static function ($result) use (&$waited): void { + $waited[] = $result->getBody(); + }), + $shared->add('file://' . $payload)->then(static function ($result) use (&$waited): void { + $waited[] = $result->getBody(); + }), + ]); + }) . ' bodies=' . json_encode($waited); + + // Only the class: the reason a stream failed to open comes from the PHP warning the + // reader raised, which this port does not have. + $rejected = null; + try { + $loop->wait([$shared->add('file:///shirabe-probe-missing.json')]); + } catch (\Throwable $e) { + $rejected = $e; + } + $lines[] = 'loop wait-rejected=' . json_encode($rejected === null ? null : \get_class($rejected)); + + $lines[] = 'abortJobs=' . $this->describe(static function () use ($loop): void { + $loop->abortJobs(); + }); + + // A plugin may drive its own loop over the services it already holds. + $lines[] = 'own loop=' . $this->describe(static function () use ($downloader): void { + (new \Composer\Util\Loop($downloader))->wait([]); + }); + file_put_contents('http-downloader-trace.txt', implode("\n", $lines) . "\n"); } diff --git a/docs/dev/plugin-class-classification.md b/docs/dev/plugin-class-classification.md index 5cb8fd6e..fd6102f3 100644 --- a/docs/dev/plugin-class-classification.md +++ b/docs/dev/plugin-class-classification.md @@ -432,10 +432,14 @@ resolves its promise with a `Symfony\Component\Process\Process`, whose state is the `proc_open()` resource of whichever process called `start()`; a request resolves its promise with a `Response`, which is data. -`Loop` remains guarded, so `Composer::getLoop()` is still an explicit error -and neither the graph's own executor nor its downloader is reachable through -it. Serving it needs only a `Loop` stub, since both surfaces it hands out are -already served. +`Loop` is a proxy stub too, so `Composer::getLoop()` — the route Composer's own +docblocks point plugin authors at — reaches the graph's own downloader and +executor. Its `wait()` drains the promises it is given and rethrows the first +rejection once the group is done, which is what `React\Promise\all()` hands +PHP; `abortJobs()` has nothing to cancel while every request settles before the +call that started it returns. A non-null `$progress` is an explicit error: a +`ProgressBar` is a symfony/console object each world runs its own +implementation of, so there is none to hand across. ### Dual instantiation diff --git a/scripts/plugin-stub-generator/targets.list b/scripts/plugin-stub-generator/targets.list index 22faf744..dce859a5 100644 --- a/scripts/plugin-stub-generator/targets.list +++ b/scripts/plugin-stub-generator/targets.list @@ -36,3 +36,4 @@ Composer\DependencyResolver\Operation\MarkAliasUninstalledOperation Composer\Util\Filesystem Composer\Util\ProcessExecutor Composer\Util\HttpDownloader +Composer\Util\Loop |
