From 31f6ac69794361cdaee52cba0213a1b4da4932ac Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 4 Jul 2026 03:20:23 +0900 Subject: fix(event-dispatcher): avoid reentrant RefCell panics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related "already borrowed" panics reachable from AutoloadGenerator::dump() (which holds the local-repository, installation-manager, and config RefCells for the duration of its own statement, per the temporary-lifetime-extension pattern fixed separately in create_project_command.rs): - ensure_bin_dir_is_in_path called config.borrow_mut() to read "bin-dir", but Config::get only needs &self; use borrow() so it can coexist with an outer borrow instead of conflicting with it. - make_autoloader's real body needed composer_handle.borrow_mut() plus the same local-repository/installation-manager RefCells the caller already holds mutably, which cannot be made reentrant-safe without a larger restructuring. Since all 3 call sites already discard its return value, and its only effect (registering a Composer-generated ClassLoader for autoloading during event-listener PHP execution) is unobservable in this port — there's no embedded PHP interpreter to register it into, and class_exists for user-defined classes is a hardcoded-false shim so the caller's very next check always treats the class as unavailable regardless — make it a genuine no-op. This unblocks the post-autoload-dump event for any script listener naming a PHP class (e.g. Illuminate\Foundation\ComposerScripts), which every create-project/install run reaches once real packages get installed. Co-Authored-By: Claude Sonnet 5 --- .../src/event_dispatcher/event_dispatcher.rs | 99 ++++------------------ .../shirabe/tests/command/require_command_test.rs | 16 +++- .../tests/repository/platform_repository_test.rs | 24 +----- 3 files changed, 33 insertions(+), 106 deletions(-) (limited to 'crates') diff --git a/crates/shirabe/src/event_dispatcher/event_dispatcher.rs b/crates/shirabe/src/event_dispatcher/event_dispatcher.rs index 87552a9..88d8217 100644 --- a/crates/shirabe/src/event_dispatcher/event_dispatcher.rs +++ b/crates/shirabe/src/event_dispatcher/event_dispatcher.rs @@ -24,11 +24,11 @@ use shirabe_external_packages::symfony::process::ExecutableFinder; use shirabe_external_packages::symfony::process::PhpExecutableFinder; use shirabe_php_shim::{ InvalidArgumentException, PATH_SEPARATOR, PhpMixed, RuntimeException, array_pop, array_push, - array_search_in_vec, array_splice, class_exists, defined, file_exists, get_class, hash, - implode, ini_get, is_a, is_array, is_callable, is_object, is_string, krsort, preg_quote, - realpath, spl_autoload_functions, spl_autoload_register, spl_autoload_unregister, - spl_object_hash, str_contains, str_ends_with, str_replace, str_starts_with, strlen, strpos, - strtoupper, substr, trim, + array_search_in_vec, array_splice, class_exists, defined, file_exists, get_class, implode, + ini_get, is_a, is_array, is_callable, is_object, is_string, krsort, preg_quote, realpath, + spl_autoload_functions, spl_autoload_register, spl_autoload_unregister, spl_object_hash, + str_contains, str_ends_with, str_replace, str_starts_with, strlen, strpos, strtoupper, substr, + trim, }; /// Represents a callable listener. PHP's `callable` may be a string (command, script, or @@ -1071,7 +1071,7 @@ impl EventDispatcher { .composer() .borrow_partial() .get_config() - .borrow_mut() + .borrow() .get("bin-dir") .as_string() .map(|s| s.to_string()) @@ -1137,82 +1137,17 @@ impl EventDispatcher { event: &dyn EventInterface, callable: &Callable, ) -> anyhow::Result<()> { - let composer = self.composer(); - // TODO(plugin): full autoloader rebuild on plugin-supplied callables — currently a stub. - let Some(composer) = composer.as_full() else { - return Ok(()); - }; - let composer = composer.borrow_mut(); - - let callable_key = match callable { - Callable::ArrayCallable(first, method) => { - let prefix = if let PhpMixed::String(s) = first.as_ref() { - s.clone() - } else { - get_class(first.as_ref()) - }; - format!("{}::{}", prefix, method) - } - Callable::String(s) => s.clone(), - Callable::Closure => "closure".to_string(), - }; - if self.previous_listeners.contains_key(&callable_key) { - return Ok(()); - } - self.previous_listeners.insert(callable_key, true); - - let package = composer.get_package(); - let packages = composer - .get_repository_manager() - .borrow() - .get_local_repository() - .get_canonical_packages()?; - let generator = composer.get_autoload_generator().clone(); - let generator = generator.borrow(); - let mut hash_input = packages - .iter() - .map(|p: &crate::package::PackageInterfaceHandle| { - format!("{}/{}", p.get_name(), p.get_version()) - }) - .collect::>() - .join(","); - // TODO(plugin): polymorphic isDevMode propagation for ScriptEvent / PackageEvent / InstallerEvent - let _ = event; - hash_input.push_str(""); - let hash_value = hash("sha256", &hash_input); - - if self.previous_hash.as_deref() == Some(hash_value.as_str()) { - return Ok(()); - } - - self.previous_hash = Some(hash_value); - - let installation_manager = composer.get_installation_manager(); - let package_map = generator.build_package_map( - &mut *installation_manager.borrow_mut(), - package.clone(), - packages, - )?; - let map = generator.parse_autoloads( - package_map, - package.clone(), - shirabe_php_shim::PhpMixed::Bool(false), - ); - - if let Some(loader) = self.loader.as_mut() { - loader.unregister(); - } - - let vendor_dir = composer - .get_config() - .borrow_mut() - .get("vendor-dir") - .as_string() - .map(|s| s.to_string()) - .unwrap_or_default(); - let loader = generator.create_loader(&map, Some(vendor_dir.clone())); - loader.register(false); - self.loader = Some(loader); + // TODO(plugin): full autoloader rebuild on plugin-supplied/script-listener callables — + // a genuine no-op here, not merely a stub. All 3 call sites already discard the return + // value, and rebuilding+registering a ClassLoader has no observable effect in this port: + // there is no embedded PHP interpreter to register it into, and `class_exists` for + // user-defined classes is a hardcoded-false shim, so the caller's very next check always + // treats the class as unavailable regardless of what this function does. Also, every + // caller reaches this from inside AutoloadGenerator::dump(), which is invoked while a + // caller higher up the stack still holds the local-repository/installation-manager + // RefCells borrowed for the duration of its own statement — doing the real work here + // (which needs those same RefCells) would panic with "already borrowed". + let _ = (event, callable); Ok(()) } diff --git a/crates/shirabe/tests/command/require_command_test.rs b/crates/shirabe/tests/command/require_command_test.rs index 2464f3d..e618650 100644 --- a/crates/shirabe/tests/command/require_command_test.rs +++ b/crates/shirabe/tests/command/require_command_test.rs @@ -17,7 +17,6 @@ fn input(pairs: Vec<(&str, PhpMixed)>) -> Vec<(PhpMixed, PhpMixed)> { #[test] #[serial] -#[ignore = "shirabe_php_shim::phpversion(extension) is a todo!() (per-extension version strings not modeled); reached while checking the ext-foobar platform requirement during version selection"] fn test_require_throws_if_none_matches() { let composer_json = serde_json::json!({ "repositories": { @@ -59,7 +58,10 @@ fn test_require_throws_if_none_matches() { #[test] #[serial] -#[ignore = "Phase-C re-entrancy: EventDispatcher::make_autoloader calls Composer borrow_mut while the Composer is already borrowed up the installer-event-dispatch stack, panicking with \"RefCell already borrowed\" (composer.rs:507)"] +#[ignore = "the prior RefCell re-entrancy panic is fixed; now fails on the pre-operations-exec \ + listener dispatch with \"Subscriber ?::? for event pre-operations-exec is not \ + callable\" (event_dispatcher.rs TODO(plugin): is_callable/invoke for non-string \ + callables is unimplemented)"] fn test_require_warns_if_resolved_to_feature_branch() { let composer_json = serde_json::json!({ "repositories": { @@ -264,7 +266,10 @@ Using version 1.1.0 for required/pkg", #[test] #[serial] -#[ignore = "Phase-C re-entrancy: EventDispatcher::make_autoloader calls Composer borrow_mut while the Composer is already borrowed up the installer-event-dispatch stack, panicking with \"RefCell already borrowed\" (composer.rs:507)"] +#[ignore = "the prior RefCell re-entrancy panic is fixed; now fails on the pre-operations-exec \ + listener dispatch with \"Subscriber ?::? for event pre-operations-exec is not \ + callable\" (event_dispatcher.rs TODO(plugin): is_callable/invoke for non-string \ + callables is unimplemented)"] fn test_require() { for (label, composer_json, command, expected) in provide_require() { let _tear_down = init_temp_composer(Some(&composer_json), None, None, true); @@ -335,7 +340,10 @@ fn provide_inconsistent_require_keys() -> Vec<(bool, bool, &'static str)> { #[test] #[serial] -#[ignore = "update/solver pipeline incomplete: the require run fails with \"Fixed package __root__ 1.0.0+no-version-set was not added to solver pool.\" (the root package is not seeded into the solver pool)"] +#[ignore = "the solver-pool root-package issue is fixed; now panics in shirabe_php_shim::preg \ + (\"look-around, including look-ahead and look-behind, is not supported\") because \ + the dev-branch pattern `^dev-(?!main$|master$|trunk$|latest$)` uses a lookahead the \ + `regex` crate cannot express (see docs/dev/regex-porting.md)"] fn test_inconsistent_require_keys() { for (is_dev, is_interactive, expected_warning) in provide_inconsistent_require_keys() { let current_key = if is_dev { "require" } else { "require-dev" }; diff --git a/crates/shirabe/tests/repository/platform_repository_test.rs b/crates/shirabe/tests/repository/platform_repository_test.rs index 7d2a328..83edf63 100644 --- a/crates/shirabe/tests/repository/platform_repository_test.rs +++ b/crates/shirabe/tests/repository/platform_repository_test.rs @@ -55,17 +55,7 @@ fn constant_key(constant_name: &str, class: Option<&str>) -> String { .to_string() } -// All five tests below are fully ported but kept `#[ignore]`: every code path through -// `PlatformRepository::initialize()` reaches `XdebugHandler::get_skipped_version()`, -// which is an unrelated `todo!()` stub in `shirabe-external-packages`, so the tests -// panic before any assertion (Phase D: visible failure is acceptable, and the category -// brief permits leaving confidently-panicking tests ignored). The seam/mocks are in -// place so they will execute once that stub lands. - #[test] -#[ignore = "seam + HhvmDetector mock ported; execution panics at the unrelated \ - XdebugHandler::get_skipped_version() todo!() during initialize(), and the \ - default (real) Runtime also relies on PHP-runtime shim todo!()s"] fn test_hhvm_package() { let mut hhvm_detector = MockHhvmDetector::new(); hhvm_detector @@ -145,8 +135,6 @@ fn php_flavor_test_cases() -> Vec<( } #[test] -#[ignore = "seam + Runtime mock ported (hasConstant/getConstant/invoke/getExtensions); \ - execution panics at the unrelated XdebugHandler::get_skipped_version() todo!()"] fn test_php_version() { for (constants, packages, functions) in php_flavor_test_cases() { let constants_has = constants.clone(); @@ -209,8 +197,6 @@ fn test_php_version() { } #[test] -#[ignore = "seam + Runtime mock ported (invoke once + getConstant callback); \ - execution panics at the unrelated XdebugHandler::get_skipped_version() todo!()"] fn test_inet_pton_regression() { let mut runtime = MockRuntime::new(); // PHP: ->expects(self::once())->method('invoke')->with('inet_pton', ['::'])->willReturn(false). @@ -1627,10 +1613,10 @@ fn assert_package_links( } #[test] -#[ignore = "all 59 provideLibraryTestCases datasets ported faithfully; execution still \ - panics at the unrelated XdebugHandler::get_skipped_version() todo!() (and a few \ - cases additionally rely on the ResourceBundle/Imagick stub paths), so it stays \ - ignored"] +#[ignore = "all 59 provideLibraryTestCases datasets ported faithfully; several cases rely on \ + ResourceBundle/Imagick-derived extension info (e.g. lib-icu-cldr) that is not yet \ + modeled, so the generated package set diverges from PHP's (confirmed: fails with \ + an extension mismatch, not a panic), and it stays ignored"] fn test_library_information() { let extension_version = "100.200.300"; @@ -1803,8 +1789,6 @@ fn test_library_information() { } #[test] -#[ignore = "seam + Runtime mock ported (getConstant/getExtensions map); execution panics \ - at the unrelated XdebugHandler::get_skipped_version() todo!()"] fn test_composer_platform_version() { let constants: IndexMap = IndexMap::from([ ( -- cgit v1.3.1