// 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) } }) })