From 9b444a9a879b75a6af3d3c7ba8b9a4294574c3ec Mon Sep 17 00:00:00 2001 From: nsfisis Date: Wed, 5 Aug 2026 03:58:03 +0900 Subject: feat(plugin): run plugin-provided commands in a worker-side application A same-FQCN Composer\Console\Application, hand-written under the new php/runtime/ tree, hosts CommandProvider commands inside the PHP worker: PhpCommandProxy overrides run() and forwards the stringified input, so the real Symfony machinery binds, validates and executes against the live command object, while help/list render Rust-side from a definition read back at construction. Reverse \Shirabe\RustCommandStub rows let a plugin command invoke built-in commands back in the Rust process, keeping every command on the side whose helper set it was written for. Composer\EventDispatcher\Event moves from a generated stub to a dual-mode runtime class: the real BaseCommand::initialize constructs a PreCommandRunEvent natively in the worker, which a proxy-only constructor guard rejected. Its PRE_COMMAND_RUN dispatch reaches a new EventDispatcher stub whose dispatch supports the observably-no-op no-listener case and fails explicitly otherwise. The stub generator now accepts runtime-provided classes as stub bases (never as targets) and cross-checks the Application handoff property table against the real class. Co-Authored-By: Claude Fable 5 --- .../php/runtime/Composer/Console/Application.php | 142 +++++++++++++++++++++ .../php/runtime/Composer/EventDispatcher/Event.php | 120 +++++++++++++++++ .../php/runtime/Shirabe/RustCommandStub.php | 34 +++++ .../php/stubs/Composer/EventDispatcher/Event.php | 67 ---------- .../Composer/EventDispatcher/EventDispatcher.php | 92 +++++++++++++ crates/shirabe-php-rpc/php/worker.php | 70 +++++++++- crates/shirabe-php-rpc/src/lib.rs | 24 +++- 7 files changed, 476 insertions(+), 73 deletions(-) create mode 100644 crates/shirabe-php-rpc/php/runtime/Composer/Console/Application.php create mode 100644 crates/shirabe-php-rpc/php/runtime/Composer/EventDispatcher/Event.php create mode 100644 crates/shirabe-php-rpc/php/runtime/Shirabe/RustCommandStub.php delete mode 100644 crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php create mode 100644 crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/EventDispatcher.php (limited to 'crates/shirabe-php-rpc') diff --git a/crates/shirabe-php-rpc/php/runtime/Composer/Console/Application.php b/crates/shirabe-php-rpc/php/runtime/Composer/Console/Application.php new file mode 100644 index 00000000..50ea31b1 --- /dev/null +++ b/crates/shirabe-php-rpc/php/runtime/Composer/Console/Application.php @@ -0,0 +1,142 @@ +setAutoExit(false); + // A command failure must reach the Rust side as an RPC throw, not get rendered here. + $app->setCatchExceptions(false); + $app->shirabeComposer = $config['composer']; + $app->shirabeIo = $config['io']; + $app->shirabeInitialWorkingDirectory = $config['initialWorkingDirectory']; + $app->shirabeDisablePluginsByDefault = $config['disablePluginsByDefault']; + $app->shirabeDisableScriptsByDefault = $config['disableScriptsByDefault']; + foreach ($config['rustCommands'] as $meta) { + $app->add(new \Shirabe\RustCommandStub($meta)); + } + foreach ($config['pluginCommands'] as $command) { + $app->add($command); + } + + return $app; + } + + public function __construct(string $name = 'Composer', string $version = '') + { + if (!self::$shirabeConstructing) { + // TODO(plugin): a plugin constructing its own Composer\Console\Application would + // run commands outside the Rust-side orchestration; whether and how to support + // that is undecided, so fail loudly instead of handing out a half-wired instance. + throw new \RuntimeException( + 'Shirabe does not support constructing Composer\Console\Application inside the plugin process yet' + ); + } + parent::__construct($name, $version !== '' ? $version : Composer::getVersion()); + } + + public function getIO(): IOInterface + { + return $this->shirabeIo; + } + + public function getComposer(bool $required = true, ?bool $disablePlugins = null, ?bool $disableScripts = null): ?Composer + { + if (null === $this->shirabeComposer && $required) { + // TODO(plugin): creating a Composer instance from scratch here would need + // Factory::create over RPC; the handoff currently always carries the instance the + // Rust side already built, so this only fires after resetComposer-style flows. + throw new \RuntimeException( + 'Shirabe cannot create a new Composer instance inside the plugin process yet' + ); + } + + return $this->shirabeComposer; + } + + public function resetComposer(): void + { + // TODO(plugin): the Composer instance is owned by the Rust process; dropping only the + // worker-side reference would desynchronize the two worlds, so this needs a + // reset-and-refetch round trip that does not exist yet. + throw new \RuntimeException( + 'Shirabe does not support resetComposer() inside the plugin process yet' + ); + } + + /** + * @return string|null + */ + public function getInitialWorkingDirectory() + { + return $this->shirabeInitialWorkingDirectory; + } + + public function getDisablePluginsByDefault(): bool + { + return $this->shirabeDisablePluginsByDefault; + } + + public function getDisableScriptsByDefault(): bool + { + return $this->shirabeDisableScriptsByDefault; + } + + // Same as the real class: without Composer's global options in the definition, binding a + // forwarded command line that carries e.g. --working-dir would fail here even though the + // Rust side already consumed the option. + protected function getDefaultInputDefinition(): InputDefinition + { + $definition = parent::getDefaultInputDefinition(); + $definition->addOption(new InputOption('--profile', null, InputOption::VALUE_NONE, 'Display timing and memory usage information')); + $definition->addOption(new InputOption('--no-plugins', null, InputOption::VALUE_NONE, 'Whether to disable plugins.')); + $definition->addOption(new InputOption('--no-scripts', null, InputOption::VALUE_NONE, 'Skips the execution of all scripts defined in composer.json file.')); + $definition->addOption(new InputOption('--working-dir', '-d', InputOption::VALUE_REQUIRED, 'If specified, use the given directory as working directory.')); + $definition->addOption(new InputOption('--no-cache', null, InputOption::VALUE_NONE, 'Prevent use of the cache')); + + return $definition; + } +} diff --git a/crates/shirabe-php-rpc/php/runtime/Composer/EventDispatcher/Event.php b/crates/shirabe-php-rpc/php/runtime/Composer/EventDispatcher/Event.php new file mode 100644 index 00000000..0d35ac55 --- /dev/null +++ b/crates/shirabe-php-rpc/php/runtime/Composer/EventDispatcher/Event.php @@ -0,0 +1,120 @@ +__rhandle = $name; + $this->__epoch = $args; + + return; + } + if (!is_string($name)) { + throw new \RuntimeException( + 'Shirabe does not support constructing ' . static::class . ' inside the plugin process without an event name' + ); + } + $this->__rhandle = null; + $this->name = $name; + $this->args = $args; + $this->flags = $flags; + } + + public function __destruct() + { + if ($this->__rhandle !== null) { + \ShirabeRustObjectRegistry::release($this->__rhandle); + } + } + + public function __shirabeRustHandleDescriptor(): ?array + { + if ($this->__rhandle === null) { + return null; + } + + return [ + '__rhandle' => $this->__rhandle, + '__class' => static::class, + '__epoch' => $this->__epoch, + ]; + } + + public function getName(): string + { + if ($this->__rhandle !== null) { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getName', []); + } + + return $this->name; + } + + public function getArguments(): array + { + if ($this->__rhandle !== null) { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getArguments', []); + } + + return $this->args; + } + + public function getFlags(): array + { + if ($this->__rhandle !== null) { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getFlags', []); + } + + return $this->flags; + } + + public function isPropagationStopped(): bool + { + if ($this->__rhandle !== null) { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'isPropagationStopped', []); + } + + return $this->propagationStopped; + } + + public function stopPropagation(): void + { + if ($this->__rhandle !== null) { + \ShirabeRpcRuntime::callRust($this->__rhandle, 'stopPropagation', []); + + return; + } + $this->propagationStopped = true; + } +} diff --git a/crates/shirabe-php-rpc/php/runtime/Shirabe/RustCommandStub.php b/crates/shirabe-php-rpc/php/runtime/Shirabe/RustCommandStub.php new file mode 100644 index 00000000..23ebfc04 --- /dev/null +++ b/crates/shirabe-php-rpc/php/runtime/Shirabe/RustCommandStub.php @@ -0,0 +1,34 @@ +, hidden: bool} $meta */ + public function __construct(array $meta) + { + parent::__construct($meta['name']); + $this->setDescription($meta['description']); + $this->setAliases($meta['aliases']); + $this->setHidden($meta['hidden']); + // The real input definition lives on the Rust side and the raw tokens are forwarded + // untouched, so nothing may be validated (or consumed) here. + $this->ignoreValidationErrors(); + } + + public function run(InputInterface $input, OutputInterface $output): int + { + return (int) \ShirabeRpcRuntime::callRust(0, '__shirabe_run_rust_command', [$this->getName(), (string) $input]); + } +} diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php b/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php deleted file mode 100644 index fea84c75..00000000 --- a/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php +++ /dev/null @@ -1,67 +0,0 @@ -__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/EventDispatcher/EventDispatcher.php b/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/EventDispatcher.php new file mode 100644 index 00000000..c0651081 --- /dev/null +++ b/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/EventDispatcher.php @@ -0,0 +1,92 @@ +__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 setRunScripts(bool $runScripts = true): self + { + \ShirabeRpcRuntime::callRust($this->__rhandle, 'setRunScripts', [$runScripts]); + return $this; + } + + public function dispatch(?string $eventName, ?Event $event = null): int + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'dispatch', [$eventName, $event]); + } + + public function dispatchScript(string $eventName, bool $devMode = false, array $additionalArgs = [], array $flags = []): int + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'dispatchScript', [$eventName, $devMode, $additionalArgs, $flags]); + } + + public function dispatchPackageEvent(string $eventName, bool $devMode, RepositoryInterface $localRepo, array $operations, OperationInterface $operation): int + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'dispatchPackageEvent', [$eventName, $devMode, $localRepo, $operations, $operation]); + } + + public function dispatchInstallerEvent(string $eventName, bool $devMode, bool $executeOperations, Transaction $transaction): int + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'dispatchInstallerEvent', [$eventName, $devMode, $executeOperations, $transaction]); + } + + public function addListener(string $eventName, $listener, int $priority = 0): void + { + \ShirabeRpcRuntime::callRust($this->__rhandle, 'addListener', [$eventName, $listener, $priority]); + } + + public function removeListener($listener): void + { + \ShirabeRpcRuntime::callRust($this->__rhandle, 'removeListener', [$listener]); + } + + public function addSubscriber(EventSubscriberInterface $subscriber): void + { + \ShirabeRpcRuntime::callRust($this->__rhandle, 'addSubscriber', [$subscriber]); + } + + public function hasEventListeners(Event $event): bool + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'hasEventListeners', [$event]); + } +} diff --git a/crates/shirabe-php-rpc/php/worker.php b/crates/shirabe-php-rpc/php/worker.php index 50e9ef76..241dad57 100644 --- a/crates/shirabe-php-rpc/php/worker.php +++ b/crates/shirabe-php-rpc/php/worker.php @@ -18,8 +18,13 @@ 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; + /** + * Null when this instance holds no Rust handle: a dual-mode class (see php/runtime/) that + * was constructed natively in this process crosses the wire as a P-table entity instead. + * + * @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. */ @@ -199,7 +204,11 @@ final class ShirabeRpcRuntime public static function toWire($value) { if ($value instanceof ShirabeRustStub) { - return $value->__shirabeRustHandleDescriptor(); + $descriptor = $value->__shirabeRustHandleDescriptor(); + if ($descriptor !== null) { + return $descriptor; + } + // A natively-constructed dual-mode instance falls through to the P table below. } if (is_object($value)) { return ShirabePhpObjectRegistry::descriptor($value); @@ -616,6 +625,61 @@ ShirabeRpcRuntime::$dispatch = [ } return $obj->{$args[1]}; }, + // Builds the worker-side Composer\Console\Application (the runtime/ definition, not the + // real class) from the Rust handoff; the caller keeps the returned handle and runs + // plugin-provided commands through __shirabe_run_console_application. + '__shirabe_console_application_boot' => static function ($args) { + return \Composer\Console\Application::__shirabeBoot($args[0]); + }, + // Runs one command line (the stringified input of the Rust-side run) through a booted + // worker-side application; output goes to the inherited stdio, the exit code returns + // over the wire, and a command failure propagates as an RPC throw (catchExceptions is + // off on the booted application). + '__shirabe_run_console_application' => static function ($args) { + [$app, $inputString] = $args; + if (!$app instanceof \Composer\Console\Application) { + throw new RuntimeException('__shirabe_run_console_application expects an application handle'); + } + return $app->run(new \Symfony\Component\Console\Input\StringInput($inputString)); + }, + // Reads a command's input definition (plus help text and extra usages) as plain data, so + // the Rust side can mirror it for `help`/`list` rendering without executing anything. + '__shirabe_read_command_definition' => static function ($args) { + $command = $args[0]; + if (!$command instanceof \Symfony\Component\Console\Command\Command) { + throw new RuntimeException('__shirabe_read_command_definition expects a command handle'); + } + $definition = $command->getDefinition(); + $arguments = []; + foreach ($definition->getArguments() as $argument) { + $arguments[] = [ + 'name' => $argument->getName(), + 'required' => $argument->isRequired(), + 'isArray' => $argument->isArray(), + 'description' => $argument->getDescription(), + 'default' => $argument->getDefault(), + ]; + } + $options = []; + foreach ($definition->getOptions() as $option) { + $options[] = [ + 'name' => $option->getName(), + 'shortcut' => $option->getShortcut(), + 'acceptValue' => $option->acceptValue(), + 'isValueRequired' => $option->isValueRequired(), + 'isArray' => $option->isArray(), + 'isNegatable' => $option->isNegatable(), + 'description' => $option->getDescription(), + 'default' => $option->getDefault(), + ]; + } + return [ + 'arguments' => $arguments, + 'options' => $options, + 'help' => $command->getHelp(), + 'usages' => $command->getUsages(), + ]; + }, ]; ShirabeRpcRuntime::serveForever(); diff --git a/crates/shirabe-php-rpc/src/lib.rs b/crates/shirabe-php-rpc/src/lib.rs index ba5443d2..ef11d542 100644 --- a/crates/shirabe-php-rpc/src/lib.rs +++ b/crates/shirabe-php-rpc/src/lib.rs @@ -563,8 +563,8 @@ const GLUE_SCRIPT: &str = include_str!("../php/worker.php"); /// generator's `--check` mode verifies both the file contents and this list). const STUB_FILES: &[(&str, &str)] = &[ ( - "Composer/EventDispatcher/Event.php", - include_str!("../php/stubs/Composer/EventDispatcher/Event.php"), + "Composer/EventDispatcher/EventDispatcher.php", + include_str!("../php/stubs/Composer/EventDispatcher/EventDispatcher.php"), ), ( "Composer/Script/Event.php", @@ -640,6 +640,24 @@ const STUB_FILES: &[(&str, &str)] = &[ ), ]; +/// Hand-written worker-side classes (two-world implementations with behavior of their own, not +/// mechanical proxies), written into the same autoload directory as the generated stubs so the +/// prepended stub autoloader resolves their FQCNs ahead of any real class file. +const RUNTIME_FILES: &[(&str, &str)] = &[ + ( + "Composer/Console/Application.php", + include_str!("../php/runtime/Composer/Console/Application.php"), + ), + ( + "Composer/EventDispatcher/Event.php", + include_str!("../php/runtime/Composer/EventDispatcher/Event.php"), + ), + ( + "Shirabe/RustCommandStub.php", + include_str!("../php/runtime/Shirabe/RustCommandStub.php"), + ), +]; + struct Worker { stream: UnixStream, // Also queried for its exit status when a socket read/write fails, to tell a dead worker @@ -707,7 +725,7 @@ fn spawn_worker() -> anyhow::Result { std::fs::write(&script_path, GLUE_SCRIPT)?; let stubs_dir = tempdir.path().join("stubs"); - for (relative_path, contents) in STUB_FILES { + for (relative_path, contents) in STUB_FILES.iter().chain(RUNTIME_FILES) { let path = stubs_dir.join(relative_path); std::fs::create_dir_all(path.parent().expect("stub paths have a parent"))?; std::fs::write(&path, contents)?; -- cgit v1.3.1