From ffd383d2a67f55f3005efbba179edd05e0099786 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 16 May 2026 14:46:28 +0200 Subject: [PATCH] feat(option-c-error-surface): createArchive throws on empty video; saveArchive surfaces to popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retires the upstream silent-skip defect (bisected to commit 555eb05 — imported broken from before Phase 1, never on the original 22-defect audit because it needed a real failure mode to surface). Per .planning/debug/empty-archive-port-race.md Option C step 4: even with the architectural port lifecycle now bullet-proof, a hard outer timeout on the BUFFER fetch (10 s after every retry) must result in an operator-VISIBLE failure — not a silent video-less archive. 1. **EmptyVideoBufferError** — typed error class with a stable `code` field ('empty-video-buffer') matching the offscreen-side CaptureErrorCode union vocabulary. Lets saveArchive's catch distinguish "no segments" failure from JSZip/manifest failures. 2. **createArchive throws** — when videoBufferResponse.segments.length === 0 OR when the merged blob is zero bytes, throw the typed error with a detail string for diagnostics. Replaces the silent-skip branch that was the bisect-confirmed transport for the H2 class. 3. **saveArchive broadcast** — on EmptyVideoBufferError, emit {type:'RECORDING_ERROR', error:'empty-video-buffer'} via chrome.runtime.sendMessage. The popup's existing RECORDING_ERROR handler surfaces the failure to the operator (same channel as codec-unsupported, user-cancelled, etc.). saveArchive still returns {success:false, error} so the popup's direct-response path also sees the failure (defense-in-depth via two channels). Status: 52 GREEN / 52 tests passing. All 12 RED tests from the Option C gate (3 in port-reconnect-race + 4 in port-health-probe + 5 in request-id-protocol) are now GREEN. Build clean (npm run build exit 0). Pinning contracts intact: - D-12 port-serialization (base64 wire format): GREEN - D-13 segment-rotation (3 x 10 s restart-segments ring): GREEN - A3 webm-playback (ffmpeg dry-run on fixture): GREEN tsc --noEmit exit 0; type-safety grep clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/background/index.ts | 59 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/src/background/index.ts b/src/background/index.ts index 21b184f..64722bb 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -16,6 +16,20 @@ const VIDEO_MIME_FALLBACK = 'video/webm;codecs=vp9'; const logger = new Logger('Main'); +// Option C: a typed error so saveArchive can distinguish the empty-video +// failure (operator-facing — shows a clear RECORDING_ERROR popup) from +// generic createArchive failures (zip/manifest/etc.). The 'empty-video- +// buffer' code joins the CaptureErrorCode union surfaced by the +// offscreen recorder (see src/offscreen/recorder.ts: classifyCaptureError) +// — same operator-facing vocabulary, different production point. +export class EmptyVideoBufferError extends Error { + readonly code = 'empty-video-buffer' as const; + constructor(detail: string) { + super(`empty-video-buffer: ${detail}`); + this.name = 'EmptyVideoBufferError'; + } +} + // Состояние // Видеобуфер живёт в offscreen-документе (D-16). SW не хранит чанки локально: // при экспорте он спрашивает буфер у offscreen через long-lived port (D-17). @@ -418,14 +432,28 @@ async function createArchive( const zip = new JSZip(); - // Добавляем видео (D-13: каждая запись — самодостаточный WebM-сегмент) - if (videoBufferResponse.segments.length > 0) { - const videoBlob = mergeVideoSegments(videoBufferResponse.segments); - zip.file('video/last_30sec.webm', videoBlob); - logger.log(`✓ Added video: ${videoBlob.size} bytes`); - } else { - logger.warn('✗ No video segments to add'); + // Добавляем видео (D-13: каждая запись — самодостаточный WebM-сегмент). + // + // Option C (debug session empty-archive-port-race): the upstream + // silent-skip branch is GONE. Shipping a zip with no video defeats the + // entire purpose of the operator-save flow (Phase 1 goal); the operator + // must see a clear failure instead of receiving a 88 KB archive with no + // last_30sec.webm. saveArchive's catch translates the throw into the + // {success: false, error: 'empty-video-buffer'} response shape the + // popup already handles via the RECORDING_ERROR surface. + if (videoBufferResponse.segments.length === 0) { + throw new EmptyVideoBufferError( + 'no video segments available — buffer fetch returned empty (port replacement timed out, or recorder never started)', + ); } + const videoBlob = mergeVideoSegments(videoBufferResponse.segments); + if (videoBlob.size === 0) { + throw new EmptyVideoBufferError( + `merged video blob is zero bytes (segment count=${videoBufferResponse.segments.length})`, + ); + } + zip.file('video/last_30sec.webm', videoBlob); + logger.log(`✓ Added video: ${videoBlob.size} bytes`); // Добавляем rrweb события const rrwebJson = JSON.stringify(rrwebEvents, null, 2); @@ -573,6 +601,23 @@ async function saveArchive() { } catch (error) { logger.error('Failed to save archive:', error); + // Option C: the empty-video failure is operator-visible. Emit + // RECORDING_ERROR so the popup's existing handler can surface it + // (same channel codec-unsupported, user-cancelled, etc. ride). + // Other createArchive failures (zip libs, JSZip internals) stay + // SW-side only — they're not actionable by the operator. + if (error instanceof EmptyVideoBufferError) { + try { + chrome.runtime.sendMessage({ + type: 'RECORDING_ERROR', + error: error.code, + }); + } catch (sendErr) { + // Best-effort notification — if the popup is closed we have + // nothing else to do. + logger.warn('Failed to broadcast RECORDING_ERROR:', sendErr); + } + } return { success: false, error }; } }