fix(desktop): verify runtime parts on disk so engine install stops failing

Part checks counted bytes from the network stream while the joined archive
was hashed from disk, so a truncated write passed part verification and only
failed later as "런타임 아카이브 해시 불일치". Verify size and hash from the
written file, check the joined size before its hash, and retry a failed part
up to 3 times.
This commit is contained in:
Yun Chan 2026-09-18 20:26:26 +09:00
parent 57c17d0977
commit 27facb8569
3 changed files with 99 additions and 51 deletions

View file

@ -14,8 +14,8 @@
import { EventEmitter, once } from 'events'
import { createHash } from 'node:crypto'
import { createReadStream, createWriteStream, existsSync, statSync } from 'node:fs'
import { mkdir, rm } from 'node:fs/promises'
import { Readable } from 'node:stream'
import { mkdir, rm, stat } from 'node:fs/promises'
import { Readable, Writable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { join } from 'node:path'
import { app } from 'electron'
@ -69,6 +69,8 @@ export interface RuntimeStatus {
const RUNTIME_DIR_NAME = 'runtime'
const DOWNLOAD_TIMEOUT_MS = 120_000
/** 부품 다운로드 재시도 횟수 — 전송 중 잘림/일시적 네트워크 오류 대비 */
const PART_DOWNLOAD_ATTEMPTS = 3
class RuntimeProvisioner extends EventEmitter {
constructor() {
@ -248,68 +250,101 @@ class RuntimeProvisioner extends EventEmitter {
for (const part of entry.parts) {
const partPath = join(tempDir, part.name)
const response = await fetch(part.url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) })
if (!response.ok || !response.body) {
throw new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
`런타임 부품을 받을 수 없습니다 (HTTP ${response.status}): ${part.name}`,
)
}
const partSize = await this._downloadPart(part, partPath)
const hash = createHash('sha256')
let partBytes = 0
const source = Readable.fromWeb(response.body as never)
source.on('data', (chunk: Buffer) => {
hash.update(chunk)
partBytes += chunk.length
downloadedBytes += chunk.length
const elapsed = Math.max(0.001, (Date.now() - startedAt) / 1000)
this._emitProgress(
component,
'downloading',
totalBytes > 0 ? Math.min(100, Math.round((downloadedBytes * 100) / totalBytes)) : 0,
downloadedBytes,
totalBytes,
Math.round(downloadedBytes / elapsed),
)
})
await pipeline(source, createWriteStream(partPath))
const actual = hash.digest('hex')
if (part.sha256 && actual !== part.sha256) {
throw new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
`런타임 부품 해시 불일치 (${part.name})`,
)
}
if (partBytes !== part.size) {
throw new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
`런타임 부품 크기 불일치 (${part.name}: ${partBytes} != ${part.size})`,
)
}
downloadedBytes += partSize
const elapsed = Math.max(0.001, (Date.now() - startedAt) / 1000)
this._emitProgress(
component,
'downloading',
totalBytes > 0 ? Math.min(100, Math.round((downloadedBytes * 100) / totalBytes)) : 0,
downloadedBytes,
totalBytes,
Math.round(downloadedBytes / elapsed),
)
}
// 부품을 순서대로 이어 인다 (스트리밍 — 메모리에 통째로 올리지 않는다)
const archiveHash = createHash('sha256')
// 부품을 순서대로 이어 붙인다 (스트리밍 — 메모리에 통째로 올리지 않는다)
const archiveStream = createWriteStream(archivePath)
for (const part of entry.parts) {
const source = createReadStream(join(tempDir, part.name))
source.on('data', (chunk: string | Buffer) => archiveHash.update(chunk))
await pipeline(source, archiveStream, { end: false })
await pipeline(createReadStream(join(tempDir, part.name)), archiveStream, { end: false })
}
archiveStream.end()
await once(archiveStream, 'finish')
const actualArchive = archiveHash.digest('hex')
// 크기를 먼저 본다 — 불일치하면 "어디까지 받았는지"가 로그에 남아 진단이 가능하다.
const archiveSize = (await stat(archivePath)).size
const expectedSize = entry.parts.reduce((sum, part) => sum + part.size, 0)
if (archiveSize !== expectedSize) {
throw new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
`런타임 아카이브 크기 불일치 (${component}: ${archiveSize} != ${expectedSize})`,
)
}
const actualArchive = await sha256File(archivePath)
if (entry.sha256 && actualArchive !== entry.sha256) {
throw new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
`런타임 아카이브 해시 불일치 (${component})`,
`런타임 아카이브 해시 불일치 (${component}: ${actualArchive} != ${entry.sha256})`,
)
}
}
/**
* · .
* ( 1 ).
*/
private async _downloadPart(part: RuntimePart, partPath: string): Promise<number> {
let lastError: Error | null = null
for (let attempt = 1; attempt <= PART_DOWNLOAD_ATTEMPTS; attempt += 1) {
try {
const response = await fetch(part.url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) })
if (!response.ok || !response.body) {
throw new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
`런타임 부품을 받을 수 없습니다 (HTTP ${response.status}): ${part.name}`,
)
}
// 스트림을 파일로 저장한 뒤 "디스크에 실제로 남은 파일"에서 크기와 해시를 계산한다.
// 메모리 스트림에서 센 값으로 검증하면, 디스크 쓰기가 잘려도 부품 검사를 통과해
// 결합 단계에 가서야 해시 불일치로 터진다 — 실측 사고.
await pipeline(Readable.fromWeb(response.body as never), createWriteStream(partPath))
const partSize = (await stat(partPath)).size
if (partSize !== part.size) {
throw new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
`런타임 부품 크기 불일치 (${part.name}: ${partSize} != ${part.size})`,
)
}
const actualPartHash = await sha256File(partPath)
if (part.sha256 && actualPartHash !== part.sha256) {
throw new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
`런타임 부품 해시 불일치 (${part.name})`,
)
}
return partSize
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err))
await rm(partPath, { force: true }).catch(() => undefined)
logger.warn(
`런타임 부품 다운로드 실패 (${part.name}, ${attempt}/${PART_DOWNLOAD_ATTEMPTS}): ${lastError.message}`,
)
}
}
throw lastError ?? new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
`런타임 부품 다운로드 실패 (${part.name})`,
)
}
private _emitProgress(
component: RuntimeComponent,
phase: RuntimeProgressEvent['phase'],
@ -329,6 +364,18 @@ class RuntimeProvisioner extends EventEmitter {
}
}
/** 파일 SHA-256 (스트림 — 메모리에 통째로 올리지 않는다) */
async function sha256File(path: string): Promise<string> {
const hash = createHash('sha256')
await pipeline(createReadStream(path), new Writable({
write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void) {
hash.update(chunk)
callback()
},
}))
return hash.digest('hex')
}
let _instance: RuntimeProvisioner | null = null
export function getRuntimeProvisioner(): RuntimeProvisioner {
@ -340,4 +387,4 @@ export function getRuntimeProvisioner(): RuntimeProvisioner {
export function resetRuntimeProvisionerForTests(): void {
_instance = null
}
}