option('from'); $to = (string) $this->option('to'); $force = (bool) $this->option('force'); if ($from === '' || $to === '') { $this->error('--from 과 --to 는 필수 옵션입니다.'); return self::INVALID; } // spawn 자식 진입 시 활성 모듈/플러그인의 PSR-4 매핑을 fresh 등록. // 부모 프로세스의 autoload-extensions.php 가 stale 한 경우 upgrade step 안에서 // ModuleManager / PluginManager 호출 → declaration 메서드가 Models/Services 등 // 다른 클래스 lazy load 시 "Class not found" 발생. 진입 직후 1회 호출로 모든 후속 // upgrade step (현재 + 미래) 이 fresh autoload 환경에서 실행됨을 보장. // // 본 커맨드 자체가 spawn 자식 (proc_open 으로 fork 된 별개 PHP 프로세스) 의 진입점이라 // 디스크의 fresh ExtensionManager(beta.X+1) 클래스를 메모리에 로드한 상태. 따라서 // `app(ExtensionManager::class)->updateComposerAutoload()` 직접 호출은 stale 가능성 // 없음. (Artisan::call 대신 직접 호출 — nested Artisan::call 이 outer 명령의 // output buffer 를 덮어쓰는 Laravel 동작 회피) try { app(\App\Extension\ExtensionManager::class)->updateComposerAutoload(); } catch (\Throwable $e) { \Illuminate\Support\Facades\Log::warning('upgrade step spawn 자식: updateComposerAutoload 호출 실패', [ 'error' => $e->getMessage(), ]); } // spawn 자식 진입 시 활성 디렉토리의 쓰기 권한 디렉토리를 멱등적으로 보장. // fresh 디스크 config (`app.update.restore_ownership_group_writable`) 를 읽어 처리하므로 // 미래 release 가 새 쓰기 권한 디렉토리를 도입할 때 본 호출은 자동으로 신규 항목을 처리한다. // upgrade step 에 mkdir/chown 코드를 매번 하드코딩할 필요 없음. // // 한계: 부모(이전 버전) 만 알고 있던 신규 디렉토리는 처리 불가 — 그 일회성 케이스는 // 해당 release 의 upgrade step 단발 처리. (예: beta.3→beta.4 의 lang-packs/* 보정) try { $writablePaths = (array) config('app.update.restore_ownership_group_writable', []); if (! empty($writablePaths)) { $service->ensureWritableDirectories( $writablePaths, function (string $level, string $msg): void { // 콘솔 + upgrade 로그 채널 동시 출력 — 운영자가 단일 파일(upgrade.log)에서 // spawn 자식의 권한 정상화 진행을 추적할 수 있도록 양쪽 모두 누적. $this->{$level === 'warning' ? 'warn' : 'info'}($msg); \Illuminate\Support\Facades\Log::channel('upgrade')->$level('[spawn] '.$msg); }, ); } } catch (\Throwable $e) { \Illuminate\Support\Facades\Log::warning('upgrade step spawn 자식: ensureWritableDirectories 호출 실패', [ 'error' => $e->getMessage(), ]); } $stepsExecuted = 0; try { $service->runUpgradeSteps( $from, $to, function (string $version) use (&$stepsExecuted): void { $this->info("upgrade step 실행: {$version}"); $stepsExecuted++; }, $force, ); } catch (UpgradeHandoffException $e) { // 업그레이드 스텝이 새 PHP 프로세스 재진입을 요청했다. // 부모 프로세스(spawnUpgradeStepsProcess)가 [HANDOFF] 라인을 stdout 에서 // 읽어 페이로드를 복원하고 UpgradeHandoffException 재구성 후 상위로 던지도록, // JSON 페이로드를 표식과 함께 출력한다. 표식 문자열은 구분자 역할이므로 일반 // step 출력과 충돌하지 않게 고정된 접두사를 사용한다. // // resumeCommand 는 step 작성자가 null 로 두는 것을 권장(CoreUpdateCommand 가 // from/to 버전을 사용해 자동 생성). null 도 JSON 으로 그대로 전달. $payload = json_encode([ 'afterVersion' => $e->afterVersion, 'reason' => $e->reason, 'resumeCommand' => $e->resumeCommand, ], JSON_UNESCAPED_UNICODE); $this->line('[HANDOFF] '.$payload); Log::info('core:execute-upgrade-steps 핸드오프', [ 'from' => $from, 'to' => $to, 'after' => $e->afterVersion, 'reason' => $e->reason, ]); return UpgradeHandoffException::EXIT_CODE; } catch (\Throwable $e) { Log::error('core:execute-upgrade-steps 실패', [ 'from' => $from, 'to' => $to, 'error' => $e->getMessage(), ]); $this->error($e->getMessage()); return self::FAILURE; } // 부모 (CoreUpdateCommand::spawnUpgradeStepsProcess) 가 silent skip 가드용으로 파싱하는 // 명시 종료 신호. [HANDOFF] 와 동일 구조의 라인 프로토콜. 정상 종료 (handoff 미발생) 경로 // 에서만 발행되며, 부모는 exit=0 인데 본 라인이 부재하면 mode=abort 시 throw, // mode=fallback 시 in-process 진행 (`spawn_failure_mode`). $this->line('[STEPS_EXECUTED] '.json_encode([ 'count' => $stepsExecuted, 'fromVersion' => $from, 'toVersion' => $to, ], JSON_UNESCAPED_UNICODE)); return self::SUCCESS; } }