aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--crates/shirabe-php-rpc/php/guards/Composer/Util/Loop.php39
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/Util/Loop.php96
-rw-r--r--crates/shirabe-php-rpc/src/lib.rs4
-rw-r--r--crates/shirabe/src/plugin/php_plugin_proxy.rs140
-rw-r--r--crates/shirabe/tests/plugin/e2e_http_downloader_test.rs5
-rw-r--r--crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/plugin/src/Plugin.php42
-rw-r--r--docs/dev/plugin-class-classification.md12
-rw-r--r--scripts/plugin-stub-generator/targets.list1
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