diff --git a/apps/desktop/src/main/services/RuntimeProvisioner.ts b/apps/desktop/src/main/services/RuntimeProvisioner.ts index 923a3c6..35e012f 100644 --- a/apps/desktop/src/main/services/RuntimeProvisioner.ts +++ b/apps/desktop/src/main/services/RuntimeProvisioner.ts @@ -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 { + 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 { + 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 -} \ No newline at end of file +} diff --git a/docs/map/10-feature-catalog.md b/docs/map/10-feature-catalog.md index 20e1714..6a6ae29 100644 --- a/docs/map/10-feature-catalog.md +++ b/docs/map/10-feature-catalog.md @@ -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 모듈로 앱이 시작 즉시 죽은 사고 + 누락으로 자동 업데이트가 죽은 사고를 함께 방지(). | --- diff --git a/docs/map/11-gap-backlog.md b/docs/map/11-gap-backlog.md index 43282a1..131d02b 100644 --- a/docs/map/11-gap-backlog.md +++ b/docs/map/11-gap-backlog.md @@ -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초 만에 성공. | ---