diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-08-07 20:51:59 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-08-07 22:23:23 +0900 |
| commit | c82da84ae1a8cee23670a74a646584ab637308d1 (patch) | |
| tree | 1c985827e9d85d11c1d8b5ded70f5cd424c1a60b | |
| parent | 18a37a098157a98edbc8473e9c0d8dff3e8a88fa (diff) | |
| download | php-shirabe-main.tar.gz php-shirabe-main.tar.zst php-shirabe-main.zip | |
InstallationManager left both PRE_PACKAGE_* and POST_PACKAGE_* as empty
stubs, so a subscriber never ran at all and the difference from upstream was
silent rather than an explicit error.
Operations cross the boundary as R-table entities with generated proxy
stubs. Materializing them the way a Link crosses is not possible: a
materialized value is revived by unserialize() on the child side, so its
properties never pass through the wire decoder and a nested handle
descriptor would not come back as a stub — and an operation always holds a
PackageInterface. execute() now shares one Rc per operation through the
whole batch pipeline, so a plugin sees one object for both the pre- and the
post-event of an operation, as it does in PHP.
POST_PACKAGE_* also moves out of the operation's promise chain into the
post-exec callback list PHP runs after waitOnPromises().
The stub generator materializes non-public class constants verbatim now,
which the operation classes need for their `protected const TYPE`: a
constant has no entity behind it, so a copy in the worker cannot diverge,
and keeping the declared visibility exposes nothing the real class hides.
The E2E fixture added here compares the recorded events against upstream
Composer. It also surfaced that upstream starts an operation's chain where
it is built (a null prepare() becomes an already-fulfilled React promise
whose handlers run through the immediately drained queue) while this port
only drives its futures in wait_on_promises, so the repository state a
pre-event observes differs; that half of the comparison is a separate
`#[ignore]`d test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
24 files changed, 883 insertions, 110 deletions
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/InstallOperation.php b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/InstallOperation.php new file mode 100644 index 00000000..6f675703 --- /dev/null +++ b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/InstallOperation.php @@ -0,0 +1,29 @@ +<?php + +// Generated by scripts/plugin-stub-generator; do not edit by hand. +// Proxy stub for Composer\DependencyResolver\Operation\InstallOperation: the public surface forwards to the Rust-side entity over RPC. + +namespace Composer\DependencyResolver\Operation; + +use Composer\Package\PackageInterface; + +class InstallOperation extends SolverOperation implements OperationInterface +{ + public function __construct(PackageInterface $package) + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$package]]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + + protected const TYPE = 'install'; + + public static function format(PackageInterface $package, bool $lock = false): string + { + return ($lock ? 'Locking ' : 'Installing ').'<info>'.$package->getPrettyName().'</info> (<comment>'.$package->getFullPrettyVersion().'</comment>)'; + } + + public function getPackage(): PackageInterface + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getPackage', []); + } +} diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/MarkAliasInstalledOperation.php b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/MarkAliasInstalledOperation.php new file mode 100644 index 00000000..08293692 --- /dev/null +++ b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/MarkAliasInstalledOperation.php @@ -0,0 +1,24 @@ +<?php + +// Generated by scripts/plugin-stub-generator; do not edit by hand. +// Proxy stub for Composer\DependencyResolver\Operation\MarkAliasInstalledOperation: the public surface forwards to the Rust-side entity over RPC. + +namespace Composer\DependencyResolver\Operation; + +use Composer\Package\AliasPackage; + +class MarkAliasInstalledOperation extends SolverOperation implements OperationInterface +{ + public function __construct(AliasPackage $package) + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$package]]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + + protected const TYPE = 'markAliasInstalled'; + + public function getPackage(): AliasPackage + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getPackage', []); + } +} diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/MarkAliasUninstalledOperation.php b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/MarkAliasUninstalledOperation.php new file mode 100644 index 00000000..acd5abec --- /dev/null +++ b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/MarkAliasUninstalledOperation.php @@ -0,0 +1,24 @@ +<?php + +// Generated by scripts/plugin-stub-generator; do not edit by hand. +// Proxy stub for Composer\DependencyResolver\Operation\MarkAliasUninstalledOperation: the public surface forwards to the Rust-side entity over RPC. + +namespace Composer\DependencyResolver\Operation; + +use Composer\Package\AliasPackage; + +class MarkAliasUninstalledOperation extends SolverOperation implements OperationInterface +{ + public function __construct(AliasPackage $package) + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$package]]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + + protected const TYPE = 'markAliasUninstalled'; + + public function getPackage(): AliasPackage + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getPackage', []); + } +} diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/SolverOperation.php b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/SolverOperation.php new file mode 100644 index 00000000..382ad4a6 --- /dev/null +++ b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/SolverOperation.php @@ -0,0 +1,91 @@ +<?php + +// Generated by scripts/plugin-stub-generator; do not edit by hand. +// Proxy stub for Composer\DependencyResolver\Operation\SolverOperation: the public surface forwards to the Rust-side entity over RPC. + +namespace Composer\DependencyResolver\Operation; + +abstract class SolverOperation implements OperationInterface, \ShirabeRustStub +{ + /** @var int */ + protected $__rhandle; + /** @var int */ + protected $__epoch; + + /** + * Binds a stub the registry built for an existing entity. Proxy instantiation bypasses + * the constructor, which belongs to plugin code building a new entity instead. + */ + public function __shirabeBind(int $rhandle, int $epoch): void + { + $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 __clone() + { + // PHP has already shallow-copied this stub, so both copies would point at one + // entity and release it twice. The Rust side clones the entity instead, applying + // whatever __clone semantics the real class defines, and this copy rebinds to the + // fresh handle. Entities without clone semantics answer with an explicit error. + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust($this->__rhandle, '__shirabeClone', []); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + + public function __get($name) + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, '__get', [$name]); + } + + public function __set($name, $value): void + { + \ShirabeRpcRuntime::callRust($this->__rhandle, '__set', [$name, $value]); + } + + public function __isset($name): bool + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, '__isset', [$name]); + } + + public function __unset($name): void + { + \ShirabeRpcRuntime::callRust($this->__rhandle, '__unset', [$name]); + } + + public function __construct() + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, []]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + + protected const TYPE = ''; + + public function getOperationType(): string + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getOperationType', []); + } + + public function show(bool $lock) + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'show', [$lock]); + } + + public function __toString() + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, '__toString', []); + } +} diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/UninstallOperation.php b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/UninstallOperation.php new file mode 100644 index 00000000..b2560873 --- /dev/null +++ b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/UninstallOperation.php @@ -0,0 +1,29 @@ +<?php + +// Generated by scripts/plugin-stub-generator; do not edit by hand. +// Proxy stub for Composer\DependencyResolver\Operation\UninstallOperation: the public surface forwards to the Rust-side entity over RPC. + +namespace Composer\DependencyResolver\Operation; + +use Composer\Package\PackageInterface; + +class UninstallOperation extends SolverOperation implements OperationInterface +{ + public function __construct(PackageInterface $package) + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$package]]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + + protected const TYPE = 'uninstall'; + + public static function format(PackageInterface $package, bool $lock = false): string + { + return 'Removing <info>'.$package->getPrettyName().'</info> (<comment>'.$package->getFullPrettyVersion().'</comment>)'; + } + + public function getPackage(): PackageInterface + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getPackage', []); + } +} diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/UpdateOperation.php b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/UpdateOperation.php new file mode 100644 index 00000000..1e60804f --- /dev/null +++ b/crates/shirabe-php-rpc/php/stubs/Composer/DependencyResolver/Operation/UpdateOperation.php @@ -0,0 +1,48 @@ +<?php + +// Generated by scripts/plugin-stub-generator; do not edit by hand. +// Proxy stub for Composer\DependencyResolver\Operation\UpdateOperation: the public surface forwards to the Rust-side entity over RPC. + +namespace Composer\DependencyResolver\Operation; + +use Composer\Package\PackageInterface; +use Composer\Package\Version\VersionParser; + +class UpdateOperation extends SolverOperation implements OperationInterface +{ + public function __construct(PackageInterface $initial, PackageInterface $target) + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$initial, $target]]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + + protected const TYPE = 'update'; + + public static function format(PackageInterface $initialPackage, PackageInterface $targetPackage, bool $lock = false): string + { + $fromVersion = $initialPackage->getFullPrettyVersion(); + $toVersion = $targetPackage->getFullPrettyVersion(); + + if ($fromVersion === $toVersion && $initialPackage->getSourceReference() !== $targetPackage->getSourceReference()) { + $fromVersion = $initialPackage->getFullPrettyVersion(true, PackageInterface::DISPLAY_SOURCE_REF); + $toVersion = $targetPackage->getFullPrettyVersion(true, PackageInterface::DISPLAY_SOURCE_REF); + } elseif ($fromVersion === $toVersion && $initialPackage->getDistReference() !== $targetPackage->getDistReference()) { + $fromVersion = $initialPackage->getFullPrettyVersion(true, PackageInterface::DISPLAY_DIST_REF); + $toVersion = $targetPackage->getFullPrettyVersion(true, PackageInterface::DISPLAY_DIST_REF); + } + + $actionName = VersionParser::isUpgrade($initialPackage->getVersion(), $targetPackage->getVersion()) ? 'Upgrading' : 'Downgrading'; + + return $actionName.' <info>'.$initialPackage->getPrettyName().'</info> (<comment>'.$fromVersion.'</comment> => <comment>'.$toVersion.'</comment>)'; + } + + public function getInitialPackage(): PackageInterface + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getInitialPackage', []); + } + + public function getTargetPackage(): PackageInterface + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getTargetPackage', []); + } +} diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Installer/PackageEvent.php b/crates/shirabe-php-rpc/php/stubs/Composer/Installer/PackageEvent.php new file mode 100644 index 00000000..99a1f0ee --- /dev/null +++ b/crates/shirabe-php-rpc/php/stubs/Composer/Installer/PackageEvent.php @@ -0,0 +1,51 @@ +<?php + +// Generated by scripts/plugin-stub-generator; do not edit by hand. +// Proxy stub for Composer\Installer\PackageEvent: the public surface forwards to the Rust-side entity over RPC. + +namespace Composer\Installer; + +use Composer\Composer; +use Composer\IO\IOInterface; +use Composer\DependencyResolver\Operation\OperationInterface; +use Composer\Repository\RepositoryInterface; +use Composer\EventDispatcher\Event; + +class PackageEvent extends Event +{ + public function __construct(string $eventName, Composer $composer, IOInterface $io, bool $devMode, RepositoryInterface $localRepo, array $operations, OperationInterface $operation) + { + [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$eventName, $composer, $io, $devMode, $localRepo, $operations, $operation]]); + \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this); + } + + public function getComposer(): Composer + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getComposer', []); + } + + public function getIO(): IOInterface + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getIO', []); + } + + public function isDevMode(): bool + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'isDevMode', []); + } + + public function getLocalRepo(): RepositoryInterface + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getLocalRepo', []); + } + + public function getOperations(): array + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getOperations', []); + } + + public function getOperation(): OperationInterface + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getOperation', []); + } +} diff --git a/crates/shirabe-php-rpc/src/lib.rs b/crates/shirabe-php-rpc/src/lib.rs index 1b501caf..eac6f523 100644 --- a/crates/shirabe-php-rpc/src/lib.rs +++ b/crates/shirabe-php-rpc/src/lib.rs @@ -915,6 +915,38 @@ const STUB_FILES: &[(&str, &str)] = &[ "Composer/Installer/InstallationManager.php", include_str!("../php/stubs/Composer/Installer/InstallationManager.php"), ), + ( + "Composer/Installer/PackageEvent.php", + include_str!("../php/stubs/Composer/Installer/PackageEvent.php"), + ), + ( + "Composer/DependencyResolver/Operation/SolverOperation.php", + include_str!("../php/stubs/Composer/DependencyResolver/Operation/SolverOperation.php"), + ), + ( + "Composer/DependencyResolver/Operation/InstallOperation.php", + include_str!("../php/stubs/Composer/DependencyResolver/Operation/InstallOperation.php"), + ), + ( + "Composer/DependencyResolver/Operation/UpdateOperation.php", + include_str!("../php/stubs/Composer/DependencyResolver/Operation/UpdateOperation.php"), + ), + ( + "Composer/DependencyResolver/Operation/UninstallOperation.php", + include_str!("../php/stubs/Composer/DependencyResolver/Operation/UninstallOperation.php"), + ), + ( + "Composer/DependencyResolver/Operation/MarkAliasInstalledOperation.php", + include_str!( + "../php/stubs/Composer/DependencyResolver/Operation/MarkAliasInstalledOperation.php" + ), + ), + ( + "Composer/DependencyResolver/Operation/MarkAliasUninstalledOperation.php", + include_str!( + "../php/stubs/Composer/DependencyResolver/Operation/MarkAliasUninstalledOperation.php" + ), + ), ]; /// Hand-written worker-side classes (two-world implementations with behavior of their own, not diff --git a/crates/shirabe/src/event_dispatcher/event_dispatcher.rs b/crates/shirabe/src/event_dispatcher/event_dispatcher.rs index 1480188a..b821f647 100644 --- a/crates/shirabe/src/event_dispatcher/event_dispatcher.rs +++ b/crates/shirabe/src/event_dispatcher/event_dispatcher.rs @@ -16,7 +16,7 @@ use crate::installer::PackageEvent; use crate::io::IOInterface; use crate::io::IOInterfaceImmutable; use crate::plugin::php_plugin_proxy::PluginRpcDispatcher; -use crate::repository::RepositoryInterface; +use crate::repository::InstalledRepositoryInterfaceHandle; use crate::script::Event as ScriptEvent; use crate::util::Platform; use crate::util::ProcessExecutor; @@ -250,9 +250,9 @@ impl EventDispatcher { &mut self, event_name: &str, dev_mode: bool, - local_repo: Box<dyn RepositoryInterface>, - operations: Vec<AnyOperation>, - operation: AnyOperation, + local_repo: InstalledRepositoryInterfaceHandle, + operations: Vec<std::rc::Rc<AnyOperation>>, + operation: std::rc::Rc<AnyOperation>, ) -> anyhow::Result<i64> { let composer = self.composer(); assert!( @@ -432,8 +432,7 @@ impl EventDispatcher { crate::io::VERBOSE, ); let stub_class = Self::event_stub_class(event).ok_or_else(|| { - // TODO(plugin): only the base Event and Script\Event proxy stubs exist so - // far; installer/package/plugin events need their own stubs. + // TODO(plugin): installer and plugin events have no proxy stub yet. anyhow::anyhow!(RuntimeException { message: format!( "no proxy stub is available yet for the event `{}` dispatched to {}::{}", @@ -1154,8 +1153,7 @@ try {{ } let stub_class = Self::event_stub_class(event).ok_or_else(|| { - // TODO(plugin): only the base Event and Script\Event proxy stubs exist so far; - // installer/package/plugin events need their own stubs. + // TODO(plugin): installer and plugin events have no proxy stub yet. anyhow::anyhow!(RuntimeException { message: format!( "no proxy stub is available yet for the event `{}` dispatched to {}::{}", @@ -1200,6 +1198,8 @@ try {{ fn event_stub_class(event: &dyn EventInterface) -> Option<&'static str> { if event.as_any().downcast_ref::<ScriptEvent>().is_some() { Some("Composer\\Script\\Event") + } else if event.as_any().downcast_ref::<PackageEvent>().is_some() { + Some("Composer\\Installer\\PackageEvent") } else if event.as_any().downcast_ref::<Event>().is_some() { Some("Composer\\EventDispatcher\\Event") } else { @@ -1755,39 +1755,75 @@ pub(crate) fn dispatch_event_method( event.get_flags().clone(), ))), "isPropagationStopped" => Ok(PluginValue::Bool(event.is_propagation_stopped())), - "isDevMode" => match event.as_any().downcast_ref::<ScriptEvent>() { - Some(script_event) => Ok(PluginValue::Bool(script_event.is_dev_mode())), + "isDevMode" => match (script_event(event), package_event(event)) { + (Some(event), _) => Ok(PluginValue::Bool(event.is_dev_mode())), + (_, Some(event)) => Ok(PluginValue::Bool(event.is_dev_mode())), + _ => Err(runtime_throw( + "isDevMode is only available on script and package events".to_string(), + )), + }, + "getComposer" => { + let composer = match (script_event(event), package_event(event)) { + (Some(event), _) => event.get_composer().upgrade(), + (_, Some(event)) => event.get_composer().upgrade(), + _ => { + return Err(runtime_throw( + "getComposer is only available on script and package events".to_string(), + )); + } + }; + let composer = composer.ok_or_else(|| { + runtime_throw("the Composer instance of this event is gone".to_string()) + })?; + let rhandle = crate::plugin::php_plugin_proxy::register_composer_entity(&composer); + Ok(crate::plugin::php_plugin_proxy::rust_handle_value( + rhandle, + "Composer\\Composer", + )) + } + "getIO" => { + let io = match (script_event(event), package_event(event)) { + (Some(event), _) => event.get_io(), + (_, Some(event)) => event.get_io(), + _ => { + return Err(runtime_throw( + "getIO is only available on script and package events".to_string(), + )); + } + }; + let class = crate::plugin::php_plugin_proxy::io_stub_class(&io) + .map_err(|error| runtime_throw(error.to_string()))?; + let rhandle = crate::plugin::php_plugin_proxy::register_io_entity(&io); + Ok(crate::plugin::php_plugin_proxy::rust_handle_value( + rhandle, class, + )) + } + "getLocalRepo" => match package_event(event) { + Some(event) => crate::plugin::php_plugin_proxy::repository_handle_value( + &event.get_local_repo().as_repository_handle(), + ), None => Err(runtime_throw( - "isDevMode is only available on script events".to_string(), + "getLocalRepo is only available on package events".to_string(), )), }, - "getComposer" => match event.as_any().downcast_ref::<ScriptEvent>() { - Some(script_event) => { - let composer = script_event.get_composer().upgrade().ok_or_else(|| { - runtime_throw("the Composer instance of this event is gone".to_string()) - })?; - let rhandle = crate::plugin::php_plugin_proxy::register_composer_entity(&composer); - Ok(crate::plugin::php_plugin_proxy::rust_handle_value( - rhandle, - "Composer\\Composer", - )) - } + "getOperations" => match package_event(event) { + Some(event) => Ok(PluginValue::List( + event + .get_operations() + .iter() + .map(crate::plugin::php_plugin_proxy::operation_handle_value) + .collect(), + )), None => Err(runtime_throw( - "getComposer is only available on script events".to_string(), + "getOperations is only available on package events".to_string(), )), }, - "getIO" => match event.as_any().downcast_ref::<ScriptEvent>() { - Some(script_event) => { - let io = script_event.get_io(); - let class = crate::plugin::php_plugin_proxy::io_stub_class(&io) - .map_err(|error| runtime_throw(error.to_string()))?; - let rhandle = crate::plugin::php_plugin_proxy::register_io_entity(&io); - Ok(crate::plugin::php_plugin_proxy::rust_handle_value( - rhandle, class, - )) - } + "getOperation" => match package_event(event) { + Some(event) => Ok(crate::plugin::php_plugin_proxy::operation_handle_value( + event.get_operation(), + )), None => Err(runtime_throw( - "getIO is only available on script events".to_string(), + "getOperation is only available on package events".to_string(), )), }, // TODO(plugin): stopPropagation and the rest need full proxying of the object graph @@ -1798,6 +1834,14 @@ pub(crate) fn dispatch_event_method( } } +fn script_event(event: &dyn EventInterface) -> Option<&ScriptEvent> { + event.as_any().downcast_ref::<ScriptEvent>() +} + +fn package_event(event: &dyn EventInterface) -> Option<&PackageEvent> { + event.as_any().downcast_ref::<PackageEvent>() +} + fn runtime_throw(message: String) -> PhpThrow { PhpThrow { exception_class: "RuntimeException".to_string(), diff --git a/crates/shirabe/src/installer/installation_manager.rs b/crates/shirabe/src/installer/installation_manager.rs index 439026b0..1f1c60a5 100644 --- a/crates/shirabe/src/installer/installation_manager.rs +++ b/crates/shirabe/src/installer/installation_manager.rs @@ -327,16 +327,19 @@ impl InstallationManager { }), ); - let all_operations: Vec<AnyOperation> = operations.clone(); + // Shared rather than owned so that one operation reaches a plugin as one object, both + // through the whole batch pipeline and through its pre- and post-event. + let all_operations: Vec<std::rc::Rc<AnyOperation>> = + operations.into_iter().map(std::rc::Rc::new).collect(); let result: anyhow::Result<()> = (|| -> anyhow::Result<()> { // execute operations in batches to make sure download-modifying-plugins are installed // before the other packages get downloaded - let mut batches: Vec<IndexMap<i64, AnyOperation>> = vec![]; - let mut batch: IndexMap<i64, AnyOperation> = IndexMap::new(); - for (index, operation) in operations.into_iter().enumerate() { + let mut batches: Vec<IndexMap<i64, std::rc::Rc<AnyOperation>>> = vec![]; + let mut batch: IndexMap<i64, std::rc::Rc<AnyOperation>> = IndexMap::new(); + for (index, operation) in all_operations.iter().cloned().enumerate() { let index = index as i64; - let package: Option<PackageInterfaceHandle> = match &operation { + let package: Option<PackageInterfaceHandle> = match &*operation { AnyOperation::Update(update) => Some(update.get_target_package()), AnyOperation::Install(install) => Some(install.get_package()), _ => None, @@ -409,7 +412,7 @@ impl InstallationManager { async fn download_and_execute_batch( &self, repo: &InstalledRepositoryInterfaceHandle, - operations: IndexMap<i64, AnyOperation>, + operations: IndexMap<i64, std::rc::Rc<AnyOperation>>, cleanup_promises: &mut IndexMap< i64, Box< @@ -421,7 +424,7 @@ impl InstallationManager { dev_mode: bool, run_scripts: bool, download_only: bool, - all_operations: Vec<AnyOperation>, + all_operations: Vec<std::rc::Rc<AnyOperation>>, ) -> anyhow::Result<()> { let mut promises: Vec< std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>>>>, @@ -436,7 +439,7 @@ impl InstallationManager { } let package = operation.get_target_package(); - let initial_package: Option<PackageInterfaceHandle> = match operation { + let initial_package: Option<PackageInterfaceHandle> = match &**operation { AnyOperation::Update(update_op) => Some(update_op.get_initial_package()), _ => None, }; @@ -505,10 +508,10 @@ impl InstallationManager { // execute operations in batches to make sure every plugin is installed in the // right order and activated before the packages depending on it are installed - let mut batches: Vec<IndexMap<i64, AnyOperation>> = vec![]; - let mut batch: IndexMap<i64, AnyOperation> = IndexMap::new(); + let mut batches: Vec<IndexMap<i64, std::rc::Rc<AnyOperation>>> = vec![]; + let mut batch: IndexMap<i64, std::rc::Rc<AnyOperation>> = IndexMap::new(); for (index, operation) in operations { - let package: Option<PackageInterfaceHandle> = match &operation { + let package: Option<PackageInterfaceHandle> = match &*operation { AnyOperation::Update(update) => Some(update.get_target_package()), AnyOperation::Install(install) => Some(install.get_package()), _ => None, @@ -551,7 +554,7 @@ impl InstallationManager { async fn execute_batch( &self, repo: &InstalledRepositoryInterfaceHandle, - operations: IndexMap<i64, AnyOperation>, + operations: IndexMap<i64, std::rc::Rc<AnyOperation>>, cleanup_promises: &IndexMap< i64, Box< @@ -562,11 +565,13 @@ impl InstallationManager { >, dev_mode: bool, run_scripts: bool, - all_operations: &[AnyOperation], + all_operations: &[std::rc::Rc<AnyOperation>], ) -> anyhow::Result<()> { let mut promises: Vec< std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + '_>>, > = vec![]; + // @var array<callable(): void> $postExecCallbacks + let mut post_exec_callbacks: Vec<Box<dyn Fn() -> anyhow::Result<()>>> = vec![]; for (index, operation) in operations { let op_type = operation.get_operation_type(); @@ -581,7 +586,7 @@ impl InstallationManager { io_interface::NORMAL, ); } - match &operation { + match &*operation { AnyOperation::MarkAliasInstalled(op) => { self.mark_alias_installed(&mut *repo.borrow_mut(), op)?; } @@ -595,7 +600,7 @@ impl InstallationManager { } let package = operation.get_target_package(); - let initial_package: Option<PackageInterfaceHandle> = match &operation { + let initial_package: Option<PackageInterfaceHandle> = match &*operation { AnyOperation::Update(update_op) => Some(update_op.get_initial_package()), _ => None, }; @@ -607,11 +612,14 @@ impl InstallationManager { _ => "", }; - if run_scripts && self.event_dispatcher.is_some() { - // TODO(phase-c): dispatch_package_event takes Box<dyn RepositoryInterface>/Vec<Box<...>> - // but we hold a RefCell'd &mut dyn here. Needs structural rework (likely shared Rc - // on repo and ops). - let _ = (event_name, dev_mode, &repo, &all_operations, &operation); + if run_scripts && let Some(event_dispatcher) = &self.event_dispatcher { + event_dispatcher.borrow_mut().dispatch_package_event( + event_name, + dev_mode, + repo.clone(), + all_operations.to_vec(), + operation.clone(), + )?; } let installer = self.get_installer(&package.get_type())?; @@ -620,16 +628,16 @@ impl InstallationManager { // ->then(fn() => $this->{$opType}($repo, $operation)) // ->then($cleanupPromises[$index]) // ->then(fn() => $repo->write($devMode, $this), fn($e) => { "<op> of <pkg> - // failed"; throw $e; }) - // ->then(fn() => dispatch POST_PACKAGE_* event); + // failed"; throw $e; }); // each package gets its own chain and the whole batch resolves via waitOnPromises. + let executed_operation = std::rc::Rc::clone(&operation); promises.push(Box::pin(async move { let chain_result: anyhow::Result<()> = async { installer .prepare(op_type, package.clone(), initial_package.clone()) .await?; - match &operation { + match &*executed_operation { AnyOperation::Install(op) => { self.install(repo, op).await?; } @@ -668,24 +676,32 @@ impl InstallationManager { // PHP: ->then(fn() => $repo->write($devMode, $this)) persists the repository after each op. repo.borrow_mut().write(dev_mode, self)?; - let event_name_post = match op_type { - "install" => PackageEvents::POST_PACKAGE_INSTALL, - "update" => PackageEvents::POST_PACKAGE_UPDATE, - "uninstall" => PackageEvents::POST_PACKAGE_UNINSTALL, - _ => "", - }; - - if run_scripts && self.event_dispatcher.is_some() { - // PHP dispatches the POST_PACKAGE_* event at the end of the chain via the event - // dispatcher with repo/all_operations/operation. - // TODO(phase-c): dispatch_package_event takes Box<dyn RepositoryInterface>/ - // Vec<Box<...>> but we hold a RefCell'd &mut dyn here. Needs structural rework - // (likely shared Rc on repo and ops). - let _ = event_name_post; - } - Ok(()) })); + + let event_name = match op_type { + "install" => PackageEvents::POST_PACKAGE_INSTALL, + "update" => PackageEvents::POST_PACKAGE_UPDATE, + "uninstall" => PackageEvents::POST_PACKAGE_UNINSTALL, + _ => "", + }; + + if run_scripts && let Some(event_dispatcher) = &self.event_dispatcher { + let event_dispatcher = event_dispatcher.clone(); + let repo = repo.clone(); + let all_operations = all_operations.to_vec(); + post_exec_callbacks.push(Box::new(move || { + event_dispatcher.borrow_mut().dispatch_package_event( + event_name, + dev_mode, + repo.clone(), + all_operations.clone(), + operation.clone(), + )?; + + Ok(()) + })); + } } if !promises.is_empty() { @@ -694,6 +710,10 @@ impl InstallationManager { Platform::workaround_filesystem_issues(); + for cb in post_exec_callbacks { + cb()?; + } + Ok(()) } diff --git a/crates/shirabe/src/installer/package_event.rs b/crates/shirabe/src/installer/package_event.rs index 9435a581..5e6ddb34 100644 --- a/crates/shirabe/src/installer/package_event.rs +++ b/crates/shirabe/src/installer/package_event.rs @@ -5,19 +5,21 @@ use crate::dependency_resolver::operation::AnyOperation; use crate::event_dispatcher::Event; use crate::event_dispatcher::EventInterface; use crate::io::IOInterface; -use crate::repository::RepositoryInterface; +use crate::repository::InstalledRepositoryInterfaceHandle; use indexmap::IndexMap; use shirabe_php_shim::PhpMixed; +/// The operations are shared rather than owned so that the same operation crosses the plugin +/// boundary as one object for both the pre- and the post-event, as it does in PHP. #[derive(Debug)] pub struct PackageEvent { inner: Event, composer: ComposerWeakHandle, io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>, dev_mode: bool, - local_repo: Box<dyn RepositoryInterface>, - operations: Vec<AnyOperation>, - operation: AnyOperation, + local_repo: InstalledRepositoryInterfaceHandle, + operations: Vec<std::rc::Rc<AnyOperation>>, + operation: std::rc::Rc<AnyOperation>, } impl PackageEvent { @@ -26,9 +28,9 @@ impl PackageEvent { composer: ComposerWeakHandle, io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>, dev_mode: bool, - local_repo: Box<dyn RepositoryInterface>, - operations: Vec<AnyOperation>, - operation: AnyOperation, + local_repo: InstalledRepositoryInterfaceHandle, + operations: Vec<std::rc::Rc<AnyOperation>>, + operation: std::rc::Rc<AnyOperation>, ) -> Self { Self { inner: Event::new(event_name, vec![], IndexMap::new()), @@ -57,15 +59,15 @@ impl PackageEvent { self.dev_mode } - pub fn get_local_repo(&self) -> &dyn RepositoryInterface { - self.local_repo.as_ref() + pub fn get_local_repo(&self) -> InstalledRepositoryInterfaceHandle { + self.local_repo.clone() } - pub fn get_operations(&self) -> &Vec<AnyOperation> { + pub fn get_operations(&self) -> &Vec<std::rc::Rc<AnyOperation>> { &self.operations } - pub fn get_operation(&self) -> &AnyOperation { + pub fn get_operation(&self) -> &std::rc::Rc<AnyOperation> { &self.operation } } diff --git a/crates/shirabe/src/plugin/php_plugin_proxy.rs b/crates/shirabe/src/plugin/php_plugin_proxy.rs index 541718af..686c05bd 100644 --- a/crates/shirabe/src/plugin/php_plugin_proxy.rs +++ b/crates/shirabe/src/plugin/php_plugin_proxy.rs @@ -8,6 +8,7 @@ use crate::autoload::ClassLoader; use crate::command::BaseCommand; use crate::composer::ComposerHandle; +use crate::dependency_resolver::operation::AnyOperation; use crate::event_dispatcher::event_dispatcher::dispatch_event_method; use crate::event_dispatcher::{ EventInterface, EventSubscriberInterface, SubscribedEventEntry, unwrap_php_result, @@ -52,6 +53,7 @@ enum RustEntity { EventDispatcher( std::rc::Rc<std::cell::RefCell<dyn crate::event_dispatcher::EventDispatcherInterface>>, ), + Operation(std::rc::Rc<AnyOperation>), } /// The pointer identity backing R-table interning: the same shared instance must always cross @@ -71,6 +73,7 @@ fn entity_ptr_id(entity: &RustEntity) -> usize { RustEntity::EventDispatcher(dispatcher) => { std::rc::Rc::as_ptr(dispatcher) as *const () as usize } + RustEntity::Operation(operation) => std::rc::Rc::as_ptr(operation) as *const () as usize, } } @@ -169,6 +172,21 @@ fn repository_stub_class(repository: &RepositoryInterfaceHandle) -> Result<&'sta } } +/// The proxy stub class matching a solver operation's concrete type. +fn operation_stub_class(operation: &AnyOperation) -> &'static str { + match operation { + AnyOperation::Install(_) => "Composer\\DependencyResolver\\Operation\\InstallOperation", + AnyOperation::Update(_) => "Composer\\DependencyResolver\\Operation\\UpdateOperation", + AnyOperation::Uninstall(_) => "Composer\\DependencyResolver\\Operation\\UninstallOperation", + AnyOperation::MarkAliasInstalled(_) => { + "Composer\\DependencyResolver\\Operation\\MarkAliasInstalledOperation" + } + AnyOperation::MarkAliasUninstalled(_) => { + "Composer\\DependencyResolver\\Operation\\MarkAliasUninstalledOperation" + } + } +} + /// Registers a package and returns its wire descriptor. pub(crate) fn package_handle_value( package: &std::rc::Rc<std::cell::RefCell<AnyPackage>>, @@ -178,6 +196,13 @@ pub(crate) fn package_handle_value( rust_handle_value(rhandle, class) } +/// Registers a solver operation and returns its wire descriptor. +pub(crate) fn operation_handle_value(operation: &std::rc::Rc<AnyOperation>) -> PluginValue { + let class = operation_stub_class(operation); + let rhandle = register_entity(RustEntity::Operation(operation.clone())); + rust_handle_value(rhandle, class) +} + /// The PHP class name (= proxy stub class) of a Rust IO instance, for the `__class` field of /// its handle descriptor. pub(crate) fn io_stub_class( @@ -335,6 +360,9 @@ impl RustMethodDispatcher for PluginRpcDispatcher<'_> { Some(RustEntity::EventDispatcher(dispatcher)) => { dispatch_event_dispatcher_method(&dispatcher, method_name, &args) } + Some(RustEntity::Operation(operation)) => { + dispatch_operation_method(&operation, method_name, &args) + } None => Err(runtime_throw(format!("unknown Rust handle {rhandle}"))), } } @@ -368,7 +396,13 @@ pub(crate) fn construct_entity(args: &[PluginValue]) -> Result<PluginValue, PhpT ))), } }; - let entity = match class.as_str() { + let alias_package_arg = + |position: usize| -> Result<crate::package::AliasPackageHandle, PhpThrow> { + package_from_arg(&class, ctor_args.get(position))? + .as_alias() + .ok_or_else(|| runtime_throw(format!("{class} expects an AliasPackage"))) + }; + let package = match class.as_str() { "Composer\\Package\\Package" => AnyPackage::Package(crate::package::Package::new( string_arg(0)?, string_arg(1)?, @@ -413,6 +447,50 @@ pub(crate) fn construct_entity(args: &[PluginValue]) -> Result<PluginValue, PhpT string_arg(2)?, )) } + // A solver operation carries no state beyond the packages it names, so a plugin-built + // one is a complete instance rather than a second view on a Rust-side service. + "Composer\\DependencyResolver\\Operation\\InstallOperation" => { + return Ok(operation_construction_result(AnyOperation::Install( + crate::dependency_resolver::operation::InstallOperation::new(package_from_arg( + &class, + ctor_args.first(), + )?), + ))); + } + "Composer\\DependencyResolver\\Operation\\UpdateOperation" => { + return Ok(operation_construction_result(AnyOperation::Update( + crate::dependency_resolver::operation::UpdateOperation::new( + package_from_arg(&class, ctor_args.first())?, + package_from_arg(&class, ctor_args.get(1))?, + ), + ))); + } + "Composer\\DependencyResolver\\Operation\\UninstallOperation" => { + return Ok(operation_construction_result(AnyOperation::Uninstall( + crate::dependency_resolver::operation::UninstallOperation::new(package_from_arg( + &class, + ctor_args.first(), + )?), + ))); + } + "Composer\\DependencyResolver\\Operation\\MarkAliasInstalledOperation" => { + return Ok(operation_construction_result( + AnyOperation::MarkAliasInstalled( + crate::dependency_resolver::operation::MarkAliasInstalledOperation::new( + alias_package_arg(0)?, + ), + ), + )); + } + "Composer\\DependencyResolver\\Operation\\MarkAliasUninstalledOperation" => { + return Ok(operation_construction_result( + AnyOperation::MarkAliasUninstalled( + crate::dependency_resolver::operation::MarkAliasUninstalledOperation::new( + alias_package_arg(0)?, + ), + ), + )); + } // TODO(plugin): the remaining proxied classes get a construction story on demand, // driven by explicit errors from real plugins. Each one has to decide what a // plugin-built instance means for the Rust-side graph, which is why none of them is @@ -424,12 +502,20 @@ pub(crate) fn construct_entity(args: &[PluginValue]) -> Result<PluginValue, PhpT } }; let rhandle = register_entity(RustEntity::Package(std::rc::Rc::new( - std::cell::RefCell::new(entity), + std::cell::RefCell::new(package), ))); - Ok(PluginValue::List(vec![ - PluginValue::Int(rhandle as i64), - PluginValue::Int(0), - ])) + Ok(construction_result(rhandle)) +} + +fn operation_construction_result(operation: AnyOperation) -> PluginValue { + construction_result(register_entity(RustEntity::Operation(std::rc::Rc::new( + operation, + )))) +} + +/// The `[$rhandle, $epoch]` pair a proxy stub's constructor binds itself to. +fn construction_result(rhandle: u64) -> PluginValue { + PluginValue::List(vec![PluginValue::Int(rhandle as i64), PluginValue::Int(0)]) } /// Serves the `__clone` forwarder every proxy stub carries. Only entities whose Rust type @@ -452,7 +538,8 @@ fn clone_entity(entity: &RustEntity) -> Result<PluginValue, PhpThrow> { | RustEntity::InstallationManager(_) | RustEntity::RepositoryManager(_) | RustEntity::Repository(_) - | RustEntity::EventDispatcher(_) => { + | RustEntity::EventDispatcher(_) + | RustEntity::Operation(_) => { return Err(runtime_throw( "cloning this Rust-side entity over RPC is not supported".to_string(), )); @@ -810,12 +897,7 @@ fn dispatch_repository_manager_method( method_name: &str, ) -> Result<PluginValue, PhpThrow> { match method_name { - "getLocalRepository" => { - let local = rm.borrow().get_local_repository(); - let class = repository_stub_class(&local)?; - let rhandle = register_entity(RustEntity::Repository(local)); - Ok(rust_handle_value(rhandle, class)) - } + "getLocalRepository" => repository_handle_value(&rm.borrow().get_local_repository()), // TODO(plugin): the remaining RepositoryManager surface is widened on demand, driven // by explicit errors from real plugins. other => Err(runtime_throw(format!( @@ -1761,6 +1843,42 @@ fn dispatch_package_method( } } +fn dispatch_operation_method( + operation: &AnyOperation, + method_name: &str, + args: &[PluginValue], +) -> Result<PluginValue, PhpThrow> { + match (method_name, operation) { + ("getOperationType", _) => Ok(PluginValue::string(operation.get_operation_type())), + ("show", _) => Ok(PluginValue::string( + operation.show(bool_arg(method_name, args.first())?), + )), + ("__toString", _) => Ok(PluginValue::string(operation.to_string())), + ("getPackage", AnyOperation::Install(op)) => { + Ok(package_handle_value(op.get_package().as_rc())) + } + ("getPackage", AnyOperation::Uninstall(op)) => { + Ok(package_handle_value(op.get_package().as_rc())) + } + ("getPackage", AnyOperation::MarkAliasInstalled(op)) => { + Ok(package_handle_value(op.get_package().as_rc())) + } + ("getPackage", AnyOperation::MarkAliasUninstalled(op)) => { + Ok(package_handle_value(op.get_package().as_rc())) + } + ("getInitialPackage", AnyOperation::Update(op)) => { + Ok(package_handle_value(op.get_initial_package().as_rc())) + } + ("getTargetPackage", AnyOperation::Update(op)) => { + Ok(package_handle_value(op.get_target_package().as_rc())) + } + (other, _) => Err(runtime_throw(format!( + "the operation method `{other}` is not available on a {} over RPC yet", + operation_stub_class(operation) + ))), + } +} + fn dispatch_installation_manager_method( im: &std::rc::Rc<std::cell::RefCell<dyn InstallationManagerInterface>>, method_name: &str, diff --git a/crates/shirabe/tests/plugin/e2e_package_event_test.rs b/crates/shirabe/tests/plugin/e2e_package_event_test.rs new file mode 100644 index 00000000..ef334685 --- /dev/null +++ b/crates/shirabe/tests/plugin/e2e_package_event_test.rs @@ -0,0 +1,105 @@ +//! Package event E2E compatibility check: upstream Composer and Shirabe each install a fixture +//! project whose plugin subscribes to every `PackageEvents` constant and appends what each event +//! exposes to a trace file. Upstream has no test that dispatches package events through a real +//! plugin, so the whole fixture is Shirabe-authored (`fixtures/e2e-package-event/`) and nothing +//! has to be fetched; the test skips only while the PHP runtime or the Composer checkout is +//! missing. + +use crate::e2e_extension_installer_test::{copy_dir, upstream_composer_bin}; +use crate::plugin_installer_test::{lock_php_worker, php_runtime_available}; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +fn fixture_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/plugin/fixtures/e2e-package-event") +} + +struct Run { + exit_code: i32, + trace: String, + repo_trace: String, +} + +/// Runs `install` in a fresh copy of the fixture and returns the exit code with the traces the +/// plugin wrote. +fn install(program: &str, prefix_args: &[&str]) -> Run { + let work = TempDir::new().unwrap(); + copy_dir(&fixture_dir(), work.path()); + let project = work.path().join("project"); + let output = std::process::Command::new(program) + .args(prefix_args) + .arg("install") + .current_dir(&project) + .env("COMPOSER_HOME", work.path().join("home")) + .env("COMPOSER_CACHE_DIR", work.path().join("cache")) + .env("COMPOSER_NO_INTERACTION", "1") + .env("COLUMNS", "120") + .env("LINES", "30") + .output() + .unwrap(); + let read = |name: &str| std::fs::read_to_string(project.join(name)).unwrap_or_default(); + Run { + exit_code: output.status.code().unwrap_or(-1), + trace: read("package-event-trace.txt"), + repo_trace: read("package-event-repo-trace.txt"), + } +} + +#[test] +fn test_package_events_match_upstream_composer() { + if !php_runtime_available() { + return; + } + let Some(composer_bin) = upstream_composer_bin() else { + return; + }; + let _worker = lock_php_worker(); + let composer_bin = composer_bin.to_str().unwrap().to_string(); + + let upstream = install("php", &[composer_bin.as_str()]); + let shirabe = install(env!("CARGO_BIN_EXE_shirabe"), &[]); + + assert_eq!(0, upstream.exit_code, "upstream install must succeed"); + assert_eq!(upstream.exit_code, shirabe.exit_code); + assert_eq!(upstream.trace, shirabe.trace); + + // Pinned as well as compared, so a run where neither side dispatches anything cannot pass. + // The plugin is activated by the very batch it observes, hence the first line: its own + // post-package-install, deferred until after the batch's operations have run. The two + // packages of the next batch report their pre-events before either post-event for the same + // reason. + assert_eq!( + "\ +post-package-install devMode=1 class=Composer\\DependencyResolver\\Operation\\InstallOperation type=install packages=shirabe-test/package-event-recorder operations=3 root=shirabe/e2e-package-event show=Installing <info>shirabe-test/package-event-recorder</info> (<comment>1.0.0</comment>) +pre-package-install devMode=1 class=Composer\\DependencyResolver\\Operation\\InstallOperation type=install packages=shirabe-test/lib-a operations=3 root=shirabe/e2e-package-event show=Installing <info>shirabe-test/lib-a</info> (<comment>1.0.0</comment>) +pre-package-install devMode=1 class=Composer\\DependencyResolver\\Operation\\InstallOperation type=install packages=shirabe-test/lib-b operations=3 root=shirabe/e2e-package-event show=Installing <info>shirabe-test/lib-b</info> (<comment>1.0.0</comment>) +post-package-install devMode=1 class=Composer\\DependencyResolver\\Operation\\InstallOperation type=install packages=shirabe-test/lib-a operations=3 root=shirabe/e2e-package-event show=Installing <info>shirabe-test/lib-a</info> (<comment>1.0.0</comment>) +post-package-install devMode=1 class=Composer\\DependencyResolver\\Operation\\InstallOperation type=install packages=shirabe-test/lib-b operations=3 root=shirabe/e2e-package-event show=Installing <info>shirabe-test/lib-b</info> (<comment>1.0.0</comment>) +", + upstream.trace + ); +} + +// Upstream's operation chain starts running where it is built, because a `prepare()` that +// returns null becomes an already-fulfilled React promise whose `then()` handlers run through +// the immediately drained queue; the pre-event of the next operation therefore already sees the +// previous one installed. Shirabe builds a lazy future per operation and only drives them in +// wait_on_promises, so every pre-event of a batch sees the repository as it was before the +// batch. Upstream: 1 / 1 / 2 / 3 / 3, Shirabe: 1 / 1 / 1 / 3 / 3. +#[ignore = "operation chains run where they are built upstream but only in wait_on_promises here, so the repository state a package event observes differs (TODO(phase-c) promise cluster)"] +#[test] +fn test_local_repository_seen_by_package_events_matches_upstream_composer() { + if !php_runtime_available() { + return; + } + let Some(composer_bin) = upstream_composer_bin() else { + return; + }; + let _worker = lock_php_worker(); + let composer_bin = composer_bin.to_str().unwrap().to_string(); + + let upstream = install("php", &[composer_bin.as_str()]); + let shirabe = install(env!("CARGO_BIN_EXE_shirabe"), &[]); + + assert_eq!(upstream.repo_trace, shirabe.repo_trace); +} diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-package-event/packages/lib-a/composer.json b/crates/shirabe/tests/plugin/fixtures/e2e-package-event/packages/lib-a/composer.json new file mode 100644 index 00000000..50b2eece --- /dev/null +++ b/crates/shirabe/tests/plugin/fixtures/e2e-package-event/packages/lib-a/composer.json @@ -0,0 +1,5 @@ +{ + "name": "shirabe-test/lib-a", + "version": "1.0.0", + "description": "Fixture package installed while the recorder plugin is active." +} diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-package-event/packages/lib-b/composer.json b/crates/shirabe/tests/plugin/fixtures/e2e-package-event/packages/lib-b/composer.json new file mode 100644 index 00000000..96713f8d --- /dev/null +++ b/crates/shirabe/tests/plugin/fixtures/e2e-package-event/packages/lib-b/composer.json @@ -0,0 +1,5 @@ +{ + "name": "shirabe-test/lib-b", + "version": "1.0.0", + "description": "Fixture dev package, so the recorded events carry devMode." +} diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-package-event/plugin/composer.json b/crates/shirabe/tests/plugin/fixtures/e2e-package-event/plugin/composer.json new file mode 100644 index 00000000..7614dc95 --- /dev/null +++ b/crates/shirabe/tests/plugin/fixtures/e2e-package-event/plugin/composer.json @@ -0,0 +1,17 @@ +{ + "name": "shirabe-test/package-event-recorder", + "version": "1.0.0", + "type": "composer-plugin", + "description": "Fixture plugin recording every PackageEvent it is subscribed to.", + "autoload": { + "psr-4": { + "ShirabeTest\\PackageEvent\\": "src/" + } + }, + "require": { + "composer-plugin-api": "^2.0" + }, + "extra": { + "class": "ShirabeTest\\PackageEvent\\Plugin" + } +} diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-package-event/plugin/src/Plugin.php b/crates/shirabe/tests/plugin/fixtures/e2e-package-event/plugin/src/Plugin.php new file mode 100644 index 00000000..d904c0e9 --- /dev/null +++ b/crates/shirabe/tests/plugin/fixtures/e2e-package-event/plugin/src/Plugin.php @@ -0,0 +1,87 @@ +<?php + +namespace ShirabeTest\PackageEvent; + +use Composer\Composer; +use Composer\DependencyResolver\Operation\InstallOperation; +use Composer\DependencyResolver\Operation\OperationInterface; +use Composer\DependencyResolver\Operation\UninstallOperation; +use Composer\DependencyResolver\Operation\UpdateOperation; +use Composer\EventDispatcher\EventSubscriberInterface; +use Composer\IO\IOInterface; +use Composer\Installer\PackageEvent; +use Composer\Installer\PackageEvents; +use Composer\Plugin\PluginInterface; + +/** + * Appends one line per PackageEvent to package-event-trace.txt, so both the order the events + * arrive in and everything the event exposes are comparable between implementations. + * + * The local repository is observed into a second file, because its size at the moment an event + * fires reports how far the batch's operations have run rather than anything the event carries. + */ +class Plugin implements PluginInterface, EventSubscriberInterface +{ + /** @var IOInterface */ + private $io; + + public function activate(Composer $composer, IOInterface $io): void + { + $this->io = $io; + } + + public function deactivate(Composer $composer, IOInterface $io): void + { + } + + public function uninstall(Composer $composer, IOInterface $io): void + { + } + + public static function getSubscribedEvents() + { + return [ + PackageEvents::PRE_PACKAGE_INSTALL => 'onPackageEvent', + PackageEvents::POST_PACKAGE_INSTALL => 'onPackageEvent', + PackageEvents::PRE_PACKAGE_UPDATE => 'onPackageEvent', + PackageEvents::POST_PACKAGE_UPDATE => 'onPackageEvent', + PackageEvents::PRE_PACKAGE_UNINSTALL => 'onPackageEvent', + PackageEvents::POST_PACKAGE_UNINSTALL => 'onPackageEvent', + ]; + } + + public function onPackageEvent(PackageEvent $event): void + { + $operation = $event->getOperation(); + $line = implode(' ', [ + $event->getName(), + 'devMode=' . ($event->isDevMode() ? '1' : '0'), + 'class=' . get_class($operation), + 'type=' . $operation->getOperationType(), + 'packages=' . $this->packages($operation), + 'operations=' . count($event->getOperations()), + 'root=' . $event->getComposer()->getPackage()->getName(), + 'show=' . $operation->show(false), + ]); + $this->io->write('package-event: ' . $line); + file_put_contents('package-event-trace.txt', $line . "\n", FILE_APPEND); + file_put_contents( + 'package-event-repo-trace.txt', + $event->getName() . ' localRepo=' . count($event->getLocalRepo()->getPackages()) . "\n", + FILE_APPEND + ); + } + + private function packages(OperationInterface $operation): string + { + if ($operation instanceof UpdateOperation) { + return $operation->getInitialPackage()->getPrettyName() + . '->' . $operation->getTargetPackage()->getPrettyName(); + } + if ($operation instanceof InstallOperation || $operation instanceof UninstallOperation) { + return $operation->getPackage()->getPrettyName(); + } + + return 'n/a'; + } +} diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-package-event/project/composer.json b/crates/shirabe/tests/plugin/fixtures/e2e-package-event/project/composer.json new file mode 100644 index 00000000..a0e63a15 --- /dev/null +++ b/crates/shirabe/tests/plugin/fixtures/e2e-package-event/project/composer.json @@ -0,0 +1,35 @@ +{ + "name": "shirabe/e2e-package-event", + "description": "E2E fixture project: record the package events a plugin receives during install.", + "repositories": [ + { + "type": "path", + "url": "../plugin", + "options": { + "symlink": false + } + }, + { + "type": "path", + "url": "../packages/*", + "options": { + "symlink": false + } + }, + { + "packagist.org": false + } + ], + "require": { + "shirabe-test/package-event-recorder": "1.0.0", + "shirabe-test/lib-a": "1.0.0" + }, + "require-dev": { + "shirabe-test/lib-b": "1.0.0" + }, + "config": { + "allow-plugins": { + "shirabe-test/package-event-recorder": true + } + } +} diff --git a/crates/shirabe/tests/plugin/main.rs b/crates/shirabe/tests/plugin/main.rs index 7e32a616..f1237731 100644 --- a/crates/shirabe/tests/plugin/main.rs +++ b/crates/shirabe/tests/plugin/main.rs @@ -9,6 +9,7 @@ mod e2e_extension_installer_test; mod e2e_installer_test; mod e2e_installers_test; mod e2e_normalize_test; +mod e2e_package_event_test; mod plugin_installer_test; mod subscriber_test; mod value_round_trip_test; diff --git a/docs/dev/php-rpc.md b/docs/dev/php-rpc.md index c833bbea..9e49dec0 100644 --- a/docs/dev/php-rpc.md +++ b/docs/dev/php-rpc.md @@ -18,10 +18,9 @@ behavior. - A `socketpair(2)` (no Windows support for now). The parent keeps one end and installs the other on descriptor 3 in the child, from a `pre_exec` hook, so the worker opens it as `php://fd/3` rather than connecting anywhere. A bound path would have to fit in `sun_path` - (108 bytes), which a long `TMPDIR` overruns, and it needs a `bind(2)` that sandboxes commonly - deny. The pair is connected from the start, so nothing has to wait for an `accept` either: a - child that dies before reading surfaces as EOF on the first call, with its exit status - attached. + (108 bytes), which a long `TMPDIR` overruns. The pair is connected from the start, so nothing + has to wait for an `accept` either: a child that dies before reading surfaces as EOF on the + first call, with its exit status attached. - The PHP glue code (`php/worker.php`) and the proxy stub classes (`php/stubs/`) are embedded in the Rust binary and written to a `0700` temp dir at spawn time, so both halves of the protocol are always the same commit. diff --git a/docs/dev/plugin-stub-generation.md b/docs/dev/plugin-stub-generation.md index 9743534a..68dde156 100644 --- a/docs/dev/plugin-stub-generation.md +++ b/docs/dev/plugin-stub-generation.md @@ -74,7 +74,8 @@ the generator's vendor directory or the classifier report is unavailable. * **Class constants, static methods and public static properties** are materialized verbatim from the real source (they read no instance state and run locally in the worker), together with any non-public static helpers the - methods call. + methods call. Constants keep their declared visibility, so a non-public one + stays unreadable from outside the stub as it is in the real class. * **Instance properties** are not declared on the stub, whatever their visibility: they are entity state. Every root stub instead carries `__get`/`__set`/`__isset`/`__unset` forwarders, so each access reaches the @@ -103,8 +104,7 @@ Generation fails — instead of emitting something quietly wrong — on: * an omitted override diverging from the inherited stub signature, * a subclass target listed before its base class, or extending a class that is neither a target nor provided by `php/runtime/`, -* a target whose FQCN is also provided by `php/runtime/`, -* non-public class constants (materializing them is unsupported so far). +* a target whose FQCN is also provided by `php/runtime/`. `generate-stubs` (in both modes) additionally fails when a `.php` file exists under the stubs directory that no target produces, or when `STUB_FILES` / diff --git a/scripts/plugin-stub-generator/generate-stubs b/scripts/plugin-stub-generator/generate-stubs index 934577f3..5db83a27 100755 --- a/scripts/plugin-stub-generator/generate-stubs +++ b/scripts/plugin-stub-generator/generate-stubs @@ -117,8 +117,9 @@ foreach ($iterator as $entry) { } } -// The Rust side embeds every stub with include_str!; the two lists must not drift apart. -$libRsText = (string) file_get_contents($libRs); +// The Rust side embeds every stub with include_str!; the two lists must not drift apart. The +// whitespace is squeezed out first because rustfmt wraps a long include_str! across lines. +$libRsText = preg_replace('/\s+/', '', (string) file_get_contents($libRs)); foreach ($files as $relative => $_) { if (!str_contains($libRsText, "include_str!(\"../php/stubs/$relative\")")) { $problems[] = "STUB_FILES in $libRs does not embed $relative"; diff --git a/scripts/plugin-stub-generator/src/Generator.php b/scripts/plugin-stub-generator/src/Generator.php index 12967787..dd62da60 100644 --- a/scripts/plugin-stub-generator/src/Generator.php +++ b/scripts/plugin-stub-generator/src/Generator.php @@ -220,12 +220,11 @@ final class Generator } } + // Constants are compile-time data with no entity behind them, so the declaration is + // copied verbatim, visibility included: a local copy cannot diverge from the entity, + // and nothing that was unreadable in the real class becomes readable here. $constants = []; foreach ($class->getConstants() as $constant) { - if (!$constant->isPublic()) { - $this->errors[] = "$fqcn declares a non-public constant; materializing it is not supported"; - continue; - } $constants[] = $file->verbatim($constant->getStartLine(), $constant->getEndLine()); } diff --git a/scripts/plugin-stub-generator/targets.list b/scripts/plugin-stub-generator/targets.list index 347680d2..411b9e3b 100644 --- a/scripts/plugin-stub-generator/targets.list +++ b/scripts/plugin-stub-generator/targets.list @@ -26,3 +26,10 @@ Composer\Repository\FilesystemRepository Composer\Repository\InstalledFilesystemRepository Composer\Repository\RepositoryManager Composer\Installer\InstallationManager +Composer\Installer\PackageEvent +Composer\DependencyResolver\Operation\SolverOperation +Composer\DependencyResolver\Operation\InstallOperation +Composer\DependencyResolver\Operation\UpdateOperation +Composer\DependencyResolver\Operation\UninstallOperation +Composer\DependencyResolver\Operation\MarkAliasInstalledOperation +Composer\DependencyResolver\Operation\MarkAliasUninstalledOperation |
