diff --git a/apps/desktop/src/main/ipc/runtime-ensure-request.ts b/apps/desktop/src/main/ipc/runtime-ensure-request.ts new file mode 100644 index 0000000..ef7b0fe --- /dev/null +++ b/apps/desktop/src/main/ipc/runtime-ensure-request.ts @@ -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) : {} + const component = record.component + if (!isRuntimeComponent(component)) { + throw new D3ROError(ErrorCode.ConfigInvalidValue, `알 수 없는 런타임 구성 요소: ${String(component)}`) + } + return { component, force: record.force !== false } +} diff --git a/apps/desktop/src/main/ipc/stt-handlers.ts b/apps/desktop/src/main/ipc/stt-handlers.ts index b2d6738..de11584 100644 --- a/apps/desktop/src/main/ipc/stt-handlers.ts +++ b/apps/desktop/src/main/ipc/stt-handlers.ts @@ -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) { diff --git a/apps/desktop/src/main/services/runtime/install-archive.ts b/apps/desktop/src/main/services/runtime/install-archive.ts index 7ea7b35..83a7b41 100644 --- a/apps/desktop/src/main/services/runtime/install-archive.ts +++ b/apps/desktop/src/main/services/runtime/install-archive.ts @@ -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 + /** 잠김 오류(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 = 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 { +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** 잠김 오류면 policy 만큼 다시 시도하는 rename */ +async function renameWithRetry(from: string, to: string, policy: SwapRetryPolicy): Promise { + 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 { 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 undefined) @@ -73,8 +122,39 @@ async function swapIntoPlace(stagingDir: string, targetDir: string): Promise undefined) } +/** + * targetDir 을 붙잡은 사용자에게 파일을 놓게 한 상태에서 교체한다. + * 다시 붙잡기(엔진 재시작)는 교체 성공·실패와 무관하게 하며, 그 실패는 설치 결과를 바꾸지 않는다. + */ +async function swapWhileReleased( + stagingDir: string, + targetDir: string, + release: (targetDir: string) => Promise, + policy: SwapRetryPolicy, +): Promise { + 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 { 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 undefined) } diff --git a/apps/desktop/src/main/services/runtime/runtime-file-holders.ts b/apps/desktop/src/main/services/runtime/runtime-file-holders.ts new file mode 100644 index 0000000..c36c853 --- /dev/null +++ b/apps/desktop/src/main/services/runtime/runtime-file-holders.ts @@ -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 + +export interface RuntimeFileHolder { + /** 로그·오류 문구용 이름 */ + readonly name: string + /** + * 파일을 놓는다 (프로세스를 멈추고 종료까지 기다린다 등). + * 끝나면 다시 붙잡는 함수를 돌려준다. 놓지 못하면 예외를 던진다. + */ + release(): Promise +} + +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 { + 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 +} diff --git a/apps/desktop/tests/main/services/runtime-install-release-redteam-r2-14.test.ts b/apps/desktop/tests/main/services/runtime-install-release-redteam-r2-14.test.ts new file mode 100644 index 0000000..05c20d1 --- /dev/null +++ b/apps/desktop/tests/main/services/runtime-install-release-redteam-r2-14.test.ts @@ -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 { + 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((resolve, reject) => { + child.once('spawn', () => resolve()) + child.once('error', reject) + }) + return child +} + +async function stopEngine(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return + const exited = new Promise((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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => 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 => { + 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 => { + 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) + } + }) +})