fix(desktop): sidecar 동적 포트 + orphan 프로세스 충돌 방지

증상:
- '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로 복귀
This commit is contained in:
윤찬 2026-04-11 09:20:38 +09:00
parent 2409cf9bd8
commit 971906bc83

View file

@ -5,6 +5,7 @@
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import { type ChildProcess, spawn } from 'child_process' import { type ChildProcess, spawn } from 'child_process'
import { createServer } from 'net'
import { getLogger } from './LoggerService' import { getLogger } from './LoggerService'
import { configGet } from './ConfigService' import { configGet } from './ConfigService'
import { getSidecarCommand } from '../utils/paths' import { getSidecarCommand } from '../utils/paths'
@ -358,7 +359,55 @@ class LocalSTTService extends EventEmitter {
// ── Sidecar 관리 ── // ── Sidecar 관리 ──
/**
* maxAttempts개 free .
* dev mode sidecar가 orphan으로
* .
*/
private _findFreePort(startPort: number, maxAttempts: number): Promise<number> {
return new Promise<number>((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<void> { private async _spawnSidecar(): Promise<void> {
// 포트 점유 시 동적으로 다음 free 포트 탐색 (최대 20번 시도).
// dev mode HMR/재시작으로 이전 sidecar가 orphan으로 남아있을 수 있음.
this._port = await this._findFreePort(SIDECAR_PORT, 20)
const { command, args } = getSidecarCommand() const { command, args } = getSidecarCommand()
const fullArgs = [...args, '--port', String(this._port)] const fullArgs = [...args, '--port', String(this._port)]
logger.info(`Sidecar 시작: ${command} ${fullArgs.join(' ')}`) logger.info(`Sidecar 시작: ${command} ${fullArgs.join(' ')}`)