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
}

View file

@ -0,0 +1,359 @@
// tests/main/services/runtime-install-release-redteam-r2-14.test.ts
// "다시 설치" 가 실행 중인 엔진 때문에 교체 단계에서 항상 실패하던 문제의 회귀 테스트.
//
// 실행 중 엔진은 자기 폴더(이미지·DLL)를 잠가 rename(target → .old) 이 EPERM/EBUSY 로 실패했다.
// 이제 installArchive 는 내려받기·추출·검증이 모두 끝난 뒤, 교체 직전에 대상 폴더를 붙잡은
// 사용자(runtime-file-holders)에게 파일을 놓게 하고, 교체 뒤 다시 붙잡게 한다.
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { spawn, type ChildProcess } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import * as tar from 'tar'
import { installArchive, isBusyFsError } from '../../../src/main/services/runtime/install-archive'
import {
RuntimeFileHolderRegistry,
getRuntimeFileHolders,
isPathInside,
resetRuntimeFileHoldersForTests,
type ReacquireRuntimeFiles,
} from '../../../src/main/services/runtime/runtime-file-holders'
import { parseRuntimeEnsureRequest } from '../../../src/main/ipc/runtime-ensure-request'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
// ── 헬퍼 ────────────────────────────────────────────────────────────
const SIDECAR_BIN = process.platform === 'win32' ? 'sidecar.exe' : 'sidecar'
type TarEntry = { path: string; body?: string; type?: 'File' | 'Directory' }
function buildTar(entries: TarEntry[]): Buffer {
const blocks: Buffer[] = []
for (const entry of entries) {
const type = entry.type ?? 'File'
const body = Buffer.from(entry.body ?? '')
const header = new tar.Header({
path: entry.path,
mode: type === 'File' ? 0o644 : 0o755,
size: type === 'File' ? body.length : 0,
type,
mtime: new Date(0),
uid: 0,
gid: 0,
})
const headerBlock = Buffer.alloc(512)
header.encode(headerBlock, 0)
blocks.push(headerBlock)
if (type === 'File') blocks.push(body, Buffer.alloc((512 - (body.length % 512)) % 512))
}
blocks.push(Buffer.alloc(1024))
return Buffer.concat(blocks)
}
function sidecarTar(version: string): Buffer {
return buildTar([
{ path: SIDECAR_BIN, body: `engine ${version}` },
{ path: '_internal', type: 'Directory' },
{ path: '_internal/core.dll', body: 'dll' },
])
}
const verify = (dir: string): boolean =>
existsSync(join(dir, SIDECAR_BIN)) && existsSync(join(dir, '_internal'))
const marker = { fileName: '.runtime-version', content: '1.9.0' }
let workDir: string
let archivePath: string
let targetDir: string
function seedPrevious(): void {
mkdirSync(join(targetDir, '_internal'), { recursive: true })
writeFileSync(join(targetDir, SIDECAR_BIN), 'old engine')
writeFileSync(join(targetDir, '.runtime-version'), '1.7.0')
}
const engineText = (): string => readFileSync(join(targetDir, SIDECAR_BIN), 'utf8')
/** 폴더 안에서 도는 프로세스 — Windows 는 이 폴더(와 상위)를 rename 하지 못한다 */
async function startEngineIn(dir: string): Promise<ChildProcess> {
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
cwd: dir,
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
stdio: 'ignore',
windowsHide: true,
})
await new Promise<void>((resolve, reject) => {
child.once('spawn', () => resolve())
child.once('error', reject)
})
return child
}
async function stopEngine(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return
const exited = new Promise<void>((resolve) => child.once('exit', () => resolve()))
child.kill('SIGKILL')
await exited
}
const running: ChildProcess[] = []
beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), 'd3ro-runtime-r2-14-'))
archivePath = join(workDir, 'ok.tar')
writeFileSync(archivePath, sidecarTar('1.9.0'))
targetDir = join(workDir, 'runtime', 'sidecar')
resetRuntimeFileHoldersForTests()
})
afterEach(async () => {
await Promise.all(running.splice(0).map(stopEngine))
resetRuntimeFileHoldersForTests()
rmSync(workDir, { recursive: true, force: true })
})
// ── 실행 중 엔진이 있는 "다시 설치" (Windows 실제 잠금) ────────────────
describe.runIf(process.platform === 'win32')('installArchive — 실행 중인 엔진', () => {
it('등록된 사용자가 없으면 잠긴 폴더를 교체하지 못하고 기존 설치를 지킨다 (문제 재현)', async () => {
seedPrevious()
running.push(await startEngineIn(join(targetDir, '_internal')))
await expect(
installArchive({ archivePath, targetDir, verify, marker, swapRetry: { attempts: 2, delayMs: 10 } }),
).rejects.toThrow(/기존 런타임을 교체할 수 없습니다/)
expect(engineText()).toBe('old engine')
})
it('엔진이 등록돼 있으면 교체 직전에 멈추고, 새 설치로 다시 띄운다', async () => {
seedPrevious()
let engine = await startEngineIn(join(targetDir, '_internal'))
running.push(engine)
const events: string[] = []
getRuntimeFileHolders().register(targetDir, {
name: 'test-sidecar',
release: async (): Promise<ReacquireRuntimeFiles> => {
events.push(`release:${engineText()}`)
await stopEngine(engine)
return async () => {
events.push(`reacquire:${engineText()}`)
engine = await startEngineIn(join(targetDir, '_internal'))
running.push(engine)
}
},
})
await installArchive({ archivePath, targetDir, verify, marker })
expect(engineText()).toBe('engine 1.9.0')
expect(readFileSync(join(targetDir, '.runtime-version'), 'utf8')).toBe('1.9.0')
expect(existsSync(`${targetDir}.old`)).toBe(false)
expect(existsSync(`${targetDir}.staging`)).toBe(false)
expect(events).toEqual(['release:old engine', 'reacquire:engine 1.9.0'])
expect(engine.exitCode).toBeNull()
})
it('프로세스 종료 직후 남은 잠금은 잠깐 다시 시도해 넘긴다', async () => {
seedPrevious()
const engine = await startEngineIn(join(targetDir, '_internal'))
running.push(engine)
// 파일을 "놓았다" 고 바로 답하지만 실제 종료는 조금 뒤에 끝난다
const release = async (): Promise<ReacquireRuntimeFiles> => {
setTimeout(() => void stopEngine(engine), 150)
return async () => undefined
}
await installArchive({ archivePath, targetDir, verify, marker, release, swapRetry: { attempts: 20, delayMs: 50 } })
expect(engineText()).toBe('engine 1.9.0')
})
})
// ── 순서 / 실패 처리 (플랫폼 무관) ────────────────────────────────────
describe('installArchive — release 훅', () => {
it('검증이 끝난 뒤, 교체 직전에 놓게 하고 교체 뒤 다시 붙잡게 한다', async () => {
seedPrevious()
const events: string[] = []
const release = async (dir: string): Promise<ReacquireRuntimeFiles> => {
events.push(`release:${dir === targetDir}:${engineText()}:staged=${verify(`${targetDir}.staging`)}`)
return async () => {
events.push(`reacquire:${engineText()}`)
}
}
await installArchive({ archivePath, targetDir, verify, marker, release })
expect(events).toEqual(['release:true:old engine:staged=true', 'reacquire:engine 1.9.0'])
})
it('검증에 실패하면 실행 중인 엔진을 건드리지 않는다', async () => {
seedPrevious()
writeFileSync(archivePath, buildTar([{ path: 'readme.txt', body: 'x' }]))
let released = false
const release = async (): Promise<ReacquireRuntimeFiles> => {
released = true
return async () => undefined
}
await expect(installArchive({ archivePath, targetDir, verify, release })).rejects.toThrow(/필수 파일/)
expect(released).toBe(false)
expect(engineText()).toBe('old engine')
})
it('놓지 못하면 교체하지 않고 기존 설치와 마커를 지킨다', async () => {
seedPrevious()
const release = async (): Promise<ReacquireRuntimeFiles> => {
throw new Error('stop timeout')
}
const err = await installArchive({ archivePath, targetDir, verify, marker, release }).catch((e: unknown) => e)
expect(err).toBeInstanceOf(D3ROError)
expect((err as D3ROError).code).toBe(ErrorCode.STTSidecarSpawnFailed)
expect((err as D3ROError).message).toMatch(/멈출 수 없어.*stop timeout/)
expect(engineText()).toBe('old engine')
expect(readFileSync(join(targetDir, '.runtime-version'), 'utf8')).toBe('1.7.0')
expect(existsSync(`${targetDir}.staging`)).toBe(false)
})
it('다시 붙잡기에 실패해도 설치 결과는 성공이다', async () => {
seedPrevious()
const release = async (): Promise<ReacquireRuntimeFiles> => async () => {
throw new Error('restart failed')
}
await installArchive({ archivePath, targetDir, verify, marker, release })
expect(engineText()).toBe('engine 1.9.0')
})
it('기본값은 전역 레지스트리에서 대상 폴더를 붙잡은 사용자다', async () => {
seedPrevious()
const events: string[] = []
const holder = (name: string) => ({
name,
release: async (): Promise<ReacquireRuntimeFiles> => {
events.push(`release:${name}`)
return async () => {
events.push(`reacquire:${name}`)
}
},
})
getRuntimeFileHolders().register(join(targetDir, '_internal'), holder('engine'))
getRuntimeFileHolders().register(join(workDir, 'runtime', 'ffmpeg'), holder('ffmpeg'))
await installArchive({ archivePath, targetDir, verify, marker })
expect(events).toEqual(['release:engine', 'reacquire:engine'])
})
})
describe('isBusyFsError', () => {
it('잠김 코드만 참이다', () => {
const withCode = (code: string): Error => Object.assign(new Error(code), { code })
expect(isBusyFsError(withCode('EBUSY'))).toBe(true)
expect(isBusyFsError(withCode('EPERM'))).toBe(true)
expect(isBusyFsError(withCode('EACCES'))).toBe(true)
expect(isBusyFsError(withCode('ENOENT'))).toBe(false)
expect(isBusyFsError(new Error('x'))).toBe(false)
expect(isBusyFsError(null)).toBe(false)
})
})
// ── RuntimeFileHolderRegistry ───────────────────────────────────────
describe('RuntimeFileHolderRegistry', () => {
const root = join(tmpdir(), 'd3ro-holders', 'runtime')
it('isPathInside 는 자기 자신과 하위만 참이다 (형제·접두어 폴더는 거짓)', () => {
expect(isPathInside(join(root, 'sidecar'), join(root, 'sidecar'))).toBe(true)
expect(isPathInside(join(root, 'sidecar', '_internal'), join(root, 'sidecar'))).toBe(true)
expect(isPathInside(join(root, 'sidecar.old'), join(root, 'sidecar'))).toBe(false)
expect(isPathInside(join(root, 'ffmpeg'), join(root, 'sidecar'))).toBe(false)
expect(isPathInside(root, join(root, 'sidecar'))).toBe(false)
})
it('등록을 풀면 더 이상 놓게 하지 않고, 같은 사용자는 한 번만 부른다', async () => {
const registry = new RuntimeFileHolderRegistry()
let releases = 0
const holder = {
name: 'engine',
release: async (): Promise<ReacquireRuntimeFiles> => {
releases++
return async () => undefined
},
}
const off1 = registry.register(join(root, 'sidecar'), holder)
const off2 = registry.register(join(root, 'sidecar', '_internal'), holder)
await registry.releaseUnder(join(root, 'sidecar'))
expect(releases).toBe(1)
off1()
off2()
expect(registry.holdersUnder(join(root, 'sidecar'))).toEqual([])
})
it('하나가 실패하면 이미 놓은 사용자를 다시 붙잡게 하고 실패를 올린다', async () => {
const registry = new RuntimeFileHolderRegistry()
const events: string[] = []
registry.register(join(root, 'sidecar'), {
name: 'a',
release: async () => {
events.push('release:a')
return async () => {
events.push('reacquire:a')
}
},
})
registry.register(join(root, 'sidecar'), {
name: 'b',
release: async () => {
throw new Error('busy')
},
})
await expect(registry.releaseUnder(join(root, 'sidecar'))).rejects.toThrow(/b .*busy/)
expect(events).toEqual(['release:a', 'reacquire:a'])
})
it('다시 붙잡기는 역순으로 모두 시도한다', async () => {
const registry = new RuntimeFileHolderRegistry()
const events: string[] = []
for (const name of ['a', 'b']) {
registry.register(join(root, 'sidecar'), {
name,
release: async () => async () => {
events.push(`reacquire:${name}`)
if (name === 'b') throw new Error('b failed')
},
})
}
const reacquire = await registry.releaseUnder(join(root, 'sidecar'))
await expect(reacquire()).rejects.toThrow(/b failed/)
expect(events).toEqual(['reacquire:b', 'reacquire:a'])
})
})
// ── RUNTIME.ENSURE 요청 해석 ─────────────────────────────────────────
describe('parseRuntimeEnsureRequest', () => {
it('force 를 생략하면 다시 설치(true), false 일 때만 건너뛴다', () => {
expect(parseRuntimeEnsureRequest({ component: 'sidecar' })).toEqual({ component: 'sidecar', force: true })
expect(parseRuntimeEnsureRequest({ component: 'ffmpeg', force: false })).toEqual({ component: 'ffmpeg', force: false })
})
it('알 수 없는 구성 요소는 ConfigInvalidValue 로 거절한다', () => {
for (const params of [undefined, null, {}, { component: 'python' }]) {
const err = (() => {
try {
parseRuntimeEnsureRequest(params)
return null
} catch (e) {
return e
}
})()
expect(err).toBeInstanceOf(D3ROError)
expect((err as D3ROError).code).toBe(ErrorCode.ConfigInvalidValue)
}
})
})