diff --git a/apps/desktop/src/main/services/LocalSTTService.ts b/apps/desktop/src/main/services/LocalSTTService.ts index 429cbf3..fdf0c09 100644 --- a/apps/desktop/src/main/services/LocalSTTService.ts +++ b/apps/desktop/src/main/services/LocalSTTService.ts @@ -4,14 +4,14 @@ // 상태 머신 및 이중 조건 플러시 패턴 적용. import { EventEmitter } from 'events' -import { type ChildProcess, spawn } from 'child_process' -import { createServer } from 'net' import { existsSync } from 'fs' import { join } from 'path' import { getLogger } from './LoggerService' import { configGet } from './ConfigService' -import { getSidecarCommand, getSidecarBaseUrl, getWhisperModelsDir } from '../utils/paths' +import { getSidecarBaseUrl, getWhisperModelsDir } from '../utils/paths' import { getRuntimeProvisioner } from './RuntimeProvisioner' +import { SidecarSupervisor, type SidecarSupervisorEvents } from './stt/SidecarSupervisor' +import { isSidecarLaunchAvailableOffline, resolveSidecarLaunch } from './stt/sidecarLaunch' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { STTModel, @@ -59,13 +59,12 @@ export interface TranscribeOptions { modelId?: string /** 내부용 — 409 후 모델을 다시 올리고 재시도한 요청인지 */ retriedAfterLoad?: boolean -} - -/** sidecar /health 응답 */ -interface HealthResponse { - status: string - model: string | null - gpu: boolean + /** + * 모델이 아직 올라가 있지 않을 때, 모델 파일과 엔진이 이미 로컬에 있어야만 초기화한다. + * 없으면 내려받기(런타임 ~100MB, 모델 최대 수 GB)를 시작하지 않고 즉시 실패한다. + * 클라우드 실패 → 로컬 폴백처럼 "지금 바로 쓸 수 있을 때만" 로컬을 쓰는 경로용. + */ + requireInstalled?: boolean } /** sidecar /load 응답 */ @@ -120,10 +119,6 @@ export interface LocalSTTEvents { // ── 상수 ────────────────────────────────────────────────── -const SIDECAR_PORT = 18765 -const HEALTH_CHECK_INTERVAL_MS = 1000 -const HEALTH_CHECK_TIMEOUT_MS = 30000 -const MAX_RESTART_COUNT = 3 const SIDECAR_REQUEST_TIMEOUT_MS = 120000 /** 부분 전사(미리보기) 타임아웃 — 실패해도 무시되므로 짧게 잡는다 */ const SIDECAR_PARTIAL_TIMEOUT_MS = 15000 @@ -190,9 +185,29 @@ const MODEL_CATALOG: STTModel[] = [ const logger = getLogger('LocalSTTService') +/** 테스트/조립용 의존성 — 기본값은 실제 사이드카 감독자 */ +export interface LocalSTTServiceDeps { + supervisor?: SidecarSupervisor +} + +function createDefaultSupervisor(): SidecarSupervisor { + return new SidecarSupervisor({ + resolveLaunch: resolveSidecarLaunch, + modelsDir: getWhisperModelsDir, + baseUrlFor: getSidecarBaseUrl, + }) +} + class LocalSTTService extends EventEmitter { - constructor() { + /** 사이드카 프로세스 수명주기(기동·헬스·크래시 재시작·종료) 담당 */ + private readonly _supervisor: SidecarSupervisor + + constructor(deps: LocalSTTServiceDeps = {}) { super() + this._supervisor = deps.supervisor ?? createDefaultSupervisor() + this._supervisor.on('exit', () => this._onSidecarExit()) + this._supervisor.on('crashed', (payload) => this._onSidecarCrashed(payload)) + this._supervisor.on('gave-up', ({ error }) => this._onSidecarGaveUp(error)) // EventEmitter는 'error' 리스너가 없으면 emit 시 프로세스 예외를 던진다 // (실측: sidecar crash 루프 중 ERR_UNHANDLED_ERROR). 기본 sink로 방지 — // 실제 로깅은 _emitError에서 수행. @@ -204,15 +219,10 @@ class LocalSTTService extends EventEmitter { } private _state: STTState = STTState.Uninitialized - private _sidecarProcess: ChildProcess | null = null - private _sidecarStarting: Promise | null = null - private _port: number = SIDECAR_PORT private _currentModelId: string | null = null /** 보조 자리에 올린 모델 (실시간 자막 전용 모델 등). 사이드카가 죽으면 함께 사라진다 */ private _auxModelId: string | null = null - private _restartCount: number = 0 private _disposed: boolean = false - private _gpuAccelerated: boolean = false // ── 준비 상태 ── // 예전엔 모델이 준비되지 않았을 때 transcribe()를 단일 슬롯(_pendingResolve)에 걸어 두고 @@ -235,9 +245,9 @@ class LocalSTTService extends EventEmitter { return this._currentModelId } - /** sidecar HTTP 기본 URL — IPv4 루프백 고정 (localhost는 ::1로 해석되어 실패) */ + /** sidecar HTTP 기본 URL — IPv4 루프백 고정, 포트는 감독자가 동적으로 정한다 */ private get _baseUrl(): string { - return getSidecarBaseUrl(this._port) + return this._supervisor.baseUrl } // ── 공개 메서드 ── @@ -290,14 +300,16 @@ class LocalSTTService extends EventEmitter { this._errorEmitted = false try { - // sidecar가 아직 실행 중이 아니면 시작 - await this._ensureSidecarRunning() + // sidecar가 헬스를 통과할 때까지 기다린다 (필요하면 기동) + await this._supervisor.ready() // 모델 로딩 await this._loadModel(targetModel) this._currentModelId = targetModel this._modelReady = true this._setState(STTState.Ready) + // 모델까지 올라갔으면 사이드카는 안정적이다 — 크래시 재시작 카운트 초기화 + this._supervisor.markStable() logger.info(`STT 초기화 완료: 모델=${targetModel}`) } catch (err) { @@ -337,6 +349,7 @@ class LocalSTTService extends EventEmitter { // 모델이 아직 준비되지 않았으면 진행 중인 초기화를 기다리거나 직접 초기화한다. // 초기화가 실패하면 그 에러로 reject 된다 — 절대 끝나지 않는 promise를 돌려주지 않는다. if (!this._modelReady) { + if (options?.requireInstalled) this._assertInstalledLocally() await this._ensureModelReady() } @@ -365,6 +378,27 @@ class LocalSTTService extends EventEmitter { } } + /** + * 설정된 모델과 엔진을 내려받기 없이 쓸 수 있는지 확인한다 (requireInstalled 경로). + * 모델이 없으면 사이드카 /load가 HF에서 암묵적으로 내려받고, 엔진이 없으면 런타임을 내려받는다 — + * 폴백에서는 둘 다 하지 않고 즉시 실패한다. + */ + private _assertInstalledLocally(): void { + const modelId = configGet('sttModelId') + if (!modelId || !existsSync(join(getWhisperModelsDir(), modelId, 'model.bin'))) { + throw new D3ROError( + ErrorCode.STTModelNotFound, + `로컬 STT 모델이 설치되어 있지 않습니다: ${modelId || '(미선택)'}`, + ) + } + if (!this._supervisor.isHealthy && !isSidecarLaunchAvailableOffline()) { + throw new D3ROError( + ErrorCode.STTEngineNotInstalled, + '로컬 음성 엔진이 설치되어 있지 않습니다', + ) + } + } + /** * 앱 시작 시 sidecar와 모델을 미리 데운다. * 첫 받아쓰기에서 모델 로딩(수초)을 기다리지 않게 하는 것이 목적이므로 @@ -402,9 +436,8 @@ class LocalSTTService extends EventEmitter { * 입력 인텔리전스(제안/학습)는 UIA 브리지가 사이드카에 있으므로 STT 모델 없이도 * 사이드카 HTTP 서버가 필요하다. 실패하면 예외를 그대로 올린다. */ - async ensureSidecar(): Promise { - await this._ensureSidecarRunning() - return this._baseUrl + ensureSidecar(): Promise { + return this._supervisor.ready() } /** @@ -419,7 +452,7 @@ class LocalSTTService extends EventEmitter { if (this._disposed) return '' if (!this._modelReady) return '' if (audioBuffer.length === 0) return '' - if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) return '' + if (!this._supervisor.isHealthy) return '' try { const result = await this._sendToSidecar(audioBuffer, { @@ -461,7 +494,7 @@ class LocalSTTService extends EventEmitter { ) } - await this._ensureSidecarRunning() + await this._supervisor.ready() const startRes = await fetch(`${this._baseUrl}/download`, { method: 'POST', @@ -566,7 +599,7 @@ class LocalSTTService extends EventEmitter { engineState: stateMap[this._state], activeModel: this._currentModelId, engineVersion: null, - gpuAccelerated: this._gpuAccelerated, + gpuAccelerated: this._supervisor.gpuAccelerated, } } @@ -581,36 +614,12 @@ class LocalSTTService extends EventEmitter { this._modelReady = false this._auxModelId = null - await this._shutdownSidecar() + await this._supervisor.dispose() this._setState(STTState.Uninitialized) logger.info('LocalSTTService dispose 완료') } - // ── Sidecar 관리 ── - - /** - * sidecar가 실행 중이 아니면 spawn + 헬스체크 대기. - * - * 동시 호출은 하나의 기동 작업을 공유한다. STT 예열과 UIA 스냅샷이 같은 순간에 - * (특히 런타임 다운로드를 함께 기다린 뒤) 각각 spawn 해 두 번째가 포트 충돌로 - * 죽고 crash 재시작까지 돌던 문제(실측 Errno 10048)를 막는다. - */ - private _ensureSidecarRunning(): Promise { - if (this._sidecarProcess && this._sidecarProcess.exitCode === null) { - return Promise.resolve() - } - if (!this._sidecarStarting) { - this._sidecarStarting = (async () => { - await this._spawnSidecar() - await this._waitForHealth() - })().finally(() => { - this._sidecarStarting = null - }) - } - return this._sidecarStarting - } - private _emitDownloadProgress( modelId: string, percent: number, @@ -627,242 +636,12 @@ class LocalSTTService extends EventEmitter { }) } - /** - * 시작 포트부터 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) - - // 설치본에는 엔진이 없다 — 없으면 여기서 feed에서 내려받고 산다. dev는 venv/번들 경로를 쓴다. - const launch = await this._resolveSidecarLaunch() - const fullArgs = [ - ...launch.args, - '--port', - String(this._port), - '--models-dir', - getWhisperModelsDir(), - ] - logger.info( - `Sidecar 시작(${launch.source}): ${launch.command} ${fullArgs.join(' ')}`, - ) - - return new Promise((resolve, reject) => { - let settled = false - const child = spawn(launch.command, fullArgs, { - stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - // 사이드카 로그와 파일 경로가 UTF-8로 오가도록 고정 (Windows cp949 깨짐 방지) - PYTHONIOENCODING: 'utf-8', - PYTHONUTF8: '1', - }, - // Windows에서 콘솔 창이 깜빡이지 않게 한다. - windowsHide: true, - }) - this._sidecarProcess = child - - this._pipeSidecarLogs(child, getLogger('sidecar')) - - // spawn 성공 = 프로세스가 실제로 시작됨. 즉시 resolve해 healthcheck로 넘어간다. - child.once('spawn', () => { - if (settled) return - settled = true - resolve() - }) - - // spawn 실패(ENOENT 등)는 즉시 실패시킨다. 예전엔 즉시 resolve 후 - // healthcheck 30초를 헛되게 태우고 원인을 숨겼다. - child.once('error', (err: Error) => { - logger.error(`Sidecar 프로세스 에러: ${err.message}`) - this._sidecarProcess = null - if (settled) return - settled = true - reject(this._spawnFailureError(err, launch)) - }) - - child.on('exit', (code: number | null, signal: string | null) => { - logger.warn(`Sidecar 프로세스 종료: code=${code}, signal=${signal}`) - if (this._sidecarProcess === child) { - this._sidecarProcess = null - } - this._modelReady = false - this._auxModelId = null - - if (!this._disposed) { - this._handleSidecarCrash() - } - }) - }) - } - - /** sidecar stdout/stderr를 줄 단위로 로그에 흘려보낸다. */ - private _pipeSidecarLogs( - child: ChildProcess, - sidecarLogger: ReturnType, - ): void { - const consume = ( - stream: NodeJS.ReadableStream | null | undefined, - write: (message: string) => void, - ): void => { - if (!stream) return - let pending = '' - stream.on('data', (chunk: Buffer) => { - pending += chunk.toString('utf8') - const lines = pending.split(/\r?\n/) - // 마지막 조각은 줄이 완성되지 않았을 수 있으니 다음 청크와 합친다. - pending = lines.pop() ?? '' - for (const line of lines) { - const trimmed = line.trim() - if (trimmed) write(trimmed) - } - }) - } - - consume(child.stdout, (message) => sidecarLogger.info(message)) - consume(child.stderr, (message) => sidecarLogger.warn(message)) - } - - /** - * 사이드카 실행 방법을 결정한다. - * 설치본에서 엔진이 아직 없으면 feed에서 내려받아 설치한 뒤 경로를 돌려준다. - * 진행률은 runtime-progress 이벤트로 노출된다. - */ - private async _resolveSidecarLaunch(): Promise<{ - command: string - args: string[] - source: 'bundled' | 'provisioned' | 'venv' | 'python' - }> { - try { - return getSidecarCommand() - } catch (err) { - const needsInstall = - err instanceof D3ROError && err.code === ErrorCode.STTEngineNotInstalled - if (!needsInstall) throw err - } - - // ensure()가 설치 여부와 버전(앱 업데이트로 낡아졌는지)을 함께 판단한다 — - // 이미 최신이면 바로 기존 경로를 돌려주고, 없거나 낡았으면 새로 받는다. - logger.info('로컬 음성 엔진을 확인합니다 (없거나 낡았으면 새로 받습니다)') - const binaryPath = await getRuntimeProvisioner().ensure('sidecar') - logger.info(`사이드카 경로 확정: ${binaryPath} (provisioned)`) - return { command: binaryPath, args: [], source: 'provisioned' } - } - - /** sidecar 기동 실패 원인을 사용자가 조치할 수 있는 문구로 바꾼다. */ - private _spawnFailureError( - err: Error, - launch: { command: string; source: 'bundled' | 'provisioned' | 'venv' | 'python' }, - ): D3ROError { - const enoent = (err as NodeJS.ErrnoException).code === 'ENOENT' - if (!enoent) { - return new D3ROError( - ErrorCode.STTSidecarSpawnFailed, - `Sidecar 프로세스 에러: ${err.message}`, - ) - } - - const hint = - launch.source === 'bundled' || launch.source === 'provisioned' - ? '번들된 사이드카 실행 파일이 손상되었거나 백신이 차단했습니다. 앱을 다시 설치하세요.' - : launch.source === 'venv' - ? '사이드카 가상환경이 손상되었습니다. `npm --prefix apps/desktop run sidecar:setup`을 실행하세요.' - : '시스템 Python을 찾을 수 없습니다. `npm --prefix apps/desktop run sidecar:setup`으로 가상환경을 만드세요.' - - return new D3ROError( - ErrorCode.STTSidecarSpawnFailed, - `Sidecar 실행 파일을 찾을 수 없습니다: ${launch.command} (${launch.source}). ${hint}`, - ) - } - - private async _waitForHealth(): Promise { - const startTime = Date.now() - - while (Date.now() - startTime < HEALTH_CHECK_TIMEOUT_MS) { - // 프로세스가 이미 죽었으면 30초 타임아웃을 기다리지 않고 즉시 실패 - // (의존성 미설치 등 즉사 크래시가 30초×큐잉으로 증폭되는 문제 방지) - if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) { - throw new D3ROError( - ErrorCode.STTSidecarSpawnFailed, - 'Sidecar process exited during startup — check Python environment/dependencies', - ) - } - - try { - const response = await fetch(`${this._baseUrl}/health`, { - signal: AbortSignal.timeout(2000), - }) - - if (response.ok) { - const data = (await response.json()) as HealthResponse - this._gpuAccelerated = data.gpu - this._restartCount = 0 - logger.info( - `Sidecar 헬스체크 성공: status=${data.status}, gpu=${data.gpu}`, - ) - return - } - } catch { - // 아직 준비 안 됨, 재시도 - } - - await this._sleep(HEALTH_CHECK_INTERVAL_MS) - } - - throw new D3ROError( - ErrorCode.STTSidecarCommunicationFailed, - `Sidecar 헬스체크 타임아웃 (${HEALTH_CHECK_TIMEOUT_MS}ms)`, - ) - } - /** * 받아쓰기(기본) 모델과 다른 모델을 보조 자리에 올린다 — 실시간 자막처럼 따로 고른 모델용. * 기본 모델과 같으면 아무것도 하지 않는다. */ async ensureAuxModel(modelId: string): Promise { - await this._ensureSidecarRunning() + await this._supervisor.ready() if (modelId === this._currentModelId || modelId === this._auxModelId) return await this._loadModel(modelId, 'aux') this._auxModelId = modelId @@ -909,7 +688,7 @@ class LocalSTTService extends EventEmitter { audioBuffer: Buffer, options?: TranscribeOptions, ): Promise { - if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) { + if (!this._supervisor.isHealthy) { throw new D3ROError( ErrorCode.STTSidecarCommunicationFailed, 'Sidecar 프로세스가 실행 중이 아닙니다', @@ -1034,34 +813,35 @@ class LocalSTTService extends EventEmitter { } } - // ── Sidecar Crash 처리 ── + // ── Sidecar 수명주기 이벤트 (SidecarSupervisor) ── - private _handleSidecarCrash(): void { + /** 사이드카가 죽으면 올려 둔 모델도 함께 사라진다 */ + private _onSidecarExit(): void { + this._modelReady = false + this._auxModelId = null + } + + /** + * 예기치 않은 종료 — 감독자가 백오프 뒤 재시작을 예약했다. + * 모델이 올라가 있었거나 올리는 중이었으면, 재시작에 합류해 그 모델을 다시 올린다. + * 재시작을 "진행 중인 초기화"로 즉시 기록한다 — 대기 구간에 들어온 transcribe()가 + * 초기화를 따로 경쟁시키지 않고 재시작 결과를 기다린다(실패하면 그 에러로 reject). + * 모델이 없었으면(UIA 브리지만 쓰던 경우) 사이드카만 되살리고 모델은 올리지 않는다 — + * 설치하지 않은 모델을 /load 하면 사이드카가 암묵적으로 내려받기 때문이다. + */ + private _onSidecarCrashed(payload: SidecarSupervisorEvents['crashed']): void { if (this._disposed) return + logger.warn( + `Sidecar crash — ${payload.delayMs}ms 뒤 재시작 (${payload.attempt}/${payload.maxAttempts})`, + ) - this._restartCount++ - logger.warn(`Sidecar crash 감지, 재시작 시도 ${this._restartCount}/${MAX_RESTART_COUNT}`) - - if (this._restartCount > MAX_RESTART_COUNT) { - const err = new D3ROError( - ErrorCode.STTSidecarCrashed, - `Sidecar가 ${MAX_RESTART_COUNT}회 crash 후 재시작 포기`, - ) - this._setState(STTState.Error) - this._emitError(err) - return - } - - // 비동기 재시작 - const modelToReload = this._currentModelId ?? configGet('sttModelId') + const modelToReload = this._currentModelId ?? this._initializing?.modelId ?? null this._currentModelId = null this._modelReady = false this._auxModelId = null + if (!modelToReload) return - // 재시작을 "진행 중인 초기화"로 즉시 기록한다 — 대기 구간에 들어온 transcribe()가 - // 초기화를 따로 경쟁시키지 않고 재시작 결과를 기다린다(실패하면 그 에러로 reject). - const delayMs = 1000 * this._restartCount // 점진적 백오프 - const restart = new Promise((resolve) => setTimeout(resolve, delayMs)).then(async () => { + const restart = this._supervisor.ready().then(async () => { if (this._disposed) { throw new D3ROError(ErrorCode.STTTranscriptionCancelled, '서비스 종료로 재시작 취소') } @@ -1075,31 +855,10 @@ class LocalSTTService extends EventEmitter { this._trackInitialization(modelToReload, restart) } - private async _shutdownSidecar(): Promise { - if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) { - this._sidecarProcess = null - return - } - - logger.info('Sidecar 종료 요청') - - try { - // POST /shutdown 요청 - await fetch(`${this._baseUrl}/shutdown`, { - method: 'POST', - signal: AbortSignal.timeout(3000), - }) - } catch { - // 이미 종료되었거나 통신 불가 — 무시 - } - - // 프로세스가 아직 살아있으면 강제 종료 - if (this._sidecarProcess && this._sidecarProcess.exitCode === null) { - logger.warn('Sidecar graceful shutdown 실패, SIGKILL 전송') - this._sidecarProcess.kill('SIGKILL') - } - - this._sidecarProcess = null + private _onSidecarGaveUp(error: D3ROError): void { + if (this._disposed) return + this._setState(STTState.Error) + this._emitError(error) } // ── 내부 유틸 ── diff --git a/apps/desktop/src/main/services/stt/STTManager.ts b/apps/desktop/src/main/services/stt/STTManager.ts index 1e48584..56b9396 100644 --- a/apps/desktop/src/main/services/stt/STTManager.ts +++ b/apps/desktop/src/main/services/stt/STTManager.ts @@ -24,6 +24,7 @@ import { GoogleDriver } from './drivers/GoogleDriver' import { CustomDriver } from './drivers/CustomDriver' import { D3ROCloudDriver } from './drivers/D3ROCloudDriver' import { normalizeLoopbackUrl } from '../../utils/loopback' +import { isRecoverableByLocalFallback } from './fallbackPolicy' const logger = getLogger('STTManager') @@ -250,7 +251,8 @@ export class STTManager extends EventEmitter implements STTEngine { const errorMsg = error instanceof Error ? error.message : String(error) logger.warn(`Cloud STT (${provider}) failed: ${errorMsg}`) - if (fallbackToLocal) { + // 무음/취소처럼 로컬로 다시 해도 결과가 같은 실패는 폴백하지 않는다 + if (fallbackToLocal && isRecoverableByLocalFallback(error)) { logger.info(`Auto-fallback: executing Local Whisper transcription...`) this.emit('fallback-to-local', { provider, reason: errorMsg }) try { @@ -270,13 +272,15 @@ export class STTManager extends EventEmitter implements STTEngine { * 클라우드 공급자를 쓰는 동안에는 아무도 로컬 모델을 올리지 않는다 — LocalSTTService.transcribe가 * 설정된 모델을 스스로 올린 뒤 전사하고(실패하면 reject), 여기서는 전체에 상한 시간을 두어 * 받아쓰기 액션 큐가 굳지 않게 한다. + * requireInstalled: 모델·엔진이 이미 로컬에 있을 때만 쓴다. 없으면 폴백이 엔진(~100MB)과 + * 모델(최대 수 GB)을 조용히 내려받기 시작하고 그동안 핫키 큐가 멈춘다. */ private _transcribeLocalFallback( audioBuffer: Buffer, options?: TranscribeOptions, ): Promise { return withTimeout( - getLocalSTTService().transcribe(audioBuffer, options), + getLocalSTTService().transcribe(audioBuffer, { ...options, requireInstalled: true }), LOCAL_FALLBACK_TIMEOUT_MS, `Local STT fallback timed out after ${LOCAL_FALLBACK_TIMEOUT_MS}ms`, ) diff --git a/apps/desktop/src/main/services/stt/SidecarSupervisor.ts b/apps/desktop/src/main/services/stt/SidecarSupervisor.ts new file mode 100644 index 0000000..e5aa5f1 --- /dev/null +++ b/apps/desktop/src/main/services/stt/SidecarSupervisor.ts @@ -0,0 +1,495 @@ +// apps/desktop/src/main/services/stt/SidecarSupervisor.ts +// faster-whisper/UIA 사이드카 프로세스의 수명주기(포트 탐색 → spawn → 헬스 → 크래시 재시작 → 종료)만 맡는다. +// +// 예전에는 LocalSTTService가 이 일을 모델/전사 API와 함께 맡았고, "기동 중"과 "실행 중"이 +// _sidecarProcess·_sidecarStarting 두 필드에 흩어져 있었다. spawn 직후(헬스 전)에 들어온 +// 호출자가 "실행 중"으로 보고 통과해, 아직 listen하지 않는 서버에 /load·/uia/focus를 보내 +// ECONNREFUSED로 실패했다. 또 헬스 성공마다 재시작 카운트를 0으로 돌려, 모델 로딩 중 +// 반복 크래시가 MAX_RESTART에 도달하지 못하고 무한 재시작했다. +// +// 이제 상태는 하나(SidecarState)이고, ready()는 헬스 통과 뒤에만 resolve한다. +// 재시작 카운트는 "안정됨"(markStable — 모델 로딩 성공 — 또는 충분히 오래 살아 있었음)일 때만 초기화된다. + +import { EventEmitter } from 'events' +import { type ChildProcess, type SpawnOptions, spawn as nodeSpawn } from 'child_process' +import { createServer } from 'net' +import { getLogger } from '../LoggerService' +import { D3ROError, ErrorCode } from '@d3ro/core/errors' +import type { SidecarLaunch } from './sidecarLaunch' + +const logger = getLogger('SidecarSupervisor') + +export type SidecarState = + | 'stopped' + /** spawn + 헬스 대기 중 — ready() 호출자는 이 기동에 합류한다 */ + | 'starting' + /** 헬스 통과 — HTTP 요청을 보내도 된다 */ + | 'healthy' + /** 프로세스는 살아 있지만 헬스 타임아웃 — 다음 ready()는 재spawn 없이 헬스만 다시 기다린다 */ + | 'unresponsive' + /** 예기치 않게 종료되어 백오프 뒤 재시작 대기 중 — ready()는 그 재시작에 합류한다 */ + | 'crashed' + | 'disposed' + +export interface SidecarSupervisorEvents { + /** 현재 프로세스가 종료됨 (expected=dispose에 의한 종료) */ + exit: { code: number | null; signal: string | null; expected: boolean } + /** 예기치 않은 종료 — 백오프 뒤 재시작이 예약됨. 리스너는 ready()로 그 재시작에 합류할 수 있다 */ + crashed: { attempt: number; maxAttempts: number; delayMs: number } + /** 재시작 한도 초과 — 자동 재시작 포기 (다음 ready()는 새로 기동한다) */ + 'gave-up': { error: D3ROError } + healthy: { gpu: boolean; baseUrl: string } +} + +export type SidecarSpawnFn = (command: string, args: string[], options: SpawnOptions) => ChildProcess +export type SidecarFetchFn = (input: string, init?: RequestInit) => Promise + +export interface SidecarSupervisorOptions { + /** 실행 파일 결정 (필요하면 런타임을 내려받는다) */ + resolveLaunch: () => Promise + modelsDir: () => string + baseUrlFor: (port: number) => string + spawn?: SidecarSpawnFn + fetch?: SidecarFetchFn + findFreePort?: (startPort: number, maxAttempts: number) => Promise + sleep?: (ms: number) => Promise + now?: () => number + basePort?: number + healthTimeoutMs?: number + healthIntervalMs?: number + maxRestarts?: number + /** n번째 재시작은 restartBackoffMs × n 뒤에 한다 */ + restartBackoffMs?: number + /** 이만큼 헬스 상태로 살아 있었으면 크래시 시 재시작 카운트를 초기화한다 */ + stableUptimeMs?: number +} + +type ResolvedOptions = Required + +interface HealthResponse { + status: string + model: string | null + gpu: boolean +} + +export const SIDECAR_BASE_PORT = 18765 +const PORT_PROBE_ATTEMPTS = 20 + +const DEFAULTS = { + basePort: SIDECAR_BASE_PORT, + healthTimeoutMs: 30_000, + healthIntervalMs: 1_000, + maxRestarts: 3, + restartBackoffMs: 1_000, + stableUptimeMs: 5 * 60_000, +} as const + +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** + * 시작 포트부터 maxAttempts개 포트 중 첫 번째 free 포트를 찾는다. + * dev mode 재시작으로 이전 sidecar가 orphan으로 남아있을 수 있어 + * 매번 동적 할당해서 충돌을 회피한다. + */ +export function findFreeLoopbackPort(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) + }) +} + +/** sidecar 기동 실패 원인을 사용자가 조치할 수 있는 문구로 바꾼다. */ +function spawnFailureError(err: Error, launch: SidecarLaunch): D3ROError { + const enoent = (err as NodeJS.ErrnoException).code === 'ENOENT' + if (!enoent) { + return new D3ROError(ErrorCode.STTSidecarSpawnFailed, `Sidecar 프로세스 에러: ${err.message}`) + } + + const hint = + launch.source === 'bundled' || launch.source === 'provisioned' + ? '번들된 사이드카 실행 파일이 손상되었거나 백신이 차단했습니다. 앱을 다시 설치하세요.' + : launch.source === 'venv' + ? '사이드카 가상환경이 손상되었습니다. `npm --prefix apps/desktop run sidecar:setup`을 실행하세요.' + : '시스템 Python을 찾을 수 없습니다. `npm --prefix apps/desktop run sidecar:setup`으로 가상환경을 만드세요.' + + return new D3ROError( + ErrorCode.STTSidecarSpawnFailed, + `Sidecar 실행 파일을 찾을 수 없습니다: ${launch.command} (${launch.source}). ${hint}`, + ) +} + +/** sidecar stdout/stderr를 줄 단위로 로그에 흘려보낸다. */ +function pipeSidecarLogs(child: ChildProcess): void { + const sidecarLogger = getLogger('sidecar') + const consume = ( + stream: NodeJS.ReadableStream | null | undefined, + write: (message: string) => void, + ): void => { + if (!stream) return + let pending = '' + stream.on('data', (chunk: Buffer) => { + pending += chunk.toString('utf8') + const lines = pending.split(/\r?\n/) + // 마지막 조각은 줄이 완성되지 않았을 수 있으니 다음 청크와 합친다. + pending = lines.pop() ?? '' + for (const line of lines) { + const trimmed = line.trim() + if (trimmed) write(trimmed) + } + }) + } + + consume(child.stdout, (message) => sidecarLogger.info(message)) + consume(child.stderr, (message) => sidecarLogger.warn(message)) +} + +function isAlive(child: ChildProcess | null): child is ChildProcess { + return child !== null && child.exitCode === null && child.signalCode === null +} + +export class SidecarSupervisor extends EventEmitter { + private readonly _o: ResolvedOptions + private _state: SidecarState = 'stopped' + private _child: ChildProcess | null = null + private _port: number + private _starting: Promise | null = null + private _pendingRestart: Promise | null = null + private _restartCount = 0 + private _healthyAt: number | null = null + private _gpu = false + + constructor(options: SidecarSupervisorOptions) { + super() + this._o = { + spawn: (command, args, spawnOptions) => nodeSpawn(command, args, spawnOptions), + // 전역 fetch를 호출 시점에 찾는다 (테스트의 stubGlobal을 존중) + fetch: (input, init) => fetch(input, init), + findFreePort: findFreeLoopbackPort, + sleep: defaultSleep, + now: () => Date.now(), + ...DEFAULTS, + ...options, + } + this._port = this._o.basePort + } + + // ── 상태 조회 ── + + get state(): SidecarState { + return this._state + } + + /** 헬스를 통과했고 프로세스가 살아 있다 — HTTP 요청을 보내도 된다 */ + get isHealthy(): boolean { + return this._state === 'healthy' && isAlive(this._child) + } + + /** sidecar HTTP 기본 URL (동적 포트) — 기동 전에는 기본 포트 기준 */ + get baseUrl(): string { + return this._o.baseUrlFor(this._port) + } + + get gpuAccelerated(): boolean { + return this._gpu + } + + get restartCount(): number { + return this._restartCount + } + + // ── 수명주기 ── + + /** + * 헬스를 통과한 사이드카의 base URL을 돌려준다. 필요하면 기동한다. + * 동시 호출은 하나의 기동(또는 예약된 재시작)을 공유하며, 헬스 통과 전에는 절대 resolve하지 않는다. + */ + ready(): Promise { + if (this._state === 'disposed') { + return Promise.reject( + new D3ROError(ErrorCode.STTSidecarSpawnFailed, 'Sidecar 감독자가 이미 종료되었습니다'), + ) + } + if (this.isHealthy) return Promise.resolve(this.baseUrl) + if (this._pendingRestart) return this._pendingRestart + if (this._starting) return this._starting + return this._start() + } + + /** + * 사이드카가 실제 작업(모델 로딩 등)을 해낼 만큼 안정적임을 알린다 — 재시작 카운트 초기화. + * 헬스 통과만으로는 초기화하지 않는다: 로딩 중 반복 크래시가 한도에 도달해야 하기 때문이다. + */ + markStable(): void { + if (!this.isHealthy) return + this._restartCount = 0 + } + + /** 사이드카 종료(/shutdown → 남아 있으면 SIGKILL). 이후 ready()는 거부된다. */ + async dispose(): Promise { + if (this._state === 'disposed') return + this._setState('disposed') + this._pendingRestart = null + + const child = this._child + if (!isAlive(child)) { + this._child = null + return + } + + logger.info('Sidecar 종료 요청') + try { + await this._o.fetch(`${this.baseUrl}/shutdown`, { + method: 'POST', + signal: AbortSignal.timeout(3000), + }) + } catch { + // 이미 종료되었거나 통신 불가 — 무시 + } + + if (isAlive(child)) { + logger.warn('Sidecar graceful shutdown 실패, SIGKILL 전송') + child.kill('SIGKILL') + } + this._child = null + } + + // ── 기동 ── + + private _start(): Promise { + this._setState('starting') + const tracked: Promise = this._runStartup() + .catch((err: unknown) => { + // 기동 중 크래시로 이미 'crashed'(재시작 예약)가 됐으면 그 상태를 덮지 않는다 + if (this._state === 'starting') { + this._setState(isAlive(this._child) ? 'unresponsive' : 'stopped') + } + throw err + }) + .finally(() => { + if (this._starting === tracked) this._starting = null + }) + this._starting = tracked + return tracked + } + + private async _runStartup(): Promise { + let child: ChildProcess + if (isAlive(this._child)) { + // 헬스 타임아웃으로 'unresponsive'가 된 프로세스 — 느린 기동(백신 검사 등)일 수 있어 다시 기다린다 + logger.info('응답 없던 사이드카의 헬스를 다시 확인합니다') + child = this._child + } else { + // 포트 점유 시 동적으로 다음 free 포트 탐색. + this._port = await this._o.findFreePort(this._o.basePort, PORT_PROBE_ATTEMPTS) + // 설치본에는 엔진이 없을 수 있다 — 없으면 여기서 feed에서 내려받는다. + const launch = await this._o.resolveLaunch() + this._assertNotDisposed() + child = await this._spawn(launch) + } + + await this._waitForHealth(child) + this._healthyAt = this._o.now() + this._setState('healthy') + this.emit('healthy', { gpu: this._gpu, baseUrl: this.baseUrl }) + return this.baseUrl + } + + private _spawn(launch: SidecarLaunch): Promise { + const fullArgs = [...launch.args, '--port', String(this._port), '--models-dir', this._o.modelsDir()] + logger.info(`Sidecar 시작(${launch.source}): ${launch.command} ${fullArgs.join(' ')}`) + + return new Promise((resolve, reject) => { + let settled = false + const child = this._o.spawn(launch.command, fullArgs, { + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + // 사이드카 로그와 파일 경로가 UTF-8로 오가도록 고정 (Windows cp949 깨짐 방지) + PYTHONIOENCODING: 'utf-8', + PYTHONUTF8: '1', + }, + // Windows에서 콘솔 창이 깜빡이지 않게 한다. + windowsHide: true, + }) + this._child = child + this._healthyAt = null + + pipeSidecarLogs(child) + + // spawn 성공 = 프로세스가 실제로 시작됨. 헬스체크로 넘어간다. + child.once('spawn', () => { + if (settled) return + settled = true + resolve(child) + }) + + // spawn 실패(ENOENT 등)는 즉시 실패시킨다 — 헬스체크 30초를 헛되게 태우지 않는다. + child.once('error', (err: Error) => { + logger.error(`Sidecar 프로세스 에러: ${err.message}`) + if (settled) return + settled = true + if (this._child === child) this._child = null + reject(spawnFailureError(err, launch)) + }) + + child.on('exit', (code: number | null, signal: string | null) => { + this._onExit(child, code, signal) + }) + }) + } + + private async _waitForHealth(child: ChildProcess): Promise { + const startedAt = this._o.now() + + while (this._o.now() - startedAt < this._o.healthTimeoutMs) { + this._assertNotDisposed() + // 프로세스가 이미 죽었으면 타임아웃을 기다리지 않고 즉시 실패 + if (this._child !== child || !isAlive(child)) { + throw new D3ROError( + ErrorCode.STTSidecarSpawnFailed, + 'Sidecar process exited during startup — check Python environment/dependencies', + ) + } + + try { + const response = await this._o.fetch(`${this.baseUrl}/health`, { + signal: AbortSignal.timeout(2000), + }) + if (response.ok) { + const data = (await response.json()) as HealthResponse + this._gpu = data.gpu === true + logger.info(`Sidecar 헬스체크 성공: status=${data.status}, gpu=${data.gpu}`) + return + } + } catch { + // 아직 준비 안 됨, 재시도 + } + + await this._o.sleep(this._o.healthIntervalMs) + } + + throw new D3ROError( + ErrorCode.STTSidecarCommunicationFailed, + `Sidecar 헬스체크 타임아웃 (${this._o.healthTimeoutMs}ms)`, + ) + } + + // ── 종료/크래시 ── + + private _onExit(child: ChildProcess, code: number | null, signal: string | null): void { + logger.warn(`Sidecar 프로세스 종료: code=${code}, signal=${signal}`) + // 이미 교체되었거나 종료 처리된 프로세스 + if (this._child !== child) return + + this._child = null + const healthyAt = this._healthyAt + this._healthyAt = null + const expected = this._state === 'disposed' + this.emit('exit', { code, signal, expected }) + if (expected) return + + this._setState('crashed') + this._scheduleRestart(healthyAt) + } + + private _scheduleRestart(healthyAt: number | null): void { + if (healthyAt !== null && this._o.now() - healthyAt >= this._o.stableUptimeMs) { + this._restartCount = 0 + } + this._restartCount += 1 + const attempt = this._restartCount + const maxAttempts = this._o.maxRestarts + logger.warn(`Sidecar crash 감지, 재시작 시도 ${attempt}/${maxAttempts}`) + + if (attempt > maxAttempts) { + this._setState('stopped') + this.emit('gave-up', { + error: new D3ROError( + ErrorCode.STTSidecarCrashed, + `Sidecar가 ${maxAttempts}회 crash 후 재시작 포기`, + ), + }) + return + } + + const delayMs = this._o.restartBackoffMs * attempt // 점진적 백오프 + const pending: Promise = this._o.sleep(delayMs).then(() => { + if (this._pendingRestart === pending) this._pendingRestart = null + if (this._state === 'disposed') { + throw new D3ROError(ErrorCode.STTTranscriptionCancelled, '서비스 종료로 재시작 취소') + } + return this.ready() + }) + pending.catch((err: unknown) => { + logger.error(`Sidecar 재시작 실패: ${err instanceof Error ? err.message : String(err)}`) + }) + this._pendingRestart = pending + this.emit('crashed', { attempt, maxAttempts, delayMs }) + } + + // ── 내부 유틸 ── + + private _assertNotDisposed(): void { + if (this._state === 'disposed') { + throw new D3ROError(ErrorCode.STTTranscriptionCancelled, '서비스 종료로 사이드카 기동 취소') + } + } + + private _setState(next: SidecarState): void { + if (this._state === next) return + logger.debug(`SidecarState: ${this._state} -> ${next}`) + this._state = next + } + + // ── 타입 안전한 이벤트 메서드 ── + + override emit( + event: K, + payload: SidecarSupervisorEvents[K], + ): boolean { + return super.emit(event, payload) + } + + override on( + event: K, + listener: (payload: SidecarSupervisorEvents[K]) => void, + ): this { + return super.on(event, listener) + } + + override off( + event: K, + listener: (payload: SidecarSupervisorEvents[K]) => void, + ): this { + return super.off(event, listener) + } +} diff --git a/apps/desktop/src/main/services/stt/fallbackPolicy.ts b/apps/desktop/src/main/services/stt/fallbackPolicy.ts new file mode 100644 index 0000000..43dfa9d --- /dev/null +++ b/apps/desktop/src/main/services/stt/fallbackPolicy.ts @@ -0,0 +1,22 @@ +// apps/desktop/src/main/services/stt/fallbackPolicy.ts +// 클라우드 STT 실패 → 로컬 Whisper 폴백 여부를 결정하는 순수 정책. + +import { D3ROError, ErrorCode } from '@d3ro/core/errors' + +/** + * 로컬 엔진으로 다시 해도 결과가 달라지지 않는 실패. + * - 음성 없음/너무 짧음: 같은 오디오를 로컬로 돌려도 빈 결과다. 무음 핫키 한 번에 + * 로컬 모델 로딩(수 초)과 액션 큐 정지를 치를 이유가 없다. + * - 취소: 사용자가 이미 그만뒀다. + */ +const NON_RECOVERABLE_CODES: ReadonlySet = new Set([ + ErrorCode.STTNoAudioData, + ErrorCode.STTAudioTooShort, + ErrorCode.STTTranscriptionCancelled, +]) + +/** 클라우드 오류가 로컬 폴백으로 복구될 수 있는 종류인지 */ +export function isRecoverableByLocalFallback(error: unknown): boolean { + if (error instanceof D3ROError && NON_RECOVERABLE_CODES.has(error.code)) return false + return true +} diff --git a/apps/desktop/src/main/services/stt/sidecarLaunch.ts b/apps/desktop/src/main/services/stt/sidecarLaunch.ts new file mode 100644 index 0000000..a080451 --- /dev/null +++ b/apps/desktop/src/main/services/stt/sidecarLaunch.ts @@ -0,0 +1,56 @@ +// apps/desktop/src/main/services/stt/sidecarLaunch.ts +// 사이드카 실행 방법 결정(번들/venv/시스템 python/내려받은 런타임). +// 프로세스 수명주기(SidecarSupervisor)와 분리해, 감독자는 "무엇을 실행할지"를 주입받기만 한다. + +import { getLogger } from '../LoggerService' +import { getSidecarCommand } from '../../utils/paths' +import { getRuntimeProvisioner } from '../RuntimeProvisioner' +import { D3ROError, ErrorCode } from '@d3ro/core/errors' + +const logger = getLogger('SidecarLaunch') + +export type SidecarLaunchSource = 'bundled' | 'provisioned' | 'venv' | 'python' + +export interface SidecarLaunch { + command: string + args: string[] + source: SidecarLaunchSource +} + +function isEngineNotInstalled(err: unknown): boolean { + return err instanceof D3ROError && err.code === ErrorCode.STTEngineNotInstalled +} + +/** + * 사이드카 실행 방법을 결정한다. + * 설치본에서 엔진이 아직 없으면 feed에서 내려받아 설치한 뒤 경로를 돌려준다. + * 진행률은 RuntimeProvisioner 'progress' 이벤트로 노출된다. + */ +export async function resolveSidecarLaunch(): Promise { + try { + return getSidecarCommand() + } catch (err) { + if (!isEngineNotInstalled(err)) throw err + } + + // ensure()가 설치 여부와 버전(앱 업데이트로 낡아졌는지)을 함께 판단한다 — + // 이미 최신이면 바로 기존 경로를 돌려주고, 없거나 낡았으면 새로 받는다. + logger.info('로컬 음성 엔진을 확인합니다 (없거나 낡았으면 새로 받습니다)') + const binaryPath = await getRuntimeProvisioner().ensure('sidecar') + logger.info(`사이드카 경로 확정: ${binaryPath} (provisioned)`) + return { command: binaryPath, args: [], source: 'provisioned' } +} + +/** + * 내려받기 없이 사이드카를 띄울 수 있는지 (번들/venv/시스템 python 또는 이미 설치된 런타임). + * 폴백처럼 "지금 당장 쓸 수 있을 때만" 로컬 엔진을 쓰는 경로가 판정에 쓴다. + */ +export function isSidecarLaunchAvailableOffline(): boolean { + try { + getSidecarCommand() + return true + } catch (err) { + if (!isEngineNotInstalled(err)) return false + return getRuntimeProvisioner().isInstalled('sidecar') + } +} diff --git a/apps/desktop/tests/main/services/stt-sidecar-redteam-r2-6.test.ts b/apps/desktop/tests/main/services/stt-sidecar-redteam-r2-6.test.ts new file mode 100644 index 0000000..5aa5a14 --- /dev/null +++ b/apps/desktop/tests/main/services/stt-sidecar-redteam-r2-6.test.ts @@ -0,0 +1,377 @@ +// tests/main/services/stt-sidecar-redteam-r2-6.test.ts +// 1) 클라우드 STT 실패 → 로컬 폴백이 무음/미설치 상황에서 엔진·모델 내려받기를 조용히 시작하던 버그 +// 2) 기동 중(헬스 전) 사이드카를 '실행 중'으로 보고 통과시키던 버그 (SidecarSupervisor.ready) +// 3) 헬스 성공마다 재시작 카운트가 0으로 돌아가 로딩 중 반복 크래시가 끝나지 않던 버그 + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { EventEmitter } from 'events' +import { mkdirSync, rmSync, writeFileSync } from 'fs' +import { join } from 'path' +import type { ChildProcess } from 'child_process' +import { D3ROError, ErrorCode } from '@d3ro/core/errors' + +const launchMock = vi.hoisted(() => ({ offline: true })) + +vi.mock('../../../src/main/services/stt/sidecarLaunch', () => ({ + resolveSidecarLaunch: vi.fn(async () => ({ command: 'fake-sidecar', args: [], source: 'venv' as const })), + isSidecarLaunchAvailableOffline: vi.fn(() => launchMock.offline), +})) + +import { getSTTManager, resetSTTManagerForTests } from '../../../src/main/services/stt/STTManager' +import { + LocalSTTService, + getLocalSTTService, + resetLocalSTTServiceForTests, +} from '../../../src/main/services/LocalSTTService' +import type { TranscribeOptions, TranscriptionResult } from '../../../src/main/services/LocalSTTService' +import { SidecarSupervisor, type SidecarFetchFn } from '../../../src/main/services/stt/SidecarSupervisor' +import { isRecoverableByLocalFallback } from '../../../src/main/services/stt/fallbackPolicy' +import { initInMemoryConfig, resetInMemoryConfig, configSet } from '../../../src/main/services/ConfigService' +import { getWhisperModelsDir } from '../../../src/main/utils/paths' + +// ── 공용 헬퍼 ──────────────────────────────────────────── + +type LocalInternals = { + _modelReady: boolean + _sendToSidecar: (buf: Buffer, options?: TranscribeOptions) => Promise +} + +const okResult = (text: string): TranscriptionResult => ({ + text, + segments: [], + language: 'ko', + duration: 1, + processingTime: 1, +}) + +async function flush(times = 5): Promise { + for (let i = 0; i < times; i++) { + await new Promise((resolve) => setImmediate(resolve)) + } +} + +class FakeChild extends EventEmitter { + exitCode: number | null = null + signalCode: NodeJS.Signals | null = null + stdout = null + stderr = null + kill = vi.fn((): boolean => { + this.die(null, 'SIGKILL') + return true + }) + + die(code: number | null, signal: NodeJS.Signals | null = null): void { + if (this.exitCode !== null || this.signalCode !== null) return + this.exitCode = code + this.signalCode = signal + this.emit('exit', code, signal) + } +} + +interface Harness { + supervisor: SidecarSupervisor + fetch: SidecarFetchFn + children: FakeChild[] + spawn: ReturnType + fetchCalls: string[] + setHealthy: (value: boolean) => void + onLoad: (handler: () => Promise) => void +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function createHarness(): Harness { + const children: FakeChild[] = [] + const fetchCalls: string[] = [] + let healthy = false + let clock = 0 + let loadHandler: () => Promise = async () => + jsonResponse({ status: 'ok', model_id: 'tiny', load_time_ms: 1 }) + + const spawn = vi.fn(() => { + const child = new FakeChild() + children.push(child) + setImmediate(() => child.emit('spawn')) + return child as unknown as ChildProcess + }) + + const fetchFn: SidecarFetchFn = async (input) => { + fetchCalls.push(input) + if (input.endsWith('/health')) { + if (!healthy) throw new TypeError('fetch failed (ECONNREFUSED)') + return jsonResponse({ status: 'ok', model: null, gpu: false }) + } + if (input.endsWith('/load')) return loadHandler() + if (input.endsWith('/shutdown')) return jsonResponse({ ok: true }) + throw new TypeError(`unexpected ${input}`) + } + + const supervisor = new SidecarSupervisor({ + resolveLaunch: async () => ({ command: 'fake-sidecar', args: [], source: 'venv' }), + modelsDir: () => 'models', + baseUrlFor: (port) => `http://127.0.0.1:${port}`, + spawn, + fetch: fetchFn, + findFreePort: async (start) => start, + now: () => clock, + sleep: async (ms) => { + clock += ms + await new Promise((resolve) => setImmediate(resolve)) + }, + }) + + return { + supervisor, + fetch: fetchFn, + children, + spawn, + fetchCalls, + setHealthy: (value) => { + healthy = value + }, + onLoad: (handler) => { + loadHandler = handler + }, + } +} + +// ── 1. 클라우드 실패 → 로컬 폴백 ───────────────────────── + +describe('STTManager — 로컬 폴백은 복구 가능한 실패 + 설치된 로컬 엔진에서만', () => { + const MODEL_ID = 'r2-6-installed-model' + + beforeEach(() => { + initInMemoryConfig() + resetSTTManagerForTests() + resetLocalSTTServiceForTests() + vi.restoreAllMocks() + launchMock.offline = true + }) + + afterEach(() => { + vi.unstubAllGlobals() + rmSync(join(getWhisperModelsDir(), MODEL_ID), { recursive: true, force: true }) + resetInMemoryConfig() + resetSTTManagerForTests() + resetLocalSTTServiceForTests() + vi.restoreAllMocks() + }) + + function useOpenAI(fetchResponse: unknown): void { + const mgr = getSTTManager() + mgr.setProvider('openai') + mgr.setProviderConfig('openai', { apiKey: 'sk-test' }) + configSet('sttFallbackToLocal', true) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(fetchResponse)) + } + + function installModel(): void { + const dir = join(getWhisperModelsDir(), MODEL_ID) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'model.bin'), 'x') + configSet('sttModelId', MODEL_ID) + } + + it('무음(STTNoAudioData)은 로컬로 넘기지 않고 그대로 던진다', async () => { + useOpenAI({ ok: true, status: 200, json: async () => ({ text: ' ' }) }) + const localTranscribe = vi.spyOn(getLocalSTTService(), 'transcribe') + + await expect(getSTTManager().transcribe(Buffer.alloc(32000))).rejects.toMatchObject({ + code: ErrorCode.STTNoAudioData, + }) + expect(localTranscribe).not.toHaveBeenCalled() + }) + + it('로컬 모델이 설치되지 않았으면 초기화(=내려받기)를 시작하지 않고 원래 클라우드 에러로 끝난다', async () => { + useOpenAI({ ok: false, status: 503, text: async () => 'offline' }) + configSet('sttModelId', 'large-v3-turbo') + const init = vi.spyOn(getLocalSTTService(), 'initialize').mockResolvedValue(undefined) + + await expect(getSTTManager().transcribe(Buffer.alloc(32000))).rejects.toMatchObject({ + code: ErrorCode.STTTranscriptionFailed, + }) + expect(init).not.toHaveBeenCalled() + }) + + it('엔진이 설치되지 않았으면(런타임 내려받기 필요) 초기화하지 않는다', async () => { + useOpenAI({ ok: false, status: 503, text: async () => 'offline' }) + installModel() + launchMock.offline = false + const init = vi.spyOn(getLocalSTTService(), 'initialize').mockResolvedValue(undefined) + + await expect(getSTTManager().transcribe(Buffer.alloc(32000))).rejects.toMatchObject({ + code: ErrorCode.STTTranscriptionFailed, + }) + expect(init).not.toHaveBeenCalled() + }) + + it('모델과 엔진이 로컬에 있으면 폴백해 로컬 결과를 돌려준다', async () => { + useOpenAI({ ok: false, status: 401, text: async () => 'Unauthorized' }) + installModel() + const local = getLocalSTTService() + const internals = local as unknown as LocalInternals + const init = vi.spyOn(local, 'initialize').mockImplementation(async () => { + internals._modelReady = true + }) + vi.spyOn(internals, '_sendToSidecar').mockResolvedValue(okResult('로컬 결과')) + + const result = await getSTTManager().transcribe(Buffer.alloc(32000), { language: 'ko' }) + expect(result.text).toBe('로컬 결과') + expect(init).toHaveBeenCalledTimes(1) + }) + + it('폴백 정책: 무음/너무 짧음/취소는 복구 불가, 그 밖(인증·서버·네트워크)은 복구 가능', () => { + expect(isRecoverableByLocalFallback(new D3ROError(ErrorCode.STTNoAudioData, 'x'))).toBe(false) + expect(isRecoverableByLocalFallback(new D3ROError(ErrorCode.STTAudioTooShort, 'x'))).toBe(false) + expect(isRecoverableByLocalFallback(new D3ROError(ErrorCode.STTTranscriptionCancelled, 'x'))).toBe(false) + expect(isRecoverableByLocalFallback(new D3ROError(ErrorCode.STTTranscriptionFailed, '401'))).toBe(true) + expect(isRecoverableByLocalFallback(new Error('network'))).toBe(true) + }) +}) + +// ── 2. SidecarSupervisor — 기동 중 호출자는 헬스까지 기다린다 ── + +describe('SidecarSupervisor', () => { + it('기동 중(헬스 전)에 들어온 두 번째 호출자는 즉시 통과하지 않고 헬스까지 기다린다', async () => { + const h = createHarness() + const first = h.supervisor.ready() + await flush() + expect(h.spawn).toHaveBeenCalledTimes(1) + expect(h.supervisor.state).toBe('starting') + + let secondSettled = false + const second = h.supervisor.ready().then((url) => { + secondSettled = true + return url + }) + await flush(10) + expect(secondSettled).toBe(false) + expect(h.supervisor.isHealthy).toBe(false) + + h.setHealthy(true) + const [a, b] = await Promise.all([first, second]) + expect(a).toBe(b) + expect(h.supervisor.isHealthy).toBe(true) + expect(h.spawn).toHaveBeenCalledTimes(1) + }) + + it('크래시 백오프 중 ready()는 새로 spawn하지 않고 예약된 재시작에 합류한다', async () => { + const h = createHarness() + h.setHealthy(true) + await h.supervisor.ready() + const crashed = vi.fn() + h.supervisor.on('crashed', crashed) + + h.children[0].die(1) + expect(h.supervisor.state).toBe('crashed') + expect(crashed).toHaveBeenCalledWith(expect.objectContaining({ attempt: 1 })) + + const [a, b] = await Promise.all([h.supervisor.ready(), h.supervisor.ready()]) + expect(a).toBe(b) + expect(h.spawn).toHaveBeenCalledTimes(2) + expect(h.supervisor.isHealthy).toBe(true) + }) + + it('markStable 후에는 재시작 카운트가 초기화된다', async () => { + const h = createHarness() + h.setHealthy(true) + await h.supervisor.ready() + h.children[0].die(1) + await h.supervisor.ready() + expect(h.supervisor.restartCount).toBe(1) + h.supervisor.markStable() + expect(h.supervisor.restartCount).toBe(0) + }) + + it('dispose 뒤 예약된 재시작은 취소되고 ready()는 거부된다', async () => { + const h = createHarness() + h.setHealthy(true) + await h.supervisor.ready() + h.children[0].die(1) + const pending = h.supervisor.ready() + await h.supervisor.dispose() + await expect(pending).rejects.toBeInstanceOf(D3ROError) + await expect(h.supervisor.ready()).rejects.toBeInstanceOf(D3ROError) + expect(h.spawn).toHaveBeenCalledTimes(1) + }) +}) + +// ── 3. LocalSTTService + 감독자 — 크래시 재시작 정책 ────── + +describe('LocalSTTService — 사이드카 크래시 재시작', () => { + beforeEach(() => { + initInMemoryConfig() + }) + + afterEach(() => { + vi.unstubAllGlobals() + resetInMemoryConfig() + vi.restoreAllMocks() + }) + + it('모델 로딩 중 반복 크래시는 MAX 재시작에서 멈추고 STTSidecarCrashed를 알린다', async () => { + const h = createHarness() + vi.stubGlobal('fetch', h.fetch) + h.setHealthy(true) + // /load 도중 사이드카가 죽는다 (OOM/CUDA 실패 등) + h.onLoad(async () => { + h.children[h.children.length - 1].die(1) + throw new TypeError('fetch failed') + }) + const local = new LocalSTTService({ supervisor: h.supervisor }) + const errors: D3ROError[] = [] + local.on('error', ({ error }) => errors.push(error)) + + await expect(local.initialize('tiny')).rejects.toBeInstanceOf(D3ROError) + for (let i = 0; i < 200 && h.supervisor.state !== 'stopped'; i++) { + await flush() + } + + expect(h.supervisor.state).toBe('stopped') + // 최초 1회 + 재시작 3회 — 헬스 성공이 카운트를 되돌리지 않는다 + expect(h.spawn).toHaveBeenCalledTimes(4) + expect(errors.some((e) => e.code === ErrorCode.STTSidecarCrashed)).toBe(true) + expect(local.getStatus().engineState).toBe('error') + await local.dispose() + }) + + it('모델이 없던(UIA만 쓰던) 사이드카가 죽으면 되살리기만 하고 모델은 올리지 않는다', async () => { + const h = createHarness() + vi.stubGlobal('fetch', h.fetch) + h.setHealthy(true) + const local = new LocalSTTService({ supervisor: h.supervisor }) + + await local.ensureSidecar() + h.children[0].die(1) + await local.ensureSidecar() + await flush() + + expect(h.spawn).toHaveBeenCalledTimes(2) + expect(h.fetchCalls.some((url) => url.endsWith('/load'))).toBe(false) + await local.dispose() + }) + + it('크래시 재시작 대기 중 들어온 transcribe는 재시작(모델 재로딩) 결과를 기다려 전사한다', async () => { + const h = createHarness() + vi.stubGlobal('fetch', h.fetch) + h.setHealthy(true) + const local = new LocalSTTService({ supervisor: h.supervisor }) + const internals = local as unknown as LocalInternals + await local.initialize('tiny') + + h.children[0].die(1) + const send = vi.spyOn(internals, '_sendToSidecar').mockResolvedValue(okResult('재시작 후 전사')) + const result = await local.transcribe(Buffer.alloc(32000)) + + expect(result.text).toBe('재시작 후 전사') + expect(send).toHaveBeenCalledTimes(1) + expect(h.fetchCalls.filter((url) => url.endsWith('/load'))).toHaveLength(2) + expect(local.currentModelId).toBe('tiny') + await local.dispose() + }) +}) diff --git a/apps/desktop/tests/red/cloud-stt-complex-journeys.test.ts b/apps/desktop/tests/red/cloud-stt-complex-journeys.test.ts index 695fd23..150d7df 100644 --- a/apps/desktop/tests/red/cloud-stt-complex-journeys.test.ts +++ b/apps/desktop/tests/red/cloud-stt-complex-journeys.test.ts @@ -651,7 +651,8 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario expect(res.text).toBe('타임아웃 로컬 대체') }) - it('50. Provider returning empty text triggers fallback or error', async () => { + // 무음(빈 전사)은 로컬로 다시 돌려도 결과가 같으므로 폴백하지 않고 STTNoAudioData 로 끝난다. + it('50. Provider returning empty text ends with no-audio error and skips local fallback', async () => { const mgr = getSTTManager() mgr.setProvider('openai') mgr.setProviderConfig('openai', { apiKey: 'sk-test' }) @@ -667,7 +668,7 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario ) const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService') - vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({ + const localTranscribe = vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({ text: '빈값 감지 후 로컬 복구', language: 'ko', duration: 1.0, @@ -675,11 +676,11 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario segments: [], }) - const res = await mgr.transcribe(makePcmBuffer(1.0)) - expect(res.text).toBe('빈값 감지 후 로컬 복구') + await expect(mgr.transcribe(makePcmBuffer(1.0))).rejects.toMatchObject({ code: ErrorCode.STTNoAudioData }) + expect(localTranscribe).not.toHaveBeenCalled() }) - it('51. Provider returning null text triggers fallback', async () => { + it('51. Provider returning null text ends with no-audio error and skips local fallback', async () => { const mgr = getSTTManager() mgr.setProvider('custom') mgr.setProviderConfig('custom', { baseUrl: 'http://custom:8000' }) @@ -695,7 +696,7 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario ) const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService') - vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({ + const localTranscribe = vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({ text: '널값 감지 로컬 복구', language: 'ko', duration: 1.0, @@ -703,8 +704,8 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario segments: [], }) - const res = await mgr.transcribe(makePcmBuffer(1.0)) - expect(res.text).toBe('널값 감지 로컬 복구') + await expect(mgr.transcribe(makePcmBuffer(1.0))).rejects.toMatchObject({ code: ErrorCode.STTNoAudioData }) + expect(localTranscribe).not.toHaveBeenCalled() }) it('52. Local Whisper execution failure throws STTTranscriptionFailed', async () => {