d3ro-voice/apps/desktop/tests/main/services/runtime-install-release-redteam-r2-14.test.ts

359 lines
14 KiB
TypeScript

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