diff --git a/app/Extension/ModuleManager.php b/app/Extension/ModuleManager.php index e26db70a..f599414a 100644 --- a/app/Extension/ModuleManager.php +++ b/app/Extension/ModuleManager.php @@ -1428,11 +1428,22 @@ class ModuleManager implements ModuleManagerInterface return null; } - try { - require_once $moduleFile; + $namespace = $this->convertDirectoryToNamespace($moduleName); + $moduleClass = "Modules\\{$namespace}\\Module"; - $namespace = $this->convertDirectoryToNamespace($moduleName); - $moduleClass = "Modules\\{$namespace}\\Module"; + try { + // 같은 모듈의 활성 디렉토리 사본이 이미 로드돼 있으면 `_bundled`/`_pending` + // 파일을 그대로 require 할 수 없다 — 같은 FQN 을 두 번 선언하게 되어 + // "Cannot declare class ..., because the name is already in use" 로 죽는다. + // 이것은 Error 라 아래 catch(\Exception) 에 걸리지 않아 프로세스가 그대로 종료된다. + // + // 파일 내용을 임시 클래스명으로 eval 해 번들 쪽 메타데이터를 얻는다 + // (loadModuleFromDirectory / getFreshModuleInstance 와 동일한 처리). + if (class_exists($moduleClass, false)) { + return $this->evalFreshModule($moduleFile, $moduleClass, dirname($moduleFile)); + } + + require_once $moduleFile; if (class_exists($moduleClass)) { $module = new $moduleClass; @@ -1440,7 +1451,7 @@ class ModuleManager implements ModuleManagerInterface return $module; } } - } catch (\Exception $e) { + } catch (\Throwable $e) { Log::debug("Failed to load bundled module instance for {$moduleName}: ".$e->getMessage()); } diff --git a/app/Extension/PluginManager.php b/app/Extension/PluginManager.php index 6126e2e8..54fdce84 100644 --- a/app/Extension/PluginManager.php +++ b/app/Extension/PluginManager.php @@ -1429,11 +1429,21 @@ class PluginManager implements PluginManagerInterface return null; } - try { - require_once $pluginFile; + $namespace = $this->convertDirectoryToNamespace($pluginName); + $pluginClass = "Plugins\\{$namespace}\\Plugin"; - $namespace = $this->convertDirectoryToNamespace($pluginName); - $pluginClass = "Plugins\\{$namespace}\\Plugin"; + try { + // 같은 플러그인의 활성 디렉토리 사본이 이미 로드돼 있으면 `_bundled`/`_pending` + // 파일을 그대로 require 할 수 없다 — 같은 FQN 을 두 번 선언하게 되어 + // "Cannot declare class ..., because the name is already in use" 로 죽는다. + // 이것은 Error 라 아래 catch(\Exception) 에 걸리지 않아 프로세스가 그대로 종료된다. + // + // 파일 내용을 임시 클래스명으로 eval 해 번들 쪽 메타데이터를 얻는다. + if (class_exists($pluginClass, false)) { + return $this->evalFreshPlugin($pluginFile, $pluginClass, dirname($pluginFile)); + } + + require_once $pluginFile; if (class_exists($pluginClass)) { $plugin = new $pluginClass; @@ -1441,7 +1451,7 @@ class PluginManager implements PluginManagerInterface return $plugin; } } - } catch (\Exception $e) { + } catch (\Throwable $e) { Log::debug("Failed to load bundled plugin instance for {$pluginName}: ".$e->getMessage()); } diff --git a/tests/Unit/Extension/BundledInstanceRedeclarationGuardTest.php b/tests/Unit/Extension/BundledInstanceRedeclarationGuardTest.php new file mode 100644 index 00000000..d95f87b9 --- /dev/null +++ b/tests/Unit/Extension/BundledInstanceRedeclarationGuardTest.php @@ -0,0 +1,165 @@ +tempRoot = sys_get_temp_dir().DIRECTORY_SEPARATOR.'g7-redeclare-guard-'.bin2hex(random_bytes(6)); + } + + protected function tearDown(): void + { + if (is_dir($this->tempRoot)) { + File::deleteDirectory($this->tempRoot); + } + + parent::tearDown(); + } + + /** + * 활성/번들 두 사본을 갖는 확장 픽스처를 만든다. + * + * 두 사본은 같은 FQN 을 쓰되 `origin()` 반환값이 달라, 어느 쪽을 읽었는지 구분된다. + * + * @param string $kind `modules` 또는 `plugins` + * @param string $identifier 확장 디렉토리명 (vendor-name) + * @param string $namespace 네임스페이스 조각 (Vendor\Name) + * @return string 활성 사본 파일 경로 + */ + private function writeFixture(string $kind, string $identifier, string $namespace): string + { + $isModule = $kind === 'modules'; + $rootNs = $isModule ? 'Modules' : 'Plugins'; + $base = $isModule ? 'App\Extension\AbstractModule' : 'App\Extension\AbstractPlugin'; + $file = $isModule ? 'module.php' : 'plugin.php'; + $class = $isModule ? 'Module' : 'Plugin'; + + $activePath = $this->tempRoot."/{$kind}/{$identifier}/{$file}"; + $bundledPath = $this->tempRoot."/{$kind}/_bundled/{$identifier}/{$file}"; + + foreach (['active' => $activePath, 'bundled' => $bundledPath] as $origin => $path) { + File::ensureDirectoryExists(dirname($path)); + File::put($path, <<getProperty($property); + $prop->setAccessible(true); + $prop->setValue($manager, $path); + } + + public function test_module_bundled_instance_does_not_redeclare_active_copy(): void + { + $activePath = $this->writeFixture('modules', 'g7guard-module_fixture', 'G7guard\\ModuleFixture'); + + // 부팅이 활성 사본을 로드한 상태를 재현한다. + require_once $activePath; + $this->assertTrue(class_exists(Module::class, false)); + + $manager = app(ModuleManager::class); + $this->repointRoot($manager, 'modulesPath', $this->tempRoot.'/modules'); + + $method = (new ReflectionClass($manager))->getMethod('tryLoadModuleInstance'); + $method->setAccessible(true); + + // 가드가 없으면 여기서 프로세스가 fatal 로 종료된다 (Error 라 catch 로 못 막는다). + $module = $method->invoke($manager, 'g7guard-module_fixture', false); + + $this->assertInstanceOf(ModuleInterface::class, $module, '번들 인스턴스를 얻지 못했습니다.'); + $this->assertSame( + 'bundled', + $module->origin(), + '활성 사본을 그대로 돌려줬습니다 — 번들 쪽 메타데이터를 읽어야 합니다.', + ); + } + + public function test_plugin_bundled_instance_does_not_redeclare_active_copy(): void + { + $activePath = $this->writeFixture('plugins', 'g7guard-plugin_fixture', 'G7guard\\PluginFixture'); + + require_once $activePath; + $this->assertTrue(class_exists(Plugin::class, false)); + + $manager = app(PluginManager::class); + $this->repointRoot($manager, 'pluginsPath', $this->tempRoot.'/plugins'); + + $method = (new ReflectionClass($manager))->getMethod('tryLoadPluginInstance'); + $method->setAccessible(true); + + $plugin = $method->invoke($manager, 'g7guard-plugin_fixture', false); + + $this->assertInstanceOf(PluginInterface::class, $plugin, '번들 인스턴스를 얻지 못했습니다.'); + $this->assertSame( + 'bundled', + $plugin->origin(), + '활성 사본을 그대로 돌려줬습니다 — 번들 쪽 메타데이터를 읽어야 합니다.', + ); + } +}