feat(option-c-error-surface): createArchive throws on empty video; saveArchive surfaces to popup

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 14:46:28 +02:00
parent 6ffa242cb9
commit ffd383d2a6

View File

@@ -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 };
}
}