fix(runtime): release engine file holders right before swapping a reinstalled runtime

This commit is contained in:
Yun Chan 2026-09-28 02:16:19 +09:00
parent e015f43de1
commit 11bf995409
5 changed files with 589 additions and 15 deletions

View file

@ -0,0 +1,27 @@
// src/main/ipc/runtime-ensure-request.ts
// RUNTIME.ENSURE IPC 요청 해석 정책 (IPC 전송과 분리된 순수 함수).
//
// 렌더러의 이 호출은 사용자가 누른 "내려받기 / 다시 설치" 버튼 하나뿐이다. 이미 설치돼
// 있는데 누른 경우는 손상(백신 격리 등)을 고치려는 것이므로 기본값으로 새로 받아
// 교체한다 — 존재·버전 확인만으로는 손상을 알 수 없어, 예전에는 아무 일도 하지 않았다.
// 설치돼 있으면 건너뛰어야 하는 호출처는 `force: false` 를 넘긴다.
//
// 실행 중인 엔진이 파일을 잠그는 문제는 설치기(install-archive)가 교체 직전에
// runtime-file-holders 레지스트리로 파일을 놓게 해 처리한다.
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { isRuntimeComponent, type RuntimeComponent } from '../services/runtime/runtime-index'
export interface RuntimeEnsureRequest {
component: RuntimeComponent
force: boolean
}
export function parseRuntimeEnsureRequest(params: unknown): RuntimeEnsureRequest {
const record = typeof params === 'object' && params !== null ? (params as Record<string, unknown>) : {}
const component = record.component
if (!isRuntimeComponent(component)) {
throw new D3ROError(ErrorCode.ConfigInvalidValue, `알 수 없는 런타임 구성 요소: ${String(component)}`)
}
return { component, force: record.force !== false }
}

View file

