refactor(desktop): session-scoped voice runtime, STT/LLM ports, caption ownership and meeting export fixes

This commit is contained in:
Yun Chan 2026-09-28 00:53:43 +09:00
parent 0adedf5e7b
commit 83cf9133ac
39 changed files with 4787 additions and 1061 deletions

View file

@ -0,0 +1,113 @@
// src/main/shutdown-registry.ts
// 앱 종료 시 정리할 자원(사이드카 프로세스, 전역 입력 훅, 타이머 등)의 등록부.
//
// 예전엔 lifecycle.ts 가 disposeLocalLLMService 하나만 하드코딩해 호출했고, 새로 프로세스를
// 소유하는 서비스(STT 사이드카 등)는 lifecycle 을 고치는 것을 잊어 종료 후에도 고아로 남았다.
// 이제 서비스는 시작 시점에 스스로 등록하고, lifecycle 은 이 등록부에만 의존한다(DIP/OCP).
export interface Disposable {
name: string
dispose(): Promise<void> | void
}
export interface ShutdownReport {
completed: string[]
failed: string[]
timedOut: string[]
}
export interface ShutdownRegistryOptions {
now?: () => number
onError?: (name: string, error: unknown) => void
}
const TIMED_OUT = Symbol('timed-out')
export class ShutdownRegistry {
private _entries: Disposable[] = []
private _running: Promise<ShutdownReport> | null = null
private readonly _now: () => number
private readonly _onError: (name: string, error: unknown) => void
constructor(options: ShutdownRegistryOptions = {}) {
this._now = options.now ?? (() => Date.now())
this._onError = options.onError ?? (() => undefined)
}
/** 정리 대상을 등록한다. 돌려받은 함수로 등록을 취소할 수 있다. */
register(entry: Disposable): () => void {
this._entries.push(entry)
return () => {
this._entries = this._entries.filter((e) => e !== entry)
}
}
get size(): number {
return this._entries.length
}
/**
* 등록의 역순(나중에 시작한 것부터)으로 하나씩 정리한다. 여러 번 불러도 한 번만 실행된다.
* 전체 시간 상한(timeoutMs)을 넘기면 남은 항목은 기다리지 않고 dispose 만 걸어 둔다
* (동기 구간 — 프로세스 kill 등 — 은 그래도 실행된다). 한 항목이 멈춰도 종료가 막히지 않는다.
*/
disposeAll(timeoutMs: number): Promise<ShutdownReport> {
if (!this._running) this._running = this._run(timeoutMs)
return this._running
}
private async _run(timeoutMs: number): Promise<ShutdownReport> {
const report: ShutdownReport = { completed: [], failed: [], timedOut: [] }
const deadline = this._now() + timeoutMs
const entries = [...this._entries].reverse()
for (const entry of entries) {
const remaining = deadline - this._now()
let pending: Promise<void>
try {
pending = Promise.resolve(entry.dispose())
} catch (error) {
this._onError(entry.name, error)
report.failed.push(entry.name)
continue
}
if (remaining <= 0) {
pending.catch((error: unknown) => this._onError(entry.name, error))
report.timedOut.push(entry.name)
continue
}
let timer: ReturnType<typeof setTimeout> | null = null
const timeout = new Promise<typeof TIMED_OUT>((resolve) => {
timer = setTimeout(() => resolve(TIMED_OUT), remaining)
})
try {
const outcome = await Promise.race([pending.then(() => 'done' as const), timeout])
if (outcome === TIMED_OUT) {
pending.catch((error: unknown) => this._onError(entry.name, error))
report.timedOut.push(entry.name)
} else {
report.completed.push(entry.name)
}
} catch (error) {
this._onError(entry.name, error)
report.failed.push(entry.name)
} finally {
if (timer) clearTimeout(timer)
}
}
return report
}
}
let instance: ShutdownRegistry | null = null
export function getShutdownRegistry(): ShutdownRegistry {
if (!instance) instance = new ShutdownRegistry()
return instance
}
export function resetShutdownRegistryForTests(): void {
instance = null
}