From a38ed045e9981664d90c05ab43ab4ab6026eb31b Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 30 Aug 2026 23:57:25 +0900 Subject: feat(plugin): carry an exception's class and state across the boundary A Rust-side failure reached plugin code as a RuntimeException whose message carried the name of the call that failed, so `catch (TransportException $e)` never matched and the status code the plugin branches on was gone. The Throw frame now names the class the exception was thrown as and carries the state that class declares beyond message and code. \Shirabe\MaterializedThrowable rebuilds it in the child: `new $class($message, $code)` for a class whose constructor has \Exception's shape, then the properties by reflection. A class the child cannot build that way keeps the RuntimeException shape. Co-Authored-By: Claude Opus 5 (1M context) --- .../php/runtime/Shirabe/MaterializedThrowable.php | 70 ++++++++++++++++++++++ crates/shirabe-php-rpc/php/worker.php | 15 +++-- crates/shirabe-php-rpc/src/frame.rs | 68 +++++++++++++++++++++ crates/shirabe-php-rpc/src/lib.rs | 17 ++++++ 4 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 crates/shirabe-php-rpc/php/runtime/Shirabe/MaterializedThrowable.php (limited to 'crates/shirabe-php-rpc') diff --git a/crates/shirabe-php-rpc/php/runtime/Shirabe/MaterializedThrowable.php b/crates/shirabe-php-rpc/php/runtime/Shirabe/MaterializedThrowable.php new file mode 100644 index 00000000..7227a5f3 --- /dev/null +++ b/crates/shirabe-php-rpc/php/runtime/Shirabe/MaterializedThrowable.php @@ -0,0 +1,70 @@ + $properties + */ + public static function revive(string $class, string $message, int $code, array $properties): \Throwable + { + $exception = self::instantiate($class, $message, $code); + foreach ($properties as $name => $value) { + // A name the class does not declare is a Shirabe bug rather than a plugin one, and + // ReflectionProperty reports it as such instead of dropping the state silently. + $property = new \ReflectionProperty($exception, $name); + $property->setAccessible(true); + $property->setValue($exception, $value); + } + + return $exception; + } + + private static function instantiate(string $class, string $message, int $code): \Throwable + { + if ($class === '' || !class_exists($class) || !is_a($class, \Throwable::class, true)) { + return new \RuntimeException($message, $code); + } + $constructor = (new \ReflectionClass($class))->getConstructor(); + if ($constructor === null || !self::acceptsMessageAndCode($constructor)) { + return new \RuntimeException($message, $code); + } + + return new $class($message, $code); + } + + /** + * Whether the constructor has \Exception's shape as far as the frame fills it in: a string + * message, an int code, and nothing else required. + */ + private static function acceptsMessageAndCode(\ReflectionMethod $constructor): bool + { + $parameters = $constructor->getParameters(); + if (count($parameters) < 2) { + return false; + } + foreach ($parameters as $position => $parameter) { + $type = $parameter->getType(); + $name = $type instanceof \ReflectionNamedType ? $type->getName() : null; + if ($position === 0 && $name !== 'string') { + return false; + } + if ($position === 1 && $name !== 'int') { + return false; + } + if ($position >= 2 && !$parameter->isOptional()) { + return false; + } + } + + return true; + } +} diff --git a/crates/shirabe-php-rpc/php/worker.php b/crates/shirabe-php-rpc/php/worker.php index bc10c2de..defa4fb5 100644 --- a/crates/shirabe-php-rpc/php/worker.php +++ b/crates/shirabe-php-rpc/php/worker.php @@ -337,10 +337,13 @@ final class ShirabeRpcRuntime $outParams = self::fromWire($fields[1] ?? []); return self::fromWire($fields[0]); } - [$class, $message, $code] = $fields; - // TODO(plugin): reconstruct the original exception class instead of collapsing - // everything to RuntimeException. - throw new RuntimeException($message, (int) $code); + [$class, $message, $code, $properties] = $fields; + throw \Shirabe\MaterializedThrowable::revive( + $class, + $message, + (int) $code, + self::fromWire($properties) + ); } self::dispatchRequest($tag, $inId, $payload); } @@ -435,7 +438,9 @@ final class ShirabeRpcRuntime self::writeFrame( SHIRABE_TAG_THROW, $corrId, - serialize([get_class($e), $e->getMessage(), (int) $e->getCode()]) + // TODO(plugin): the properties field is empty in this direction; no Rust-side + // consumer rebuilds a ported exception from a Throw frame yet. + serialize([get_class($e), $e->getMessage(), (int) $e->getCode(), []]) ); } } diff --git a/crates/shirabe-php-rpc/src/frame.rs b/crates/shirabe-php-rpc/src/frame.rs index 3a355c86..fe7752bd 100644 --- a/crates/shirabe-php-rpc/src/frame.rs +++ b/crates/shirabe-php-rpc/src/frame.rs @@ -66,6 +66,9 @@ pub enum Frame { exception_class: String, message: String, code: i64, + /// The state the exception carries beyond `message` and `code`, keyed by the property + /// names its class declares. Empty for an exception that carries none. + properties: IndexMap, }, ReleaseRustHandle { rhandle: u64, @@ -180,11 +183,18 @@ impl Frame { exception_class, message, code, + properties, .. } => vec![ PluginValue::string(exception_class.clone()), PluginValue::string(message.clone()), PluginValue::Int(*code), + PluginValue::Array( + properties + .iter() + .map(|(name, v)| (name.clone().into_bytes(), v.clone())) + .collect(), + ), ], Frame::ReleaseRustHandle { rhandle } => vec![int_value(*rhandle)], Frame::ReleasePhpHandle { phandle } => vec![int_value(*phandle)], @@ -302,6 +312,7 @@ fn decode_frame(tag: u8, corr_id: u64, payload: &[u8]) -> Frame { panic!("PHP RPC: protocol violation — Throw code is not an int: {other:?}") } }, + properties: expect_properties(next()), }, TAG_RELEASE_RUST_HANDLE => Frame::ReleaseRustHandle { rhandle: expect_id(next()), @@ -354,6 +365,27 @@ fn expect_positions(value: PluginValue) -> Vec { .collect() } +fn expect_properties(value: PluginValue) -> IndexMap { + match value { + PluginValue::List(items) if items.is_empty() => IndexMap::new(), + PluginValue::Array(map) => map + .into_iter() + .map(|(key, item)| { + let name = String::from_utf8(key).unwrap_or_else(|error| { + panic!( + "PHP RPC: protocol violation — exception property name is not UTF-8: {:?}", + String::from_utf8_lossy(error.as_bytes()) + ) + }); + (name, item) + }) + .collect(), + other => { + panic!("PHP RPC: protocol violation — exception properties is not an array: {other:?}") + } + } +} + fn expect_out_params(value: PluginValue) -> IndexMap { match value { PluginValue::List(items) if items.is_empty() => IndexMap::new(), @@ -415,6 +447,42 @@ mod tests { } } + #[test] + fn frame_roundtrip_throw_with_properties() { + let frame = roundtrip(Frame::Throw { + corr_id: 11, + exception_class: "Composer\\Downloader\\TransportException".to_string(), + message: "The \"https://example.org\" file could not be downloaded".to_string(), + code: 401, + properties: [ + ("statusCode".to_string(), PluginValue::Int(401)), + ("response".to_string(), PluginValue::Null), + ] + .into_iter() + .collect(), + }); + match frame { + Frame::Throw { + corr_id, + exception_class, + message, + code, + properties, + } => { + assert_eq!(corr_id, 11); + assert_eq!(exception_class, "Composer\\Downloader\\TransportException"); + assert_eq!( + message, + "The \"https://example.org\" file could not be downloaded" + ); + assert_eq!(code, 401); + assert_eq!(properties.get("statusCode"), Some(&PluginValue::Int(401))); + assert_eq!(properties.get("response"), Some(&PluginValue::Null)); + } + other => panic!("unexpected frame: {other:?}"), + } + } + #[test] fn frame_roundtrip_return_with_out_params() { let frame = roundtrip(Frame::Return { diff --git a/crates/shirabe-php-rpc/src/lib.rs b/crates/shirabe-php-rpc/src/lib.rs index dfd8fa67..c898fb82 100644 --- a/crates/shirabe-php-rpc/src/lib.rs +++ b/crates/shirabe-php-rpc/src/lib.rs @@ -621,6 +621,15 @@ pub struct PhpThrow { pub exception_class: String, pub message: String, pub code: i64, + /// The state the exception carries beyond `message` and `code`, keyed by the property names + /// its class declares. The child revives them onto the instance it rebuilds. + /// + /// TODO(plugin): populated only Rust to PHP. An exception a plugin throws crosses with its + /// class, message and code alone, because no Rust-side consumer rebuilds a ported exception + /// from a `PhpThrow` yet. + /// + /// Boxed so a `Result<_, PhpThrow>` stays small; every dispatcher returns one. + pub properties: Box>, } impl PhpThrow { @@ -629,6 +638,7 @@ impl PhpThrow { exception_class: "RuntimeException".to_string(), message, code: 0, + properties: Box::new(IndexMap::new()), } } } @@ -805,11 +815,13 @@ fn rpc_call( exception_class, message, code, + properties, } if corr_id == my_id => { return Ok(Err(PhpThrow { exception_class, message, code, + properties: Box::new(properties), })); } Frame::CallRustMethod { @@ -846,6 +858,7 @@ fn rpc_call( exception_class: throw.exception_class, message: throw.message, code: throw.code, + properties: *throw.properties, }, }; send_frame(&reply)?; @@ -1041,6 +1054,10 @@ const RUNTIME_FILES: &[(&str, &str)] = &[ "Composer/EventDispatcher/Event.php", include_str!("../php/runtime/Composer/EventDispatcher/Event.php"), ), + ( + "Shirabe/MaterializedThrowable.php", + include_str!("../php/runtime/Shirabe/MaterializedThrowable.php"), + ), ( "Shirabe/MaterializedValue.php", include_str!("../php/runtime/Shirabe/MaterializedValue.php"), -- cgit v1.3.1-4-g156e