@ -7,7 +7,7 @@ import { ipcSuccess, ipcError, ErrorCode, D3ROError, type IPCResult } from '@d3r
import { getLocalSTTService } from '../services/LocalSTTService'
import { getRuntimeProvisioner } from '../services/RuntimeProvisioner'
import type { RuntimeComponent } from '../services/RuntimeProvisioner'
import { isRuntimeComponent } from '../services/runtime/runtime-index'
import { parseRuntimeEnsureRequest } from './runtime-ensure-request'
import { getSTTManager } from '../services/stt/STTManager'
import { configGet, configSet } from '../services/ConfigService'
import { getMainWindow } from '../windows/WindowManager'
@ -52,19 +52,12 @@ export function registerSTTHandlers(): void {
}
})
// 렌더러의 이 호출은 사용자가 누른 "내려받기 / 다시 설치" 버튼 하나뿐이다. 이미 설치돼
// 있는데 누른 경우는 손상(백신 격리 등)을 고치려는 것이므로 기본값으로 새로 받아
// 교체한다 — 존재·버전 확인만으로는 손상을 알 수 없어, 예전에는 아무 일도 하지 않았다.
// 설치돼 있으면 건너뛰어야 하는 호출처는 `force: false` 를 넘긴다.
// "내려받기 / 다시 설치" — 요청 해석 정책은 parseRuntimeEnsureRequest 에 있다
ipcMain.handle(
IPC_CHANNELS.RUNTIME.ENSURE,
async (_event, params: { component: RuntimeComponent; force?: boolean }) => {
try {
const component = params?.component
if (!isRuntimeComponent(component)) {
throw new D3ROError(ErrorCode.ConfigInvalidValue, `알 수 없는 런타임 구성 요소: ${String(component)}`)
}
const force = params?.force !== false
const { component, force } = parseRuntimeEnsureRequest(params)
const binaryPath = await getRuntimeProvisioner().ensure(component, { force })
return ipcSuccess({ component, binaryPath })
} catch (error) {

View file

@ -11,13 +11,21 @@
// - `${target}.staging` 에 풀고 verify() 를 통과해야만 교체한다.
// - 버전 마커는 검증이 끝난 스테이징 안에 마지막으로 쓴다 (교체와 함께 나타난다).
// - Windows 순서: target → `.old` rename → staging → target rename → `.old` 삭제.
// 어느 rename 이든 실패하면 기존 설치를 그대로 되돌려 둔다 (실행 중 엔진은 EBUSY 로 실패).
// 어느 rename 이든 실패하면 기존 설치를 그대로 되돌려 둔다.
// - 실행 중 엔진은 자기 이미지·DLL 을 잠가 rename 이 EPERM/EBUSY 로 실패한다. 그래서 교체
// 직전에(내려받기·추출·검증이 모두 끝난 뒤) 대상 폴더를 붙잡은 사용자에게 파일을 놓게 하고
// (runtime-file-holders), 교체가 끝나면 성공·롤백과 무관하게 다시 붙잡게 한다.
// 프로세스가 끝난 뒤에도 핸들이 잠깐 남을 수 있어 잠김 오류는 짧게 다시 시도한다.
import { existsSync } from 'node:fs'
import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import * as tar from 'tar'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { getLogger } from '../LoggerService'
import { getRuntimeFileHolders, type ReacquireRuntimeFiles } from './runtime-file-holders'
const logger = getLogger('InstallArchive')
export interface InstallArchiveOptions {
archivePath: string
@ -26,6 +34,29 @@ export interface InstallArchiveOptions {
verify: (dir: string) => boolean
/** 검증 뒤 스테이징 안에 마지막으로 쓰는 버전 마커 */
marker?: { fileName: string; content: string }
/**
* 교체 직전에 targetDir 을 붙잡은 사용자(실행 중 엔진)에게 파일을 놓게 하고, 다시 붙잡는
* 함수를 돌려받는다. 기본값은 전역 런타임 파일 사용자 레지스트리.
*/
release?: (targetDir: string) => Promise<ReacquireRuntimeFiles>
/** 잠김 오류(EBUSY/EPERM/EACCES) rename 재시도 — 기본 5회, 200ms 간격 */
swapRetry?: SwapRetryPolicy
}
export interface SwapRetryPolicy {
attempts: number
delayMs: number
}
const DEFAULT_SWAP_RETRY: SwapRetryPolicy = { attempts: 5, delayMs: 200 }
const BUSY_FS_CODES: ReadonlySet<string> = new Set(['EBUSY', 'EPERM', 'EACCES'])
/** 다른 프로세스가 파일을 잡고 있어 생기는(잠시 뒤 풀릴 수 있는) 파일시스템 오류인지 */
export function isBusyFsError(err: unknown): boolean {
if (typeof err !== 'object' || err === null || !('code' in err)) return false
const code = (err as { code: unknown }).code
return typeof code === 'string' && BUSY_FS_CODES.has(code)
}
export function stagingDirOf(targetDir: string): string {
@ -45,14 +76,32 @@ function installFailed(message: string): D3ROError {
return new D3ROError(ErrorCode.STTSidecarSpawnFailed, message)
}
async function swapIntoPlace(stagingDir: string, targetDir: string): Promise<void> {
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/** 잠김 오류면 policy 만큼 다시 시도하는 rename */
async function renameWithRetry(from: string, to: string, policy: SwapRetryPolicy): Promise<void> {
const attempts = Math.max(1, policy.attempts)
for (let attempt = 1; ; attempt++) {
try {
await rename(from, to)
return
} catch (err) {
if (attempt >= attempts || !isBusyFsError(err)) throw err
await sleep(policy.delayMs)
}
}
}
async function swapIntoPlace(stagingDir: string, targetDir: string, policy: SwapRetryPolicy): Promise<void> {
const backupDir = backupDirOf(targetDir)
await rm(backupDir, { recursive: true, force: true })
const hadPrevious = existsSync(targetDir)
if (hadPrevious) {
try {
await rename(targetDir, backupDir)
await renameWithRetry(targetDir, backupDir, policy)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
throw installFailed(`기존 런타임을 교체할 수 없습니다 (사용 중일 수 있습니다): ${message}`)
@ -60,7 +109,7 @@ async function swapIntoPlace(stagingDir: string, targetDir: string): Promise<voi
}
try {
await rename(stagingDir, targetDir)
await renameWithRetry(stagingDir, targetDir, policy)
} catch (err) {
if (hadPrevious) {
await rename(backupDir, targetDir).catch(() => undefined)
@ -73,8 +122,39 @@ async function swapIntoPlace(stagingDir: string, targetDir: string): Promise<voi
await rm(backupDir, { recursive: true, force: true }).catch(() => undefined)
}
/**
* targetDir 을 붙잡은 사용자에게 파일을 놓게 한 상태에서 교체한다.
* 다시 붙잡기(엔진 재시작)는 교체 성공·실패와 무관하게 하며, 그 실패는 설치 결과를 바꾸지 않는다.
*/
async function swapWhileReleased(
stagingDir: string,
targetDir: string,
release: (targetDir: string) => Promise<ReacquireRuntimeFiles>,
policy: SwapRetryPolicy,
): Promise<void> {
let reacquire: ReacquireRuntimeFiles
try {
reacquire = await release(targetDir)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
throw installFailed(`실행 중인 런타임을 멈출 수 없어 교체하지 못했습니다: ${message}`)
}
try {
await swapIntoPlace(stagingDir, targetDir, policy)
} finally {
await reacquire().catch((err: unknown) => {
logger.warn(
`런타임 교체 뒤 사용자를 다시 시작하지 못했습니다: ${err instanceof Error ? err.message : String(err)}`,
)
})
}
}
export async function installArchive(options: InstallArchiveOptions): Promise<void> {
const { archivePath, targetDir, verify, marker } = options
const release = options.release ?? ((dir: string) => getRuntimeFileHolders().releaseUnder(dir))
const swapRetry = options.swapRetry ?? DEFAULT_SWAP_RETRY
const stagingDir = stagingDirOf(targetDir)
await rm(stagingDir, { recursive: true, force: true })
@ -98,7 +178,7 @@ export async function installArchive(options: InstallArchiveOptions): Promise<vo
await writeFile(join(stagingDir, marker.fileName), marker.content, 'utf8')
}
await swapIntoPlace(stagingDir, targetDir)
await swapWhileReleased(stagingDir, targetDir, release, swapRetry)
} finally {
await rm(stagingDir, { recursive: true, force: true }).catch(() => undefined)
}

View file

@ -0,0 +1,115 @@
// src/main/services/runtime/runtime-file-holders.ts
// 런타임 디렉터리 안의 파일을 붙잡고 있는 사용자(실행 중 엔진 등)를 모아 두는 레지스트리.
//
// Windows 는 실행 중인 이미지(sidecar.exe)나 올라가 있는 DLL(_internal/*.dll)이 든 폴더를
// rename 하지 못한다(EPERM/EBUSY). 그래서 "다시 설치" 가 엔진이 도는 동안에는 항상
// 교체 단계에서 실패했다. 설치기는 교체 직전에 이 레지스트리로 대상 폴더를 붙잡은
// 사용자에게 파일을 놓아 달라고 하고, 교체가 끝나면(성공이든 롤백이든) 다시 붙잡게 한다.
//
// 의존 방향: 설치기(install-archive)는 레지스트리만 안다. 엔진을 띄우는 서비스가 자기가
// 붙잡은 폴더를 등록한다 — 설치기가 LocalSTTService 등을 직접 알 필요가 없다.
import { isAbsolute, relative, resolve, sep } from 'node:path'
/** 놓았던 파일을 다시 붙잡는다 (예: 엔진 재시작) */
export type ReacquireRuntimeFiles = () => Promise<void>
export interface RuntimeFileHolder {
/** 로그·오류 문구용 이름 */
readonly name: string
/**
* 파일을 놓는다 (프로세스를 멈추고 종료까지 기다린다 등).
* 끝나면 다시 붙잡는 함수를 돌려준다. 놓지 못하면 예외를 던진다.
*/
release(): Promise<ReacquireRuntimeFiles>
}
interface HolderEntry {
dir: string
holder: RuntimeFileHolder
}
/** child 가 parent 자신이거나 그 아래 경로인지 (플랫폼 path 규칙을 따른다) */
export function isPathInside(child: string, parent: string): boolean {
const rel = relative(resolve(parent), resolve(child))
if (rel === '') return true
return !isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`)
}
const noopReacquire: ReacquireRuntimeFiles = async () => undefined
export class RuntimeFileHolderRegistry {
private readonly _entries: HolderEntry[] = []
/**
* `dir`(또는 그 아래) 파일을 붙잡는 사용자를 등록한다. 돌려준 함수로 등록을 푼다.
* 같은 사용자를 여러 폴더에 등록할 수 있다.
*/
register(dir: string, holder: RuntimeFileHolder): () => void {
const entry: HolderEntry = { dir, holder }
this._entries.push(entry)
return () => {
const index = this._entries.indexOf(entry)
if (index >= 0) this._entries.splice(index, 1)
}
}
/** targetDir 안의 파일을 붙잡은 사용자 (중복 제거, 등록 순서) */
holdersUnder(targetDir: string): RuntimeFileHolder[] {
const holders: RuntimeFileHolder[] = []
for (const entry of this._entries) {
if (isPathInside(entry.dir, targetDir) && !holders.includes(entry.holder)) {
holders.push(entry.holder)
}
}
return holders
}
/**
* targetDir 을 붙잡은 사용자 모두에게 파일을 놓게 한다.
* 하나라도 실패하면 이미 놓은 사용자를 다시 붙잡게 한 뒤 그 오류를 던진다.
* 돌려준 함수는 모두를 (역순으로) 다시 붙잡게 하며, 전부 시도한 뒤 실패가 있으면 던진다.
*/
async releaseUnder(targetDir: string): Promise<ReacquireRuntimeFiles> {
const holders = this.holdersUnder(targetDir)
if (holders.length === 0) return noopReacquire
const reacquires: ReacquireRuntimeFiles[] = []
const reacquireAll: ReacquireRuntimeFiles = async () => {
const errors: unknown[] = []
for (const reacquire of [...reacquires].reverse()) {
try {
await reacquire()
} catch (err) {
errors.push(err)
}
}
if (errors.length === 1) throw errors[0]
if (errors.length > 1) throw new AggregateError(errors, '런타임 사용자를 다시 시작하지 못했습니다')
}
for (const holder of holders) {
try {
reacquires.push(await holder.release())
} catch (err) {
await reacquireAll().catch(() => undefined)
const message = err instanceof Error ? err.message : String(err)
throw new Error(`${holder.name} 이(가) 런타임 파일을 놓지 못했습니다: ${message}`)
}
}
return reacquireAll
}
}
// ── 싱글톤 ──
let instance: RuntimeFileHolderRegistry | null = null
export function getRuntimeFileHolders(): RuntimeFileHolderRegistry {
if (!instance) instance = new RuntimeFileHolderRegistry()
return instance
}
export function resetRuntimeFileHoldersForTests(): void {
instance = null
}