aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe-php-rpc/php
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-08-03 01:05:57 +0900
committernsfisis <nsfisis@gmail.com>2026-08-03 01:05:57 +0900
commit2f28d8112970960dbb9b6b582a3c6cd259337d21 (patch)
treeaf8bc223b3af9098e6925cbf6c47e198459ac818 /crates/shirabe-php-rpc/php
parentbf2c6fa58ae51f44fa0ec65c615f8e62de87812c (diff)
downloadphp-shirabe-2f28d8112970960dbb9b6b582a3c6cd259337d21.tar.gz
php-shirabe-2f28d8112970960dbb9b6b582a3c6cd259337d21.tar.zst
php-shirabe-2f28d8112970960dbb9b6b582a3c6cd259337d21.zip
feat(php-rpc): rework the RPC channel into the tagged plugin protocol
Replace the name\0arg framing with the plugin wire protocol: tagged frames with corr_id multiplexing, a MAX_FRAME_LEN bound, a thread-ID based reentrant SessionLock, and the PluginValue codec (encoder plus the first recursive decoder, iterative with a 512-level depth cap). Float formatting is ported from php-src into shirabe-php-src so the encoder is byte-compatible with serialize() under serialize_precision=-1, which the spawned worker now pins. The worker gains a standing dispatch loop, CallRustMethod reentrancy, hand-written Event proxy stubs, and explicit-error answers for everything not implemented yet. The public query API (get_php_version and friends) is unchanged and now rides the new protocol; the codec is verified against real PHP by roundtrip oracle tests covering floats, non-UTF-8 bytes and deep nesting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe-php-rpc/php')
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php59
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/Script/Event.php37
-rw-r--r--crates/shirabe-php-rpc/php/worker.php407
3 files changed, 459 insertions, 44 deletions
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php b/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php
new file mode 100644
index 00000000..9ce9c12f
--- /dev/null
+++ b/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php
@@ -0,0 +1,59 @@
+<?php
+
+// Hand-written proxy stub for Composer\EventDispatcher\Event, kept in the shape the future stub
+// generator will output: the real public methods, each forwarding to the Rust-side entity.
+
+namespace Composer\EventDispatcher;
+
+class Event implements \ShirabeRustStub
+{
+ /** @var int */
+ protected $__rhandle;
+ /** @var int */
+ protected $__epoch;
+
+ public function __construct(int $rhandle, int $epoch)
+ {
+ $this->__rhandle = $rhandle;
+ $this->__epoch = $epoch;
+ }
+
+ public function __destruct()
+ {
+ \ShirabeRustObjectRegistry::release($this->__rhandle);
+ }
+
+ public function __shirabeRustHandleDescriptor(): array
+ {
+ return [
+ '__rhandle' => $this->__rhandle,
+ '__class' => static::class,
+ '__epoch' => $this->__epoch,
+ ];
+ }
+
+ public function getName(): string
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getName', []);
+ }
+
+ public function getArguments(): array
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getArguments', []);
+ }
+
+ public function getFlags(): array
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getFlags', []);
+ }
+
+ public function isPropagationStopped(): bool
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'isPropagationStopped', []);
+ }
+
+ public function stopPropagation(): void
+ {
+ \ShirabeRpcRuntime::callRust($this->__rhandle, 'stopPropagation', []);
+ }
+}
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Script/Event.php b/crates/shirabe-php-rpc/php/stubs/Composer/Script/Event.php
new file mode 100644
index 00000000..80afe1b7
--- /dev/null
+++ b/crates/shirabe-php-rpc/php/stubs/Composer/Script/Event.php
@@ -0,0 +1,37 @@
+<?php
+
+// Hand-written proxy stub for Composer\Script\Event, kept in the shape the future stub
+// generator will output. See Composer/EventDispatcher/Event.php.
+
+namespace Composer\Script;
+
+use Composer\EventDispatcher\Event as BaseEvent;
+
+class Event extends BaseEvent
+{
+ public function getComposer(): \Composer\Composer
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getComposer', []);
+ }
+
+ public function getIO(): \Composer\IO\IOInterface
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getIO', []);
+ }
+
+ public function isDevMode(): bool
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'isDevMode', []);
+ }
+
+ public function getOriginatingEvent(): ?BaseEvent
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getOriginatingEvent', []);
+ }
+
+ public function setOriginatingEvent(BaseEvent $event): self
+ {
+ \ShirabeRpcRuntime::callRust($this->__rhandle, 'setOriginatingEvent', [$event]);
+ return $this;
+ }
+}
diff --git a/crates/shirabe-php-rpc/php/worker.php b/crates/shirabe-php-rpc/php/worker.php
index 6c3ab443..ce11158e 100644
--- a/crates/shirabe-php-rpc/php/worker.php
+++ b/crates/shirabe-php-rpc/php/worker.php
@@ -1,11 +1,348 @@
<?php
-// PHP glue worker. See docs/dev/php-rpc.md.
+// PHP glue worker. See docs/dev/php-rpc.md for the frame layout and message catalogue.
+
+const SHIRABE_TAG_CALL_FUNCTION = 0x00;
+const SHIRABE_TAG_CALL_STATIC_METHOD = 0x01;
+const SHIRABE_TAG_NEW_OBJECT = 0x02;
+const SHIRABE_TAG_CALL_PHP_METHOD = 0x03;
+const SHIRABE_TAG_CALL_RUST_METHOD = 0x04;
+const SHIRABE_TAG_RETURN = 0x05;
+const SHIRABE_TAG_THROW = 0x06;
+const SHIRABE_TAG_RELEASE_RUST_HANDLE = 0x07;
+const SHIRABE_TAG_RELEASE_PHP_HANDLE = 0x08;
+const SHIRABE_TAG_EPOCH_BUMP = 0x09;
+
+const SHIRABE_MAX_FRAME_LEN = 268435456; // 256 MiB, mirrored on the Rust side.
+
+/** Marker interface every Rust-proxy stub class implements. */
+interface ShirabeRustStub
+{
+ /** @return array{__rhandle: int, __class: string, __epoch: int} */
+ public function __shirabeRustHandleDescriptor(): array;
+}
+
+/** Interns proxy stubs so the same Rust handle always yields the same stub instance. */
+final class ShirabeRustObjectRegistry
+{
+ /** @var array<int, WeakReference> */
+ private static array $internTable = [];
+
+ public static function stubFor(int $rhandle, string $class, int $epoch): object
+ {
+ if (isset(self::$internTable[$rhandle])) {
+ $existing = self::$internTable[$rhandle]->get();
+ if ($existing !== null) {
+ return $existing;
+ }
+ }
+ if (!class_exists($class)) {
+ throw new RuntimeException(
+ "no proxy stub class is available for {$class}"
+ );
+ }
+ $stub = new $class($rhandle, $epoch);
+ self::$internTable[$rhandle] = WeakReference::create($stub);
+ return $stub;
+ }
+
+ /** Invoked when an EpochBump frame arrives. No-op if the stub already died. */
+ public static function bumpEpoch(int $rhandle, int $epoch): void
+ {
+ $ref = self::$internTable[$rhandle] ?? null;
+ $stub = $ref !== null ? $ref->get() : null;
+ if ($stub !== null && method_exists($stub, '__invalidateCache')) {
+ $stub->__invalidateCache($epoch);
+ }
+ }
+
+ /** Invoked from stub destructors. */
+ public static function release(int $rhandle): void
+ {
+ unset(self::$internTable[$rhandle]);
+ ShirabeRpcRuntime::notifyReleaseRustHandle($rhandle);
+ }
+}
+
+final class ShirabeRpcRuntime
+{
+ /** @var resource */
+ public static $socket;
+ public static ?string $stubsDir = null;
+ /** @var array<string, callable(array): mixed> */
+ public static array $dispatch = [];
+ /** Even correlation ids; the Rust side allocates odd ones. */
+ private static int $nextCorrId = 2;
+ private static bool $scriptAutoloaderRegistered = false;
+ private static bool $shuttingDown = false;
+
+ public static function fail(string $message): void
+ {
+ // A malformed frame means the Rust side and this script disagree about the protocol,
+ // which is a bug in Shirabe itself (both halves ship in the same commit). Dying makes
+ // the Rust side observe EOF and report a fatal error.
+ fwrite(STDERR, "shirabe php worker: {$message}\n");
+ exit(1);
+ }
+
+ private static function readExact(int $len): ?string
+ {
+ $buf = '';
+ while (strlen($buf) < $len) {
+ $chunk = fread(self::$socket, $len - strlen($buf));
+ if ($chunk === false || $chunk === '') {
+ return null;
+ }
+ $buf .= $chunk;
+ }
+ return $buf;
+ }
+
+ /** @return ?array{0: int, 1: int, 2: string} [tag, corrId, payload], null on clean EOF */
+ public static function readFrame(): ?array
+ {
+ $header = self::readExact(8);
+ if ($header === null) {
+ return null;
+ }
+ $len = unpack('P', $header)[1];
+ if ($len < 9 || $len > SHIRABE_MAX_FRAME_LEN) {
+ self::fail("invalid frame length {$len}");
+ }
+ $rest = self::readExact($len);
+ if ($rest === null) {
+ self::fail('connection lost mid-frame');
+ }
+ $tag = ord($rest[0]);
+ $corrId = unpack('P', substr($rest, 1, 8))[1];
+ return [$tag, $corrId, substr($rest, 9)];
+ }
+
+ public static function writeFrame(int $tag, int $corrId, string $payload): void
+ {
+ $frame = pack('P', 9 + strlen($payload)) . chr($tag) . pack('P', $corrId) . $payload;
+ if (fwrite(self::$socket, $frame) === false) {
+ // Cannot report an error over a broken channel; die and let Rust observe EOF.
+ exit(1);
+ }
+ }
+
+ public static function notifyReleaseRustHandle(int $rhandle): void
+ {
+ if (self::$shuttingDown) {
+ // Destructors run after the socket may already be closed at shutdown; the whole
+ // process is going away, so there is nothing left to release remotely.
+ return;
+ }
+ self::writeFrame(SHIRABE_TAG_RELEASE_RUST_HANDLE, 0, serialize([$rhandle]));
+ }
+
+ /**
+ * Converts a value about to be serialized onto the wire: proxy stubs become handle
+ * descriptor arrays; plain scalars and arrays pass through.
+ */
+ public static function toWire($value)
+ {
+ if ($value instanceof ShirabeRustStub) {
+ return $value->__shirabeRustHandleDescriptor();
+ }
+ if (is_object($value)) {
+ // TODO(plugin): PHP-owned objects (the P table) are not implemented yet; only Rust
+ // proxy stubs can cross the boundary until the plugin activation milestone.
+ throw new RuntimeException(
+ 'returning a PHP object over RPC is not supported yet: ' . get_class($value)
+ );
+ }
+ if (is_resource($value)) {
+ throw new RuntimeException('a PHP resource cannot cross the RPC boundary');
+ }
+ if (is_array($value)) {
+ return array_map([self::class, 'toWire'], $value);
+ }
+ return $value;
+ }
+
+ /** Converts a decoded wire value: handle descriptor arrays become live objects. */
+ public static function fromWire($value)
+ {
+ if (!is_array($value)) {
+ return $value;
+ }
+ if (isset($value['__rhandle'])) {
+ return ShirabeRustObjectRegistry::stubFor(
+ $value['__rhandle'],
+ $value['__class'],
+ $value['__epoch']
+ );
+ }
+ if (isset($value['__phandle'])) {
+ // TODO(plugin): the P table is not implemented yet.
+ throw new RuntimeException('__phandle descriptors are not supported yet');
+ }
+ if (isset($value['__pclass']) && count($value) === 1) {
+ return $value['__pclass'];
+ }
+ return array_map([self::class, 'fromWire'], $value);
+ }
+
+ /** Sends a CallRustMethod request and drives the cooperative loop until its Return. */
+ public static function callRust(int $rhandle, string $method, array $args)
+ {
+ $corrId = self::$nextCorrId;
+ self::$nextCorrId += 2;
+ self::writeFrame(
+ SHIRABE_TAG_CALL_RUST_METHOD,
+ $corrId,
+ serialize([$rhandle, $method, self::toWire($args), []])
+ );
+ while (true) {
+ $frame = self::readFrame();
+ if ($frame === null) {
+ self::fail('connection lost while waiting for a Return from Rust');
+ }
+ [$tag, $inId, $payload] = $frame;
+ if ($tag === SHIRABE_TAG_RETURN || $tag === SHIRABE_TAG_THROW) {
+ if ($inId !== $corrId) {
+ self::fail("protocol violation: response for unexpected corr_id {$inId}");
+ }
+ $fields = unserialize($payload, ['allowed_classes' => false]);
+ if (!is_array($fields)) {
+ self::fail('protocol violation: unparseable response payload');
+ }
+ if ($tag === SHIRABE_TAG_RETURN) {
+ 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);
+ }
+ self::dispatchRequest($tag, $inId, $payload);
+ }
+ }
+
+ /** The top-level standing loop: serve incoming requests until the Rust side goes away. */
+ public static function serveForever(): void
+ {
+ while (($frame = self::readFrame()) !== null) {
+ self::dispatchRequest(...$frame);
+ }
+ self::$shuttingDown = true;
+ }
+
+ public static function dispatchRequest(int $tag, int $corrId, string $payload): void
+ {
+ $fields = unserialize($payload, ['allowed_classes' => false]);
+ if (!is_array($fields)) {
+ self::fail('protocol violation: unparseable frame payload');
+ }
+ switch ($tag) {
+ case SHIRABE_TAG_CALL_FUNCTION:
+ [$name, $args] = $fields;
+ self::replyWith($corrId, static function () use ($name, $args) {
+ $args = ShirabeRpcRuntime::fromWire($args);
+ if (isset(ShirabeRpcRuntime::$dispatch[$name])) {
+ return (ShirabeRpcRuntime::$dispatch[$name])($args);
+ }
+ if (function_exists($name)) {
+ return $name(...$args);
+ }
+ throw new RuntimeException("PHP function `{$name}` does not exist");
+ });
+ break;
+ case SHIRABE_TAG_CALL_STATIC_METHOD:
+ [$class, $method, $args] = $fields;
+ self::replyWith($corrId, static function () use ($class, $method, $args) {
+ $args = ShirabeRpcRuntime::fromWire($args);
+ if (!is_callable([$class, $method])) {
+ throw new RuntimeException("{$class}::{$method} is not callable");
+ }
+ return $class::$method(...$args);
+ });
+ break;
+ case SHIRABE_TAG_NEW_OBJECT:
+ self::replyWith($corrId, static function () {
+ // TODO(plugin): requires the P table (plugin activation milestone M2).
+ throw new RuntimeException('NewObject is not supported yet');
+ });
+ break;
+ case SHIRABE_TAG_CALL_PHP_METHOD:
+ self::replyWith($corrId, static function () {
+ // TODO(plugin): requires the P table (plugin activation milestone M2).
+ throw new RuntimeException('CallPhpMethod is not supported yet');
+ });
+ break;
+ case SHIRABE_TAG_RELEASE_PHP_HANDLE:
+ // TODO(plugin): the P table is not implemented yet; nothing to release.
+ break;
+ case SHIRABE_TAG_EPOCH_BUMP:
+ [$rhandle, $epoch] = $fields;
+ ShirabeRustObjectRegistry::bumpEpoch((int) $rhandle, (int) $epoch);
+ break;
+ default:
+ self::fail("protocol violation: unexpected frame tag {$tag}");
+ }
+ }
+
+ /** Runs a handler and sends its result as Return, or the raised Throwable as Throw. */
+ private static function replyWith(int $corrId, callable $handler): void
+ {
+ try {
+ $result = $handler();
+ self::writeFrame(
+ SHIRABE_TAG_RETURN,
+ $corrId,
+ serialize([self::toWire($result), []])
+ );
+ } catch (Throwable $e) {
+ self::writeFrame(
+ SHIRABE_TAG_THROW,
+ $corrId,
+ serialize([get_class($e), $e->getMessage(), (int) $e->getCode()])
+ );
+ }
+ }
+
+ /**
+ * Registers the script-class autoloader: classes referenced by composer.json scripts are
+ * resolved by asking the Rust-side ClassLoader (built by EventDispatcher::makeAutoloader)
+ * where the class file lives. Handle 0 is the runtime service endpoint on the Rust side.
+ */
+ public static function enableScriptAutoloader(): void
+ {
+ if (self::$scriptAutoloaderRegistered) {
+ return;
+ }
+ self::$scriptAutoloaderRegistered = true;
+ spl_autoload_register(static function (string $class): void {
+ $file = ShirabeRpcRuntime::callRust(0, '__shirabe_find_file', [$class]);
+ if (is_string($file) && $file !== '') {
+ require $file;
+ }
+ });
+ }
+}
$client = @stream_socket_client('unix://' . $argv[1], $errno, $errstr);
if ($client === false) {
exit(1);
}
+ShirabeRpcRuntime::$socket = $client;
+ShirabeRpcRuntime::$stubsDir = $argv[2] ?? null;
+
+// Proxy stub classes take priority over any other autoloader (including autoloaders that a
+// script or the composer runtime registers later), so a proxied FQCN can never be shadowed by
+// the real implementation.
+spl_autoload_register(static function (string $class): void {
+ if (ShirabeRpcRuntime::$stubsDir === null) {
+ return;
+ }
+ $file = ShirabeRpcRuntime::$stubsDir . '/' . str_replace('\\', '/', $class) . '.php';
+ if (is_file($file)) {
+ require $file;
+ }
+}, true, true);
+
// Port of Composer\XdebugHandler\XdebugHandler::setXdebugDetails(), which the diagnose payload
// reports as `xdebug_active`.
$xdebug_active = static function (): bool {
@@ -39,14 +376,13 @@ $xdebug_active = static function (): bool {
return $mode !== 'off';
};
-$dispatch = [
- 'defined' => static fn($name) => defined($name),
- 'constant' => static fn($name) => defined($name) ? constant($name) : null,
- 'inet_pton' => static fn($arg) => @inet_pton($arg),
- 'curl_version' => static fn($arg) => function_exists('curl_version') ? (curl_version()['version'] ?? null) : null,
- 'phpversion' => static fn($name) => phpversion($name),
- 'get_loaded_extensions' => static fn($arg) => get_loaded_extensions(),
- 'get_all_ini_files' => static function ($arg) {
+
+ShirabeRpcRuntime::$dispatch = [
+ 'constant' => static fn($args) => defined($args[0]) ? constant($args[0]) : null,
+ 'inet_pton' => static fn($args) => @inet_pton($args[0]),
+ 'curl_version' => static fn($args) => function_exists('curl_version') ? (curl_version()['version'] ?? null) : null,
+ 'get_loaded_extensions' => static fn($args) => get_loaded_extensions(),
+ 'get_all_ini_files' => static function ($args) {
$paths = [(string) php_ini_loaded_file()];
$scanned = php_ini_scanned_files();
if ($scanned !== false) {
@@ -54,16 +390,16 @@ $dispatch = [
}
return $paths;
},
- 'extension_info' => static function ($name) {
- if (!extension_loaded($name)) {
+ 'extension_info' => static function ($args) {
+ if (!extension_loaded($args[0])) {
return '';
}
- $re = new ReflectionExtension($name);
+ $re = new ReflectionExtension($args[0]);
ob_start();
$re->info();
return (string) ob_get_clean();
},
- 'diagnose' => static function ($arg) use ($xdebug_active) {
+ 'diagnose' => static function ($args) use ($xdebug_active) {
$extensions = [];
foreach ([
'apcu',
@@ -139,35 +475,18 @@ $dispatch = [
'ini' => $ini,
];
},
+ // 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_require' => static function ($args) {
+ require_once $args[0];
+ return true;
+ },
+ '__shirabe_enable_script_autoloader' => static function ($args) {
+ ShirabeRpcRuntime::enableScriptAutoloader();
+ return true;
+ },
];
-$read_exact = static function ($conn, int $len): ?string {
- $buf = '';
- while (strlen($buf) < $len) {
- $chunk = fread($conn, $len - strlen($buf));
- if ($chunk === false || $chunk === '') {
- return null;
- }
- $buf .= $chunk;
- }
- return $buf;
-};
-while (true) {
- $header = $read_exact($client, 8);
- if ($header === null) {
- break;
- }
- $len = unpack('P', $header)[1];
- $name = $len === 0 ? '' : $read_exact($client, $len);
- if ($name === null) {
- break;
- }
- $sep = strpos($name, "\0");
- $arg = null;
- if ($sep !== false) {
- $arg = substr($name, $sep + 1);
- $name = substr($name, 0, $sep);
- }
- $result = isset($dispatch[$name]) ? ($dispatch[$name])($arg) : null;
- $payload = serialize($result);
- fwrite($client, pack('P', strlen($payload)) . $payload);
-}
+
+ShirabeRpcRuntime::serveForever();