aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe-php-rpc/php
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-08-07 05:11:55 +0900
committernsfisis <nsfisis@gmail.com>2026-08-07 05:11:55 +0900
commit9a393adc0ace86cac788723b524e83c63dfc91c1 (patch)
treefadac10efa6518c1a5d4d16f829da6969229ce09 /crates/shirabe-php-rpc/php
parent14a8a474120ef4289625f05ba5d87fa9e9d19542 (diff)
downloadphp-shirabe-9a393adc0ace86cac788723b524e83c63dfc91c1.tar.gz
php-shirabe-9a393adc0ace86cac788723b524e83c63dfc91c1.tar.zst
php-shirabe-9a393adc0ace86cac788723b524e83c63dfc91c1.zip
feat(php-rpc): cross materialized values as PHP object records
A materialized value used to cross as a constructor call: the class name, the arguments, and any post-construction setter. Describing a real instance that way needed a ReflectionProperty read for every field the class exposes no getter for, and state no constructor takes (an unset pretty string, a Link built without a pretty constraint) had no faithful call to describe it at all. The value now crosses as the object record serialize() writes for it, which unserialize() revives without running a constructor, so both sides transfer the state itself instead of a recipe for rebuilding it. The PHP half keeps only the class list (also the allowed_classes list of every frame payload) and the UTC rebasing of dates; describe(), build() and the reflection are gone. The wire codec gains O: records (PluginValue::PhpObject) and r: back references, whose resolution reproduces PHP's numbering of every value in a payload; a cyclic object graph and a PHP reference (R:) are rejected. Two behaviours change with it: a date crosses carrying timezone_type 3 "UTC" rather than a +00:00 offset, which is what ArrayLoader builds a release date as, and a Link subclass crosses as a P-table entity instead of being silently downgraded to a plain Link. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe-php-rpc/php')
-rw-r--r--crates/shirabe-php-rpc/php/runtime/Shirabe/MaterializedValue.php98
-rw-r--r--crates/shirabe-php-rpc/php/worker.php22
2 files changed, 31 insertions, 89 deletions
diff --git a/crates/shirabe-php-rpc/php/runtime/Shirabe/MaterializedValue.php b/crates/shirabe-php-rpc/php/runtime/Shirabe/MaterializedValue.php
index eee48266..c16b4e18 100644
--- a/crates/shirabe-php-rpc/php/runtime/Shirabe/MaterializedValue.php
+++ b/crates/shirabe-php-rpc/php/runtime/Shirabe/MaterializedValue.php
@@ -3,10 +3,12 @@
// The PHP half of the materialized-value codec (crates/shirabe/src/plugin/php_plugin_value.rs).
// An object whose entity lives on the Rust side crosses as a handle, but an immutable value
// has no entity to point at: the child holds a genuine instance of the real class instead, and
-// the wire carries the class name plus the constructor arguments needed to rebuild it.
+// the wire carries the object record `serialize()` writes for it, which `unserialize()` revives
+// without running a constructor.
//
-// Only the classes listed in CLASSES cross this way. An unknown class name is an explicit
-// error rather than a `new $class`, so the descriptor can never name an arbitrary class.
+// CLASSES is the whole vocabulary that may cross this way: it is both the set of classes handed
+// to `serialize()` in place of a handle descriptor, and the `allowed_classes` list every frame
+// payload is unserialized under, so a payload can never name another class.
namespace Shirabe;
@@ -18,7 +20,7 @@ use Composer\Semver\Constraint\MultiConstraint;
final class MaterializedValue
{
- private const CLASSES = [
+ public const CLASSES = [
Link::class,
Constraint::class,
MultiConstraint::class,
@@ -29,86 +31,22 @@ final class MaterializedValue
];
/**
- * @param array{__pnew: string, __args?: array, __calls?: array} $descriptor
+ * The object to serialize onto the wire in place of $value, or null when the Rust side has
+ * no value to rebuild it as (it then crosses as a P-table entity).
*/
- public static function build(array $descriptor): object
+ public static function forWire(object $value): ?object
{
- $class = $descriptor['__pnew'];
- if (!\in_array($class, self::CLASSES, true)) {
- throw new \RuntimeException(
- "the class {$class} cannot be materialized in the plugin runtime"
- );
- }
- $value = new $class(...array_values($descriptor['__args'] ?? []));
- foreach ($descriptor['__calls'] ?? [] as [$method, $args]) {
- $value->$method(...array_values($args));
- }
- return $value;
- }
-
- /**
- * The descriptor for a value the Rust side rebuilds by value, or null when the object is
- * not one of them (it then crosses as a P-table entity).
- *
- * @return ?array{__pnew: string, __args: array, __calls?: array}
- */
- public static function describe(object $value): ?array
- {
- if ($value instanceof Link) {
- return [
- '__pnew' => Link::class,
- '__args' => [
- self::field($value, 'source'),
- self::field($value, 'target'),
- $value->getConstraint(),
- self::field($value, 'description'),
- // Not getPrettyConstraint(): that throws when the link was built without
- // one, and an absent pretty constraint has to cross as absent.
- self::field($value, 'prettyConstraint'),
- ],
- ];
- }
- if ($value instanceof Constraint) {
- return self::constraint($value, [$value->getOperator(), $value->getVersion()]);
- }
- if ($value instanceof MultiConstraint) {
- return self::constraint($value, [$value->getConstraints(), $value->isConjunctive()]);
- }
- if ($value instanceof MatchAllConstraint || $value instanceof MatchNoneConstraint) {
- return self::constraint($value, []);
- }
if ($value instanceof \DateTimeInterface) {
- return [
- '__pnew' => $value instanceof \DateTime ? \DateTime::class : \DateTimeImmutable::class,
- // DATE_ATOM widened by the microseconds a PHP date carries, so the instant
- // crosses at the full precision this side can represent.
- '__args' => [$value->format('Y-m-d\TH:i:s.uP')],
- ];
+ // The Rust side holds instants in UTC and carries no timezone database, so the date
+ // crosses rebased on UTC. Going through the offset rather than the zone keeps the
+ // instant exact across an ambiguous wall clock, and drops any subclass a plugin
+ // brought along.
+ return (new \DateTimeImmutable($value->format('Y-m-d H:i:s.uP')))
+ ->setTimezone(new \DateTimeZone('UTC'));
}
- return null;
- }
- /**
- * No constraint constructor takes the pretty string, and whether it was ever set is
- * observable through getPrettyString(), so it travels as a post-construction call.
- *
- * @param list<mixed> $args
- * @return array{__pnew: string, __args: list<mixed>, __calls: list<array{string, list<mixed>}>}
- */
- private static function constraint(object $value, array $args): array
- {
- return [
- '__pnew' => \get_class($value),
- '__args' => $args,
- '__calls' => [['setPrettyString', [self::field($value, 'prettyString')]]],
- ];
- }
-
- /** Reads a protected field these value classes expose no getter for. */
- private static function field(object $value, string $name)
- {
- $property = new \ReflectionProperty($value, $name);
- (\PHP_VERSION_ID < 80100) and $property->setAccessible(true);
- return $property->getValue($value);
+ // Not instanceof: a subclass has state and behaviour of its own that no Rust value
+ // carries, so it crosses as an entity instead.
+ return \in_array(\get_class($value), self::CLASSES, true) ? $value : null;
}
}
diff --git a/crates/shirabe-php-rpc/php/worker.php b/crates/shirabe-php-rpc/php/worker.php
index 092f535f..15b38770 100644
--- a/crates/shirabe-php-rpc/php/worker.php
+++ b/crates/shirabe-php-rpc/php/worker.php
@@ -227,9 +227,11 @@ final class ShirabeRpcRuntime
// A natively-constructed dual-mode instance falls through to the P table below.
}
if (is_object($value)) {
- $materialized = \Shirabe\MaterializedValue::describe($value);
+ // A materialized value needs no descriptor: it crosses as the object record
+ // serialize() writes for it, which the Rust side decodes into its own value.
+ $materialized = \Shirabe\MaterializedValue::forWire($value);
if ($materialized !== null) {
- return array_map([self::class, 'toWire'], $materialized);
+ return $materialized;
}
return ShirabePhpObjectRegistry::descriptor($value);
}
@@ -242,7 +244,10 @@ final class ShirabeRpcRuntime
return $value;
}
- /** Converts a decoded wire value: handle descriptor arrays become live objects. */
+ /**
+ * Converts a decoded wire value: handle descriptor arrays become live objects. A
+ * materialized value arrives as a real instance already, revived by unserialize().
+ */
public static function fromWire($value)
{
if (!is_array($value)) {
@@ -261,9 +266,6 @@ final class ShirabeRpcRuntime
if (isset($value['__pclass']) && count($value) === 1) {
return $value['__pclass'];
}
- if (isset($value['__pnew'])) {
- return \Shirabe\MaterializedValue::build(array_map([self::class, 'fromWire'], $value));
- }
return array_map([self::class, 'fromWire'], $value);
}
@@ -287,7 +289,7 @@ final class ShirabeRpcRuntime
if ($inId !== $corrId) {
self::fail("protocol violation: response for unexpected corr_id {$inId}");
}
- $fields = unserialize($payload, ['allowed_classes' => false]);
+ $fields = unserialize($payload, ['allowed_classes' => \Shirabe\MaterializedValue::CLASSES]);
if (!is_array($fields)) {
self::fail('protocol violation: unparseable response payload');
}
@@ -314,7 +316,7 @@ final class ShirabeRpcRuntime
public static function dispatchRequest(int $tag, int $corrId, string $payload): void
{
- $fields = unserialize($payload, ['allowed_classes' => false]);
+ $fields = unserialize($payload, ['allowed_classes' => \Shirabe\MaterializedValue::CLASSES]);
if (!is_array($fields)) {
self::fail('protocol violation: unparseable frame payload');
}
@@ -588,7 +590,9 @@ ShirabeRpcRuntime::$dispatch = [
// Shirabe-internal helpers, not PHP builtins:
'__shirabe_eval' => static fn($args) => eval($args[0]),
// Round-trips raw serialize() bytes through the PHP core codec, for the codec oracle tests.
- '__shirabe_oracle_roundtrip' => static fn($args) => serialize(unserialize($args[0], ['allowed_classes' => false])),
+ '__shirabe_oracle_roundtrip' => static fn($args) => serialize(
+ unserialize($args[0], ['allowed_classes' => \Shirabe\MaterializedValue::CLASSES])
+ ),
'__shirabe_require' => static function ($args) {
require_once $args[0];
// The required file may have registered further prepending autoloaders (a Composer