fix(stt): gate local fallback on installed engine and extract SidecarSupervisor
This commit is contained in:
parent
4588b65dfa
commit
2cd462333f
7 changed files with 1057 additions and 343 deletions
|
|
@ -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<TranscriptionResult> {
|
||||
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`,
|
||||
)
|
||||
|
|
|
|||
495
apps/desktop/src/main/services/stt/SidecarSupervisor.ts
Normal file
495
apps/desktop/src/main/services/stt/SidecarSupervisor.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
22
apps/desktop/src/main/services/stt/fallbackPolicy.ts
Normal file
22
apps/desktop/src/main/services/stt/fallbackPolicy.ts
Normal 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
|
||||
}
|
||||
56
apps/desktop/src/main/services/stt/sidecarLaunch.ts
Normal file
56
apps/desktop/src/main/services/stt/sidecarLaunch.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue