workerId = $workerId; } public function emit(string $event, array $data): void { // SSE data 라인은 항상 유효 JSON 이어야 한다 (gnuboard/g7#62). // // composer install 등 외부 프로세스 로그에 invalid UTF-8 바이트가 섞이면 // 표준 json_encode 는 false 를 반환하고, 그러면 빈 data 라인이 송출되어 // 프론트 EventSource 리스너의 JSON.parse(e.data) 가 throw 되고 로그 이벤트가 유실된다. // // installer_json_encode 는 값을 정규화하고 substitute 를 적용하며, // 실패하더라도 파싱 가능한 오류 JSON 을 돌려주므로 빈 data 라인이 나갈 수 없다. $payload = installer_json_encode($data); echo "event: {$event}\n"; echo 'data: '.$payload."\n\n"; if (ob_get_level()) { @ob_flush(); } flush(); // log 이벤트는 installation.log에도 기록 (disconnect 감지 전에 우선 기록) if ($event === 'log' && isset($data['message'])) { addLog($data['message']); } // 워커 lock heartbeat 갱신 — 자기가 owner 가 아니면 즉시 종료 (다른 워커 takeover) $this->exitIfNotOwner(); // flush 직후 connection 상태 검사 — disconnect 면 워커 즉시 종료. // 이슈 #319 부수 발견: SSE 워커가 disconnect 후 백그라운드에서 진행을 // 계속하여 폴링 워커와 race condition 발생. flush 시 EPIPE 가 발생해야 // connection_status() 가 NORMAL 외 값을 반환하므로 emit 마다 검사. $this->exitIfDisconnected(); } /** * 자기가 워커 lock owner 가 아니면 워커 종료. * * 다른 워커가 stale takeover 한 경우 자기는 stale loser 이므로 즉시 종료하여 * race condition 차단. workerId 가 null 이면 lock 미사용 모드 (legacy). */ private function exitIfNotOwner(): void { if ($this->workerId === null || $this->aborting) { return; } if (refreshWorkerHeartbeat($this->workerId)) { return; } $this->aborting = true; addLog('=== SSE Worker lock lost — another worker took over, aborting ==='); exit(0); } public function isConnectionAborted(): bool { return connection_aborted() === 1 || connection_status() !== CONNECTION_NORMAL; } /** * 클라이언트 연결이 끊겼으면 워커 프로세스를 즉시 종료한다. * * 폴링 워커가 같은 인스톨러 흐름에 진입한 직후, 백그라운드에서 살아있는 * SSE 워커가 DB 를 추가 조작하여 race 가 발생하는 것을 차단한다. */ private function exitIfDisconnected(): void { if ($this->aborting) { return; } if (! $this->isConnectionAborted()) { return; } $this->aborting = true; addLog('=== SSE Worker disconnected — aborting ==='); exit(0); } } /** * No-op 진행 상황 송출 (폴링 모드 전용). * * SSE 이벤트는 브라우저로 보내지 않고, log 메시지만 installation.log에 기록합니다. * 프론트엔드는 state-management.php?action=get 폴링으로 진행 상황을 조회합니다. */ class NullEmitter implements ProgressEmitter { /** * 워커 lock owner ID (시나리오 B). null 이면 lock 무관 모드 (legacy). */ private ?string $workerId; /** * lock 손실 후 종료 처리 락 (재진입 방지). */ private bool $aborting = false; public function __construct(?string $workerId = null) { $this->workerId = $workerId; } public function emit(string $event, array $data): void { if ($event === 'log' && isset($data['message'])) { addLog($data['message']); } // 그 외 이벤트는 state.json 업데이트(task-runner.php 내부)로 커버됨 // 워커 lock heartbeat 갱신 — 자기가 owner 가 아니면 즉시 종료. $this->exitIfNotOwner(); } public function isConnectionAborted(): bool { // 폴링 모드: fastcgi_finish_request() 이후 호출되므로 connection_aborted()는 의미 없음. // 사용자 중단은 state.json의 installation_status === 'aborted'로 감지. return false; } /** * 자기가 워커 lock owner 가 아니면 워커 종료 (race 차단). */ private function exitIfNotOwner(): void { if ($this->workerId === null || $this->aborting) { return; } if (refreshWorkerHeartbeat($this->workerId)) { return; } $this->aborting = true; addLog('=== Polling Worker lock lost — another worker took over, aborting ==='); exit(0); } } /** * 현재 등록된 ProgressEmitter 조회. * * task-runner.php의 sendSSEEvent() 호환 래퍼가 이 함수를 통해 delegate 합니다. * 등록되지 않은 경우 안전한 기본값으로 SseEmitter를 반환합니다. */ function getProgressEmitter(): ProgressEmitter { if (! isset($GLOBALS['g7_progress_emitter']) || ! ($GLOBALS['g7_progress_emitter'] instanceof ProgressEmitter)) { $GLOBALS['g7_progress_emitter'] = new SseEmitter; } return $GLOBALS['g7_progress_emitter']; } /** * ProgressEmitter 등록. * * 모드별 진입점(install-worker.php / install-process.php 인라인 실행)에서 호출합니다. */ function setProgressEmitter(ProgressEmitter $emitter): void { $GLOBALS['g7_progress_emitter'] = $emitter; }