diff --git a/AGENTS.md b/AGENTS.md index 624f6cf9..c7ab3daa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -692,6 +692,8 @@ Laravel 은 `bootstrap/cache/packages.php` 가 있으면 stale 여부를 검사 | 정리 로직을 호출부마다 `@unlink` 로 복제 | 헬퍼 단일 지점 — `clearAllCaches()` 도 같은 헬퍼를 쓴다 | | 자식 프로세스 보호를 부모 코드에만 두기 | 부모는 이미 배포된 옛 코드일 수 있다 — 새 버전의 `bootstrap/app.php` 가 `G7_UPDATE_IN_PROGRESS` 를 보고 스스로 비운다(App\ 클래스 미참조·실패 무시) | | 코어 버전 판정에서 프로세스 env `APP_VERSION` 을 무조건 우선 | env 우선은 `CoreUpdateContext::isInProgress()` 인 프로세스 트리 안에서만 — 업데이트 전에 뜬 `artisan serve`·큐 워커는 옛 값을 물고 있다 | +| 업데이트 커맨드 argv 판정을 SAPI 게이트 없이 두기 | argv 는 명령줄 SAPI(`cli`·`phpdbg`)에서만 읽는다 — CGI/FPM 은 `register_argc_argv=On` 이면 `$_SERVER['argv']` 를 쿼리스트링에서 채워(`?x+core:update`) 비인증 웹 요청이 업데이트 트리로 판정되고, 자가 치유가 요청마다 매니페스트를 지운다. env 플래그 채널은 웹에서 주입할 수 없으므로 그대로 둔다 | +| 매니페스트 삭제 실패를 `@unlink` 로 삼키기 | `clear()` 가 지우지 못한 경로를 돌려주고 spawn 직전 호출부가 업그레이드 로그에 경고로 남긴다 — 권한·소유권 불일치면 자식도 같은 이유로 실패해 증상은 제보와 같고, 이 경고가 원인을 가리키는 유일한 흔적이다 | | 업데이트 트리 판정을 지점마다 다시 작성 | `App\Support\CoreUpdateContext` 단일 SSoT — `CoreServiceProvider::isCoreUpdateInProgress()` 도 위임이다. `bootstrap/app.php` 의 복제본은 부팅 전이라 불가피한 예외이며 주석으로 상호 참조한다 | | 자동 비활성화 로그의 `core_version` 을 `config('app.version')` 으로 적기 | 판정과 같은 `CoreVersionChecker::getCoreVersion()` — 로그와 판정 근거가 갈리면 운영자가 원인을 특정할 수 없다 | | "이미 있으니 건너뛴다" 분기(인스톨러 vendor 재사용)가 산출물의 출처를 보지 않음 | `installed.json` 의 `dev`/`dev-package-names` 로 출처를 보고 경고 카드·로그를 남기며, 재사용 경로에서도 컴파일 캐시를 정리한다 | diff --git a/CHANGELOG.md b/CHANGELOG.md index 05e463e5..c5a3cdd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,10 +32,11 @@ - 검색엔진 봇에게 대신 그려 주는 페이지가 주소의 물음표 뒤 값만 바꿔 계속 요청하면 매번 새로 그려지고 그 결과가 무한정 저장되던 문제를 수정했습니다. 봇으로 위장한 요청이 서버 부하와 저장 공간 증가로 이어질 수 있었습니다. 이제 한 IP 가 분당 일정 횟수를 넘겨 새 페이지를 요청하면 그 초과분에는 일반 페이지를 주고, 저장 개수에도 상한을 둡니다. 상한값은 서버 설정으로 바꿀 수 있습니다. - 관리자 SEO 통계와 `seo:stats` 명령이 항상 0 으로 표시되던 문제를 수정했습니다. 캐시 적중·미적중이 기록되지 않고 있었습니다. - 확장의 스크립트·스타일 파일을 읽지 못하는 상태가 되면 그 확장의 자산이 빠진 결과가 저장되어, 파일 문제가 풀린 뒤에도 확장을 다시 설치하거나 설정을 바꿀 때까지 계속 빠진 채로 남던 문제를 수정했습니다. 이제 읽지 못한 확장이 있으면 그 결과를 저장하지 않아 원인이 사라지는 즉시 정상으로 돌아오며, 어느 확장을 읽지 못했는지 서버 기록에 남습니다. -- 개발용 패키지가 포함된 상태로 설치된 사이트에서 코어 업데이트가 업그레이드 스텝 단계에서 「Class ... not found」 오류와 「수동 재개」 안내로 멈추던 문제를 수정했습니다. 이전 설치본이 만든 패키지 목록 캐시가 새 `vendor/` 로 교체된 뒤에도 남아 있던 것이 원인이며, 이제 업데이트는 스텝 실행 전에 그 캐시를 함께 비웁니다. (sir.kr 커뮤니티에서 제보해주신 내용입니다.) +- 개발용 패키지가 포함된 상태로 설치된 사이트에서 코어 업데이트가 업그레이드 스텝 단계에서 「Class ... not found」 오류와 「수동 재개」 안내로 멈추던 문제를 수정했습니다. 이전 설치본이 만든 패키지 목록 캐시가 새 `vendor/` 로 교체된 뒤에도 남아 있던 것이 원인이며, 이제 업데이트는 스텝 실행 전에 그 캐시를 함께 비웁니다. 비우지 못한 파일이 있으면 업데이트 로그에 남겨 권한 문제를 바로 확인할 수 있습니다. (sir.kr 커뮤니티에서 제보해주신 내용입니다.) - 이미 배포된 이전 버전(7.0.9·7.0.10)에서 업데이트를 시작하는 경우에도 같은 중단이 나지 않도록, 새 버전의 스텝 프로세스가 시작할 때 이전 패키지 목록 캐시를 스스로 정리합니다. - `php artisan serve` 나 큐 워커처럼 오래 떠 있는 프로세스가 기동 시점의 버전 값을 계속 쓰면서, 업데이트 직후 확장이 「코어 버전 미달」로 잘못 비활성화되던 문제를 수정했습니다. 업데이트 진행 중이 아닐 때는 프로세스 환경값이 아니라 설정의 버전을 기준으로 판정합니다. - 코어 업데이트가 마지막 정리 단계에서 「Target class [...] does not exist」 오류로 실패하고 백업으로 되돌아가던 문제를 수정했습니다. 설정 캐시를 다시 만드는 과정이 내부적으로 띄우는 일회용 애플리케이션이 그 뒤의 모든 작업까지 넘겨받은 채로 남아 있던 것이 원인입니다. +- 업데이트 진행 중 판정이 명령줄에서 실행될 때만 커맨드 이름을 참고하도록 좁혔습니다. 일부 PHP 설정(`register_argc_argv` 활성)에서는 웹 주소의 쿼리만으로 업데이트가 진행 중인 것처럼 보이게 할 수 있었습니다. ## [7.0.10] - 2026-09-06 diff --git a/app/Console/Commands/Core/CoreUpdateCommand.php b/app/Console/Commands/Core/CoreUpdateCommand.php index 05a0e6c6..47565d05 100644 --- a/app/Console/Commands/Core/CoreUpdateCommand.php +++ b/app/Console/Commands/Core/CoreUpdateCommand.php @@ -919,7 +919,17 @@ class CoreUpdateCommand extends Command // dev composer(require-dev 전이 의존성 laravel/mcp 의 McpServiceProvider 포함)로 깔렸다면 // 자식은 새 vendor 에 없는 provider 를 new 하다 부팅 단계에서 죽는다 (7.0.9→7.0.10 실사례). // 부모 메모리의 매니페스트는 영향받지 않고, Step 11 이 package:discover 로 다시 만든다. - PackageManifestCacheHelper::clear(); + // + // 지우지 못한 파일(권한·소유권 불일치)은 로그에 남긴다. 그 상태면 자식의 자가 치유도 같은 + // 권한으로 실패하므로 증상은 이전 설치본 provider 의 「Class not found」 그대로이고, 이 기록이 + // 권한이 원인이라는 유일한 흔적이다. + $remainingManifests = PackageManifestCacheHelper::clear(); + if ($remainingManifests !== []) { + $manifestWarning = 'spawn 직전 패키지 매니페스트 삭제 실패 — 자식이 이전 설치본의 provider 목록으로 부팅할 수 있습니다 (권한·소유권 확인): ' + .implode(', ', $remainingManifests); + $log($manifestWarning); + $this->warn($manifestWarning); + } $process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env); if (! is_resource($process)) { diff --git a/app/Support/CoreUpdateContext.php b/app/Support/CoreUpdateContext.php index bafeda2b..9ce03b17 100644 --- a/app/Support/CoreUpdateContext.php +++ b/app/Support/CoreUpdateContext.php @@ -15,6 +15,12 @@ namespace App\Support; * `$env` 로 전파되지만, `variables_order` 에 `E` 가 없는 호스팅에서는 `$_ENV` 가 비어 있을 수 * 있어 argv 보조 판정을 함께 둔다. * + * argv 채널은 명령줄 SAPI 에서만 읽는다. CGI/FPM 은 `register_argc_argv=On` 이면 `$_SERVER['argv']` + * 를 쿼리스트링을 `+` 로 쪼갠 값으로 채우므로(`GET /?x+core:update` → `argv[1] === 'core:update'`), + * 그 SAPI 에서 argv 를 믿으면 비인증 웹 요청이 업데이트 트리로 판정되어 `bootstrap/app.php` 의 + * 자가 치유가 요청마다 매니페스트를 지운다. env 플래그 채널은 웹 요청으로 주입할 수 없어 SAPI 와 + * 무관하게 인정한다 — 웹 요청 안에서 시작하는 업데이트 흐름은 그 플래그를 프로세스 안에서 세운다. + * * 주의: `bootstrap/app.php` 의 자가 치유 블록은 부팅 전이라 이 클래스를 참조할 수 없어 * 같은 판정을 순수 PHP 로 복제한다. 조건을 바꾸면 그쪽도 함께 고친다. */ @@ -25,22 +31,32 @@ final class CoreUpdateContext */ private const UPDATE_COMMANDS = ['core:update', 'core:execute-upgrade-steps']; + /** + * argv 보조 판정을 신뢰하는 SAPI — 명령줄 프로세스만. 웹 SAPI 의 argv 는 쿼리스트링에서 채워질 수 있다. + */ + private const CONSOLE_SAPIS = ['cli', 'phpdbg']; + /** * 현재 프로세스가 코어 업데이트 트리(부모 core:update 또는 그 spawn 자식) 안에 있는지 판정합니다. * * 판정 조건 (OR): * 1. 환경변수 `G7_UPDATE_IN_PROGRESS=1` — 부모가 시작 시 설정하고 spawn 자식에 전파 - * 2. artisan 커맨드 이름이 `core:update` / `core:execute-upgrade-steps` — 1 이 전파되지 - * 않은 극단 상황 대비 보조 판정 + * 2. 명령줄 SAPI 에서 artisan 커맨드 이름이 `core:update` / `core:execute-upgrade-steps` — + * 1 이 전파되지 않은 극단 상황 대비 보조 판정. 웹 SAPI 에서는 읽지 않는다 * + * @param string|null $sapi 판정에 쓸 SAPI 이름. 기본은 `PHP_SAPI` 이며, 테스트가 웹 SAPI 를 주입할 때만 지정 * @return bool 업데이트 트리 안이면 true */ - public static function isInProgress(): bool + public static function isInProgress(?string $sapi = null): bool { if (self::hasEnvFlag()) { return true; } + if (! in_array($sapi ?? PHP_SAPI, self::CONSOLE_SAPIS, true)) { + return false; + } + $argv = $_SERVER['argv'] ?? []; return in_array($argv[1] ?? '', self::UPDATE_COMMANDS, true); diff --git a/app/Support/PackageManifestCacheHelper.php b/app/Support/PackageManifestCacheHelper.php index dcd49d0b..204d3729 100644 --- a/app/Support/PackageManifestCacheHelper.php +++ b/app/Support/PackageManifestCacheHelper.php @@ -25,22 +25,35 @@ class PackageManifestCacheHelper * 경로는 `Application` 의 게터로 읽어 `APP_PACKAGES_CACHE`/`APP_SERVICES_CACHE` 환경변수 * 재지정을 존중한다(테스트 격리가 이 경로 재지정에 의존한다). * - * 삭제 실패(권한 · Windows 파일 핸들 점유)는 치명적이지 않다 — 종전 상태로 남을 뿐이며 - * 코어 업데이트의 마지막 단계(`clearAllCaches()`)가 다시 시도한다. + * 삭제 실패(권한 · 소유권 불일치 · Windows 파일 핸들 점유)는 예외로 올리지 않는다 — 부팅 직전에 + * 불리므로 실패가 흐름을 막으면 안 되고, 코어 업데이트의 마지막 단계(`clearAllCaches()`)가 다시 + * 시도한다. 대신 지우지 못한 파일의 경로를 돌려준다. spawn 직전 호출부는 그 목록을 업그레이드 + * 로그에 남긴다 — 그 상태면 자식의 자가 치유도 같은 권한으로 같은 이유로 실패해 증상은 이전 + * 설치본 provider 의 「Class not found」 그대로인데, 이 기록이 권한이 원인이라는 유일한 흔적이다. * - * @return void + * @return array 삭제하지 못하고 남은 파일의 절대 경로. 전부 지웠거나 원래 없었으면 빈 배열 */ - public static function clear(): void + public static function clear(): array { $app = app(); + $remaining = []; foreach ([$app->getCachedServicesPath(), $app->getCachedPackagesPath()] as $path) { - if (is_file($path)) { - @unlink($path); + if (! is_file($path)) { + continue; + } + + if (! @unlink($path)) { + clearstatcache(true, $path); + if (is_file($path)) { + $remaining[] = $path; + } } } clearstatcache(); + + return $remaining; } /** diff --git a/bootstrap/app.php b/bootstrap/app.php index 9a7066d9..9f05c9f9 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -225,9 +225,13 @@ $app = Application::configure(basePath: dirname(__DIR__)) | 규칙: App\ 클래스를 참조하지 않는다 (부팅 전이라 오토로드를 신뢰할 수 없고, 자가 치유가 자기 | 실패로 부팅을 막아서는 안 된다). 판정은 App\Support\CoreUpdateContext::isInProgress() 와 동일 — | 조건을 바꾸면 양쪽을 함께 고친다. +| argv 채널은 명령줄 SAPI 에서만 읽는다. CGI/FPM 은 register_argc_argv=On 이면 $_SERVER['argv'] 를 +| 쿼리스트링을 '+' 로 쪼갠 값으로 채우므로(`GET /?x+core:update` → argv[1] === 'core:update'), 이 게이트가 +| 없으면 비인증 웹 요청이 요청마다 매니페스트를 지우고 다시 만들게 한다. env 플래그 채널은 웹 요청으로 +| 주입할 수 없어 그대로 두며, 웹 요청 안에서 시작하는 업데이트 흐름은 그 플래그를 프로세스 안에서 세운다. */ $g7UpdateFlag = $_ENV['G7_UPDATE_IN_PROGRESS'] ?? $_SERVER['G7_UPDATE_IN_PROGRESS'] ?? getenv('G7_UPDATE_IN_PROGRESS'); -$g7UpdateArgv = $_SERVER['argv'][1] ?? ''; +$g7UpdateArgv = in_array(PHP_SAPI, ['cli', 'phpdbg'], true) ? ($_SERVER['argv'][1] ?? '') : ''; if ($g7UpdateFlag === '1' || $g7UpdateFlag === 1 || $g7UpdateFlag === true || in_array($g7UpdateArgv, ['core:update', 'core:execute-upgrade-steps'], true)) { diff --git a/docs/backend/core-update-system.md b/docs/backend/core-update-system.md index ac1e1dc7..a1274fd6 100644 --- a/docs/backend/core-update-system.md +++ b/docs/backend/core-update-system.md @@ -280,11 +280,13 @@ config 캐시와 같은 문제가 **패키지 매니페스트**(`bootstrap/cache | 계층 | 위치 | 막는 실패 | 잠그는 테스트 | |------|------|-----------|--------------| -| ① 부모 선정리 | `CoreUpdateCommand::spawnUpgradeStepsProcess()` 가 `proc_open` 직전 `PackageManifestCacheHelper::clear()` | 7.0.11+ 부모가 띄우는 자식의 부팅 실패 | `CoreUpdateCommandStalePackageManifestTest` | -| ② 자식 자가 치유 | `bootstrap/app.php` 가 `G7_UPDATE_IN_PROGRESS=1`(또는 업데이트 argv)이면 두 파일을 스스로 삭제 | **이미 배포된** 7.0.9·7.0.10 부모 아래에서 도는 신버전 자식 — 그 부모 코드는 고칠 수 없다 | 같은 테스트 (플래그 유·무 대조군 포함) | +| ① 부모 선정리 | `CoreUpdateCommand::spawnUpgradeStepsProcess()` 가 `proc_open` 직전 `PackageManifestCacheHelper::clear()`. 지우지 못한 파일이 있으면 그 경로를 업그레이드 로그에 경고로 남긴다 — 권한·소유권 불일치면 자식의 계층 ② 도 같은 이유로 실패해 증상은 제보와 같은 「Class not found」 인데, 이 경고가 원인을 가리키는 유일한 흔적이다 | 7.0.11+ 부모가 띄우는 자식의 부팅 실패 | `CoreUpdateCommandStalePackageManifestTest` | +| ② 자식 자가 치유 | `bootstrap/app.php` 가 `G7_UPDATE_IN_PROGRESS=1`(또는 명령줄 SAPI 에서의 업데이트 argv)이면 두 파일을 스스로 삭제 | **이미 배포된** 7.0.9·7.0.10 부모 아래에서 도는 신버전 자식 — 그 부모 코드는 고칠 수 없다 | 같은 테스트 (플래그 유·무 대조군 포함) | | ③ 버전 판독 범위 | `CoreVersionChecker::getCoreVersion()` 의 env 우선은 `CoreUpdateContext::isInProgress()` 트리 안에서만 | 업데이트 **전에** 뜬 `php artisan serve`·큐 워커가 옛 `APP_VERSION` 을 물고 확장을 `incompatible_core` 로 끄는 것 | `CoreVersionCheckerEnvPriorityTest` · `CoreUpdateContextTest` | -계층 ②는 `config:cache`/`route:cache` 가 만드는 in-process 일회용 앱에도 발동한다 — 그 부팅도 `bootstrap/app.php` 를 다시 require 하고 플래그를 상속하기 때문이다. 웹 요청·`queue:work`·운영자 셸은 플래그도 argv 도 없어 no-op 이다. +계층 ②는 `config:cache`/`route:cache` 가 만드는 in-process 일회용 앱에도 발동한다 — 그 부팅도 `bootstrap/app.php` 를 다시 require 하고 플래그를 상속하기 때문이다. 웹 요청·`queue:work`·운영자 셸은 플래그가 없어 no-op 이다. + +argv 채널은 명령줄 SAPI(`cli`·`phpdbg`)에서만 읽는다. CGI/FPM 은 `register_argc_argv=On` 이면 `$_SERVER['argv']` 를 쿼리스트링을 `+` 로 쪼갠 값으로 채우므로(`GET /?x+core:update` → `argv[1] === 'core:update'`), 그 게이트가 없으면 비인증 웹 요청이 요청마다 매니페스트를 지우고 다시 만들게 된다. env 플래그 채널은 웹 요청으로 주입할 수 없어 그대로 두며, 웹 요청 안에서 시작되는 업데이트 흐름은 그 플래그를 프로세스 안에서 세워 판정된다. 계층 ③의 판정은 `App\Support\CoreUpdateContext` 가 단독으로 소유하고 `CoreServiceProvider::isCoreUpdateInProgress()` 가 그리로 위임한다. 자동 비활성화 로그의 `core_version` 도 같은 게터를 쓴다 — 로그가 `config('app.version')` 을 적고 판정은 env 로 하면 운영자가 보는 근거와 실제 판정이 어긋난다. diff --git a/docs/extension/extension-update-system.md b/docs/extension/extension-update-system.md index a80ebc80..a39f9bdc 100644 --- a/docs/extension/extension-update-system.md +++ b/docs/extension/extension-update-system.md @@ -238,6 +238,8 @@ $process = proc_open($cmd, $descriptors, $pipes, $cwd, $env); `isCoreUpdateInProgress()` 는 env 외에 `$argv[1]` 이 `core:update` / `core:execute-upgrade-steps` 인 경우도 true 판정 — env 전파 실패 극단 상황 방어용. 하지만 `php -r '...'` 로 기동되는 spawn (inline 스크립트) 은 argv 판정이 되지 않으므로 env 전파가 유일한 수단입니다. +argv 판정은 명령줄 SAPI(`cli`·`phpdbg`)에서만 유효합니다. 웹 요청의 `$_SERVER['argv']` 는 `register_argc_argv` 설정에 따라 쿼리스트링에서 채워지므로 신뢰하지 않으며, 웹 요청 안에서 업데이트 흐름을 시작하는 코드는 env 플래그를 프로세스 안에서 세워 판정을 받습니다. + #### 판정의 단일 출처와 이 플래그가 게이트하는 것 `CoreServiceProvider::isCoreUpdateInProgress()` 는 `App\Support\CoreUpdateContext::isInProgress()` 위임입니다. 같은 플래그가 서로 다른 계층에서 셋을 게이트하므로 판정이 갈라지면 그중 한 경로만 조용히 다르게 동작합니다. diff --git a/tests/Feature/Api/Public/ExtensionBundleServingTest.php b/tests/Feature/Api/Public/ExtensionBundleServingTest.php index 1b22b2b0..37c251b5 100644 --- a/tests/Feature/Api/Public/ExtensionBundleServingTest.php +++ b/tests/Feature/Api/Public/ExtensionBundleServingTest.php @@ -9,7 +9,7 @@ use Mockery; use Tests\TestCase; /** - * 확장 프론트엔드 병합 번들 서빙 엔드포인트 Feature 테스트 + * [case:backend-27] 확장 프론트엔드 병합 번들 서빙 엔드포인트 Feature 테스트 * * /api/{modules,plugins}/bundle.{js,css} 의 응답 계약(Content-Type, ETag, * 304 Not Modified, 빈 번들 처리)을 검증한다. ExtensionBundleService 를 diff --git a/tests/Feature/Auth/TwoFactorAuthTest.php b/tests/Feature/Auth/TwoFactorAuthTest.php index 8bbd936b..5d7dae3f 100644 --- a/tests/Feature/Auth/TwoFactorAuthTest.php +++ b/tests/Feature/Auth/TwoFactorAuthTest.php @@ -19,7 +19,7 @@ use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; /** - * 로그인 2단계 인증 테스트 + * [case:backend-29] 로그인 2단계 인증 테스트 * * `security.two_factor_auth` 는 설정 화면에만 있고 구현이 없어, 켜도 아무 일도 일어나지 * 않았습니다. 관리자는 2단계 인증이 걸린 줄 알지만 실제로는 비밀번호 하나로 로그인됩니다. diff --git a/tests/Feature/Console/CoreUpdateCommandStalePackageManifestTest.php b/tests/Feature/Console/CoreUpdateCommandStalePackageManifestTest.php index 678b64fb..30e1fa64 100644 --- a/tests/Feature/Console/CoreUpdateCommandStalePackageManifestTest.php +++ b/tests/Feature/Console/CoreUpdateCommandStalePackageManifestTest.php @@ -12,7 +12,7 @@ use Symfony\Component\Console\Output\BufferedOutput; use Tests\TestCase; /** - * stale 패키지 매니페스트로 인한 spawn 자식 부팅 실패 회귀 테스트 (dev-g7 #658). + * [case:backend-31] stale 패키지 매니페스트로 인한 spawn 자식 부팅 실패 회귀 테스트 (dev-g7 #658). * * 회귀 시나리오 (sir.kr 제보, 7.0.9 → 7.0.10): * 운영 사이트가 `composer install`(옵션 없음)로 깔린 dev 설치본이면 @@ -202,6 +202,88 @@ class CoreUpdateCommandStalePackageManifestTest extends TestCase $this->assertSame($before, md5_file($this->packagesPath), '매니페스트 내용이 변경되지 않아야 한다'); } + /** + * 계층 ①: spawn 직전에 매니페스트를 지우지 못하면 그 파일을 업그레이드 로그에 남긴다. + * + * 권한·소유권 불일치로 삭제가 막히면 자식의 자가 치유도 같은 권한으로 같은 이유로 실패해 + * 증상은 이전 설치본 provider 의 「Class not found」 그대로다 — 이 경고가 원인이 권한이라는 + * 유일한 흔적이다. 삭제 실패는 실제로 만든다(Windows: 열린 핸들 / POSIX: 부모 디렉토리 쓰기 + * 권한 제거). 자식은 존재하지 않는 php 바이너리로 부팅 불가하게 두어 `proc_open` 앞의 동작만 + * 관측한다. + * + * @effects spawnUpgradeStepsProcess_logs_manifest_files_it_could_not_remove + */ + #[Test] + public function spawn_직전_매니페스트를_지우지_못하면_업그레이드_로그에_남은_파일을_경고한다(): void + { + if (! function_exists('proc_open')) { + $this->markTestSkipped('proc_open 미지원 환경'); + } + + config(['app.update.spawn_failure_mode' => 'abort']); + config(['process.php_binary' => base_path('storage/framework/testing/no-such-php-binary')]); + + $this->writeStaleManifests(); + + $release = $this->makeUndeletable($this->packagesPath); + $logged = []; + + try { + try { + $this->invokeSpawn(function (string $line) use (&$logged): void { + $logged[] = $line; + }); + $this->fail('자식이 부팅할 수 없으므로 abort 모드에서 UpgradeHandoffException 이 발생해야 한다'); + } catch (UpgradeHandoffException) { + // 기대된 경로 — 아래 단언이 본 검증이다. + } + } finally { + $release(); + } + + $this->assertFileExists($this->packagesPath, '삭제가 막힌 packages.php 는 남는다'); + $this->assertFileDoesNotExist($this->servicesPath, '지울 수 있는 services.php 는 지운다'); + + $warnings = array_values(array_filter($logged, static fn (string $line): bool => str_contains($line, '패키지 매니페스트 삭제 실패'))); + $this->assertCount(1, $warnings, '지우지 못한 파일이 있으면 업그레이드 로그에 경고 한 줄을 남긴다'); + $this->assertStringContainsString($this->packagesPath, $warnings[0], '경고는 지우지 못한 파일의 경로를 지목한다'); + $this->assertStringNotContainsString($this->servicesPath, $warnings[0], '지운 파일은 경고에 싣지 않는다'); + } + + /** + * 파일을 현재 프로세스가 삭제할 수 없는 상태로 만들고, 되돌리는 클로저를 반환합니다. + * + * Windows 는 읽기 전용 속성(0444)이 삭제를 막고(PHP 7.3+ 는 파일을 `FILE_SHARE_DELETE` 로 열어 + * 열린 핸들로는 막히지 않는다 — 실측), POSIX 는 부모 디렉토리의 쓰기 권한이 삭제를 막는다(root 는 + * 권한을 우회하므로 이 방법으로 실패를 만들 수 없다). + * + * @param string $path 삭제를 막을 파일 + * @return \Closure 원상 복구 클로저 + */ + private function makeUndeletable(string $path): \Closure + { + if (PHP_OS_FAMILY === 'Windows') { + $this->assertTrue(chmod($path, 0444), '전제: 읽기 전용 속성을 건다'); + + return static function () use ($path): void { + if (is_file($path)) { + chmod($path, 0644); + } + }; + } + + if (function_exists('posix_geteuid') && posix_geteuid() === 0) { + $this->markTestSkipped('root 는 디렉토리 권한으로 삭제 실패를 만들 수 없다 — 비-root 로 실행해야 이 축이 측정된다'); + } + + $dir = dirname($path); + $this->assertTrue(chmod($dir, 0555), '전제: 부모 디렉토리의 쓰기 권한을 뺀다'); + + return static function () use ($dir): void { + chmod($dir, 0755); + }; + } + /** * 존재하지 않는 provider 를 등재한 stale 매니페스트 두 개를 만든다. */ @@ -218,9 +300,10 @@ class CoreUpdateCommandStalePackageManifestTest extends TestCase /** * `spawnUpgradeStepsProcess` 를 리플렉션으로 호출합니다. * + * @param \Closure|null $log 업그레이드 로그 클로저. 생략하면 버린다 * @return bool spawn 성공 여부 */ - private function invokeSpawn(): bool + private function invokeSpawn(?\Closure $log = null): bool { $command = app(CoreUpdateCommand::class); @@ -242,7 +325,7 @@ class CoreUpdateCommandStalePackageManifestTest extends TestCase $method = new \ReflectionMethod(CoreUpdateCommand::class, 'spawnUpgradeStepsProcess'); $method->setAccessible(true); - return (bool) $method->invoke($command, '9.9.8', '9.9.9', true, fn () => null); + return (bool) $method->invoke($command, '9.9.8', '9.9.9', true, $log ?? fn () => null); } /** diff --git a/tests/Feature/Settings/EnvPriorityContractTest.php b/tests/Feature/Settings/EnvPriorityContractTest.php index 5dcc3e34..8031cf4b 100644 --- a/tests/Feature/Settings/EnvPriorityContractTest.php +++ b/tests/Feature/Settings/EnvPriorityContractTest.php @@ -6,7 +6,7 @@ use App\Support\EnvPriority; use Tests\TestCase; /** - * `.env` 키 단위 우선 규약의 계약 테스트. + * [case:backend-26] `.env` 키 단위 우선 규약의 계약 테스트. * * 이 결함군은 오류를 남기지 않는다 — 매핑이 뒤처지거나 배선이 빠지면 잠금이 조용히 * 미발동하고, 그 상태는 "그 키가 `.env` 에 없다"와 화면상 구분되지 않는다. 그래서 diff --git a/tests/Unit/Extension/TemplateUpdateConfigCacheInvalidationTest.php b/tests/Unit/Extension/TemplateUpdateConfigCacheInvalidationTest.php index 14ce1b3a..e7534ca7 100644 --- a/tests/Unit/Extension/TemplateUpdateConfigCacheInvalidationTest.php +++ b/tests/Unit/Extension/TemplateUpdateConfigCacheInvalidationTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; /** - * 템플릿 업데이트 성공 cleanup 의 config 캐시 무효화 구조 테스트 (#588, 공개 #119) + * [case:cache-17] 템플릿 업데이트 성공 cleanup 의 config 캐시 무효화 구조 테스트 (#588, 공개 #119) * * `template.config.{identifier}` 는 버전 접미사 없는 고정 키라 캐시 버전 bump 로 * 무효화되지 않는다. updateTemplate() 성공 cleanup 이 clearTemplateCache() 를 diff --git a/tests/Unit/Helpers/SettingsMigratorOwnershipTest.php b/tests/Unit/Helpers/SettingsMigratorOwnershipTest.php index eca34568..fd2daa22 100644 --- a/tests/Unit/Helpers/SettingsMigratorOwnershipTest.php +++ b/tests/Unit/Helpers/SettingsMigratorOwnershipTest.php @@ -8,7 +8,7 @@ use Illuminate\Support\Facades\File; use Tests\TestCase; /** - * SettingsMigrator owner 상속 회귀 테스트. + * [case:backend-28] SettingsMigrator owner 상속 회귀 테스트. * * sudo update 흐름에서 모듈/플러그인 upgrade step 이 root 로 실행될 때 * `SettingsMigrator::writeJsonFile` 가 만드는 *.json 파일이 root 소유로 영구 잔존하는 diff --git a/tests/Unit/Services/ExtensionBundleServiceTest.php b/tests/Unit/Services/ExtensionBundleServiceTest.php index 740ca680..f1246ca7 100644 --- a/tests/Unit/Services/ExtensionBundleServiceTest.php +++ b/tests/Unit/Services/ExtensionBundleServiceTest.php @@ -13,7 +13,7 @@ use Mockery; use Tests\TestCase; /** - * ExtensionBundleService 단위 테스트 + * [case:backend-30] ExtensionBundleService 단위 테스트 * * 활성 확장 IIFE/CSS 의 priority 정렬, `\n;\n` 구분자 병합, sourceMappingURL * 처리, 확장별 fault tolerance, 캐시 파일 생성/정리를 검증한다. diff --git a/tests/Unit/Services/SettingsServiceSetSettingSideEffectsTest.php b/tests/Unit/Services/SettingsServiceSetSettingSideEffectsTest.php index 796cda5e..87788adf 100644 --- a/tests/Unit/Services/SettingsServiceSetSettingSideEffectsTest.php +++ b/tests/Unit/Services/SettingsServiceSetSettingSideEffectsTest.php @@ -13,7 +13,7 @@ use Illuminate\Support\Facades\Cache; use Tests\TestCase; /** - * 코어 단건 설정 저장(setSetting)의 부수효과 정합 테스트 (공개 #114 동종, B-3) + * [case:cache-18] 코어 단건 설정 저장(setSetting)의 부수효과 정합 테스트 (공개 #114 동종, B-3) * * 단건 저장은 `PUT /api/admin/settings/{key}` 로 실제 도달 가능한 경로인데, 벌크 저장이 * 수행하는 부수효과 중 일부를 건너뛰었다: diff --git a/tests/Unit/Support/CoreUpdateContextTest.php b/tests/Unit/Support/CoreUpdateContextTest.php index 6a412a7c..17979453 100644 --- a/tests/Unit/Support/CoreUpdateContextTest.php +++ b/tests/Unit/Support/CoreUpdateContextTest.php @@ -121,6 +121,48 @@ class CoreUpdateContextTest extends TestCase } } + /** + * 웹 SAPI 에서는 argv 를 인정하지 않는다. + * + * CGI/FPM 은 `register_argc_argv=On` 이면 `$_SERVER['argv']` 를 쿼리스트링을 `+` 로 쪼갠 값으로 + * 채운다 — `GET /?x+core:update` 가 `argv[1] === 'core:update'` 를 만든다(2026-09-08 php-cgi 실측). + * 그 SAPI 에서 argv 를 믿으면 비인증 요청이 업데이트 트리로 판정되어 `bootstrap/app.php` 의 + * 자가 치유가 요청마다 매니페스트를 지운다. + * + * @effects CoreUpdateContext_ignores_argv_outside_console_sapi + */ + #[Test] + public function 웹_sapi_에서는_위조_가능한_argv_를_인정하지_않는다(): void + { + // register_argc_argv=On 인 FPM 이 `?x+core:update` 로 만드는 형태. + $_SERVER['argv'] = ['x', 'core:update']; + + foreach (['fpm-fcgi', 'cgi-fcgi', 'apache2handler', 'litespeed', 'cli-server'] as $sapi) { + $this->assertFalse(CoreUpdateContext::isInProgress($sapi), "{$sapi} 에서는 argv 로 트리 안이 되면 안 된다"); + } + + foreach (['cli', 'phpdbg'] as $sapi) { + $this->assertTrue(CoreUpdateContext::isInProgress($sapi), "{$sapi} 에서는 argv 보조 판정이 유효하다"); + } + } + + /** + * env 플래그 채널은 SAPI 와 무관하다 — 웹 요청으로는 주입할 수 없고, 웹 요청 안에서 시작하는 + * 업데이트 흐름이 프로세스 안에서 세우는 채널이다. + * + * @effects CoreUpdateContext_env_flag_is_honored_regardless_of_sapi + */ + #[Test] + public function env_플래그는_웹_sapi_에서도_인정한다(): void + { + $_ENV['G7_UPDATE_IN_PROGRESS'] = '1'; + $_SERVER['argv'] = ['index.php']; + + foreach (['fpm-fcgi', 'cgi-fcgi', 'cli'] as $sapi) { + $this->assertTrue(CoreUpdateContext::isInProgress($sapi), "{$sapi} 에서도 env 플래그는 트리 안이다"); + } + } + /** * `hasEnvFlag()` 는 argv 보조 판정을 섞지 않는다 — 섞으면 단독 실행이 spawn 자식으로 * 오판되어 사전·사후 단계를 통째로 건너뛴다. @@ -143,9 +185,9 @@ class CoreUpdateContextTest extends TestCase * 드러나지 않는다. 그래서 두 조건이 같은지를 여기서 잠근다 — 종전에는 양쪽 주석의 상호 * 참조가 유일한 방어였다. * - * 대조 항목은 판정을 이루는 네 축 전부다: 환경변수 이름 · 읽는 채널 3종 · 참으로 받는 값 - * 3종 · argv 로 인정하는 커맨드 목록(리플렉션으로 이 클래스에서 파생 — 손으로 적으면 - * 커맨드가 하나 늘어도 통과한다). + * 대조 항목은 판정을 이루는 다섯 축 전부다: 환경변수 이름 · 읽는 채널 3종 · 참으로 받는 값 + * 3종 · argv 로 인정하는 커맨드 목록 · argv 를 신뢰하는 SAPI 목록(뒤의 둘은 리플렉션으로 이 + * 클래스에서 파생 — 손으로 적으면 항목이 하나 늘어도 통과한다). */ #[Test] public function bootstrap_자가치유_조건이_이_판정기와_동형이다(): void @@ -196,6 +238,34 @@ class CoreUpdateContextTest extends TestCase "자가 치유 블록의 argv 판정에 `{$command}` 가 없다 — 그 커맨드로 도는 자식은 방어를 받지 못한다" ); } + + // ⑤ argv 를 신뢰하는 SAPI — 목록은 이 클래스에서 파생한다. 게이트가 빠지면 register_argc_argv=On 인 + // CGI/FPM 에서 `?x+core:update` 한 번에 자가 치유가 켜진다. + $sapis = $reflection->getConstant('CONSOLE_SAPIS'); + + $this->assertIsArray($sapis); + $this->assertNotEmpty($sapis, 'SAPI 목록이 비면 이 대조는 공허하게 통과한다'); + $this->assertStringContainsString( + 'PHP_SAPI', + $block, + '자가 치유 블록의 argv 판정에 SAPI 게이트가 없다 — 웹 요청이 쿼리스트링으로 매니페스트 삭제를 켤 수 있다' + ); + + foreach ($sapis as $sapi) { + $this->assertStringContainsString( + "'{$sapi}'", + $block, + "자가 치유 블록의 SAPI 게이트에 `{$sapi}` 가 없다 — 그 SAPI 로 도는 명령줄 자식은 argv 방어를 받지 못한다" + ); + } + + foreach (['fpm-fcgi', 'cgi-fcgi', 'apache2handler'] as $webSapi) { + $this->assertStringNotContainsString( + "'{$webSapi}'", + $block, + "자가 치유 블록이 웹 SAPI `{$webSapi}` 의 argv 를 신뢰한다" + ); + } } /** diff --git a/tests/Unit/Support/CustomAssetsPublishableFilesTest.php b/tests/Unit/Support/CustomAssetsPublishableFilesTest.php index 7bc90212..c228f0d5 100644 --- a/tests/Unit/Support/CustomAssetsPublishableFilesTest.php +++ b/tests/Unit/Support/CustomAssetsPublishableFilesTest.php @@ -8,7 +8,7 @@ use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; /** - * `CustomAssets::publishableFiles()` — 게시·변경 감지가 공유하는 열거자 테스트 (#651 F7). + * [case:cache-19] `CustomAssets::publishableFiles()` — 게시·변경 감지가 공유하는 열거자 테스트 (#651 F7). * * 정적 게시는 `custom/**` 를 재귀로 복사하고, 변경 감지는 종전에 최상위 css/js 의 mtime 만 * 서명했다. 두 범위를 서로 다른 코드가 정의하면 어긋나고, 그 어긋남은 "글꼴을 바꿨는데 diff --git a/tests/Unit/Support/PackageManifestCacheHelperTest.php b/tests/Unit/Support/PackageManifestCacheHelperTest.php index 74a25336..e4e234a7 100644 --- a/tests/Unit/Support/PackageManifestCacheHelperTest.php +++ b/tests/Unit/Support/PackageManifestCacheHelperTest.php @@ -79,10 +79,11 @@ class PackageManifestCacheHelperTest extends TestCase $this->assertSame($this->packagesPath, $this->app->getCachedPackagesPath(), '전제: env 재지정이 경로를 정한다'); $this->assertSame($this->servicesPath, $this->app->getCachedServicesPath(), '전제: env 재지정이 경로를 정한다'); - PackageManifestCacheHelper::clear(); + $remaining = PackageManifestCacheHelper::clear(); $this->assertFileDoesNotExist($this->packagesPath); $this->assertFileDoesNotExist($this->servicesPath); + $this->assertSame([], $remaining, '전부 지웠으면 남은 파일이 없다'); } /** @@ -93,10 +94,74 @@ class PackageManifestCacheHelperTest extends TestCase { $this->assertFileDoesNotExist($this->packagesPath, '전제: 파일이 없다'); - PackageManifestCacheHelper::clear(); + $remaining = PackageManifestCacheHelper::clear(); $this->assertFileDoesNotExist($this->packagesPath); $this->assertFileDoesNotExist($this->servicesPath); + $this->assertSame([], $remaining, '원래 없던 파일은 "지우지 못한 파일" 이 아니다'); + } + + /** + * 지우지 못한 파일은 경로로 돌려주고, 지울 수 있는 형제 파일은 그대로 지운다. + * + * 호출부(spawn 직전)가 이 목록을 업그레이드 로그에 남긴다 — 권한·소유권 불일치면 자식의 자가 + * 치유도 같은 이유로 실패해 증상은 「Class not found」 그대로인데, 그 기록이 원인을 가리키는 + * 유일한 흔적이다. 삭제 실패는 플랫폼마다 실제로 만든다: Windows 는 열린 핸들이, POSIX 는 부모 + * 디렉토리의 쓰기 권한이 삭제를 막는다. + * + * @effects PackageManifestCacheHelper_clear_returns_paths_it_could_not_remove + */ + #[Test] + public function clear_는_지우지_못한_파일의_경로를_돌려준다(): void + { + File::put($this->packagesPath, "servicesPath, "makeUndeletable($this->packagesPath); + + try { + $remaining = PackageManifestCacheHelper::clear(); + } finally { + $release(); + } + + $this->assertSame([$this->packagesPath], $remaining, '지우지 못한 파일만 경로로 돌려준다'); + $this->assertFileExists($this->packagesPath, '삭제가 막힌 파일은 그대로 남는다'); + $this->assertFileDoesNotExist($this->servicesPath, '지울 수 있는 형제 파일은 지운다'); + } + + /** + * 파일을 현재 프로세스가 삭제할 수 없는 상태로 만들고, 되돌리는 클로저를 반환합니다. + * + * Windows 는 읽기 전용 속성(0444)이 삭제를 막고(PHP 7.3+ 는 파일을 `FILE_SHARE_DELETE` 로 열어 + * 열린 핸들로는 막히지 않는다 — 실측), POSIX 는 부모 디렉토리의 쓰기 권한이 삭제를 막는다(root 는 + * 권한을 우회하므로 이 방법으로 실패를 만들 수 없다). + * + * @param string $path 삭제를 막을 파일 + * @return \Closure 원상 복구 클로저 + */ + private function makeUndeletable(string $path): \Closure + { + if (PHP_OS_FAMILY === 'Windows') { + $this->assertTrue(chmod($path, 0444), '전제: 읽기 전용 속성을 건다'); + + return static function () use ($path): void { + if (is_file($path)) { + chmod($path, 0644); + } + }; + } + + if (function_exists('posix_geteuid') && posix_geteuid() === 0) { + $this->markTestSkipped('root 는 디렉토리 권한으로 삭제 실패를 만들 수 없다 — 비-root 로 실행해야 이 축이 측정된다'); + } + + $dir = dirname($path); + $this->assertTrue(chmod($dir, 0555), '전제: 부모 디렉토리의 쓰기 권한을 뺀다'); + + return static function () use ($dir): void { + chmod($dir, 0755); + }; } /** diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 880bb2ca..d8fd9d7f 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -174,6 +174,39 @@ putenv('APP_ROUTES_CACHE='.$testingRoutesCache); $_ENV['APP_ROUTES_CACHE'] = $testingRoutesCache; $_SERVER['APP_ROUTES_CACHE'] = $testingRoutesCache; +/* +|-------------------------------------------------------------------------- +| 패키지 매니페스트 캐시 격리 (테스트 환경 보장) +|-------------------------------------------------------------------------- +| +| `bootstrap/cache/packages.php` · `services.php` 도 라우트 캐시와 같이 **경로를 돌린다.** +| 코어 업데이트 흐름은 이 두 파일을 지우고 다시 만드는 코드를 여럿 갖고 있고(spawn 직전 선정리, +| `bootstrap/app.php` 의 업데이트 트리 자가 치유, `clearAllCaches()` 의 `package:discover`), 그 +| 코드를 태우는 테스트 가운데 몇은 `proc_open` 으로 **진짜 자식 프로세스**를 띄운다. 자식은 부모의 +| 테스트 격리(임시 base path·목)를 하나도 물려받지 못하고 env 만 물려받으므로, 경로를 env 로 +| 돌려 두는 것이 부모와 자식을 한 번에 격리하는 유일한 수단이다. 돌리지 않으면 테스트를 한 번 +| 돌릴 때마다 개발 클론의 실제 매니페스트가 지워지고 다시 만들어진다 — 오늘은 재생성 내용이 같아 +| 무해하지만, 그 사이 다른 프로세스가 부팅하면 매번 재빌드를 겪고, 테스트가 실제 파일을 건드리는 +| 구조 자체가 `.env` 삭제 사고와 같은 부류다. +| +| 자기 경로를 따로 쓰는 테스트(`CoreUpdateCommandStalePackageManifestTest` 등)는 여기 값을 +| 원값으로 보관·복원하므로 그대로 동작한다. +| +*/ +$testingManifestDir = __DIR__.'/../storage/framework/testing'; +if (! is_dir($testingManifestDir)) { + @mkdir($testingManifestDir, 0777, true); +} +foreach ([ + 'APP_PACKAGES_CACHE' => 'storage/framework/testing/packages.php', + 'APP_SERVICES_CACHE' => 'storage/framework/testing/services.php', +] as $manifestEnvKey => $manifestRelativePath) { + putenv($manifestEnvKey.'='.$manifestRelativePath); + $_ENV[$manifestEnvKey] = $manifestRelativePath; + $_SERVER[$manifestEnvKey] = $manifestRelativePath; +} +unset($testingManifestDir, $manifestEnvKey, $manifestRelativePath); + // Composer 오토로더 로드 $loader = require __DIR__.'/../vendor/autoload.php'; diff --git a/tests/scenarios/core-update-spawn-failure-mode.yaml b/tests/scenarios/core-update-spawn-failure-mode.yaml index 239abc19..7a99a925 100644 --- a/tests/scenarios/core-update-spawn-failure-mode.yaml +++ b/tests/scenarios/core-update-spawn-failure-mode.yaml @@ -56,6 +56,8 @@ axes: parent_generation: [pre_7_0_11, 7_0_11_plus] # 부모가 spawn 직전 매니페스트를 비우는 세대인가 (이미 배포된 7.0.9·7.0.10 은 비우지 않는다) child_self_heal_flag: [present, absent] # 자식이 G7_UPDATE_IN_PROGRESS 를 물려받았는가 (자가 치유 게이트) process_context: [update_tree, long_lived_outside_update] # 버전 판독 범위 — 상주 프로세스(artisan serve·큐 워커)는 트리 밖이다 + argv_sapi: [console, web_with_forged_query_argv] # argv 보조 판정을 읽는 SAPI — CGI/FPM 은 register_argc_argv=On 이면 `?x+core:update` 로 argv 를 위조할 수 있다 + manifest_unlink_result: [removed, left_by_permission] # spawn 직전 매니페스트 삭제 결과 — 지우지 못한 파일은 업그레이드 로그에 남긴다 vendor_dev_packages: [present, absent, unknown] # 운영 vendor 에 require-dev 가 섞여 있는가 (installed.json 판정) composer_step_branch: [reinstalled, skipped_unchanged] # Step 6 이 vendor 를 교체했는가 스킵했는가 @@ -70,6 +72,7 @@ exclusions: - { parent_generation: 7_0_11_plus, package_manifest_state: stale_dev_provider, reason: "7.0.11+ 부모는 spawn 직전에 매니페스트를 비우므로 자식이 stale 을 보는 상태 자체가 성립하지 않는다 — 계층 ② 검증은 pre_7_0_11 부모에서만 의미가 있다" } - { process_context: long_lived_outside_update, child_self_heal_flag: present, reason: "플래그를 물고 있으면 정의상 업데이트 트리 안이다 — 모순" } - { vendor_dev_packages: unknown, composer_step_branch: skipped_unchanged, reason: "판정 불가(installed.json 부재)에서는 감지 로그 자체가 출력되지 않아 분기가 구분되지 않는다" } + - { argv_sapi: web_with_forged_query_argv, child_self_heal_flag: present, reason: "env 플래그는 웹 요청으로 주입할 수 없다 — 위조 argv 축은 플래그 부재 상태에서만 의미가 있다" } effects: # §2 spawn_failure_mode + failSpawnWithMode @@ -130,6 +133,10 @@ effects: - PackageManifestCacheHelper_clear_unlinks_packages_and_services_at_configured_paths - clearAllCaches_rebuilds_package_manifest_via_helper - CoreUpdateContext_isInProgress_detects_env_flag_or_update_argv + - CoreUpdateContext_ignores_argv_outside_console_sapi + - CoreUpdateContext_env_flag_is_honored_regardless_of_sapi + - PackageManifestCacheHelper_clear_returns_paths_it_could_not_remove + - spawnUpgradeStepsProcess_logs_manifest_files_it_could_not_remove - getCoreVersion_prefers_env_APP_VERSION_only_inside_update_tree - getCoreVersion_ignores_env_APP_VERSION_outside_update_tree - getCoreVersion_falls_back_to_config_when_env_absent_inside_update_tree