diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e73358d..a6f53b28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ - 첫 화면 접속 시 내려받는 코어 스크립트 용량을 절반 이하로 줄여 초기 로딩을 개선했습니다. 레이아웃 편집기와 개발자 진단도구를 코어에서 분리해, 편집기는 편집 화면에 들어갈 때만, 진단도구는 디버그 모드를 켰을 때만 내려받도록 했습니다. 일반 사용자는 평소 쓰지 않는 편집기·진단도구 코드를 더 이상 받지 않습니다. +### Fixed + +- Windows 환경에서 코어 업데이트가 `public/storage` 링크를 복사하는 단계에서 "디렉토리는 복사할 수 없습니다" 오류로 중단되던 문제를 수정했습니다. Windows의 저장소 링크(정션)가 이제 링크 그대로 안전하게 보존되어 업데이트가 정상 완료됩니다. +- 별도 데이터 변환 작업이 필요 없는 버전으로 코어를 업데이트할 때, "실행할 업그레이드 단계가 없다"는 정상 상황을 실패로 표시하고 중단하던 문제를 수정했습니다. 이제 변환 작업이 없는 업데이트는 매끄럽게 완료되며, 이어서 설치된 확장의 최신 번들 반영 단계까지 정상적으로 진행됩니다. + ## [7.0.0] - 2026-07-01 ### Added diff --git a/app/Console/Commands/Core/CoreUpdateCommand.php b/app/Console/Commands/Core/CoreUpdateCommand.php index 25d1b806..dec79206 100644 --- a/app/Console/Commands/Core/CoreUpdateCommand.php +++ b/app/Console/Commands/Core/CoreUpdateCommand.php @@ -806,6 +806,7 @@ class CoreUpdateCommand extends Command // 페이로드만 보관한다 (각각 UpgradeHandoffException 재구성 / silent skip 가드용). $handoffPayload = null; $stepsExecuted = null; + $stepsDiscovered = null; while (! feof($pipes[1])) { $line = fgets($pipes[1]); if ($line !== false) { @@ -837,7 +838,13 @@ class CoreUpdateCommand extends Command $decoded = json_decode($json, true); if (is_array($decoded) && isset($decoded['count']) && is_int($decoded['count']) && $decoded['count'] >= 0) { $stepsExecuted = $decoded['count']; - $log('[spawn] 실행된 step 수: '.$stepsExecuted); + // discovered 는 신버전 자식만 발행하는 필드. 구버전 자식(필드 부재)은 + // null 로 남아 handleSpawnExit 가 기존(레거시) 판정 경로를 탄다. + if (isset($decoded['discovered']) && is_int($decoded['discovered']) && $decoded['discovered'] >= 0) { + $stepsDiscovered = $decoded['discovered']; + } + $log('[spawn] 실행된 step 수: '.$stepsExecuted + .($stepsDiscovered !== null ? " (발견 {$stepsDiscovered})" : '')); continue; } @@ -852,6 +859,50 @@ class CoreUpdateCommand extends Command fclose($pipes[2]); $exitCode = proc_close($process); + return $this->handleSpawnExit( + $exitCode, + $handoffPayload, + $stepsExecuted, + $stepsDiscovered, + $fromVersion, + $toVersion, + $log, + ); + } + + /** + * spawn 자식 종료 후 exit code · 신호를 해석해 성공/실패/핸드오프를 판정합니다. + * + * 판정 규칙 (exit=0 경우): + * - `[STEPS_EXECUTED]` 신호 미수신 (`$stepsExecuted === null`) → fail-fast. + * 이전 버전 자식 (신호 미발행) 또는 silent skip 의심. + * - executed=0 인데 **discovered>0** → fail-fast. 범위 내에 스텝 파일이 실제로 + * 존재하는데 자식이 하나도 실행하지 못함 (이슈 #28 silent skip — 케이스 A). + * - executed=0 이고 **discovered=0** → 정상 통과. 해당 from~to 범위에 스텝 파일이 + * 애초에 없는 릴리즈 (예: 데이터/설정 변경 없는 패치). fromfailSpawnWithMode( 'spawn 자식이 [STEPS_EXECUTED] 신호 미발행 — 이전 버전 자식 또는 silent skip 의심', @@ -878,9 +927,27 @@ class CoreUpdateCommand extends Command ); } - if ($stepsExecuted === 0 && version_compare($fromVersion, $toVersion, '<')) { + // executed=0 + discovered=0 → 범위 내 스텝 파일 부재. 정상 통과 (케이스 B). + // 스텝이 필요 없는 릴리즈(데이터/설정 변경 없음)를 실패로 오판하지 않는다. + if ($stepsExecuted === 0 && $stepsDiscovered === 0) { + $log("spawn 완료 (exit=0, 실행할 스텝 없음 — 범위 내 스텝 파일 부재, from={$fromVersion} to={$toVersion})"); + + return true; + } + + // executed=0 + (discovered>0 또는 discovered 신호 부재) + from0: 스텝 파일이 있는데 자식이 실행 못함 (이슈 #28 케이스 A). + // discovered=null: 구버전 자식 (discovered 미발행) — 레거시 판정 유지. + if ($stepsExecuted === 0 + && $stepsDiscovered !== 0 + && version_compare($fromVersion, $toVersion, '<')) { return $this->failSpawnWithMode( - sprintf('spawn 자식 exit=0 이지만 step 0건 실행 — 의도된 동작이 아님 (from=%s to=%s)', $fromVersion, $toVersion), + sprintf( + 'spawn 자식 exit=0 이지만 step 0건 실행 — 의도된 동작이 아님 (from=%s to=%s%s)', + $fromVersion, + $toVersion, + $stepsDiscovered !== null ? ", 발견 {$stepsDiscovered}건" : '', + ), $log, $fromVersion, $toVersion, diff --git a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php index 19c8f690..9f7ff3bc 100644 --- a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php +++ b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php @@ -164,6 +164,7 @@ class ExecuteUpgradeStepsCommand extends Command } $stepsExecuted = 0; + $stepsDiscovered = 0; try { $service->runUpgradeSteps( @@ -174,6 +175,9 @@ class ExecuteUpgradeStepsCommand extends Command $stepsExecuted++; }, $force, + function (int $discovered) use (&$stepsDiscovered): void { + $stepsDiscovered = $discovered; + }, ); } catch (UpgradeHandoffException $e) { // 업그레이드 스텝이 새 PHP 프로세스 재진입을 요청했다. @@ -221,6 +225,7 @@ class ExecuteUpgradeStepsCommand extends Command // mode=fallback 시 in-process 진행 (`spawn_failure_mode`). $this->line('[STEPS_EXECUTED] '.json_encode([ 'count' => $stepsExecuted, + 'discovered' => $stepsDiscovered, 'fromVersion' => $from, 'toVersion' => $to, ], JSON_UNESCAPED_UNICODE)); diff --git a/app/Extension/Helpers/FilePermissionHelper.php b/app/Extension/Helpers/FilePermissionHelper.php index 67db687d..e29877b2 100644 --- a/app/Extension/Helpers/FilePermissionHelper.php +++ b/app/Extension/Helpers/FilePermissionHelper.php @@ -53,15 +53,25 @@ class FilePermissionHelper $destPath = $destination.DIRECTORY_SEPARATOR.$itemName; - // symlink 자체 보존: target 추적 없이 동일 symlink 재생성. + // symlink / Windows junction 자체 보존: target 추적 없이 동일 링크 재생성. // SplFileInfo::isDir() 가 symlink 를 추적하므로 검사 순서가 isDir() 보다 먼저여야 한다. - // 미적용 시 `public/storage` 등 symlink 가 target 디렉토리 내용으로 복사되어 백업/복원 양쪽에서 손상. - if ($item->isLink()) { - if (static::copySymlink($item->getPathname(), $destPath)) { + // 미적용 시 `public/storage` 등 링크가 target 디렉토리 내용으로 복사되어 백업/복원 양쪽에서 손상. + // + // Windows JUNCTION(`mklink /J`)은 reparse point 지만 PHP `isLink()`/`isDir()` 이 모두 + // false 를 반환한다(`storage:link` 가 Windows 에서 생성하는 형태). isLink() 만 검사하면 + // junction 이 파일로 오판되어 copyFile → `copy(디렉토리)` 로 코어 업데이트가 실패한다. + // isReparsePoint() 로 junction 도 이 분기에 포함시킨다. + if ($item->isLink() || static::isReparsePoint($item->getPathname())) { + if (static::copyLink($item->getPathname(), $destPath)) { continue; } // 복원 실패 (Windows SeCreateSymbolicLink 권한 부족 등) — 일반 복사로 fall-through. + // 단, junction 은 isDir() 이 false 라 아래 else 분기의 copyFile 로 새어 파일 복사가 + // 다시 실패하므로, fall-through 대신 skip 하여 손상 없이 넘어간다. // 운영자가 추후 `php artisan storage:link` 수동 실행으로 회복 가능. + if (! $item->isLink()) { + continue; + } } if ($item->isDir()) { @@ -79,25 +89,63 @@ class FilePermissionHelper } /** - * symlink 자체를 보존 복사합니다 (target 미추적). + * 경로가 Windows JUNCTION 등 PHP `isLink()` 가 인식하지 못하는 reparse point 인지 판정합니다. * - * 기존 dest 가 symlink 또는 파일이면 unlink, 디렉토리면 deleteDirectory 후 symlink 재생성. - * readlink / symlink 실패 시 false 반환 — 호출자가 일반 복사로 fall-through. + * Windows JUNCTION(`mklink /J`)은 디렉토리 reparse point 로, `is_link()` / `is_file()` / + * `is_dir()` 이 **모두** false 를 반환한다(그러나 경로 항목으로는 존재하며 `readlink()` 로 + * target 을 얻을 수 있다). `php artisan storage:link` 가 Windows 에서 생성하는 + * `public/storage` 가 이 형태다. * - * Windows 환경에서 PHP `symlink()` 는 `SeCreateSymbolicLink` 권한이 필요하며 일반 사용자는 - * 권한이 없을 가능성이 높다. 실패 시 warning 로그 후 false 반환하여 호출자가 일반 복사로 - * fall-through 하도록 한다. 운영자는 업그레이드 후 `php artisan storage:link` 등 수동 - * 명령으로 symlink 를 회복할 수 있다. + * 주의: Windows 의 `readlink()` 는 일반 파일/디렉토리에도 자기 경로를 반환하므로 junction + * 판별 기준이 될 수 없다. 따라서 "존재하는 경로인데 link/file/dir 어디에도 해당하지 않음" + * 을 판정 기준으로 삼는다. 여기에 추가로 `readlink()` 성공(!== false)을 요구해 존재하지 + * 않는 경로를 배제한다. * - * @param string $source 소스 symlink 경로 - * @param string $destination 대상 symlink 경로 - * @return bool symlink 복원 성공 여부 (false 면 호출자가 일반 복사로 폴백) + * 일반 symlink(`is_link()` true)는 호출부가 별도로 처리하므로 여기서는 false 를 반환한다. + * junction 은 Windows 전용이므로 비-Windows 에서는 항상 false. + * + * @param string $path 검사할 경로 + * @return bool junction 등 isLink() 미인식 reparse point 여부 */ - protected static function copySymlink(string $source, string $destination): bool + protected static function isReparsePoint(string $path): bool + { + if (PHP_OS_FAMILY !== 'Windows') { + return false; + } + + // 일반 symlink / 파일 / 디렉토리는 각 검사가 true → junction 아님. + if (is_link($path) || is_file($path) || is_dir($path)) { + return false; + } + + // 위 셋에 모두 해당하지 않으면서 readlink 가 target 을 반환하면 junction(reparse point). + return @readlink($path) !== false; + } + + /** + * symlink 또는 Windows junction 자체를 보존 복사합니다 (target 미추적). + * + * 기존 dest 가 symlink 또는 파일이면 unlink, 디렉토리면 deleteDirectory 후 링크 재생성. + * readlink 실패 시 false 반환 — 호출자가 일반 복사로 fall-through. + * + * 재생성 전략: + * - 일반 symlink: `symlink()` + * - target 이 디렉토리인 경우(Windows junction 포함): `symlink()` 실패 시 `mklink /J` 로 폴백. + * Windows 에서 PHP `symlink()` 는 `SeCreateSymbolicLink` 권한이 필요하지만 junction 은 + * 권한 없이 생성 가능하므로, junction 복원의 실질 경로가 된다. + * + * 모든 재생성 시도 실패 시 warning 로그 후 false 반환. 운영자는 업그레이드 후 + * `php artisan storage:link` 등 수동 명령으로 링크를 회복할 수 있다. + * + * @param string $source 소스 링크(symlink/junction) 경로 + * @param string $destination 대상 링크 경로 + * @return bool 링크 복원 성공 여부 (false 면 호출자가 일반 복사로 폴백) + */ + protected static function copyLink(string $source, string $destination): bool { $target = @readlink($source); if ($target === false) { - Log::warning('copySymlink: readlink 실패 — 일반 복사로 폴백', ['source' => $source]); + Log::warning('copyLink: readlink 실패 — 일반 복사로 폴백', ['source' => $source]); return false; } @@ -109,17 +157,29 @@ class FilePermissionHelper File::deleteDirectory($destination); } - if (! @symlink($target, $destination)) { - Log::warning('copySymlink: symlink 생성 실패 — 일반 복사로 폴백', [ - 'source' => $source, - 'destination' => $destination, - 'target' => $target, - ]); - - return false; + if (@symlink($target, $destination)) { + return true; } - return true; + // symlink 실패 + target 이 디렉토리 → Windows junction 으로 폴백 재생성. + if (PHP_OS_FAMILY === 'Windows' && is_dir($target)) { + exec( + 'cmd /c mklink /J '.escapeshellarg($destination).' '.escapeshellarg($target).' 2>&1', + $out, + $code + ); + if ($code === 0 && is_dir($destination)) { + return true; + } + } + + Log::warning('copyLink: 링크 생성 실패 — 일반 복사로 폴백', [ + 'source' => $source, + 'destination' => $destination, + 'target' => $target, + ]); + + return false; } /** @@ -157,11 +217,19 @@ class FilePermissionHelper // 소스에 존재하지 않는 항목만 삭제 if (! File::exists($srcPath) && ! File::isDirectory($srcPath)) { - // symlink 는 항상 unlink — File::deleteDirectory 는 is_dir() 추적 검사 후 - // 재귀 삭제하므로 symlink-to-dir 인 경우 target 의 모든 파일을 삭제하는 사고 - // 발생 가능. is_link() 가 isDir() 보다 먼저 평가되어야 한다. + // symlink / Windows junction 은 링크 자체만 제거 — File::deleteDirectory 는 is_dir() + // 추적 검사 후 재귀 삭제하므로 link-to-dir 인 경우 target 의 모든 파일을 삭제하는 + // 사고 발생 가능. 링크 검사가 isDir() 보다 먼저 평가되어야 한다. + // + // junction 은 is_link() 가 false 지만 isReparsePoint() 가 감지한다. junction 링크 + // 제거는 파일 대상 unlink 가 아닌 rmdir() 로 수행해야 target 내용을 보존한다. if (is_link($destItem->getPathname())) { @unlink($destItem->getPathname()); + } elseif (static::isReparsePoint($destItem->getPathname())) { + // junction: rmdir 로 링크만 제거 (target 미추적). 폴백으로 unlink 시도. + if (! @rmdir($destItem->getPathname())) { + @unlink($destItem->getPathname()); + } } elseif ($destItem->isDir()) { File::deleteDirectory($destItem->getPathname()); } else { diff --git a/app/Services/CoreUpdateService.php b/app/Services/CoreUpdateService.php index 57f6dc6e..0cc8c6af 100644 --- a/app/Services/CoreUpdateService.php +++ b/app/Services/CoreUpdateService.php @@ -1558,8 +1558,9 @@ class CoreUpdateService * @param string $toVersion 종료 버전 * @param \Closure|null $onStep 각 스텝 실행 시 콜백 (버전 문자열 전달) * @param bool $force true 시 fromVersion == toVersion이면 해당 버전 스텝도 포함 + * @param \Closure|null $onDiscovered 범위 내 발견된 스텝 파일 수를 실행 전 1회 통지 (int 전달) */ - public function runUpgradeSteps(string $fromVersion, string $toVersion, ?\Closure $onStep = null, bool $force = false): void + public function runUpgradeSteps(string $fromVersion, string $toVersion, ?\Closure $onStep = null, bool $force = false, ?\Closure $onDiscovered = null): void { // 부모 in-process fallback 진입 시 stale 메모리 가드. // @@ -1597,6 +1598,9 @@ class CoreUpdateService $upgradesPath = base_path('upgrades'); if (! File::isDirectory($upgradesPath)) { + // upgrades 디렉토리 자체가 없으면 발견된 스텝 0건 — 정상 통지 후 종료. + $onDiscovered?->__invoke(0); + return; } @@ -1656,6 +1660,12 @@ class CoreUpdateService uksort($steps, 'version_compare'); + // 범위 내에서 발견된 스텝 파일 수(discovered)를 먼저 통지한다. + // 호출자(spawn 자식)는 이 값으로 "스텝 파일이 애초에 없어 0건(정상)" 과 "스텝 파일은 + // 있는데 실행이 0건(비정상 — 이슈 #28 silent skip)" 을 구분한다. onStep 은 실제 실행 + // 건마다 호출되므로 executed 수만 세며, discovered 는 실행 전에 1회 통지한다. + $onDiscovered?->__invoke(count($steps)); + $context = new UpgradeContext($fromVersion, $toVersion); foreach ($steps as $version => $step) { diff --git a/tests/Feature/Console/CoreUpdateCommandSpawnFailureTest.php b/tests/Feature/Console/CoreUpdateCommandSpawnFailureTest.php index 19ad804b..a6550b1a 100644 --- a/tests/Feature/Console/CoreUpdateCommandSpawnFailureTest.php +++ b/tests/Feature/Console/CoreUpdateCommandSpawnFailureTest.php @@ -96,31 +96,107 @@ class CoreUpdateCommandSpawnFailureTest extends TestCase } #[Test] - public function spawn_자식_silent_skip_시_abort_모드는_throw_한다(): void + public function spawn_자식_범위내_스텝_파일이_없어_0건이면_정상_통과한다(): void { if (! function_exists('proc_open')) { $this->markTestSkipped('proc_open 미지원 환경'); } - // upgrade 파일 없이 호출 → 자식은 [STEPS_EXECUTED] count=0 발행 + exit=0 - // from < to 인 상태에서 0건 실행은 비정상 → mode=abort 시 throw + // upgrade 파일 없이 호출 → 자식은 [STEPS_EXECUTED] executed=0, discovered=0 발행 + exit=0 + // "범위 내 스텝 파일 자체가 없음" 은 정상 상황 (예: 스텝 불필요 릴리즈). + // discovered=0 이면 fail-fast 를 발동하지 않고 정상 통과해야 한다 (케이스 B). config(['app.update.spawn_failure_mode' => 'abort']); $command = $this->makeCommandWithDummyIo(); $method = new \ReflectionMethod(CoreUpdateCommand::class, 'spawnUpgradeStepsProcess'); $method->setAccessible(true); + $logs = []; + $log = function (string $msg) use (&$logs): void { + $logs[] = $msg; + }; + + // 실 스텝 파일이 존재하지 않는 버전 범위 (9.9.8 → 9.9.9) + $result = $method->invoke($command, '9.9.8', '9.9.9', true, $log); + + $this->assertTrue($result, '범위 내 스텝 파일이 없어 0건이면 정상 통과(true)여야 한다'); + $allLogs = implode("\n", $logs); + $this->assertStringContainsString('실행할 스텝 없음', $allLogs, '스텝 부재 정상 통과 로그'); + } + + #[Test] + public function spawn_자식_범위내_스텝이_있는데_0건_실행이면_abort_throw_한다(): void + { + // 범위 내에 스텝 파일이 실제로 존재(discovered>0)하는데 executed=0 인 경우 — + // 이전 버전 자식이 신규 스텝을 놓치고 silent skip 한 이슈 #28 케이스 (케이스 A). + // 이 경우에만 fail-fast 를 발동해야 한다. + // + // 재현: 스텝 파일은 존재하나 자식의 runUpgradeSteps 가 executed 를 0 으로 보고하도록, + // discovered 는 세되 onStep 은 호출하지 않는 상황을 자식 프로세스 안에서 만들 수 없으므로, + // 부모 가드 로직(handleSpawnExit)을 직접 호출해 discovered>0 && executed=0 분기를 검증한다. + config(['app.update.spawn_failure_mode' => 'abort']); + + $command = $this->makeCommandWithDummyIo(); + $method = new \ReflectionMethod(CoreUpdateCommand::class, 'handleSpawnExit'); + $method->setAccessible(true); + try { - $method->invoke($command, '9.9.8', '9.9.9', true, fn () => null); - $this->fail('step 0건 실행 시 mode=abort 에서 throw 되어야 한다'); + // exitCode=0, handoffPayload=null, stepsExecuted=0, stepsDiscovered=5 + $method->invoke($command, 0, null, 0, 5, '9.9.8', '9.9.9', fn () => null); + $this->fail('discovered>0 && executed=0 이면 mode=abort 에서 throw 되어야 한다'); } catch (UpgradeHandoffException $e) { $this->assertStringContainsString('step 0건 실행', $e->reason); + $this->assertStringContainsString('발견 5건', $e->reason); $this->assertSame('9.9.8', $e->afterVersion); } } #[Test] - public function spawn_자식_silent_skip_시_fallback_모드는_false_반환한다(): void + public function handleSpawnExit_executed_0_discovered_0_이면_from_lt_to_여도_정상_통과한다(): void + { + // 케이스 B — 범위 내 스텝 파일 부재. from 'abort']); + + $command = $this->makeCommandWithDummyIo(); + $method = new \ReflectionMethod(CoreUpdateCommand::class, 'handleSpawnExit'); + $method->setAccessible(true); + + $logs = []; + $log = function (string $msg) use (&$logs): void { + $logs[] = $msg; + }; + + // exitCode=0, handoffPayload=null, stepsExecuted=0, stepsDiscovered=0 + $result = $method->invoke($command, 0, null, 0, 0, '7.0.0', '7.0.1', $log); + + $this->assertTrue($result, 'discovered=0 이면 abort 모드에서도 정상 통과(true)'); + $this->assertStringContainsString('실행할 스텝 없음', implode("\n", $logs)); + } + + #[Test] + public function handleSpawnExit_구버전_자식_discovered_null_은_레거시_fail_fast_판정을_유지한다(): void + { + // discovered 필드를 모르는 구버전 자식 — executed=0 + discovered=null + from 'abort']); + + $command = $this->makeCommandWithDummyIo(); + $method = new \ReflectionMethod(CoreUpdateCommand::class, 'handleSpawnExit'); + $method->setAccessible(true); + + try { + // stepsExecuted=0, stepsDiscovered=null (구버전 자식) + $method->invoke($command, 0, null, 0, null, '7.0.0', '7.0.1', fn () => null); + $this->fail('discovered=null + executed=0 + fromassertStringContainsString('step 0건 실행', $e->reason); + $this->assertStringNotContainsString('발견', $e->reason, 'discovered 미상이므로 발견 건수 표기 없음'); + } + } + + #[Test] + public function spawn_자식_범위내_스텝_파일이_없어_0건이면_fallback_모드도_정상_통과한다(): void { if (! function_exists('proc_open')) { $this->markTestSkipped('proc_open 미지원 환경'); @@ -134,7 +210,7 @@ class CoreUpdateCommandSpawnFailureTest extends TestCase $result = $method->invoke($command, '9.9.8', '9.9.9', true, fn () => null); - $this->assertFalse($result, 'mode=fallback 에서 step 0건 실행은 false 반환'); + $this->assertTrue($result, '스텝 파일 부재(discovered=0)는 fallback 모드에서도 정상 통과(true)'); } #[Test] diff --git a/tests/Unit/Extension/Helpers/FilePermissionHelperSymlinkTest.php b/tests/Unit/Extension/Helpers/FilePermissionHelperSymlinkTest.php index f9062d9f..799083fb 100644 --- a/tests/Unit/Extension/Helpers/FilePermissionHelperSymlinkTest.php +++ b/tests/Unit/Extension/Helpers/FilePermissionHelperSymlinkTest.php @@ -134,4 +134,78 @@ class FilePermissionHelperSymlinkTest extends TestCase $this->assertSame('hi', File::get($dest.'/file.txt')); $this->assertSame('world', File::get($dest.'/sub/nested.txt')); } + + /** + * Windows JUNCTION (`mklink /J`) 는 PHP 의 `SplFileInfo::isLink()` / `isDir()` 이 + * 모두 false 를 반환하는 reparse point 다. `php artisan storage:link` 가 Windows 에서 + * 생성하는 `public/storage` 가 이 형태이며, 이를 파일로 오판하면 `copyFile` 이 + * 디렉토리를 `copy()` 에 넘겨 `copy(): The first argument ... cannot be a directory` 로 + * 코어 업데이트가 실패한다 (회귀 가드). + * + * junction 은 Windows 전용이므로 비-Windows 환경에서는 skip. + */ + #[Test] + public function copyDirectory_는_windows_junction_을_파일로_오판하지_않고_보존합니다(): void + { + if (PHP_OS_FAMILY !== 'Windows') { + $this->markTestSkipped('junction 은 Windows 전용 (mklink /J)'); + } + + $source = $this->tempRoot.'/src'; + $dest = $this->tempRoot.'/dst'; + $linkTarget = $this->tempRoot.'/link_target'; + + File::ensureDirectoryExists($source); + File::ensureDirectoryExists($linkTarget); + File::put($linkTarget.'/inside.txt', 'junction 대상 안 파일'); + + $junctionPath = $source.'/storage'; + // mklink /J 는 관리자 권한 없이 생성 가능 (심볼릭 링크와 달리 SeCreateSymbolicLink 불필요) + exec('cmd /c mklink /J '.escapeshellarg($junctionPath).' '.escapeshellarg($linkTarget).' 2>&1', $out, $code); + if ($code !== 0 || ! is_dir($junctionPath)) { + $this->markTestSkipped('junction 생성 실패: '.implode("\n", $out)); + } + + // 사전 조건: PHP 가 junction 을 link/dir 어느 쪽으로도 인식하지 못함을 확인 + $this->assertFalse(is_link($junctionPath), 'junction 은 is_link() 가 false (전제)'); + + // copyFile 이 디렉토리를 받아 던지던 예외 없이 완료되어야 한다 + FilePermissionHelper::copyDirectory($source, $dest); + + // junction 은 dest 에도 junction 으로 보존되어 target 내용에 접근 가능해야 한다 + $this->assertTrue(is_dir($dest.'/storage'), 'dest 의 storage 는 디렉토리(junction)로 접근 가능해야 한다'); + $this->assertFileExists($dest.'/storage/inside.txt', 'junction 통과 시 target 파일 접근 가능'); + } + + /** + * removeOrphanItems 가 source 부재 junction 을 재귀 삭제(target 내용 삭제)하지 않고 + * 링크 자체만 제거하는지 검증 (junction 은 isDir() 이 false 라 별도 rmdir 경로 필요). + */ + #[Test] + public function removeOrphanItems_는_source_부재_junction_을_target_추적_없이_링크만_제거합니다(): void + { + if (PHP_OS_FAMILY !== 'Windows') { + $this->markTestSkipped('junction 은 Windows 전용 (mklink /J)'); + } + + $source = $this->tempRoot.'/src'; + $dest = $this->tempRoot.'/dst'; + $linkTarget = $this->tempRoot.'/link_target'; + + File::ensureDirectoryExists($source); + File::ensureDirectoryExists($dest); + File::ensureDirectoryExists($linkTarget); + File::put($linkTarget.'/precious.txt', '중요한 파일'); + + // dest 에만 존재하는 junction (source 에는 없음) + exec('cmd /c mklink /J '.escapeshellarg($dest.'/orphan_link').' '.escapeshellarg($linkTarget).' 2>&1', $out, $code); + if ($code !== 0 || ! is_dir($dest.'/orphan_link')) { + $this->markTestSkipped('junction 생성 실패: '.implode("\n", $out)); + } + + FilePermissionHelper::copyDirectory($source, $dest, null, [], '', true); + + $this->assertDirectoryDoesNotExist($dest.'/orphan_link', 'orphan junction 은 제거되어야 한다'); + $this->assertFileExists($linkTarget.'/precious.txt', 'junction target 의 파일은 보존되어야 한다 (재귀 삭제 사고 차단)'); + } } diff --git a/tests/scenarios/core-update-spawn-failure-mode.yaml b/tests/scenarios/core-update-spawn-failure-mode.yaml index f5da7a86..2cf2111f 100644 --- a/tests/scenarios/core-update-spawn-failure-mode.yaml +++ b/tests/scenarios/core-update-spawn-failure-mode.yaml @@ -15,19 +15,26 @@ description: | 구성 (정책 결정 2026-05-11): 1. `spawn_failure_mode` 기본값 `abort` — 4분기 모두 UpgradeHandoffException throw. - 2. `[STEPS_EXECUTED]` stdout 라인 프로토콜 — 자식이 실행한 step 수 명시. - 부모가 신호 미수신 또는 step 0건 실행 (from0 (스텝 파일이 있는데 실행 못함) 을 silent skip 으로 판정. executed=0 && + discovered=0 (스텝 파일 부재) 은 정상 통과 — from0 — zero 모순" } effects: # §2 spawn_failure_mode + failSpawnWithMode @@ -45,12 +56,17 @@ effects: - spawnUpgradeStepsProcess_proc_open_disabled_dispatches_to_failSpawnWithMode - spawnUpgradeStepsProcess_proc_open_resource_fail_dispatches_to_failSpawnWithMode - spawnUpgradeStepsProcess_child_abnormal_exit_dispatches_to_failSpawnWithMode - # §2.1 [STEPS_EXECUTED] silent skip 가드 - - ExecuteUpgradeStepsCommand_emits_STEPS_EXECUTED_with_count_on_normal_completion + # §2.1 [STEPS_EXECUTED] silent skip 가드 (count + discovered) + - ExecuteUpgradeStepsCommand_emits_STEPS_EXECUTED_with_count_and_discovered_on_normal_completion - ExecuteUpgradeStepsCommand_does_not_emit_STEPS_EXECUTED_on_handoff_exit - spawnUpgradeStepsProcess_parses_STEPS_EXECUTED_with_positive_count_returns_true - spawnUpgradeStepsProcess_missing_STEPS_EXECUTED_dispatches_to_failSpawnWithMode - - spawnUpgradeStepsProcess_zero_steps_with_from_lt_to_dispatches_to_failSpawnWithMode + # discovered 기반 분기 (#432 후속) — executed=0 을 discovered 로 구분 + - handleSpawnExit_zero_executed_zero_discovered_returns_true # 케이스 B: 스텝 파일 부재 정상 통과 + - handleSpawnExit_zero_executed_positive_discovered_dispatches_to_failSpawnWithMode # 케이스 A: #28 silent skip + - handleSpawnExit_zero_executed_null_discovered_from_lt_to_dispatches_to_failSpawnWithMode # 구버전 자식 레거시 판정 + - spawnUpgradeStepsProcess_no_step_files_returns_true_even_when_from_lt_to # 7.0.0→7.0.1 실증 + - runUpgradeSteps_notifies_discovered_zero_when_no_step_in_range - spawnUpgradeStepsProcess_zero_steps_with_from_eq_to_forced_returns_true # §6 stale 메모리 가드 - runUpgradeSteps_throws_UpgradeHandoffException_when_memory_lt_to_with_abort_mode