diff options
Diffstat (limited to 'crates')
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 { |
