aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/shirabe-php-rpc/php/worker.php15
-rw-r--r--crates/shirabe/src/plugin/php_plugin_proxy.rs77
-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.php33
4 files changed, 123 insertions, 7 deletions
diff --git a/crates/shirabe-php-rpc/php/worker.php b/crates/shirabe-php-rpc/php/worker.php
index defa4fb5..b2323eb8 100644
--- a/crates/shirabe-php-rpc/php/worker.php
+++ b/crates/shirabe-php-rpc/php/worker.php
@@ -841,6 +841,21 @@ ShirabeRpcRuntime::$dispatch = [
}
return \React\Promise\resolve($args[0]);
},
+ // An already-rejected promise for a Rust-side call whose PHP signature declares
+ // PromiseInterface. A failed request surfaces to the caller as a rejection it handles, the
+ // way it does under Composer, rather than as a throw out of the call that started it.
+ '__shirabe_rejected_promise' => static function ($args) {
+ if (!function_exists('React\\Promise\\reject')) {
+ throw new RuntimeException(
+ 'react/promise is not loaded in the plugin process, so a PromiseInterface cannot be built'
+ );
+ }
+ [$class, $message, $code, $properties] = $args;
+
+ return \React\Promise\reject(
+ \Shirabe\MaterializedThrowable::revive($class, $message, (int) $code, $properties)
+ );
+ },
// Drains a promise a plugin returned to the Rust side. React settles promises
// synchronously, so an already-settled one runs these handlers during then(); one that is
// still pending is an explicit error rather than a silently dropped continuation.
diff --git a/crates/shirabe/src/plugin/php_plugin_proxy.rs b/crates/shirabe/src/plugin/php_plugin_proxy.rs
index c966897b..0465eb6d 100644
--- a/crates/shirabe/src/plugin/php_plugin_proxy.rs
+++ b/crates/shirabe/src/plugin/php_plugin_proxy.rs
@@ -33,7 +33,7 @@ use shirabe_php_rpc::{
PhpObjHandle, PhpThrow, PluginValue, RustMethodDispatcher, RustObjHandle,
call_function_with_dispatcher, call_php_method, new_object, release_php_handle,
};
-use shirabe_php_shim::{AnyThrowable, Catch as _, PhpClass as _, PhpMixed};
+use shirabe_php_shim::{AnyThrowable, Catch as _, LogicException, PhpClass as _, PhpMixed};
use shirabe_symfony_console::command::Command;
use shirabe_symfony_console::input::InputInterface;
use shirabe_symfony_console::input::InputValue;
@@ -886,6 +886,35 @@ fn resolved_promise(value: PluginValue) -> Result<PluginValue, PhpThrow> {
}
}
+/// A `\React\Promise\PromiseInterface` already rejected with the exception `throw` describes,
+/// built in the worker. A failed request reaches the plugin as a rejection it handles, the way it
+/// does under Composer, rather than as a throw out of the call that started it.
+fn rejected_promise(throw: PhpThrow) -> Result<PluginValue, PhpThrow> {
+ let properties = PluginValue::Array(
+ throw
+ .properties
+ .iter()
+ .map(|(name, value)| (name.clone().into_bytes(), value.clone()))
+ .collect(),
+ );
+ match call_function_with_dispatcher(
+ "__shirabe_rejected_promise",
+ vec![
+ PluginValue::string(throw.exception_class),
+ PluginValue::string(throw.message),
+ PluginValue::Int(throw.code),
+ properties,
+ ],
+ Some(&mut PluginRpcDispatcher::default()),
+ ) {
+ Ok(Ok(promise)) => Ok(promise),
+ Ok(Err(throw)) => Err(throw),
+ Err(error) => Err(runtime_throw(format!(
+ "creating a rejected promise in the plugin process failed: {error:#}"
+ ))),
+ }
+}
+
fn dispatch_filesystem_method(
fs: &std::rc::Rc<std::cell::RefCell<crate::util::Filesystem>>,
method_name: &str,
@@ -1198,12 +1227,46 @@ fn dispatch_http_downloader_method(
.map_err(|error| error_throw("copy failed", &error))?;
Ok(response_to_wire(&response))
}
- // TODO(plugin,async): the async surface resolves its promises with a Response the wire
- // cannot carry, and driving it needs a promise representation that crosses the boundary
- // unresolved. Neither exists yet.
- "add" | "addCopy" | "wait" | "enableAsync" | "countActiveJobs" => Err(runtime_throw(
- format!("Shirabe does not support HttpDownloader::{method_name}() from a plugin yet"),
- )),
+ // TODO(async): the future runs to completion here, so the promise the plugin receives
+ // is already settled and requests it starts together run one after another instead of
+ // overlapping. Deferring the settlement needs a promise representation that crosses the
+ // boundary unresolved.
+ "add" | "addCopy" => {
+ let url = arg::<String>(method_name, args, 0)?;
+ let outcome = if method_name == "add" {
+ let options =
+ arg_or::<IndexMap<String, PhpMixed>>(method_name, args, 1, IndexMap::new())?;
+ crate::util::sync_executor::block_on(downloader.borrow().add(&url, options))
+ } else {
+ let to = arg::<String>(method_name, args, 1)?;
+ let options =
+ arg_or::<IndexMap<String, PhpMixed>>(method_name, args, 2, IndexMap::new())?;
+ crate::util::sync_executor::block_on(
+ downloader.borrow().add_copy(&url, &to, options),
+ )
+ };
+ match outcome {
+ Ok(response) => resolved_promise(response_to_wire(&response)),
+ // PHP raises the empty-url check and the async gate out of `add()`/`addCopy()`
+ // itself, before the promise exists, and only what the job's resolver raises
+ // becomes a rejection. Those two are the port's only `LogicException`s here, and
+ // a failed request is a `TransportException`.
+ Err(error) if error.is_instanceof::<LogicException>() => {
+ Err(error_throw(&format!("{method_name} failed"), &error))
+ }
+ Err(error) => {
+ rejected_promise(error_throw(&format!("{method_name} failed"), &error))
+ }
+ }
+ }
+ "enableAsync" => {
+ downloader.borrow_mut().enable_async();
+ Ok(PluginValue::Null)
+ }
+ // TODO(async): every request settles before the call that started it returns, so the
+ // downloader never holds a queued or started job for these two to report on.
+ "wait" => Ok(PluginValue::Null),
+ "countActiveJobs" => Ok(PluginValue::Int(0)),
other => Err(runtime_throw(format!(
"unknown HttpDownloader method `{other}`"
))),
diff --git a/crates/shirabe/tests/plugin/e2e_http_downloader_test.rs b/crates/shirabe/tests/plugin/e2e_http_downloader_test.rs
index 42471b61..f5fdfa97 100644
--- a/crates/shirabe/tests/plugin/e2e_http_downloader_test.rs
+++ b/crates/shirabe/tests/plugin/e2e_http_downloader_test.rs
@@ -76,6 +76,11 @@ get=ok
response class=\"Composer\\\\Util\\\\Http\\\\Response\" body=\"{\\\"probe\\\":true,\\\"n\\\":42}\" headers=[]
getHeader=null
copy=ok file=\"{\\\"probe\\\":true,\\\"n\\\":42}\"
+add-before-enable=LogicException: You must use the HttpDownloader instance which is part of a Composer\\Loop instance to be able to run async http requests
+add=ok body=\"{\\\"probe\\\":true,\\\"n\\\":42}\"
+add-missing=ok rejected=\"Composer\\\\Downloader\\\\TransportException\"
+addCopy=ok file=\"{\\\"probe\\\":true,\\\"n\\\":42}\"
+countActiveJobs=0 wait=ok
collect=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 054a47ca..6e91ad00 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
@@ -98,6 +98,39 @@ class Plugin implements PluginInterface, EventSubscriberInterface
$downloader->copy('file://' . $payload, $target);
}) . ' file=' . json_encode(@file_get_contents($target));
+ // A downloader that is not part of a Loop refuses async requests, so the probe records
+ // both sides of the gate.
+ $lines[] = 'add-before-enable=' . $this->describe(static function () use ($downloader, $payload): void {
+ $downloader->add('file://' . $payload);
+ });
+ $downloader->enableAsync();
+
+ $added = null;
+ $lines[] = 'add=' . $this->describe(static function () use ($downloader, $payload, &$added): void {
+ $downloader->add('file://' . $payload)->then(static function ($result) use (&$added): void {
+ $added = $result;
+ });
+ }) . ' body=' . json_encode($added === null ? null : $added->getBody());
+
+ // A path that cannot exist keeps the rejection reason free of the temporary directory.
+ $rejection = null;
+ $lines[] = 'add-missing=' . $this->describe(static function () use ($downloader, &$rejection): void {
+ $downloader->add('file:///shirabe-probe-missing.json')->then(null, static function ($error) use (&$rejection): void {
+ $rejection = $error;
+ });
+ }) . ' rejected=' . json_encode($rejection === null ? null : \get_class($rejection));
+
+ $lines[] = 'addCopy=' . $this->describe(static function () use ($downloader, $payload): void {
+ $downloader->addCopy('file://' . $payload, getcwd() . '/probe-async-copy.json')->then(null, static function ($error): void {
+ throw $error;
+ });
+ }) . ' file=' . json_encode(@file_get_contents(getcwd() . '/probe-async-copy.json'));
+
+ $lines[] = 'countActiveJobs=' . json_encode($downloader->countActiveJobs())
+ . ' wait=' . $this->describe(static function () use ($downloader): void {
+ $downloader->wait();
+ });
+
// collect() unsets the response's own properties, so the object is spent afterwards and
// nothing may read it again.
$lines[] = 'collect=' . $this->describe(static function () use ($response): void {