Merge pull request from gnuboard:HeuJung/issue432
fix(core): 코어 업데이트가 특정 상황에서 중단되던 두 결함 수정
This commit is contained in:
@@ -10,6 +10,11 @@
|
||||
|
||||
- 첫 화면 접속 시 내려받는 코어 스크립트 용량을 절반 이하로 줄여 초기 로딩을 개선했습니다. 레이아웃 편집기와 개발자 진단도구를 코어에서 분리해, 편집기는 편집 화면에 들어갈 때만, 진단도구는 디버그 모드를 켰을 때만 내려받도록 했습니다. 일반 사용자는 평소 쓰지 않는 편집기·진단도구 코드를 더 이상 받지 않습니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Windows 환경에서 코어 업데이트가 `public/storage` 링크를 복사하는 단계에서 "디렉토리는 복사할 수 없습니다" 오류로 중단되던 문제를 수정했습니다. Windows의 저장소 링크(정션)가 이제 링크 그대로 안전하게 보존되어 업데이트가 정상 완료됩니다.
|
||||
- 별도 데이터 변환 작업이 필요 없는 버전으로 코어를 업데이트할 때, "실행할 업그레이드 단계가 없다"는 정상 상황을 실패로 표시하고 중단하던 문제를 수정했습니다. 이제 변환 작업이 없는 업데이트는 매끄럽게 완료되며, 이어서 설치된 확장의 최신 번들 반영 단계까지 정상적으로 진행됩니다.
|
||||
|
||||
## [7.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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 범위에 스텝 파일이
|
||||
* 애초에 없는 릴리즈 (예: 데이터/설정 변경 없는 패치). from<to 여도 실패 아님 (케이스 B).
|
||||
* - discovered 신호 부재 (`null`, 구버전 자식) + executed=0 + from<to → 레거시 판정 유지
|
||||
* (fail-fast). 신버전 자식은 항상 discovered 를 발행하므로 이 경로는 구버전 자식 한정.
|
||||
*
|
||||
* @param int $exitCode 자식 프로세스 종료 코드
|
||||
* @param array|null $handoffPayload 파싱된 [HANDOFF] 페이로드 (없으면 null)
|
||||
* @param int|null $stepsExecuted 실행된 스텝 수 ([STEPS_EXECUTED] 미수신 시 null)
|
||||
* @param int|null $stepsDiscovered 범위 내 발견된 스텝 파일 수 (구버전 자식은 null)
|
||||
* @param string $fromVersion 업그레이드 시작 버전
|
||||
* @param string $toVersion 업그레이드 대상 버전
|
||||
* @param \Closure $log 로그 수집 콜백
|
||||
* @return bool spawn 성공 여부 (mode=fallback 실패 시 false — 호출자 in-process fallback)
|
||||
*
|
||||
* @throws UpgradeHandoffException 핸드오프 수신 또는 mode=abort 실패 시
|
||||
*/
|
||||
private function handleSpawnExit(
|
||||
int $exitCode,
|
||||
?array $handoffPayload,
|
||||
?int $stepsExecuted,
|
||||
?int $stepsDiscovered,
|
||||
string $fromVersion,
|
||||
string $toVersion,
|
||||
\Closure $log,
|
||||
): bool {
|
||||
if ($exitCode === UpgradeHandoffException::EXIT_CODE && $handoffPayload !== null) {
|
||||
$log("spawn 핸드오프 종료 (exit={$exitCode})");
|
||||
|
||||
@@ -866,9 +917,7 @@ class CoreUpdateCommand extends Command
|
||||
}
|
||||
|
||||
if ($exitCode === 0) {
|
||||
// silent skip 가드 — 자식이 [STEPS_EXECUTED] 신호를 발행하지 않거나, step 0건
|
||||
// 실행한 채 exit=0 으로 종료한 경우. 이전 버전 자식 (beta.5 이전 디스크) 또는
|
||||
// 자식이 비정상 종료 직전 silent skip 한 상태로 추정. fail-fast 모드 가드 적용.
|
||||
// 신호 미수신 — 이전 버전 자식 (신호 미발행) 또는 silent skip 의심. fail-fast.
|
||||
if ($stepsExecuted === null) {
|
||||
return $this->failSpawnWithMode(
|
||||
'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 신호 부재) + from<to → fail-fast.
|
||||
// discovered>0: 스텝 파일이 있는데 자식이 실행 못함 (이슈 #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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<to 여도 실패 아님 (스텝 불필요 릴리즈).
|
||||
// proc_open 불필요한 순수 판정 로직 단위 검증.
|
||||
config(['app.update.spawn_failure_mode' => '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<to.
|
||||
// 신버전 부모는 이 케이스를 기존(레거시) fail-fast 로 판정해야 한다 (안전 우선).
|
||||
config(['app.update.spawn_failure_mode' => '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 + from<to 는 레거시 fail-fast 여야 한다');
|
||||
} catch (UpgradeHandoffException $e) {
|
||||
$this->assertStringContainsString('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]
|
||||
|
||||
@@ -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 의 파일은 보존되어야 한다 (재귀 삭제 사고 차단)');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,19 +15,26 @@ description: |
|
||||
|
||||
구성 (정책 결정 2026-05-11):
|
||||
1. `spawn_failure_mode` 기본값 `abort` — 4분기 모두 UpgradeHandoffException throw.
|
||||
2. `[STEPS_EXECUTED]` stdout 라인 프로토콜 — 자식이 실행한 step 수 명시.
|
||||
부모가 신호 미수신 또는 step 0건 실행 (from<to) 감지 시 silent skip 으로 판정.
|
||||
2. `[STEPS_EXECUTED]` stdout 라인 프로토콜 — 자식이 실행한 step 수(count) + 범위 내
|
||||
발견된 스텝 파일 수(discovered) 명시. 부모는 신호 미수신 또는 executed=0 &&
|
||||
discovered>0 (스텝 파일이 있는데 실행 못함) 을 silent skip 으로 판정. executed=0 &&
|
||||
discovered=0 (스텝 파일 부재) 은 정상 통과 — from<to 여도 실패 아님 (스텝 불필요 릴리즈).
|
||||
3. `runUpgradeSteps` 진입 직후 stale 메모리 가드 — config('app.version') < toVersion
|
||||
이면 mode=abort 시 throw, mode=fallback 시 warning log.
|
||||
4. V-1 안전 작성 패턴 docs + audit rule `upgrade-step-vone-safety` (warning) —
|
||||
upgrade step 안의 `app(*Service|*Manager|*Repository::class)` 호출을 reviewer 에게 surface.
|
||||
|
||||
분기 개정 (2026-07-02 #432 후속): `child_silent_skip_zero_steps` 를 discovered 축으로
|
||||
세분화. 범위 내 스텝 파일이 애초에 없어 0건인 경우(discovered=0)를 정상 통과로 재분류하여,
|
||||
스텝이 필요 없는 패치 릴리즈(예: 7.0.0→7.0.1) 를 실패로 오판하던 결함을 차단.
|
||||
|
||||
axes:
|
||||
spawn_branch: [proc_open_disabled, proc_open_resource_fail, child_abnormal_exit, child_silent_skip_no_signal, child_silent_skip_zero_steps]
|
||||
spawn_branch: [proc_open_disabled, proc_open_resource_fail, child_abnormal_exit, child_silent_skip_no_signal, child_zero_steps_with_discovered, child_zero_steps_no_step_files]
|
||||
spawn_failure_mode: [abort, fallback]
|
||||
from_to_relation: [from_lt_to, from_eq_to_forced] # version_compare 분기
|
||||
parent_memory_version: [equal_to_to, less_than_to, greater_than_to] # stale 가드 트리거 조건
|
||||
steps_executed_signal: [present_positive, present_zero, absent] # 자식의 [STEPS_EXECUTED] 발행 상태
|
||||
steps_executed_signal: [present_positive, present_zero, absent] # 자식의 [STEPS_EXECUTED] count 발행 상태
|
||||
steps_discovered_signal: [discovered_positive, discovered_zero, absent] # 범위 내 발견된 스텝 파일 수 (신버전 자식만 발행, 구버전=absent)
|
||||
child_process_origin: [beta_5_plus, beta_4_or_earlier] # 이전 버전 자식 (silent skip 시뮬레이션)
|
||||
upgrade_step_v_one_pattern: [app_service_call, app_manager_call, app_repository_call, local_only]
|
||||
audit_allow_inline: [present, absent] # 면제 주석 적용 여부
|
||||
@@ -35,8 +42,12 @@ axes:
|
||||
|
||||
exclusions:
|
||||
- { spawn_branch: proc_open_disabled, parent_memory_version: equal_to_to, reason: "proc_open 비활성 시점에 부모는 항상 fromVersion 메모리 — equal 케이스 시뮬 불가" }
|
||||
- { from_to_relation: from_eq_to_forced, spawn_branch: child_silent_skip_zero_steps, reason: "from==to + force 케이스는 step 0건이 정상 동작이므로 silent skip 가드 미트리거" }
|
||||
- { from_to_relation: from_eq_to_forced, spawn_branch: child_zero_steps_with_discovered, reason: "from==to + force 케이스는 step 0건이 정상 동작이므로 silent skip 가드 미트리거" }
|
||||
- { child_process_origin: beta_4_or_earlier, steps_executed_signal: present_positive, reason: "beta.5 이전 자식은 [STEPS_EXECUTED] 신호 자체를 모름 — present_positive 시뮬 불가" }
|
||||
- { child_process_origin: beta_4_or_earlier, steps_discovered_signal: discovered_positive, reason: "discovered 필드는 #432 후속 신버전 자식만 발행 — 구버전 자식은 항상 absent" }
|
||||
- { child_process_origin: beta_4_or_earlier, steps_discovered_signal: discovered_zero, reason: "동일 — 구버전 자식은 discovered 필드 미발행(absent)" }
|
||||
- { spawn_branch: child_zero_steps_no_step_files, steps_discovered_signal: discovered_positive, reason: "스텝 파일 부재 브랜치는 정의상 discovered=0 — positive 모순" }
|
||||
- { spawn_branch: child_zero_steps_with_discovered, steps_discovered_signal: discovered_zero, reason: "스텝 파일 존재 브랜치는 정의상 discovered>0 — 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
|
||||
|
||||
Reference in New Issue
Block a user