From 327c4332503336d808f12368245305dcb0a2aa32 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Tue, 21 Jul 2026 17:22:08 +0900 Subject: [PATCH] =?UTF-8?q?fix(core):=20=EB=B2=88=EB=93=A4=20=EC=9D=B8?= =?UTF-8?q?=EC=8A=A4=ED=84=B4=EC=8A=A4=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=9E=AC=EC=84=A0=EC=96=B8?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=94=84=EB=A1=9C=EC=84=B8=EC=8A=A4?= =?UTF-8?q?=EA=B0=80=20=EC=A3=BD=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_bundled`(또는 `_pending`) 확장의 상세 정보를 읽는 두 메서드가 파일을 가드 없이 require 했다. 같은 확장의 활성 디렉토리 사본이 이미 로드돼 있으면 두 파일은 경로가 달라 중복 제거가 통하지 않으므로 같은 클래스명을 두 번 선언하게 되고, 그 자리에서 죽는다. Cannot declare class Modules\Sirsoft\Board\Module, because the name is already in use 특히 위험한 것은 클래스 재선언이 Exception 이 아니라 Error 라는 점이다. 두 메서드 모두 try/catch 로 감싸여 있어 안전해 보이지만 실제로는 잡히지 않아 프로세스가 통째로 종료된다. 운영에서는 해당 요청이 죽고, 테스트에서는 요약 줄도 없이 스위트가 중단된다. 같은 파일의 다른 로드 지점들은 이미 "이미 선언돼 있으면 임시 클래스명으로 로드" 패턴을 갖고 있었고 이 두 곳만 빠져 있었다. 기존 패턴을 그대로 적용하고, 놓치던 Error 까지 잡도록 catch 범위를 넓혔다. 회귀 테스트는 처음에 실제 번들 확장으로 짰다가 폐기했다 — 테스트 부팅이 번들 쪽을 먼저 로드하므로 같은 경로를 다시 읽는 셈이라 위험한 조합이 만들어지지 않았고, 가드를 지워도 통과했다. 활성/번들 두 사본을 갖는 임시 확장 루트를 만들어 조합을 직접 재현하고, 어느 쪽 파일을 읽었는지까지 단언하도록 다시 작성했다. 가드를 제거하면 예측한 메시지 그대로 죽는다. 이 경로가 앞서 관측된 스위트 중단과 같은 사건인지는 단정하지 않는다. 메시지와 메커니즘은 일치하지만 그 중단은 이후 재현되지 않아 수정 전후 비교가 불가능했다. 검증: 신규 2건 + 확장 매니저 회귀 120건 green, 인스톨러·업그레이드 통합 581건 green. --- app/Extension/ModuleManager.php | 21 ++- app/Extension/PluginManager.php | 20 ++- .../BundledInstanceRedeclarationGuardTest.php | 165 ++++++++++++++++++ 3 files changed, 196 insertions(+), 10 deletions(-) create mode 100644 tests/Unit/Extension/BundledInstanceRedeclarationGuardTest.php 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(), + '활성 사본을 그대로 돌려줬습니다 — 번들 쪽 메타데이터를 읽어야 합니다.', + ); + } +}