From 9a393adc0ace86cac788723b524e83c63dfc91c1 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Fri, 7 Aug 2026 05:11:55 +0900 Subject: 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) --- .../php/runtime/Shirabe/MaterializedValue.php | 98 ++++------------------ 1 file changed, 18 insertions(+), 80 deletions(-) (limited to 'crates/shirabe-php-rpc/php/runtime/Shirabe') 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 $args - * @return array{__pnew: string, __args: list, __calls: list}>} - */ - 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; } } -- cgit v1.3.1