From 971906bc835c07fe5e91c83386ffd36066d20a04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=B0=AC?= Date: Sat, 11 Apr 2026 09:20:38 +0900 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20sidecar=20=EB=8F=99=EC=A0=81=20?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20+=20orphan=20=ED=94=84=EB=A1=9C=EC=84=B8?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=A9=EB=8F=8C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 증상: - 'Transcription failed: Sidecar 프로세스가 실행 중이 아닙니다' 에러 - 로그: [Errno 48] address already in use ('127.0.0.1', 18765) - 새로 spawn한 sidecar가 즉시 exit code 1로 종료 - 그러나 health check은 성공 — 이전 인스턴스의 orphan sidecar가 응답 - _sidecarProcess 핸들이 새 (실패한) ChildProcess라서 transcribe 시 null check가 통과 못 하고 STTSidecarNotRunning 에러 원인: - dev mode HMR/재시작 시 main process는 죽지만 spawn한 python 자식 프로세스가 orphan으로 살아남는 경우가 있음 (특히 SIGKILL이 자식까지 전파 안 될 때) - 다음 main process가 SIDECAR_PORT(18765)에 다시 바인드하려다 충돌 수정: - LocalSTTService._spawnSidecar 진입 시 _findFreePort(SIDECAR_PORT, 20) 호출 - net.createServer로 127.0.0.1:port listen 시도해 비어있는 첫 포트 탐색 - 18765부터 18784까지 순차 시도 - 모두 점유 시 STTSidecarSpawnFailed 에러 - this._port를 동적 할당 결과로 갱신 → spawn args + health/load/transcribe URL이 자동으로 새 포트 사용 영향: - orphan sidecar가 18765를 점유하고 있어도 새 sidecar가 18766/18767에서 정상 부팅 - 이전 orphan은 시간이 지나거나 사용자가 직접 kill하거나 app quit 시 정리 가능 - 클린 환경에서는 항상 18765로 복귀 --- .../src/main/services/LocalSTTService.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/apps/desktop/src/main/services/LocalSTTService.ts b/apps/desktop/src/main/services/LocalSTTService.ts index 60d134d..ff229b6 100644 --- a/apps/desktop/src/main/services/LocalSTTService.ts +++ b/apps/desktop/src/main/services/LocalSTTService.ts @@ -5,6 +5,7 @@ import { EventEmitter } from 'events' import { type ChildProcess, spawn } from 'child_process' +import { createServer } from 'net' import { getLogger } from './LoggerService' import { configGet } from './ConfigService' import { getSidecarCommand } from '../utils/paths' @@ -358,7 +359,55 @@ class LocalSTTService extends EventEmitter { // ── Sidecar 관리 ── + /** + * 시작 포트부터 maxAttempts개 포트 중 첫 번째 free 포트를 찾는다. + * dev mode 재시작으로 이전 sidecar가 orphan으로 남아있을 수 있어 + * 매번 동적 할당해서 충돌을 회피한다. + */ + private _findFreePort(startPort: number, maxAttempts: number): Promise { + return new Promise((resolve, reject) => { + let attempt = 0 + const tryPort = (port: number): void => { + const server = createServer() + server.unref() + server.once('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE' || err.code === 'EACCES') { + attempt += 1 + if (attempt >= maxAttempts) { + reject( + new D3ROError( + ErrorCode.STTSidecarSpawnFailed, + `Free port not found in range ${startPort}~${startPort + maxAttempts - 1}` + ) + ) + return + } + tryPort(port + 1) + } else { + reject( + new D3ROError( + ErrorCode.STTSidecarSpawnFailed, + `Port probe failed: ${err.message}` + ) + ) + } + }) + server.once('listening', () => { + server.close(() => { + resolve(port) + }) + }) + server.listen(port, '127.0.0.1') + } + tryPort(startPort) + }) + } + private async _spawnSidecar(): Promise { + // 포트 점유 시 동적으로 다음 free 포트 탐색 (최대 20번 시도). + // dev mode HMR/재시작으로 이전 sidecar가 orphan으로 남아있을 수 있음. + this._port = await this._findFreePort(SIDECAR_PORT, 20) + const { command, args } = getSidecarCommand() const fullArgs = [...args, '--port', String(this._port)] logger.info(`Sidecar 시작: ${command} ${fullArgs.join(' ')}`)