fix(runtime): release engine file holders right before swapping a reinstalled runtime
This commit is contained in:
parent
e015f43de1
commit
11bf995409
5 changed files with 589 additions and 15 deletions
27
apps/desktop/src/main/ipc/runtime-ensure-request.ts
Normal file
27
apps/desktop/src/main/ipc/runtime-ensure-request.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
115
apps/desktop/src/main/services/runtime/runtime-file-holders.ts
Normal file
115
apps/desktop/src/main/services/runtime/runtime-file-holders.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue