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 {

View file

@ -194,7 +194,7 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]`
| INFRA-15 | Update & release system | [x] | Canonical Forgejo feed + channels/policy (`release/update-policy.json`, `src/main/update-policy.ts`), canonical publisher `scripts/ci/publish-forgejo-release.mjs`, legacy GitLab mirror; `npm run release:metadata:test`. v1.1.0 was published to Forgejo on 2026-09-15; product version moved to `1.2.0` as a forward-fix with CI-only publication, a same-version re-release guard, and download centers that link the feed instead of repository paths. `1.3.0` (2026-09-18) carries the local-STT fixes; Windows publication still needs the CI signing secrets (`11` GAP-REL-02). |
| INFRA-16 | Desktop STT engine packaging | [x] | `apps/desktop/scripts/setup-sidecar.mjs` + `build-sidecar.mjs`, `electron-builder.yml` `extraResources` (`sidecar-dist/sidecar``resources/sidecar`, `resources/ffmpeg`), and `scripts/ci/verify-sidecar-bundle.mjs` run in `package-windows`/`package-macos` before electron-builder. Verified on the real bundle: `sidecar.exe` + `_internal` including `faster_whisper/assets/silero_vad_v6.onnx`, plus a packaged-engine transcription round-trip on GPU. |
| INFRA-17 | 서명 없는 배포 채널 (portable + Scoop) | [x] | `scripts/ci/build-portable.mjs` (95MiB 7z 분할 볼륨 + Scoop 매니페스트), `scripts/ci/publish-portable-release.mjs`, `scripts/local/install-d3ro-voice.ps1`, `bucket/` 버킷, `.forgejo/workflows/portable.yml`; 7z 분할 볼륨(Scoop, 162MiB) + zip 분할 부품(수동 설치, 243MiB, 7-Zip 불필요); updater feed와 분리. 2026-09-18 `portable-1.3.1` 게시 + 실제 설치 검증. |
| INFRA-18 | 로컬 런타임 온디맨드 설치 | [x] | `RuntimeProvisioner`(부품 다운로드 + SHA-256 검증 + tar 해제, `%APPDATA%/d3ro-voice/runtime`), `POST runtime:ensure` / `runtime:progress` IPC, 설정 > STT 상태/내려받기 UI. 설치본에서 엔진/ffmpeg를 분리해 189MB → 90.6MiB, 업데이트 피드 게시 복구. 2026-09-18 실제 feed 통합 검증(엔진 94.4MiB/17초, ffmpeg 21.7MiB/5초). |
| INFRA-18 | 로컬 런타임 온디맨드 설치 | [x] | `RuntimeProvisioner`(부품 다운로드 + SHA-256 검증 + tar 해제, `%APPDATA%/d3ro-voice/runtime`), `POST runtime:ensure` / `runtime:progress` IPC, 설정 > STT 상태/내려받기 UI. 설치본에서 엔진/ffmpeg를 분리해 189MB → 90.6MiB, 업데이트 피드 게시 복구. 검증은 전부 디스크에 기록된 파일 기준이며(부품 크기·해시 → 결합본 크기·해시), 부품 다운로드는 최대 3회 재시도한다. 2026-09-18 실제 feed 통합 검증(엔진 94.4MiB/18초, ffmpeg 21.7MiB/5초). |
| INFRA-19 | 네이티브 ABI + updater 설정 게이트 | [x] | `scripts/ci/verify-native-abi.mjs`(패키징된 `better_sqlite3.node`가 Electron ABI인지 호스트 Node 로드 거부로 판별) + `scripts/ci/fix-native-abi.mjs`(로컬 잠금 우회용 주입). GitLab/Forgejo/GitHub 패키징 단계에 검증 삽입. 2026-09-18: Node ABI 모듈로 앱이 시작 즉시 죽은 사고 + 누락으로 자동 업데이트가 죽은 사고를 함께 방지(). |
---

View file

@ -53,6 +53,7 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res
| GAP-REL-04 | Release | canonical feed는 Cloudflare 뒤에 있어 업로드 본문이 **100MiB**를 넘으면 HTTP 413으로 거부한다. | `scripts/ci/publish-updater-release.mjs`, `docs/deployment/unsigned-distribution.md` | `[~]` 2026-09-18: 설치본을 90.6MiB로 줄여 업데이트 피드 게시를 복구했다(GAP-STT-07). 휴대용/Scoop 채널은 여전히 95MiB 분할이 필요하다. |
| GAP-STT-07 | Local STT | 진(사이드카)을 앱 번들에 넣으면 설치본이 100MiB를 넘고 매 업데이트마다 162MiB를 다시 받는다. | `apps/desktop/src/main/services/RuntimeProvisioner.ts`, `apps/desktop/electron-builder.yml` | `[x]` 2026-09-18: 설치본에서 엔진/ffmpeg를 제거하고 처음 필요할 때 `runtime-latest`에서 내려받는다(부품별 + 결합본 SHA-256 검증). 설치본 189MB → 90.6MiB, 런타임 1회 116MiB(엔진 94.4 + ffmpeg 21.7). 실제 feed로 통합 검증 완료. |
| GAP-STT-06 | Local STT | Decode settings were untuned: previous-text conditioning let repeated hallucinations compound, and no VAD parameters meant slow, uneven segments. | `apps/desktop/sidecar/main.py` | `[x]` 2026-09-18: `condition_on_previous_text=false`, bounded low-temperature fallback, `no_speech`/`compression_ratio`/`log_prob` thresholds, 300 ms silence trimming. Same transcript, ~5x faster on the reference machine (7.7 s audio: 1609 ms → 303 ms). |
| GAP-STT-08 | Local STT | 1.3.5 설치본에서 엔진 설치가 "런타임 아카이브 해시 불일치 (sidecar)"로 항상 실패했다. 부품 검증은 **메모리 스트림**에서 센 값으로, 결합 검증은 **디스크 파일**에서 계산해 기준이 서로 달랐다. 디스크 쓰기가 잘려도 부품 검사를 통과하고 결합 단계에서만 터지므로 원인 파악도 불가능했다. 재시도가 없어 전송이 한 번 끊기면 곧바로 설치 실패였다. | `apps/desktop/src/main/services/RuntimeProvisioner.ts` | `[x]` 2026-09-18: 부품 크기·해시를 디스크 파일 기준으로 통일하고, 결합본은 크기를 먼저 검사한 뒤 해시를 본다(오류 메시지에 실제/기대값 포함). 부품 다운로드는 실패 시 해당 파일을 지우고 최대 3회 재시도한다. 서버 아티팩트는 무결함을 확인했고(부품 2개 해시 일치, 결합본 `e203aa53…` = 인덱스 기대값), 실제 feed로 설치를 재현해 18초 만에 성공. |
---