fix(stt): gate local fallback on installed engine and extract SidecarSupervisor

This commit is contained in:
Yun Chan 2026-09-28 02:16:16 +09:00
parent 4588b65dfa
commit 2cd462333f
7 changed files with 1057 additions and 343 deletions

View file

@ -4,14 +4,14 @@
// 상태 머신 및 이중 조건 플러시 패턴 적용. // 상태 머신 및 이중 조건 플러시 패턴 적용.
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import { type ChildProcess, spawn } from 'child_process'
import { createServer } from 'net'
import { existsSync } from 'fs' import { existsSync } from 'fs'
import { join } from 'path' import { join } from 'path'
import { getLogger } from './LoggerService' import { getLogger } from './LoggerService'
import { configGet } from './ConfigService' import { configGet } from './ConfigService'
import { getSidecarCommand, getSidecarBaseUrl, getWhisperModelsDir } from '../utils/paths' import { getSidecarBaseUrl, getWhisperModelsDir } from '../utils/paths'
import { getRuntimeProvisioner } from './RuntimeProvisioner' 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 { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { import type {
STTModel, STTModel,
@ -59,13 +59,12 @@ export interface TranscribeOptions {
modelId?: string modelId?: string
/** 내부용 — 409 후 모델을 다시 올리고 재시도한 요청인지 */ /** 내부용 — 409 후 모델을 다시 올리고 재시도한 요청인지 */
retriedAfterLoad?: boolean retriedAfterLoad?: boolean
} /**
* 모델이 아직 올라가 있지 않을 때, 모델 파일과 엔진이 이미 로컬에 있어야만 초기화한다.
/** sidecar /health 응답 */ * 없으면 내려받기(런타임 ~100MB, 모델 최대 수 GB)를 시작하지 않고 즉시 실패한다.
interface HealthResponse { * 클라우드 실패 → 로컬 폴백처럼 "지금 바로 쓸 수 있을 때만" 로컬을 쓰는 경로용.
status: string */
model: string | null requireInstalled?: boolean
gpu: boolean
} }
/** sidecar /load 응답 */ /** 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_REQUEST_TIMEOUT_MS = 120000
/** 부분 전사(미리보기) 타임아웃 — 실패해도 무시되므로 짧게 잡는다 */ /** 부분 전사(미리보기) 타임아웃 — 실패해도 무시되므로 짧게 잡는다 */
const SIDECAR_PARTIAL_TIMEOUT_MS = 15000 const SIDECAR_PARTIAL_TIMEOUT_MS = 15000
@ -190,9 +185,29 @@ const MODEL_CATALOG: STTModel[] = [
const logger = getLogger('LocalSTTService') 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 { class LocalSTTService extends EventEmitter {
constructor() { /** 사이드카 프로세스 수명주기(기동·헬스·크래시 재시작·종료) 담당 */
private readonly _supervisor: SidecarSupervisor
constructor(deps: LocalSTTServiceDeps = {}) {
super() 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 시 프로세스 예외를 던진다 // EventEmitter는 'error' 리스너가 없으면 emit 시 프로세스 예외를 던진다
// (실측: sidecar crash 루프 중 ERR_UNHANDLED_ERROR). 기본 sink로 방지 — // (실측: sidecar crash 루프 중 ERR_UNHANDLED_ERROR). 기본 sink로 방지 —
// 실제 로깅은 _emitError에서 수행. // 실제 로깅은 _emitError에서 수행.
@ -204,15 +219,10 @@ class LocalSTTService extends EventEmitter {
} }
private _state: STTState = STTState.Uninitialized private _state: STTState = STTState.Uninitialized
private _sidecarProcess: ChildProcess | null = null
private _sidecarStarting: Promise<void> | null = null
private _port: number = SIDECAR_PORT
private _currentModelId: string | null = null private _currentModelId: string | null = null
/** 보조 자리에 올린 모델 (실시간 자막 전용 모델 등). 사이드카가 죽으면 함께 사라진다 */ /** 보조 자리에 올린 모델 (실시간 자막 전용 모델 등). 사이드카가 죽으면 함께 사라진다 */
private _auxModelId: string | null = null private _auxModelId: string | null = null
private _restartCount: number = 0
private _disposed: boolean = false private _disposed: boolean = false
private _gpuAccelerated: boolean = false
// ── 준비 상태 ── // ── 준비 상태 ──
// 예전엔 모델이 준비되지 않았을 때 transcribe()를 단일 슬롯(_pendingResolve)에 걸어 두고 // 예전엔 모델이 준비되지 않았을 때 transcribe()를 단일 슬롯(_pendingResolve)에 걸어 두고
@ -235,9 +245,9 @@ class LocalSTTService extends EventEmitter {
return this._currentModelId return this._currentModelId
} }
/** sidecar HTTP 기본 URL — IPv4 루프백 고정 (localhost는 ::1로 해석되어 실패) */ /** sidecar HTTP 기본 URL — IPv4 루프백 고정, 포트는 감독자가 동적으로 정한다 */
private get _baseUrl(): string { private get _baseUrl(): string {
return getSidecarBaseUrl(this._port) return this._supervisor.baseUrl
} }
// ── 공개 메서드 ── // ── 공개 메서드 ──
@ -290,14 +300,16 @@ class LocalSTTService extends EventEmitter {
this._errorEmitted = false this._errorEmitted = false
try { try {
// sidecar가 아직 실행 중이 아니면 시작 // sidecar가 헬스를 통과할 때까지 기다린다 (필요하면 기동)
await this._ensureSidecarRunning() await this._supervisor.ready()
// 모델 로딩 // 모델 로딩
await this._loadModel(targetModel) await this._loadModel(targetModel)
this._currentModelId = targetModel this._currentModelId = targetModel
this._modelReady = true this._modelReady = true
this._setState(STTState.Ready) this._setState(STTState.Ready)
// 모델까지 올라갔으면 사이드카는 안정적이다 — 크래시 재시작 카운트 초기화
this._supervisor.markStable()
logger.info(`STT 초기화 완료: 모델=${targetModel}`) logger.info(`STT 초기화 완료: 모델=${targetModel}`)
} catch (err) { } catch (err) {
@ -337,6 +349,7 @@ class LocalSTTService extends EventEmitter {
// 모델이 아직 준비되지 않았으면 진행 중인 초기화를 기다리거나 직접 초기화한다. // 모델이 아직 준비되지 않았으면 진행 중인 초기화를 기다리거나 직접 초기화한다.
// 초기화가 실패하면 그 에러로 reject 된다 — 절대 끝나지 않는 promise를 돌려주지 않는다. // 초기화가 실패하면 그 에러로 reject 된다 — 절대 끝나지 않는 promise를 돌려주지 않는다.
if (!this._modelReady) { if (!this._modelReady) {
if (options?.requireInstalled) this._assertInstalledLocally()
await this._ensureModelReady() 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와 모델을 미리 데운다. * 앱 시작 시 sidecar와 모델을 미리 데운다.
* 첫 받아쓰기에서 모델 로딩(수초)을 기다리지 않게 하는 것이 목적이므로 * 첫 받아쓰기에서 모델 로딩(수초)을 기다리지 않게 하는 것이 목적이므로
@ -402,9 +436,8 @@ class LocalSTTService extends EventEmitter {
* 입력 인텔리전스(제안/학습)는 UIA 브리지가 사이드카에 있으므로 STT 모델 없이도 * 입력 인텔리전스(제안/학습)는 UIA 브리지가 사이드카에 있으므로 STT 모델 없이도
* 사이드카 HTTP 서버가 필요하다. 실패하면 예외를 그대로 올린다. * 사이드카 HTTP 서버가 필요하다. 실패하면 예외를 그대로 올린다.
*/ */
async ensureSidecar(): Promise<string> { ensureSidecar(): Promise<string> {
await this._ensureSidecarRunning() return this._supervisor.ready()
return this._baseUrl
} }
/** /**
@ -419,7 +452,7 @@ class LocalSTTService extends EventEmitter {
if (this._disposed) return '' if (this._disposed) return ''
if (!this._modelReady) return '' if (!this._modelReady) return ''
if (audioBuffer.length === 0) return '' if (audioBuffer.length === 0) return ''
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) return '' if (!this._supervisor.isHealthy) return ''
try { try {
const result = await this._sendToSidecar(audioBuffer, { 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`, { const startRes = await fetch(`${this._baseUrl}/download`, {
method: 'POST', method: 'POST',
@ -566,7 +599,7 @@ class LocalSTTService extends EventEmitter {
engineState: stateMap[this._state], engineState: stateMap[this._state],
activeModel: this._currentModelId, activeModel: this._currentModelId,
engineVersion: null, engineVersion: null,
gpuAccelerated: this._gpuAccelerated, gpuAccelerated: this._supervisor.gpuAccelerated,
} }
} }
@ -581,36 +614,12 @@ class LocalSTTService extends EventEmitter {
this._modelReady = false this._modelReady = false
this._auxModelId = null this._auxModelId = null
await this._shutdownSidecar() await this._supervisor.dispose()
this._setState(STTState.Uninitialized) this._setState(STTState.Uninitialized)
logger.info('LocalSTTService dispose 완료') logger.info('LocalSTTService dispose 완료')
} }
// ── Sidecar 관리 ──
/**
* sidecar가 실행 중이 아니면 spawn + 헬스체크 대기.
*
* 동시 호출은 하나의 기동 작업을 공유한다. STT 예열과 UIA 스냅샷이 같은 순간에
* (특히 런타임 다운로드를 함께 기다린 뒤) 각각 spawn 해 두 번째가 포트 충돌로
* 죽고 crash 재시작까지 돌던 문제(실측 Errno 10048)를 막는다.
*/
private _ensureSidecarRunning(): Promise<void> {
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( private _emitDownloadProgress(
modelId: string, modelId: string,
percent: number, percent: number,
@ -627,242 +636,12 @@ class LocalSTTService extends EventEmitter {
}) })
} }
/**
* 시작 포트부터 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> {
// 포트 점유 시 동적으로 다음 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<void>((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<typeof getLogger>,
): 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<void> {
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<void> { async ensureAuxModel(modelId: string): Promise<void> {
await this._ensureSidecarRunning() await this._supervisor.ready()
if (modelId === this._currentModelId || modelId === this._auxModelId) return if (modelId === this._currentModelId || modelId === this._auxModelId) return
await this._loadModel(modelId, 'aux') await this._loadModel(modelId, 'aux')
this._auxModelId = modelId this._auxModelId = modelId
@ -909,7 +688,7 @@ class LocalSTTService extends EventEmitter {
audioBuffer: Buffer, audioBuffer: Buffer,
options?: TranscribeOptions, options?: TranscribeOptions,
): Promise<TranscriptionResult> { ): Promise<TranscriptionResult> {
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) { if (!this._supervisor.isHealthy) {
throw new D3ROError( throw new D3ROError(
ErrorCode.STTSidecarCommunicationFailed, ErrorCode.STTSidecarCommunicationFailed,
'Sidecar 프로세스가 실행 중이 아닙니다', 'Sidecar 프로세스가 실행 중이 아닙니다',
@ -1034,34 +813,35 @@ class LocalSTTService extends EventEmitter {
} }
} }
// ── Sidecar Crash 처리 ── // ── Sidecar 수명주기 이벤트 (SidecarSupervisor) ──
private _handleSidecarCrash(): void { /** 사이드카가 죽으면 올려 둔 모델도 함께 사라진다 */
if (this._disposed) return private _onSidecarExit(): void {
this._modelReady = false
this._restartCount++ this._auxModelId = null
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') * 예기치 않은 종료 — 감독자가 백오프 뒤 재시작을 예약했다.
* 모델이 올라가 있었거나 올리는 중이었으면, 재시작에 합류해 그 모델을 다시 올린다.
* 재시작을 "진행 중인 초기화"로 즉시 기록한다 — 대기 구간에 들어온 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})`,
)
const modelToReload = this._currentModelId ?? this._initializing?.modelId ?? null
this._currentModelId = null this._currentModelId = null
this._modelReady = false this._modelReady = false
this._auxModelId = null this._auxModelId = null
if (!modelToReload) return
// 재시작을 "진행 중인 초기화"로 즉시 기록한다 — 대기 구간에 들어온 transcribe()가 const restart = this._supervisor.ready().then(async () => {
// 초기화를 따로 경쟁시키지 않고 재시작 결과를 기다린다(실패하면 그 에러로 reject).
const delayMs = 1000 * this._restartCount // 점진적 백오프
const restart = new Promise<void>((resolve) => setTimeout(resolve, delayMs)).then(async () => {
if (this._disposed) { if (this._disposed) {
throw new D3ROError(ErrorCode.STTTranscriptionCancelled, '서비스 종료로 재시작 취소') throw new D3ROError(ErrorCode.STTTranscriptionCancelled, '서비스 종료로 재시작 취소')
} }
@ -1075,31 +855,10 @@ class LocalSTTService extends EventEmitter {
this._trackInitialization(modelToReload, restart) this._trackInitialization(modelToReload, restart)
} }
private async _shutdownSidecar(): Promise<void> { private _onSidecarGaveUp(error: D3ROError): void {
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) { if (this._disposed) return
this._sidecarProcess = null this._setState(STTState.Error)
return this._emitError(error)
}
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
} }
// ── 내부 유틸 ── // ── 내부 유틸 ──

View file

@ -24,6 +24,7 @@ import { GoogleDriver } from './drivers/GoogleDriver'
import { CustomDriver } from './drivers/CustomDriver' import { CustomDriver } from './drivers/CustomDriver'
import { D3ROCloudDriver } from './drivers/D3ROCloudDriver' import { D3ROCloudDriver } from './drivers/D3ROCloudDriver'
import { normalizeLoopbackUrl } from '../../utils/loopback' import { normalizeLoopbackUrl } from '../../utils/loopback'
import { isRecoverableByLocalFallback } from './fallbackPolicy'
const logger = getLogger('STTManager') const logger = getLogger('STTManager')
@ -250,7 +251,8 @@ export class STTManager extends EventEmitter implements STTEngine {
const errorMsg = error instanceof Error ? error.message : String(error) const errorMsg = error instanceof Error ? error.message : String(error)
logger.warn(`Cloud STT (${provider}) failed: ${errorMsg}`) logger.warn(`Cloud STT (${provider}) failed: ${errorMsg}`)
if (fallbackToLocal) { // 무음/취소처럼 로컬로 다시 해도 결과가 같은 실패는 폴백하지 않는다
if (fallbackToLocal && isRecoverableByLocalFallback(error)) {
logger.info(`Auto-fallback: executing Local Whisper transcription...`) logger.info(`Auto-fallback: executing Local Whisper transcription...`)
this.emit('fallback-to-local', { provider, reason: errorMsg }) this.emit('fallback-to-local', { provider, reason: errorMsg })
try { try {
@ -270,13 +272,15 @@ export class STTManager extends EventEmitter implements STTEngine {
* 클라우드 공급자를 쓰는 동안에는 아무도 로컬 모델을 올리지 않는다 — LocalSTTService.transcribe가 * 클라우드 공급자를 쓰는 동안에는 아무도 로컬 모델을 올리지 않는다 — LocalSTTService.transcribe가
* 설정된 모델을 스스로 올린 뒤 전사하고(실패하면 reject), 여기서는 전체에 상한 시간을 두어 * 설정된 모델을 스스로 올린 뒤 전사하고(실패하면 reject), 여기서는 전체에 상한 시간을 두어
* 받아쓰기 액션 큐가 굳지 않게 한다. * 받아쓰기 액션 큐가 굳지 않게 한다.
* requireInstalled: 모델·엔진이 이미 로컬에 있을 때만 쓴다. 없으면 폴백이 엔진(~100MB)과
* 모델(최대 수 GB)을 조용히 내려받기 시작하고 그동안 핫키 큐가 멈춘다.
*/ */
private _transcribeLocalFallback( private _transcribeLocalFallback(
audioBuffer: Buffer, audioBuffer: Buffer,
options?: TranscribeOptions, options?: TranscribeOptions,
): Promise<TranscriptionResult> { ): Promise<TranscriptionResult> {
return withTimeout( return withTimeout(
getLocalSTTService().transcribe(audioBuffer, options), getLocalSTTService().transcribe(audioBuffer, { ...options, requireInstalled: true }),
LOCAL_FALLBACK_TIMEOUT_MS, LOCAL_FALLBACK_TIMEOUT_MS,
`Local STT fallback timed out after ${LOCAL_FALLBACK_TIMEOUT_MS}ms`, `Local STT fallback timed out after ${LOCAL_FALLBACK_TIMEOUT_MS}ms`,
) )

View file

@ -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<Response>
export interface SidecarSupervisorOptions {
/** 실행 파일 결정 (필요하면 런타임을 내려받는다) */
resolveLaunch: () => Promise<SidecarLaunch>
modelsDir: () => string
baseUrlFor: (port: number) => string
spawn?: SidecarSpawnFn
fetch?: SidecarFetchFn
findFreePort?: (startPort: number, maxAttempts: number) => Promise<number>
sleep?: (ms: number) => Promise<void>
now?: () => number
basePort?: number
healthTimeoutMs?: number
healthIntervalMs?: number
maxRestarts?: number
/** n번째 재시작은 restartBackoffMs × n 뒤에 한다 */
restartBackoffMs?: number
/** 이만큼 헬스 상태로 살아 있었으면 크래시 시 재시작 카운트를 초기화한다 */
stableUptimeMs?: number
}
type ResolvedOptions = Required<SidecarSupervisorOptions>
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* 시작 포트부터 maxAttempts개 포트 중 첫 번째 free 포트를 찾는다.
* dev mode 재시작으로 이전 sidecar가 orphan으로 남아있을 수 있어
* 매번 동적 할당해서 충돌을 회피한다.
*/
export function findFreeLoopbackPort(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)
})
}
/** 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<string> | null = null
private _pendingRestart: Promise<string> | 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<string> {
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<void> {
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<string> {
this._setState('starting')
const tracked: Promise<string> = 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<string> {
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<ChildProcess> {
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<ChildProcess>((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<void> {
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<string> = 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<K extends keyof SidecarSupervisorEvents>(
event: K,
payload: SidecarSupervisorEvents[K],
): boolean {
return super.emit(event, payload)
}
override on<K extends keyof SidecarSupervisorEvents>(
event: K,
listener: (payload: SidecarSupervisorEvents[K]) => void,
): this {
return super.on(event, listener)
}
override off<K extends keyof SidecarSupervisorEvents>(
event: K,
listener: (payload: SidecarSupervisorEvents[K]) => void,
): this {
return super.off(event, listener)
}
}

View file

@ -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<ErrorCode> = 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
}

View file

@ -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<SidecarLaunch> {
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')
}
}

View file

@ -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<TranscriptionResult>
}
const okResult = (text: string): TranscriptionResult => ({
text,
segments: [],
language: 'ko',
duration: 1,
processingTime: 1,
})
async function flush(times = 5): Promise<void> {
for (let i = 0; i < times; i++) {
await new Promise<void>((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<typeof vi.fn>
fetchCalls: string[]
setHealthy: (value: boolean) => void
onLoad: (handler: () => Promise<Response>) => 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<Response> = 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<void>((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()
})
})

View file

@ -651,7 +651,8 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
expect(res.text).toBe('타임아웃 로컬 대체') 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() const mgr = getSTTManager()
mgr.setProvider('openai') mgr.setProvider('openai')
mgr.setProviderConfig('openai', { apiKey: 'sk-test' }) 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') const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService')
vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({ const localTranscribe = vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({
text: '빈값 감지 후 로컬 복구', text: '빈값 감지 후 로컬 복구',
language: 'ko', language: 'ko',
duration: 1.0, duration: 1.0,
@ -675,11 +676,11 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
segments: [], segments: [],
}) })
const res = await mgr.transcribe(makePcmBuffer(1.0)) await expect(mgr.transcribe(makePcmBuffer(1.0))).rejects.toMatchObject({ code: ErrorCode.STTNoAudioData })
expect(res.text).toBe('빈값 감지 후 로컬 복구') 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() const mgr = getSTTManager()
mgr.setProvider('custom') mgr.setProvider('custom')
mgr.setProviderConfig('custom', { baseUrl: 'http://custom:8000' }) 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') const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService')
vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({ const localTranscribe = vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({
text: '널값 감지 로컬 복구', text: '널값 감지 로컬 복구',
language: 'ko', language: 'ko',
duration: 1.0, duration: 1.0,
@ -703,8 +704,8 @@ describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenario
segments: [], segments: [],
}) })
const res = await mgr.transcribe(makePcmBuffer(1.0)) await expect(mgr.transcribe(makePcmBuffer(1.0))).rejects.toMatchObject({ code: ErrorCode.STTNoAudioData })
expect(res.text).toBe('널값 감지 로컬 복구') expect(localTranscribe).not.toHaveBeenCalled()
}) })
it('52. Local Whisper execution failure throws STTTranscriptionFailed', async () => { it('52. Local Whisper execution failure throws STTTranscriptionFailed', async () => {