// src/main/shutdown-registry.ts // 앱 종료 시 정리할 자원(사이드카 프로세스, 전역 입력 훅, 타이머 등)의 등록부. // // 예전엔 lifecycle.ts 가 disposeLocalLLMService 하나만 하드코딩해 호출했고, 새로 프로세스를 // 소유하는 서비스(STT 사이드카 등)는 lifecycle 을 고치는 것을 잊어 종료 후에도 고아로 남았다. // 이제 서비스는 시작 시점에 스스로 등록하고, lifecycle 은 이 등록부에만 의존한다(DIP/OCP). export interface Disposable { name: string dispose(): Promise | 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 | 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 { if (!this._running) this._running = this._run(timeoutMs) return this._running } private async _run(timeoutMs: number): Promise { 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 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 | null = null const timeout = new Promise((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 }