fix(runtime): stage runtime installs strictly, time out only on stalls, and make reinstall actually reinstall
This commit is contained in:
parent
ba0dbbd813
commit
524d390bc2
10 changed files with 1160 additions and 238 deletions
|
|
@ -0,0 +1,390 @@
|
|||
// tests/main/services/runtime-provisioner-redteam-r1-8.test.ts
|
||||
// RuntimeProvisioner 분리(인덱스 검증 / 부품 다운로드 / 스테이징 설치)와 회귀 테스트.
|
||||
//
|
||||
// 회귀 대상:
|
||||
// 1) tar 항목 쓰기 오류가 경고로 삼켜져 반쯤 풀린 엔진에 버전 마커가 붙던 문제
|
||||
// 2) 부품 전체 전송에 120초 제한이 걸려 느린 링크에서 영영 설치할 수 없던 문제
|
||||
// 3) "다시 설치" 가 설치된 런타임에서 아무 일도 하지 않던 문제
|
||||
// 4) 최소 버전 미만 인덱스를 받아들여 매번 다시 받는 루프
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { createHash } from 'node:crypto'
|
||||
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 {
|
||||
isRuntimeVersionSatisfied,
|
||||
parseRuntimeIndex,
|
||||
type RuntimePart,
|
||||
} from '../../../src/main/services/runtime/runtime-index'
|
||||
import {
|
||||
downloadPart,
|
||||
type RuntimeFetch,
|
||||
type RuntimeFetchResponse,
|
||||
} from '../../../src/main/services/runtime/download-part'
|
||||
import { installArchive } from '../../../src/main/services/runtime/install-archive'
|
||||
import { RuntimeProvisioner } from '../../../src/main/services/RuntimeProvisioner'
|
||||
|
||||
// ── 헬퍼 ────────────────────────────────────────────────────────────
|
||||
|
||||
const sha = (data: Buffer): string => createHash('sha256').update(data).digest('hex')
|
||||
|
||||
type TarEntry = { path: string; body?: string; type?: 'File' | 'Directory' }
|
||||
|
||||
/** 손으로 만든 tar (파일 다음에 같은 이름의 하위 항목을 넣어 항목 오류를 재현할 수 있다) */
|
||||
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)
|
||||
}
|
||||
|
||||
const SIDECAR_BIN = process.platform === 'win32' ? 'sidecar.exe' : 'sidecar'
|
||||
|
||||
function sidecarTar(version: string): Buffer {
|
||||
return buildTar([
|
||||
{ path: SIDECAR_BIN, body: `engine ${version}` },
|
||||
{ path: '_internal', type: 'Directory' },
|
||||
{ path: '_internal/core.dll', body: 'dll' },
|
||||
])
|
||||
}
|
||||
|
||||
/** 항목 하나가 반드시 실패하는 tar: 파일 `_internal/x` 뒤에 `_internal/x/y` */
|
||||
function brokenSidecarTar(): Buffer {
|
||||
return buildTar([
|
||||
{ path: SIDECAR_BIN, body: 'engine' },
|
||||
{ path: '_internal', type: 'Directory' },
|
||||
{ path: '_internal/x', body: 'file in the way' },
|
||||
{ path: '_internal/x/y.dll', body: 'never written' },
|
||||
])
|
||||
}
|
||||
|
||||
function response(
|
||||
body: BodyInit | null,
|
||||
init: { status?: number; headers?: Record<string, string> } = {},
|
||||
): RuntimeFetchResponse {
|
||||
return new Response(body, { status: init.status ?? 200, headers: init.headers }) as RuntimeFetchResponse
|
||||
}
|
||||
|
||||
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
/** chunkCount 개의 청크를 intervalMs 간격으로 보내는 스트림 */
|
||||
function slowStream(data: Buffer, chunkCount: number, intervalMs: number): ReadableStream<Uint8Array> {
|
||||
const chunkSize = Math.ceil(data.length / chunkCount)
|
||||
let offset = 0
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
if (offset >= data.length) {
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
await delay(intervalMs)
|
||||
controller.enqueue(new Uint8Array(data.subarray(offset, offset + chunkSize)))
|
||||
offset += chunkSize
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 앞부분 bytes 만 보내고 영원히 멈추는 스트림 */
|
||||
function stallingStream(data: Buffer, bytes: number): ReadableStream<Uint8Array> {
|
||||
let sent = false
|
||||
return new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (!sent) {
|
||||
sent = true
|
||||
controller.enqueue(new Uint8Array(data.subarray(0, bytes)))
|
||||
return
|
||||
}
|
||||
return new Promise<void>(() => undefined)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let workDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
workDir = mkdtempSync(join(tmpdir(), 'd3ro-runtime-r1-8-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(workDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// ── parseRuntimeIndex ───────────────────────────────────────────────
|
||||
|
||||
function validIndex(version = '1.9.0'): Record<string, unknown> {
|
||||
const part = Buffer.from('abc')
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
version,
|
||||
components: {
|
||||
sidecar: {
|
||||
archive: 'd3ro-runtime-sidecar.tar.gz',
|
||||
sha256: sha(part),
|
||||
totalSize: part.length,
|
||||
parts: [{ name: 'd3ro-runtime-sidecar.tar.gz.001', size: part.length, sha256: sha(part), url: 'https://feed/x.001' }],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseRuntimeIndex', () => {
|
||||
it('정상 인덱스에서 구성 요소 항목과 버전을 돌려준다', () => {
|
||||
const resolved = parseRuntimeIndex(validIndex(), 'sidecar', '1.7.0')
|
||||
expect(resolved.version).toBe('1.9.0')
|
||||
expect(resolved.entry.parts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('최소 버전보다 낮은 인덱스는 거부한다 (재다운로드 루프 방지)', () => {
|
||||
expect(() => parseRuntimeIndex(validIndex('1.6.9'), 'sidecar', '1.7.0')).toThrow(/최소 요구 버전/)
|
||||
})
|
||||
|
||||
it('semver 가 아닌 version 은 거부한다', () => {
|
||||
expect(() => parseRuntimeIndex(validIndex('latest'), 'sidecar', '1.7.0')).toThrow(/semver/)
|
||||
})
|
||||
|
||||
it('경로 구분자나 .. 가 든 부품 이름은 거부한다', () => {
|
||||
const index = validIndex()
|
||||
const components = index.components as Record<string, { parts: RuntimePart[] }>
|
||||
components.sidecar.parts[0].name = '..\\..\\evil.dll'
|
||||
expect(() => parseRuntimeIndex(index, 'sidecar', '1.7.0')).toThrow(/안전한 파일 이름/)
|
||||
})
|
||||
|
||||
it('해시가 SHA-256 형식이 아니면 거부한다', () => {
|
||||
const index = validIndex()
|
||||
const components = index.components as Record<string, { sha256: string }>
|
||||
components.sidecar.sha256 = 'nope'
|
||||
expect(() => parseRuntimeIndex(index, 'sidecar', '1.7.0')).toThrow(/SHA-256/)
|
||||
})
|
||||
|
||||
it('구성 요소가 없으면 그 이름으로 알린다', () => {
|
||||
expect(() => parseRuntimeIndex(validIndex(), 'ffmpeg', null)).toThrow(/ffmpeg 구성 요소가 없습니다/)
|
||||
})
|
||||
|
||||
it('isRuntimeVersionSatisfied 는 최소 버전 null 이면 항상 참, 읽을 수 없으면 거짓', () => {
|
||||
expect(isRuntimeVersionSatisfied(undefined, null)).toBe(true)
|
||||
expect(isRuntimeVersionSatisfied('1.7.0\n', '1.7.0')).toBe(true)
|
||||
expect(isRuntimeVersionSatisfied('1.6.0', '1.7.0')).toBe(false)
|
||||
expect(isRuntimeVersionSatisfied('garbage', '1.7.0')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── downloadPart ────────────────────────────────────────────────────
|
||||
|
||||
describe('downloadPart', () => {
|
||||
const data = Buffer.alloc(64 * 1024, 7)
|
||||
const part: RuntimePart = { name: 'p.001', size: data.length, sha256: sha(data), url: 'https://feed/p.001' }
|
||||
|
||||
it('전체 전송이 정체 타임아웃보다 길어도 데이터가 계속 오면 끝까지 받는다', async () => {
|
||||
// 10청크 × 40ms ≈ 400ms > stallTimeout 150ms — 예전(전송 전체 타임아웃)에는 끊겼다
|
||||
const fetchImpl: RuntimeFetch = async () => response(slowStream(data, 10, 40))
|
||||
const dest = join(workDir, part.name)
|
||||
const size = await downloadPart(part, dest, { fetchImpl, stallTimeoutMs: 150, attempts: 1 })
|
||||
expect(size).toBe(data.length)
|
||||
expect(sha(readFileSync(dest))).toBe(part.sha256)
|
||||
})
|
||||
|
||||
it('데이터가 멈추면 끊고, 재시도에서 Range 로 이어 받는다', async () => {
|
||||
const requests: Array<Record<string, string> | undefined> = []
|
||||
const fetchImpl: RuntimeFetch = async (_url, init) => {
|
||||
requests.push(init.headers)
|
||||
if (requests.length === 1) return response(stallingStream(data, 1000))
|
||||
const range = init.headers?.Range ?? ''
|
||||
const start = Number(/bytes=(\d+)-/.exec(range)?.[1] ?? 0)
|
||||
return response(new Uint8Array(data.subarray(start)), {
|
||||
status: 206,
|
||||
headers: { 'content-range': `bytes ${start}-${data.length - 1}/${data.length}` },
|
||||
})
|
||||
}
|
||||
const dest = join(workDir, part.name)
|
||||
const size = await downloadPart(part, dest, { fetchImpl, stallTimeoutMs: 80, attempts: 2 })
|
||||
expect(size).toBe(data.length)
|
||||
expect(requests[0]).toBeUndefined()
|
||||
expect(requests[1]).toEqual({ Range: 'bytes=1000-' })
|
||||
expect(sha(readFileSync(dest))).toBe(part.sha256)
|
||||
})
|
||||
|
||||
it('모든 시도가 정체되면 정체 오류로 실패하고 부분 파일을 남기지 않는다', async () => {
|
||||
const fetchImpl: RuntimeFetch = async () => response(stallingStream(data, 10))
|
||||
const dest = join(workDir, part.name)
|
||||
await expect(downloadPart(part, dest, { fetchImpl, stallTimeoutMs: 50, attempts: 2 })).rejects.toThrow(/멈췄습니다/)
|
||||
expect(existsSync(dest)).toBe(false)
|
||||
})
|
||||
|
||||
it('해시가 틀리면 부분 파일을 버리고 처음부터 다시 받는다', async () => {
|
||||
let calls = 0
|
||||
const fetchImpl: RuntimeFetch = async (_url, init) => {
|
||||
calls += 1
|
||||
expect(init.headers).toBeUndefined()
|
||||
return response(new Uint8Array(calls === 1 ? Buffer.alloc(data.length, 1) : data))
|
||||
}
|
||||
const dest = join(workDir, part.name)
|
||||
await downloadPart(part, dest, { fetchImpl, stallTimeoutMs: 1000, attempts: 2 })
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ── installArchive ──────────────────────────────────────────────────
|
||||
|
||||
describe('installArchive', () => {
|
||||
const verify = (dir: string): boolean =>
|
||||
existsSync(join(dir, SIDECAR_BIN)) && existsSync(join(dir, '_internal'))
|
||||
|
||||
function seedPrevious(targetDir: string): void {
|
||||
mkdirSync(join(targetDir, '_internal'), { recursive: true })
|
||||
writeFileSync(join(targetDir, SIDECAR_BIN), 'old engine')
|
||||
writeFileSync(join(targetDir, '.runtime-version'), '1.7.0')
|
||||
}
|
||||
|
||||
it('항목 쓰기 오류가 나면 실패하고 기존 설치와 마커를 그대로 둔다', async () => {
|
||||
const archivePath = join(workDir, 'broken.tar')
|
||||
writeFileSync(archivePath, brokenSidecarTar())
|
||||
const targetDir = join(workDir, 'sidecar')
|
||||
seedPrevious(targetDir)
|
||||
|
||||
await expect(
|
||||
installArchive({ archivePath, targetDir, verify, marker: { fileName: '.runtime-version', content: '1.9.0' } }),
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(readFileSync(join(targetDir, SIDECAR_BIN), 'utf8')).toBe('old engine')
|
||||
expect(readFileSync(join(targetDir, '.runtime-version'), 'utf8')).toBe('1.7.0')
|
||||
expect(existsSync(`${targetDir}.staging`)).toBe(false)
|
||||
})
|
||||
|
||||
it('성공하면 새 트리로 교체하고 마커를 새 버전으로 쓴다', async () => {
|
||||
const archivePath = join(workDir, 'ok.tar')
|
||||
writeFileSync(archivePath, sidecarTar('1.9.0'))
|
||||
const targetDir = join(workDir, 'sidecar')
|
||||
seedPrevious(targetDir)
|
||||
|
||||
await installArchive({ archivePath, targetDir, verify, marker: { fileName: '.runtime-version', content: '1.9.0' } })
|
||||
|
||||
expect(readFileSync(join(targetDir, SIDECAR_BIN), 'utf8')).toBe('engine 1.9.0')
|
||||
expect(readFileSync(join(targetDir, '.runtime-version'), 'utf8')).toBe('1.9.0')
|
||||
expect(existsSync(join(targetDir, '_internal', 'core.dll'))).toBe(true)
|
||||
expect(existsSync(`${targetDir}.old`)).toBe(false)
|
||||
})
|
||||
|
||||
it('필수 파일이 없으면 교체하지 않는다', async () => {
|
||||
const archivePath = join(workDir, 'noexe.tar')
|
||||
writeFileSync(archivePath, buildTar([{ path: 'readme.txt', body: 'x' }]))
|
||||
const targetDir = join(workDir, 'sidecar')
|
||||
seedPrevious(targetDir)
|
||||
|
||||
await expect(installArchive({ archivePath, targetDir, verify })).rejects.toThrow(/필수 파일/)
|
||||
expect(readFileSync(join(targetDir, SIDECAR_BIN), 'utf8')).toBe('old engine')
|
||||
})
|
||||
})
|
||||
|
||||
// ── RuntimeProvisioner (주입된 IO로 전체 흐름) ─────────────────────────
|
||||
|
||||
describe('RuntimeProvisioner', () => {
|
||||
const FEED = 'https://feed.test/runtime-latest'
|
||||
|
||||
function feedFor(archive: Buffer, version: string): { fetchImpl: RuntimeFetch; calls: string[] } {
|
||||
const calls: string[] = []
|
||||
const partName = 'd3ro-runtime-sidecar.tar.gz.001'
|
||||
const index = {
|
||||
schemaVersion: 1,
|
||||
version,
|
||||
components: {
|
||||
sidecar: {
|
||||
archive: 'd3ro-runtime-sidecar.tar.gz',
|
||||
sha256: sha(archive),
|
||||
totalSize: archive.length,
|
||||
parts: [{ name: partName, size: archive.length, sha256: sha(archive), url: `${FEED}/${partName}` }],
|
||||
},
|
||||
},
|
||||
}
|
||||
const fetchImpl: RuntimeFetch = async (url) => {
|
||||
calls.push(url)
|
||||
if (url === `${FEED}/runtime.json`) return response(JSON.stringify(index))
|
||||
if (url === `${FEED}/${partName}`) return response(new Uint8Array(archive))
|
||||
return response(null, { status: 404 })
|
||||
}
|
||||
return { fetchImpl, calls }
|
||||
}
|
||||
|
||||
function provisioner(fetchImpl: RuntimeFetch): RuntimeProvisioner {
|
||||
return new RuntimeProvisioner({
|
||||
userDataDir: () => workDir,
|
||||
fetchImpl,
|
||||
feedUrl: FEED,
|
||||
platform: process.platform,
|
||||
stallTimeoutMs: 1000,
|
||||
partAttempts: 1,
|
||||
})
|
||||
}
|
||||
|
||||
const markerPath = (): string => join(workDir, 'runtime', 'sidecar', '.runtime-version')
|
||||
|
||||
it('설치 후 마커를 쓰고, 다음 ensure 는 내려받지 않는다', async () => {
|
||||
const feed = feedFor(sidecarTar('1.9.0'), '1.9.0')
|
||||
const p = provisioner(feed.fetchImpl)
|
||||
|
||||
const path = await p.ensure('sidecar')
|
||||
expect(path).toBe(p.binaryPath('sidecar'))
|
||||
expect(readFileSync(markerPath(), 'utf8')).toBe('1.9.0')
|
||||
|
||||
feed.calls.length = 0
|
||||
await p.ensure('sidecar')
|
||||
expect(feed.calls).toEqual([])
|
||||
})
|
||||
|
||||
it('force 면 설치돼 있어도 다시 받아 손상된 트리를 복구한다', async () => {
|
||||
const feed = feedFor(sidecarTar('1.9.0'), '1.9.0')
|
||||
const p = provisioner(feed.fetchImpl)
|
||||
await p.ensure('sidecar')
|
||||
|
||||
// 백신이 DLL 을 격리한 상황
|
||||
rmSync(join(workDir, 'runtime', 'sidecar', '_internal', 'core.dll'))
|
||||
feed.calls.length = 0
|
||||
|
||||
await p.ensure('sidecar', { force: true })
|
||||
expect(feed.calls.length).toBeGreaterThan(0)
|
||||
expect(existsSync(join(workDir, 'runtime', 'sidecar', '_internal', 'core.dll'))).toBe(true)
|
||||
})
|
||||
|
||||
it('추출 중 항목 오류가 나면 실패하고 버전 마커를 남기지 않는다', async () => {
|
||||
const feed = feedFor(brokenSidecarTar(), '1.9.0')
|
||||
const p = provisioner(feed.fetchImpl)
|
||||
|
||||
await expect(p.ensure('sidecar')).rejects.toThrow()
|
||||
expect(existsSync(markerPath())).toBe(false)
|
||||
expect(p.isInstalled('sidecar')).toBe(false)
|
||||
})
|
||||
|
||||
it('최소 버전 미만 인덱스는 부품을 받기 전에 거부한다', async () => {
|
||||
const feed = feedFor(sidecarTar('1.6.0'), '1.6.0')
|
||||
const p = provisioner(feed.fetchImpl)
|
||||
|
||||
await expect(p.ensure('sidecar')).rejects.toThrow(/최소 요구 버전/)
|
||||
expect(feed.calls).toEqual([`${FEED}/runtime.json`])
|
||||
})
|
||||
|
||||
it('다운로드가 끝나면 임시 폴더를 남기지 않는다', async () => {
|
||||
const feed = feedFor(sidecarTar('1.9.0'), '1.9.0')
|
||||
await provisioner(feed.fetchImpl).ensure('sidecar')
|
||||
expect(existsSync(join(workDir, 'runtime', '.download-sidecar'))).toBe(false)
|
||||
expect(existsSync(join(workDir, 'runtime', 'sidecar.staging'))).toBe(false)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue