diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-08-30 23:57:25 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-08-30 23:57:25 +0900 |
| commit | a38ed045e9981664d90c05ab43ab4ab6026eb31b (patch) | |
| tree | 537dbd660882e9af7eec9ddcb7abee4c8aae6e1a /crates/shirabe-php-rpc/php/runtime/Shirabe/MaterializedThrowable.php | |
| parent | d3bc3354c9705dfc6dc5e9b9adb5eb64d41e4c49 (diff) | |
| download | php-shirabe-a38ed045e9981664d90c05ab43ab4ab6026eb31b.tar.gz php-shirabe-a38ed045e9981664d90c05ab43ab4ab6026eb31b.tar.zst php-shirabe-a38ed045e9981664d90c05ab43ab4ab6026eb31b.zip | |
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) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe-php-rpc/php/runtime/Shirabe/MaterializedThrowable.php')
| -rw-r--r-- | crates/shirabe-php-rpc/php/runtime/Shirabe/MaterializedThrowable.php | 70 |
1 files changed, 70 insertions, 0 deletions
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 @@ +<?php + +// The PHP half of the Throw frame's exception codec. A Rust-side failure carries the class it +// was thrown as, so `catch (TransportException $e)` in plugin code catches what it would catch +// under Composer, plus the state that class declares beyond message and code. + +namespace Shirabe; + +final class MaterializedThrowable +{ + /** + * Rebuilds the exception a Throw frame describes. A class the child cannot construct from a + * message and a code keeps the RuntimeException shape, which is all the frame guarantees. + * + * @param array<string, mixed> $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; + } +} |
