fix(installer): Composer 설치 로그 인코딩으로 인한 진행 상태 응답 빈 본문 보완

Composer 실행 모드로 설치할 때 일부 Windows 환경에서 composer 출력의
invalid UTF-8 바이트가 정규화 없이 로그에 저장되어, 진행 상태 응답의
json_encode 가 false 를 반환 → 빈 본문(HTTP 200) → 프론트 파싱 실패로
진행 상황이 표시되지 않던 문제.

- 로그 저장(addLog)에서 mb_scrub 로 항상 유효 UTF-8 보장 (근본)
- composer 스트림 라인 정규화 (SSE/폴링 공통 경계)
- 폴링(state-management)·SSE(progress-emitter) 응답 json_encode 에
 JSON_INVALID_UTF8_SUBSTITUTE + false 가드 (양쪽 안전망)

무관 결함 동시 정리: 신규 격리 테스트가 트리거한 BASE_PATH 누수로
깨지던 DeleteDirectoryTest·InstallerWindowsCommandsTest 를 프로젝트 루트
절대경로 앵커로 격리.

공개 이슈 gnuboard/g7 (@kitrio 제보).
This commit is contained in:
HeuJung
2026-07-06 17:09:41 +09:00
parent 5ee1d1f2b5
commit 342486a20b
8 changed files with 615 additions and 246 deletions
+22 -1
View File
@@ -138,7 +138,28 @@ class StateManagementApi
];
// JSON 응답 반환
echo json_encode($response, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
//
// 최종 안전망 (gnuboard/g7#62): $response['logs'] 에 invalid UTF-8 바이트가
// 섞이면 json_encode 가 false 를 반환하고 echo false = 빈 본문(HTTP 200) 이 되어
// 프론트 폴링(res.json())이 "Unexpected end of JSON input" 으로 폭주한다.
// 로그는 addLog / task-runner 에서 이미 scrub 되지만, 어떤 경로로도 응답이
// 빈 본문이 되지 않도록 JSON_INVALID_UTF8_SUBSTITUTE 로 치환하고 false 를 가드한다.
$encoded = json_encode(
$response,
JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE
);
if ($encoded === false) {
// substitute 플래그로도 인코딩 실패한 극단적 케이스 — 폴링이 파싱 가능한
// 최소 유효 JSON 을 반환해 프론트가 다음 tick 에서 정상 복구되도록 한다.
$encoded = json_encode([
'status' => $status,
'logs' => [],
'log_total' => $logTotal,
], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
}
echo $encoded;
}
/**
+77 -50
View File
@@ -5,16 +5,15 @@
*
* 설치 진행 상태를 storage/installer-state.json에 저장하고 조회합니다.
*/
if (!defined('BASE_PATH')) {
if (! defined('BASE_PATH')) {
define('BASE_PATH', realpath(dirname(__DIR__, 3)) ?: dirname(__DIR__, 3)); // public/install/includes에서 프로젝트 루트로
}
if (! defined('STATE_PATH')) {
define('STATE_PATH', BASE_PATH . '/storage/installer-state.json');
define('STATE_PATH', BASE_PATH.'/storage/installer-state.json');
}
if (! defined('INSTALLER_DIR')) {
define('INSTALLER_DIR', BASE_PATH . '/storage/installer');
define('INSTALLER_DIR', BASE_PATH.'/storage/installer');
}
/**
@@ -36,15 +35,16 @@ function getInstallationState(): array
// state.json 파일이 없으면 기본 상태 반환
// (설치 완료 후 워커가 DELETE_INSTALLER_AFTER_COMPLETE로 정상 삭제하는 경우가 있으므로
// 부재 자체는 에러가 아님. 호출자가 g7_installed 플래그 등으로 완료 여부 판정)
if (!file_exists(STATE_PATH)) {
if (! file_exists(STATE_PATH)) {
return $defaultState;
}
// 파일 읽기 권한 체크
if (!is_readable(STATE_PATH)) {
$msg = "[installer-state] State file is not readable: " . STATE_PATH;
if (! is_readable(STATE_PATH)) {
$msg = '[installer-state] State file is not readable: '.STATE_PATH;
error_log($msg);
addLog($msg);
return $defaultState;
}
@@ -53,9 +53,10 @@ function getInstallationState(): array
// 파일 읽기 실패 시 기본 상태 반환
if ($content === false) {
$msg = "[installer-state] Failed to read state file: " . STATE_PATH;
$msg = '[installer-state] Failed to read state file: '.STATE_PATH;
error_log($msg);
addLog($msg);
return $defaultState;
}
@@ -65,9 +66,10 @@ function getInstallationState(): array
if (json_last_error() !== JSON_ERROR_NONE) {
$contentLen = strlen($content);
$preview = substr($content, 0, 200);
$msg = "[installer-state] Failed to parse state file JSON (length={$contentLen}): " . json_last_error_msg() . " / preview: " . $preview;
$msg = "[installer-state] Failed to parse state file JSON (length={$contentLen}): ".json_last_error_msg().' / preview: '.$preview;
error_log($msg);
addLog($msg);
return $defaultState;
}
@@ -77,25 +79,27 @@ function getInstallationState(): array
/**
* 설치 상태 저장
*
* @param array $state 저장할 상태 배열
* @param array $state 저장할 상태 배열
* @return bool 저장 성공 여부
*/
function saveInstallationState(array $state): bool
{
// storage 디렉토리 존재 여부 확인 (생성하지 않음)
$storageDir = BASE_PATH . '/storage';
if (!is_dir($storageDir)) {
$storageDir = BASE_PATH.'/storage';
if (! is_dir($storageDir)) {
$msg = "[installer-state] Storage directory does not exist: {$storageDir}";
error_log($msg);
addLog($msg);
return false;
}
// 디렉토리 쓰기 권한 확인
if (!is_writable($storageDir)) {
if (! is_writable($storageDir)) {
$msg = "[installer-state] Storage directory is not writable: {$storageDir}";
error_log($msg);
addLog($msg);
return false;
}
@@ -106,33 +110,36 @@ function saveInstallationState(array $state): bool
$content = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($content === false) {
$msg = "[installer-state] Failed to encode state as JSON: " . json_last_error_msg();
$msg = '[installer-state] Failed to encode state as JSON: '.json_last_error_msg();
error_log($msg);
addLog($msg);
return false;
}
// 원자적 쓰기: tmp 파일에 쓰고 rename으로 교체
// 동시 쓰기 경쟁 상황에서 부분 쓰기(half-written file)로 인한 JSON 손상 방지.
// Linux에서 rename()은 atomic syscall이므로 다른 리더가 항상 완전한 파일만 읽는다.
$tmpPath = STATE_PATH . '.tmp.' . getmypid() . '.' . uniqid();
$tmpPath = STATE_PATH.'.tmp.'.getmypid().'.'.uniqid();
$result = @file_put_contents($tmpPath, $content, LOCK_EX);
if ($result === false || $result !== strlen($content)) {
$msg = "[installer-state] Failed to write state tmp file: " . $tmpPath;
$msg = '[installer-state] Failed to write state tmp file: '.$tmpPath;
error_log($msg);
addLog($msg);
@unlink($tmpPath);
return false;
}
@chmod($tmpPath, 0664);
if (!@rename($tmpPath, STATE_PATH)) {
$msg = "[installer-state] Failed to rename state tmp file: " . $tmpPath . ' → ' . STATE_PATH;
if (! @rename($tmpPath, STATE_PATH)) {
$msg = '[installer-state] Failed to rename state tmp file: '.$tmpPath.' → '.STATE_PATH;
error_log($msg);
addLog($msg);
@unlink($tmpPath);
return false;
}
@@ -161,13 +168,13 @@ function isInstallationCompleted(): bool
}
// g7_installed 파일 존재 여부 확인 (추가 안전장치)
$installedFlagPath = BASE_PATH . '/storage/app/g7_installed';
$installedFlagPath = BASE_PATH.'/storage/app/g7_installed';
if (file_exists($installedFlagPath)) {
return true;
}
// .env 파일의 INSTALLER_COMPLETED 플래그 확인
$envPath = BASE_PATH . '/.env';
$envPath = BASE_PATH.'/.env';
if (file_exists($envPath)) {
$envContent = file_get_contents($envPath);
if (strpos($envContent, 'INSTALLER_COMPLETED=true') !== false) {
@@ -181,7 +188,7 @@ function isInstallationCompleted(): bool
/**
* 특정 작업을 완료로 표시
*
* @param string $task 작업 식별자
* @param string $task 작업 식별자
* @return bool 업데이트 성공 여부
*/
function markTaskCompleted(string $task): bool
@@ -189,7 +196,7 @@ function markTaskCompleted(string $task): bool
$state = getInstallationState();
// completed_tasks 배열에 추가 (중복 방지)
if (!in_array($task, $state['completed_tasks'])) {
if (! in_array($task, $state['completed_tasks'])) {
$state['completed_tasks'][] = $task;
}
@@ -205,7 +212,7 @@ function markTaskCompleted(string $task): bool
/**
* 특정 작업을 완료 목록에서 제거
*
* @param string $task 작업 식별자
* @param string $task 작업 식별자
* @return bool 업데이트 성공 여부
*/
function removeTaskCompleted(string $task): bool
@@ -213,7 +220,7 @@ function removeTaskCompleted(string $task): bool
$state = getInstallationState();
$state['completed_tasks'] = array_values(
array_filter($state['completed_tasks'], fn($t) => $t !== $task)
array_filter($state['completed_tasks'], fn ($t) => $t !== $task)
);
return saveInstallationState($state);
@@ -251,7 +258,7 @@ function removeTaskCompleted(string $task): bool
*
* @param int $staleSeconds heartbeat 가 이 시간 이상 갱신 안 되면 stale 판정 (default 15초)
* @return array{acquired: bool, worker_id: string|null, reason: string}
* reason: 'available' | 'takeover_stale' | 'busy'
* reason: 'available' | 'takeover_stale' | 'busy'
*/
function acquireWorkerLock(int $staleSeconds = 15): array
{
@@ -337,7 +344,7 @@ function applyExistingDbActionStateGuard(array $state, string $newAction): array
$resetTasks = ['db_cleanup', 'db_migrate', 'db_seed'];
$state['completed_tasks'] = array_values(array_filter(
$state['completed_tasks'] ?? [],
fn($t) => !in_array($t, $resetTasks, true)
fn ($t) => ! in_array($t, $resetTasks, true)
));
} else {
// 키 정규화 — completed_tasks 가 부재인 경우 빈 배열 보장
@@ -350,7 +357,7 @@ function applyExistingDbActionStateGuard(array $state, string $newAction): array
/**
* 현재 진행 중인 작업 업데이트
*
* @param string $task 작업 식별자
* @param string $task 작업 식별자
* @return bool 업데이트 성공 여부
*/
function updateCurrentTask(string $task): bool
@@ -369,7 +376,7 @@ function updateCurrentTask(string $task): bool
* 페이지 새로고침/재시작 시 로그가 즉시 표시되도록
* fflush() + clearstatcache()를 적용합니다.
*
* @param string $message 로그 메시지
* @param string $message 로그 메시지
* @return bool 저장 성공 여부
*/
/**
@@ -389,23 +396,25 @@ function addLogBatch(array $messages): bool
return true;
}
$logDir = BASE_PATH . '/storage/logs';
$logFile = $logDir . '/installation.log';
$logDir = BASE_PATH.'/storage/logs';
$logFile = $logDir.'/installation.log';
if (!is_dir($logDir)) {
if (! is_dir($logDir)) {
$created = @mkdir($logDir, 0775, true);
if (!$created) {
if (! $created) {
$msg = "[addLogBatch] Failed to create log directory: {$logDir}";
error_log($msg);
addLog($msg); // 재귀 가드가 차단하므로 안전 — error_log fallback 만 수행
return false;
}
}
if (!is_writable($logDir)) {
if (! is_writable($logDir)) {
$msg = "[addLogBatch] Log directory is not writable: {$logDir}";
error_log($msg);
addLog($msg); // 재귀 가드가 차단
return false;
}
@@ -425,9 +434,9 @@ function addLogBatch(array $messages): bool
}
clearstatcache(true, $logFile);
$isNewFile = !file_exists($logFile) || filesize($logFile) === 0;
$isNewFile = ! file_exists($logFile) || filesize($logFile) === 0;
if ($isNewFile) {
$entries = "\xEF\xBB\xBF" . $entries;
$entries = "\xEF\xBB\xBF".$entries;
}
$handle = @fopen($logFile, 'a');
@@ -435,6 +444,7 @@ function addLogBatch(array $messages): bool
$msg = "[addLogBatch] Failed to open log file: {$logFile}";
error_log($msg);
addLog($msg); // 재귀 가드가 차단
return false;
}
flock($handle, LOCK_EX);
@@ -455,7 +465,8 @@ function addLog(string $message): bool
static $inAddLog = false;
if ($inAddLog) {
// 재진입: installation.log 시도 skip, PHP error_log 만 호출하고 즉시 종료
error_log("[addLog reentrant] " . $message);
error_log('[addLog reentrant] '.$message);
return false;
}
$inAddLog = true;
@@ -478,25 +489,27 @@ function addLog(string $message): bool
*/
function _addLogInternal(string $message): bool
{
$logDir = BASE_PATH . '/storage/logs';
$logFile = $logDir . '/installation.log';
$logDir = BASE_PATH.'/storage/logs';
$logFile = $logDir.'/installation.log';
// 로그 디렉토리 확인 및 생성 시도
if (!is_dir($logDir)) {
if (! is_dir($logDir)) {
$created = @mkdir($logDir, 0775, true);
if (!$created) {
if (! $created) {
$msg = "[addLog] Failed to create log directory: {$logDir} (storage 권한 확인 필요)";
error_log($msg);
addLog($msg); // 재귀 가드가 차단 (본 함수는 이미 가드 안)
return false;
}
}
// 로그 디렉토리 쓰기 권한 확인
if (!is_writable($logDir)) {
if (! is_writable($logDir)) {
$msg = "[addLog] Log directory is not writable: {$logDir}";
error_log($msg);
addLog($msg); // 재귀 가드가 차단
return false;
}
@@ -508,6 +521,17 @@ function _addLogInternal(string $message): bool
}
}
// OS 무관 최종 방어 — 항상 유효 UTF-8 만 로그에 저장한다 (gnuboard/g7#62).
// composer install 등 외부 프로세스 stdout 이 시스템 코드페이지로 출력되거나
// 진행바(\r 갱신)가 임의 바이트 경계에서 잘려 mb_detect_encoding 이 확정 감지에
// 실패한 경우(위 분기 미적용), invalid 바이트가 그대로 로그에 남으면
// 폴링 응답 state-management.php 의 json_encode 가 false 를 반환해 빈 본문(HTTP 200)
// → 프론트 res.json() 이 "Unexpected end of JSON input" 으로 폭주한다.
// mb_scrub 은 invalid 바이트를 U+FFFD 로 치환해 응답 무력화를 원천 차단한다.
if (! mb_check_encoding($message, 'UTF-8')) {
$message = mb_scrub($message, 'UTF-8');
}
// 타임스탬프와 함께 로그 작성 — millisecond 정밀도 (hang 진단 시 정확한 timing 필요)
$microtime = microtime(true);
$ms = sprintf('%03d', (int) (($microtime - floor($microtime)) * 1000));
@@ -518,10 +542,10 @@ function _addLogInternal(string $message): bool
clearstatcache(true, $logFile);
// 파일이 없거나 빈 파일이면 UTF-8 BOM 추가 (Windows 텍스트 편집기 호환성)
$isNewFile = !file_exists($logFile) || filesize($logFile) === 0;
$isNewFile = ! file_exists($logFile) || filesize($logFile) === 0;
if ($isNewFile) {
$utf8Bom = "\xEF\xBB\xBF";
$logEntry = $utf8Bom . $logEntry;
$logEntry = $utf8Bom.$logEntry;
}
// 파일 핸들 열기 (append 모드)
@@ -530,6 +554,7 @@ function _addLogInternal(string $message): bool
$msg = "[addLog] Failed to open log file: {$logFile}";
error_log($msg);
addLog($msg); // 재귀 가드가 차단
return false;
}
@@ -553,6 +578,7 @@ function _addLogInternal(string $message): bool
$msg = "[addLog] Failed to write log file: {$logFile}";
error_log($msg);
addLog($msg); // 재귀 가드가 차단
return false;
}
@@ -565,16 +591,16 @@ function _addLogInternal(string $message): bool
* 페이지 새로고침 시 최신 로그를 즉시 표시하기 위해
* clearstatcache()를 적용합니다.
*
* @param int $offset 건너뛸 로그 줄 수 (폴링 모드 증분 조회용, 기본 0)
* @param int $offset 건너뛸 로그 줄 수 (폴링 모드 증분 조회용, 기본 0)
* @return array 로그 배열 [{timestamp, message}, ...]
*/
function getInstallationLogs(int $offset = 0): array
{
$logFile = BASE_PATH . '/storage/logs/installation.log';
$logFile = BASE_PATH.'/storage/logs/installation.log';
clearstatcache(true, $logFile);
if (!file_exists($logFile)) {
if (! file_exists($logFile)) {
return [];
}
@@ -621,11 +647,11 @@ function getInstallationLogs(int $offset = 0): array
*/
function getInstallationLogCount(): int
{
$logFile = BASE_PATH . '/storage/logs/installation.log';
$logFile = BASE_PATH.'/storage/logs/installation.log';
clearstatcache(true, $logFile);
if (!file_exists($logFile)) {
if (! file_exists($logFile)) {
return 0;
}
@@ -637,10 +663,11 @@ function getInstallationLogCount(): int
$lines = explode("\n", trim($content));
$count = 0;
foreach ($lines as $line) {
if (!empty($line)) {
if (! empty($line)) {
$count++;
}
}
return $count;
}
@@ -655,7 +682,7 @@ function getLastCompletedStep(): int
// step_status를 역순으로 확인
for ($step = 5; $step >= 0; $step--) {
$stepKey = (string)$step;
$stepKey = (string) $step;
if (isset($state['step_status'][$stepKey]) && $state['step_status'][$stepKey] === 'completed') {
return $step;
}
+21 -9
View File
@@ -6,11 +6,8 @@
* SSE/폴링 듀얼 모드 지원을 위한 진행 상황 보고 인터페이스.
* - SseEmitter: 실시간 SSE 스트리밍 + addLog
* - NullEmitter: addLog만 수행 (폴링 모드, state.json 기반 조회)
*
* @package G7\Installer
*/
if (!defined('BASE_PATH')) {
if (! defined('BASE_PATH')) {
define('BASE_PATH', realpath(dirname(__DIR__, 3)) ?: dirname(__DIR__, 3));
}
@@ -19,8 +16,8 @@ interface ProgressEmitter
/**
* 임의 이벤트 송출 (SSE 이벤트 타입과 데이터 배열).
*
* @param string $event 이벤트 타입 (connected, task_start, task_complete, log, completed, error, aborted, rollback_failed)
* @param array $data 이벤트 데이터
* @param string $event 이벤트 타입 (connected, task_start, task_complete, log, completed, error, aborted, rollback_failed)
* @param array $data 이벤트 데이터
*/
public function emit(string $event, array $data): void;
@@ -57,8 +54,22 @@ class SseEmitter implements ProgressEmitter
public function emit(string $event, array $data): void
{
// SSE data 라인은 항상 유효 JSON 이어야 한다 (gnuboard/g7#62).
// composer install 등 외부 프로세스 로그에 invalid UTF-8 바이트가 섞이면
// json_encode 가 false 를 반환해 "data: \n\n" (빈 값) 이 송출되고,
// 프론트 EventSource 리스너의 JSON.parse(e.data) 가 throw 되어 로그 이벤트가 유실된다.
// 폴링 응답(state-management)과 동일하게 JSON_INVALID_UTF8_SUBSTITUTE 로 치환하고
// false 를 가드한다. (task-runner 의 스트림 scrub 과 이중 안전망)
$payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
if ($payload === false) {
$payload = json_encode(
['message' => '(log line dropped: encoding error)'],
JSON_UNESCAPED_UNICODE
);
}
echo "event: {$event}\n";
echo "data: " . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n";
echo 'data: '.$payload."\n\n";
if (ob_get_level()) {
@ob_flush();
}
@@ -195,9 +206,10 @@ class NullEmitter implements ProgressEmitter
*/
function getProgressEmitter(): ProgressEmitter
{
if (!isset($GLOBALS['g7_progress_emitter']) || !($GLOBALS['g7_progress_emitter'] instanceof ProgressEmitter)) {
$GLOBALS['g7_progress_emitter'] = new SseEmitter();
if (! isset($GLOBALS['g7_progress_emitter']) || ! ($GLOBALS['g7_progress_emitter'] instanceof ProgressEmitter)) {
$GLOBALS['g7_progress_emitter'] = new SseEmitter;
}
return $GLOBALS['g7_progress_emitter'];
}
File diff suppressed because it is too large Load Diff