diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-08-20 09:50:58 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-08-20 09:50:58 +0900 |
| commit | 94507ec2f265ca9400d64d42036ca1e8722e27da (patch) | |
| tree | fca64359a8d9257717bc41822ae7119bdc3e2b26 | |
| parent | 80c2120a64ce9b989da288ce1fcf80dec5cf38c1 (diff) | |
| download | php-shirabe-94507ec2f265ca9400d64d42036ca1e8722e27da.tar.gz php-shirabe-94507ec2f265ca9400d64d42036ca1e8722e27da.tar.zst php-shirabe-94507ec2f265ca9400d64d42036ca1e8722e27da.zip | |
fix(event-dispatcher): let a PHP script reach the Composer object graph
A PHP script listener that walks the event it receives (Laravel's
Illuminate\Foundation\ComposerScripts::postAutoloadDump asks for
$event->getComposer()->getConfig()->get('vendor-dir')) aborted the run
with `unknown Rust handle 2`.
The event's getComposer/getIO answers register an entity in the R table
and hand back its rhandle, but ScriptRpcDispatcher resolved only rhandle
0 and the one event handle of the call in flight, so it could not serve a
method on a handle it had just minted itself. The R-table lookup
PluginRpcDispatcher already does is now dispatch_r_table_method, shared
by both.
The stubs that graph hands out extend and implement the real Composer
contracts (Composer\Package\PackageInterface and the rest), which live in
the Composer PHP runtime and not among the generated stubs or guards, so
execute_event_php_script loads that runtime the way the plugin and
command-class paths do.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed, 200 insertions, 52 deletions
diff --git a/crates/shirabe/src/event_dispatcher/event_dispatcher.rs b/crates/shirabe/src/event_dispatcher/event_dispatcher.rs index b22c7c0a..ece83533 100644 --- a/crates/shirabe/src/event_dispatcher/event_dispatcher.rs +++ b/crates/shirabe/src/event_dispatcher/event_dispatcher.rs @@ -1150,6 +1150,16 @@ try {{ .into() })?; + // The event stub the script receives hands out stubs of the Composer object graph, and + // those extend and implement the real Composer contracts (`PackageInterface` and the + // rest), which only the Composer PHP runtime carries. + let cache_dir = self + .composer() + .borrow_partial() + .get_config() + .borrow() + .get_str("cache-dir")?; + Self::ensure_composer_php_runtime(std::path::Path::new(&cache_dir))?; Self::ensure_script_autoloader()?; let rhandle = shirabe_php_rpc::alloc_rhandle(); let mut dispatcher = ScriptRpcDispatcher { @@ -1640,12 +1650,12 @@ try {{ /// Serves `CallRustMethod` requests issued by the PHP worker while a script-related call is in /// flight: Rust handle 0 is the runtime service endpoint (autoload lookups against the -/// Rust-side [`ClassLoader`]), and at most one live event handle is exposed per dispatched -/// call. +/// Rust-side [`ClassLoader`]), every other rhandle resolves through the R table, except the +/// one live event handle exposed per dispatched call. /// -/// TODO(plugin): this per-call scope stands in for persistent R-table registration; a stub -/// retained by the script beyond the call observes an unknown handle error instead of the -/// live object. +/// TODO(plugin): a script that stores the event stub beyond its own call observes an unknown +/// handle error afterwards; keeping events in the R table needs full proxying of the object +/// graph an event exposes, which does not exist yet. struct ScriptRpcDispatcher<'a> { loader: Option<ClassLoader>, event: Option<(u64, &'a dyn EventInterface)>, @@ -1691,10 +1701,11 @@ impl RustMethodDispatcher for ScriptRpcDispatcher<'_> { Some((event_rhandle, event)) if event_rhandle == rhandle => { dispatch_event_method(event, method_name) } - _ => Err(runtime_throw(format!( - "unknown Rust handle {rhandle} (script-event handles are scoped to a single \ - dispatched call)" - ))), + _ => crate::plugin::php_plugin_proxy::dispatch_r_table_method( + rhandle, + method_name, + &args, + ), } } } diff --git a/crates/shirabe/src/plugin/php_plugin_proxy.rs b/crates/shirabe/src/plugin/php_plugin_proxy.rs index 0cd36b18..9ebdbbff 100644 --- a/crates/shirabe/src/plugin/php_plugin_proxy.rs +++ b/crates/shirabe/src/plugin/php_plugin_proxy.rs @@ -340,52 +340,59 @@ impl RustMethodDispatcher for PluginRpcDispatcher<'_> { return dispatch_event_method(event, method_name); } - // The entity is cloned out so no table borrow is held while the handler runs (a - // handler that re-enters register_*_entity would otherwise panic on the RefCell). - let entity = R_TABLE.with(|table| table.borrow().get(&rhandle).cloned()); - if method_name == "__shirabeClone" { - return match entity { - Some(entity) => clone_entity(&entity), - None => Err(runtime_throw(format!("unknown Rust handle {rhandle}"))), - }; + dispatch_r_table_method(rhandle, method_name, &args) + } +} + +/// Serves a method call on an R-table entity, shared by every dispatcher: the child holds a +/// stub for an entity registered by an earlier call, and the handle outlives the call that +/// minted it. +pub(crate) fn dispatch_r_table_method( + rhandle: u64, + method_name: &str, + args: &[PluginValue], +) -> Result<PluginValue, PhpThrow> { + // The entity is cloned out so no table borrow is held while the handler runs (a + // handler that re-enters register_*_entity would otherwise panic on the RefCell). + let entity = R_TABLE.with(|table| table.borrow().get(&rhandle).cloned()); + if method_name == "__shirabeClone" { + return match entity { + Some(entity) => clone_entity(&entity), + None => Err(runtime_throw(format!("unknown Rust handle {rhandle}"))), + }; + } + if matches!(method_name, "__get" | "__set" | "__isset" | "__unset") { + return match entity { + Some(entity) => dispatch_property_access(&entity, method_name, args), + None => Err(runtime_throw(format!("unknown Rust handle {rhandle}"))), + }; + } + match entity { + Some(RustEntity::Io(io)) => dispatch_io_method(&io, method_name, args), + Some(RustEntity::Composer(composer)) => dispatch_composer_method(&composer, method_name), + Some(RustEntity::Config(config)) => dispatch_config_method(&config, method_name, args), + Some(RustEntity::DownloadManager(dm)) => { + dispatch_download_manager_method(&dm, method_name, args) } - if matches!(method_name, "__get" | "__set" | "__isset" | "__unset") { - return match entity { - Some(entity) => dispatch_property_access(&entity, method_name, &args), - None => Err(runtime_throw(format!("unknown Rust handle {rhandle}"))), - }; + Some(RustEntity::Filesystem(fs)) => dispatch_filesystem_method(&fs, method_name, args), + Some(RustEntity::InstallationManager(im)) => { + dispatch_installation_manager_method(&im, method_name, args) } - match entity { - Some(RustEntity::Io(io)) => dispatch_io_method(&io, method_name, &args), - Some(RustEntity::Composer(composer)) => { - dispatch_composer_method(&composer, method_name) - } - Some(RustEntity::Config(config)) => dispatch_config_method(&config, method_name, &args), - Some(RustEntity::DownloadManager(dm)) => { - dispatch_download_manager_method(&dm, method_name, &args) - } - Some(RustEntity::Filesystem(fs)) => dispatch_filesystem_method(&fs, method_name, &args), - Some(RustEntity::InstallationManager(im)) => { - dispatch_installation_manager_method(&im, method_name, &args) - } - Some(RustEntity::RepositoryManager(rm)) => { - dispatch_repository_manager_method(&rm, method_name) - } - Some(RustEntity::Repository(repository)) => { - dispatch_repository_method(&repository, method_name, &args) - } - Some(RustEntity::Package(package)) => { - dispatch_package_method(&package, method_name, &args) - } - Some(RustEntity::EventDispatcher(dispatcher)) => { - dispatch_event_dispatcher_method(&dispatcher, method_name, &args) - } - Some(RustEntity::Operation(operation)) => { - dispatch_operation_method(&operation, method_name, &args) - } - Some(RustEntity::Plugin(plugin)) => dispatch_plugin_method(&plugin, method_name), - None => Err(runtime_throw(format!("unknown Rust handle {rhandle}"))), + Some(RustEntity::RepositoryManager(rm)) => { + dispatch_repository_manager_method(&rm, method_name) + } + Some(RustEntity::Repository(repository)) => { + dispatch_repository_method(&repository, method_name, args) + } + Some(RustEntity::Package(package)) => dispatch_package_method(&package, method_name, args), + Some(RustEntity::EventDispatcher(dispatcher)) => { + dispatch_event_dispatcher_method(&dispatcher, method_name, args) + } + Some(RustEntity::Operation(operation)) => { + dispatch_operation_method(&operation, method_name, args) } + Some(RustEntity::Plugin(plugin)) => dispatch_plugin_method(&plugin, method_name), + None => Err(runtime_throw(format!("unknown Rust handle {rhandle}"))), } } diff --git a/crates/shirabe/tests/plugin/e2e_script_event_test.rs b/crates/shirabe/tests/plugin/e2e_script_event_test.rs new file mode 100644 index 00000000..5a0267d7 --- /dev/null +++ b/crates/shirabe/tests/plugin/e2e_script_event_test.rs @@ -0,0 +1,90 @@ +//! Script event E2E compatibility check: upstream Composer and Shirabe each run `dump-autoload` +//! in a fixture project whose `post-autoload-dump` script is a PHP static method, and the method +//! records what the `Composer\Script\Event` it receives exposes. Upstream has no test that reads +//! the Composer object graph out of an event from a script, so the whole fixture is +//! Shirabe-authored (`fixtures/e2e-script-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::php_worker::{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-script-event") +} + +struct Run { + exit_code: i32, + trace: String, + stdout: String, +} + +/// Runs `dump-autoload` in a fresh copy of the fixture and returns the exit code with what the +/// script wrote to its trace file and through the event's IO. +fn dump_autoload(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("dump-autoload") + .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(); + Run { + exit_code: output.status.code().unwrap_or(-1), + trace: std::fs::read_to_string(project.join("script-event-trace.txt")).unwrap_or_default(), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + } +} + +#[test] +fn test_script_event_object_graph_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 = dump_autoload("php", &[composer_bin.as_str()]); + let shirabe = dump_autoload(env!("CARGO_BIN_EXE_shirabe"), &[]); + + assert_eq!(0, upstream.exit_code, "upstream dump-autoload 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 the script cannot pass. + assert_eq!( + "\ +event=post-autoload-dump +dev=0 +root=shirabe/e2e-script-event +vendor-dir=vendor +bin-dir=bin +", + upstream.trace + ); + + let io_line = |run: &Run| -> String { + run.stdout + .lines() + .find(|line| line.starts_with("script-event: ")) + .unwrap_or_default() + .to_string() + }; + assert_eq!(io_line(&upstream), io_line(&shirabe)); + assert_eq!( + "script-event: event=post-autoload-dump dev=0 root=shirabe/e2e-script-event \ + vendor-dir=vendor bin-dir=bin", + io_line(&upstream) + ); +} diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-script-event/project/composer.json b/crates/shirabe/tests/plugin/fixtures/e2e-script-event/project/composer.json new file mode 100644 index 00000000..59fc8f13 --- /dev/null +++ b/crates/shirabe/tests/plugin/fixtures/e2e-script-event/project/composer.json @@ -0,0 +1,17 @@ +{ + "name": "shirabe/e2e-script-event", + "description": "E2E fixture project: record what a post-autoload-dump PHP script sees on its Event.", + "repositories": [ + { + "packagist.org": false + } + ], + "autoload": { + "psr-4": { + "Shirabe\\E2e\\": "src/" + } + }, + "scripts": { + "post-autoload-dump": "Shirabe\\E2e\\ScriptEventRecorder::postAutoloadDump" + } +} diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-script-event/project/src/ScriptEventRecorder.php b/crates/shirabe/tests/plugin/fixtures/e2e-script-event/project/src/ScriptEventRecorder.php new file mode 100644 index 00000000..84fa0da9 --- /dev/null +++ b/crates/shirabe/tests/plugin/fixtures/e2e-script-event/project/src/ScriptEventRecorder.php @@ -0,0 +1,22 @@ +<?php + +namespace Shirabe\E2e; + +use Composer\Script\Event; + +class ScriptEventRecorder +{ + public static function postAutoloadDump(Event $event): void + { + $composer = $event->getComposer(); + $lines = [ + 'event=' . $event->getName(), + 'dev=' . ($event->isDevMode() ? '1' : '0'), + 'root=' . $composer->getPackage()->getName(), + 'vendor-dir=' . basename($composer->getConfig()->get('vendor-dir')), + 'bin-dir=' . basename($composer->getConfig()->get('bin-dir')), + ]; + $event->getIO()->write('script-event: ' . implode(' ', $lines)); + file_put_contents(__DIR__ . '/../script-event-trace.txt', implode("\n", $lines) . "\n"); + } +} diff --git a/crates/shirabe/tests/plugin/main.rs b/crates/shirabe/tests/plugin/main.rs index d8defcbc..000ca3a9 100644 --- a/crates/shirabe/tests/plugin/main.rs +++ b/crates/shirabe/tests/plugin/main.rs @@ -13,6 +13,7 @@ mod e2e_installers_test; mod e2e_normalize_test; mod e2e_package_event_test; mod e2e_script_command_test; +mod e2e_script_event_test; mod plugin_installer_test; mod subscriber_test; mod value_round_trip_test; |
