113 lines
3.7 KiB
TypeScript
113 lines
3.7 KiB
TypeScript
// 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
|
|
}
|