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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user