Commit Graph

15 Commits

Author SHA1 Message Date
ffd383d2a6 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>
2026-05-16 14:46:28 +02:00
6ffa242cb9 feat(option-c-sw): request-id'd BUFFER routing + retry on port replacement + PONG echo
Implements the SW-side architectural refactor per
.planning/debug/empty-archive-port-race.md "Fix Strategy: Option C":

1. **Request-id'd protocol** — getVideoBufferFromOffscreen generates a
   uuid (crypto.randomUUID with Math.random fallback) and sends
   {type:'REQUEST_BUFFER', requestId} on the live videoPort. The
   per-request listener pattern is GONE; replaced by a module-level
   pendingBufferRequests Map<requestId, PendingBufferRequest>. The
   onConnect-level message sink routes BUFFER -> resolve by id.

2. **Stale BUFFER routing** — BUFFER messages without a matching
   requestId in the Map are silently dropped (no cross-talk). BUFFER
   without a valid requestId at all is rejected with a warn (Option C
   protocol requires the id).

3. **Retry on port replacement** — every onConnect (post-bootstrap)
   scans pendingBufferRequests and re-issues REQUEST_BUFFER on the
   fresh port with the SAME requestId. The offscreen posts BUFFER on
   the current keepalivePort (see prior offscreen commit), the sink
   matches by id, and the request resolves. This retires the H2
   silent-drop class architecturally — the BUFFER reaches the SW
   regardless of port-replacement timing.

4. **PING -> PONG echo** — the sink replies to every PING with PONG.
   Closes the offscreen's health-probe loop (it counts missed PONGs
   and reconnects when MAX_MISSED_PONGS exceeded — see prior offscreen
   commit). The PONG post is wrapped in try/catch to absorb the same
   port-closed-mid-response race the offscreen ping path handles.

5. **Outer hard-timeout bumped 2s -> 10s** — the legacy per-port
   BUFFER_FETCH_TIMEOUT_MS = 2000 was too tight to retry across a
   reconnect. The new outer budget covers EVERY retry across port
   replacements; the inner round-trip is still ~100-200 ms.

6. **decodeBufferSegments extracted** — pulled out of the legacy
   inline handler so the new onConnect sink can decode wire segments
   without duplicating the logic. Preserves WR-07 (empty wire segment
   filter) and base64ToBlob defensive catch behaviour. Closes the
   pre-existing implicit-undefined-return path the legacy flatMap
   catch had (tsc happy but semantically ambiguous).

Status: 51 GREEN, 1 RED. The remaining RED (createArchive must throw
on empty video, surfacing to operator) is addressed in the next commit.

Pinning contracts (D-12 port-serialization, D-13 segment-rotation,
A3 webm-playback) untouched. tsc --noEmit exit 0; type-safety grep clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:43:12 +02:00
034155bc4e fix(01-review): sweep #5 surface port-replaced-during-fetch diagnostic on buffer timeout 2026-05-16 11:00:55 +02:00
6286957f53 fix(01-review): IN-01 read extensionVersion from chrome.runtime.getManifest() 2026-05-16 10:29:28 +02:00
f8a9c10758 fix(01-review): WR-08 downloadArchive use shared blobToBase64 helper 2026-05-16 10:25:34 +02:00
e9aae09f6d fix(01-review): WR-07 base64ToBlob empty-input shortcut + SW-side empty-segment filter 2026-05-16 10:24:38 +02:00
2e3f5248ce fix(01-review): CR-01+CR-02+CR-03+WR-03+WR-09 critical port + handshake race fixes
What was wrong:
- CR-01 (recorder.ts): encodeAndSendBuffer captured no port identity before
  awaiting Promise.all(blobToBase64). If the port disconnected mid-encode
  and onDisconnect synchronously reconnected (re-assigning keepalivePort
  to a fresh instance), the post-await null-check evaluated false and
  the BUFFER was posted on the NEW port — but the SW's per-request
  onMessage listener was still bound to the OLD port (captured at
  getVideoBufferFromOffscreen line 110). Result: SW timed out after
  2 s, SAVE_ARCHIVE produced an empty-segments zip, data-loss path
  masquerading as a benign timeout.
- CR-02 (background/index.ts): SW's onConnect handler attached
  ONLY onDisconnect — no permanent onMessage sink. PING traffic
  had no listener when getVideoBufferFromOffscreen wasn't running
  (the normal idle state of the port), and field reports note Chrome's
  SW idle-timer reset behaves inconsistently when no listener is
  attached. Risk: PINGs silently dropped, SW evicted ~30 s into
  recording, port torn down, next SAVE_ARCHIVE fails entirely.
- CR-03 (background/index.ts): offscreenReady is a one-shot Promise
  resolved on the FIRST OFFSCREEN_READY message. If the SW is evicted
  while the offscreen document persists, the next SW lifetime creates
  a fresh Promise and waits on it forever — the offscreen never
  re-emits OFFSCREEN_READY. startVideoCapture() hangs at
  `await offscreenReady` until Chrome restarts.
- WR-03 (recorder.ts): `baseTimestamp + idx` (Date.now() + idx) used
  millisecond resolution + array offset. Two REQUEST_BUFFER calls
  within the same millisecond would collide, breaking the sort-by-
  timestamp contract in SW-side mergeVideoSegments.
- WR-09 (recorder.ts): encodeAndSendBuffer always appended the
  unfinalized in-flight segment to the BUFFER. That segment lacks
  the Matroska SegmentSize and Cues that MediaRecorder.stop()
  writes — re-introducing the "File ended prematurely" symptom
  documented in debug session webm-playback-freeze.

What changed:
- recorder.ts encodeAndSendBuffer:
  - Capture `portAtRequest = keepalivePort` BEFORE the encode.
  - After the await, refuse to post if `keepalivePort !== portAtRequest`
    (port was replaced by reconnect). SW already times out cleanly
    after BUFFER_FETCH_TIMEOUT_MS = 2 s; the next SAVE_ARCHIVE
    re-issues REQUEST_BUFFER on the fresh port. Stale data
    NEVER reaches a stranger port.
  - Include the in-flight segment ONLY when finalized.length === 0
    (preserve the SAVE-within-first-10-s UX trade-off documented at
    the original comment) — otherwise drop the unfinalized tail.
  - Replace `baseTimestamp + idx` with module-level monotonic
    `++segmentSeq` counter (zero wall-clock dependency).
  - Switch from Promise.all/map+filter to a sequential for-loop
    because each iteration now mutates the shared `segmentSeq`;
    Promise.all timing would interleave assignments. Throughput
    impact negligible (3 segments × ~50 ms base64 each ≈ 150 ms
    vs ~50 ms parallel — still well under the 2 s SW budget).

- background/index.ts onConnect:
  - Install a permanent `port.onMessage.addListener` that
    explicitly drains PING and silently drops unknown traffic.
    Per-request BUFFER listener still wins because it's attached
    LATER in the listener chain when getVideoBufferFromOffscreen
    fires; this sink only catches the idle PING stream and
    guarantees the SW idle-timer reset is consumed by a real
    handler.

- background/index.ts initialize():
  - When `chrome.offscreen.hasDocument()` returns true on SW init,
    immediately resolve `offscreenReady` AND null
    `offscreenReadyResolve`. The offscreen MUST have completed its
    bootstrap before it was observable via hasDocument(); waiting
    for an OFFSCREEN_READY that will never come is a deadlock.

Why these fixes vs alternatives:
- CR-01: alternatives considered: (a) cancel encoding when port
  disconnects (requires AbortController plumbing into blobToBase64);
  (b) re-route the BUFFER through the new port via a per-port
  request-id correlation. Both add machinery for a case the SW
  already handles correctly (2 s timeout → retry). The capture-
  identity check is the minimum-mechanism fix and matches REVIEW.md
  CR-01 fix guidance exactly.
- CR-02: alternative considered: documenting "rely on kernel-level
  port-message side effect for idle-timer reset" — REJECTED, this
  is what the existing comment did and the field evidence shows
  it's unreliable. Explicit listener is the safe default.
- CR-03: alternative considered: (a) have offscreen re-emit
  OFFSCREEN_READY on every inbound SW message — adds noise to
  the message bus and races with the original-bootstrap-emit.
  Option (b) (resolve-on-hasDocument-true) is simpler, narrower,
  and was explicitly recommended by REVIEW.md.
- WR-03: alternative considered: keeping Date.now() and adding a
  microsecond-resolution offset via performance.now() — fragile
  across SW respawns where performance.now() resets. Module-level
  monotonic counter has zero wall-clock dependency.
- WR-09: alternative considered: forcing a synchronous rotation
  at REQUEST_BUFFER time. Rejected — adds ~50–200 ms latency to
  every save AND races with scheduleRotation()'s timer. The
  "exclude unless empty" trade-off matches REVIEW.md option (a)
  exactly and preserves the documented first-10-s UX path.

Validation evidence:
- npx tsc --noEmit: exit 0 (no type errors).
- npx vitest run --reporter=dot: 30/30 tests pass in 2.67 s
  (8 test files, including port.test.ts which pins the reconnect
  invariant and port-serialization.test.ts which pins the wire format).
- grep "as any\|@ts-ignore" src/offscreen/ src/background/index.ts
  src/shared/: no matches (type-safety gate stays clean).
2026-05-16 09:21:34 +02:00
f81438d6c8 feat(fix-a3): rename TransferredVideoChunk → TransferredVideoSegment
Pure rename pass — zero behavioural change at any call site. The
prior "chunk" naming is a vestige of D-09..D-11's chunk-level
buffer; under D-13 the unit of transfer is a self-contained ~10 s
WebM segment, so the type name now matches the data shape.

Renames propagated atomically:
- src/shared/types.ts
  * TransferredVideoChunk → TransferredVideoSegment
    (also: the `isFirst?: boolean` field is dropped — D-13 segments
    are all implicitly their own header, so the flag is meaningless
    and only existed for the retired ring-buffer's pin semantics)
  * VideoChunk → VideoSegment (drops `isFirst?` for the same reason)
  * PortMessage.chunks? → PortMessage.segments?
  * VideoBufferResponse.chunks → VideoBufferResponse.segments
- src/offscreen/recorder.ts
  * import type rename
  * encodeAndSendBuffer's per-element accumulator + filter type
  * the port.postMessage payload field
- src/background/index.ts
  * import type rename
  * getVideoBufferFromOffscreen reads `(msg as {segments?}).segments`
    (matching the new wire field name)
  * empty-buffer returns `{ segments: [] }`
  * mergeVideoSegments signature takes VideoSegment[]
  * createArchive consumes videoBufferResponse.segments
  * saveArchive log line says "segments"

Verification:
- npx tsc --noEmit clean.
- npx vitest run → 28 passed / 2 failed (the 2 fixture-empirical
  ffmpeg dry-runs; unchanged — they're gated on ./smoke.sh regen).
- npm run build succeeds, all 60 modules transformed.
- grep predicates clean in src/:
  * no addChunk / trimAged / firstChunkSaved / isFirst
  * no TransferredVideoChunk / VideoChunk (old names)
  * 21 occurrences of new names propagated correctly
- Pre-existing port-serialization.test.ts still GREEN: its `isFirst`
  references are inside inline-test-scoped objects (not imports
  from production types), and its tests assert JSON-roundtrip
  behaviour rather than the production type shape.
2026-05-15 21:15:19 +02:00
670daa3fe8 feat(fix-a3): adapt SW receive path to segment semantics
Follow-up to the D-13 activation in src/offscreen/recorder.ts. Each
entry on the BUFFER port message is now a self-contained WebM
segment (not a partial chunk), so the SW-side concat is trivial:
sort by timestamp and Blob-concatenate. The resulting multi-EBML-
header file plays natively in Chrome (SPEC §10 #7 scope).

Changes in src/background/index.ts:
- mergeVideoChunks renamed to mergeVideoSegments; doc comment and
  log lines say "segment" throughout. No behavioural change beyond
  removing the now-stale per-chunk `isFirst` logging.
- getVideoBufferFromOffscreen no longer reads or carries the
  `isFirst` field when decoding wire payload into VideoChunk —
  D-13's segment lifecycle makes the flag meaningless (every
  segment is a fresh recorder boundary, every segment's first
  chunk is implicitly the header). The field stays optional on
  VideoChunk for one more commit; commit 4 sweeps the type
  rename and drops it.
- The single mergeVideoChunks call site in createArchive updated
  to the new name.

Verification:
- npx vitest run → 28 passed / 2 failed (same 2 empirical-ffmpeg
  REDs from webm-playback.test.ts; unchanged from prior commit —
  the fixture is still the stale single-continuous-recorder one).
- npx tsc --noEmit clean.
- src/background/index.ts now has zero references to addChunk /
  trimAged / firstChunkSaved / isFirst.
2026-05-15 21:12:46 +02:00
d5bb948d95 feat(fix-d12): decode chunks from base64 in SW BUFFER receive
- Read incoming port.chunks as TransferredVideoChunk[] (was
  VideoChunk[] — but that was a lie because Blob doesn't survive
  JSON serialization across the port boundary).
- Decode each wire chunk via base64ToBlob(wire.data, wire.type) and
  resolve VideoBufferResponse with the resulting VideoChunk[]. The
  existing mergeVideoChunks downstream sees real Blobs and produces
  a real WebM-prefixed merged blob.
- Defensive per-chunk decode: log + skip individual decode failures
  rather than blowing up the whole fetch. Falls back to
  video/webm;codecs=vp9 if the wire chunk somehow omits the type
  (defense-in-depth — the offscreen always populates it).
- Document the 2 s BUFFER_FETCH_TIMEOUT_MS budget: covers worst-case
  encode + post-message + JSON parse with > 1.5 s of headroom for
  the current 15-chunk × 100 KB sizing.

Refs: debug session d12-blob-port-transfer-fails, D-17 port lifecycle.
2026-05-15 20:18:31 +02:00
6aeeda495c fix(01-06): align ensureOffscreen URL with crxjs emit path
After collapsing vite.config.ts to use rollupOptions.input.offscreen =
'src/offscreen/index.html', crxjs preserves the 'src/' prefix in the
bundled output (Outcome A per RESEARCH.md Pitfall 5 dichotomy):
  dist/src/offscreen/index.html  (NOT dist/offscreen/index.html)

The pre-amendment leftover string 'offscreen/index.html' at
src/background/index.ts:45 would have produced
ERR_FILE_NOT_FOUND in chrome.offscreen.createDocument and broken
Plan 07's manual smoke load. Updated to match the actual emit path.

- npm run build exits 0; 7 dist/assets/*.js bundles produced
- dist/manifest.json permissions: desktopCapture present, tabCapture absent
- tsc --noEmit clean; 9/9 vitest tests still green
- ensureOffscreen URL string now matches dist/src/offscreen/index.html

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:11:05 +02:00
5cd1519858 feat(01-05): wire SW-side port host and port-based buffer fetch
Plan 05 Task 2 — make the SW a pure coordinator that talks to the offscreen
via the long-lived 'video-keepalive' port (D-17, RESEARCH.md Pattern 5).

Additions:
- chrome.runtime.onConnect.addListener handler scoped to port name
  'video-keepalive' + T-1-04 mitigation (port.sender?.id check). Stores port
  in module-level videoPort: chrome.runtime.Port | null.
- getVideoBufferFromOffscreen(): port-based REQUEST_BUFFER round-trip with
  a 2s timeout fallback to { chunks: [] }. Replaces the synchronous SW-local
  getVideoBuffer() stub from Task 1.
- offscreenReady Promise + OFFSCREEN_READY onMessage case (RESEARCH.md
  Pattern 4): startVideoCapture awaits this before sending START_RECORDING,
  closing the 'Receiving end does not exist' race window (audit P1 #12).
- onMessage GET_VIDEO_BUFFER + SAVE_ARCHIVE rewritten to fetch the buffer
  via the port instead of the deleted local array.
- onMessage sender.id !== chrome.runtime.id guard at handler top
  (T-1-NEW-05-01 mitigation).
- chrome.runtime.onInstalled now calls indexedDB.deleteDatabase('VideoRecorderDB')
  once to clean up the orphaned database from pre-Phase-01 builds
  (T-1-NEW-05-02 / RESEARCH.md Runtime State Inventory).

Rule 2 deviation (orchestrator-flagged robustness):
- initialize() now calls chrome.offscreen.hasDocument() to detect existing
  offscreen documents across SW respawns and update offscreenCreated
  accordingly (audit P1 #8). Guarded with a typeof check to stay safe under
  partial chrome stubs.

Verified: npx tsc --noEmit clean; npx vitest run 9/9 green (Plan 04
offscreen-side tests stay un-touched); no as any / @ts-ignore.
2026-05-15 18:02:51 +02:00
886376e789 refactor(01-05): delete legacy SW buffer, alarms, IndexedDB, tabCapture paths
Plan 05 Task 1 — finish the SW shrink:
- DELETE videoBuffer: VideoChunk[] module state (buffer lives in offscreen per D-16)
- DELETE setupKeepalive + chrome.alarms registration (D-18; alarms never reset SW idle timer — port does)
- DELETE chrome.tabCapture.getMediaStreamId call (D-01: getDisplayMedia now runs inside offscreen)
- DELETE chrome.permissions.contains/request for tabCapture (broken — desktopCapture is the new manifest entry, but getDisplayMedia needs no runtime perm)
- DELETE comment-only references to removed symbols (so grep gates pass)
- REPLACE 'USER_MEDIA' as any → chrome.offscreen.Reason.DISPLAY_MEDIA (D-02; @types/chrome 0.0.268 exposes it)
- REPLACE justification copy to match RESEARCH.md Example C
- FIX (error as any) → instanceof Error pattern (CLAUDE.md rule)
- FIX chrome.tabs.sendMessage cast: explicit response type instead of 'as any'
- COLLAPSE REQUEST_PERMISSIONS handler: under getDisplayMedia, no runtime perm check is meaningful — just call startVideoCapture() (Rule 1 deviation; old code returned granted=false because tabCapture is no longer in manifest)
- Temporary stub: getVideoBuffer() returns { chunks: [] } — Task 2 deletes this and wires the port-based getVideoBufferFromOffscreen()

Verified: npx tsc --noEmit clean, npx vitest run 9/9 green, no as any / @ts-ignore.
2026-05-15 17:59:53 +02:00
c5828d38ef feat(01-03): add OffscreenLogger and clean up shared types
- Add PortMessageType and PortMessage interface to src/shared/types.ts
  for the long-lived port (offscreen ↔ SW; D-17 / Plan 04 wires the
  ping loop + REQUEST_BUFFER / BUFFER traffic).
- Remove 'VIDEO_CHUNK' and 'VIDEO_CHUNK_SAVED' from MessageType union
  (per D-19 — chunks no longer travel via chrome.runtime.sendMessage;
  the IndexedDB SW-side plumbing is the audit P0 #2 broken path).
- OffscreenLogger class was already added alongside Task 2 because
  recorder.ts imports it at module top.

Inline SW cleanup (Rule 3 — blocking dependency, plan acceptance gates
on `npx tsc --noEmit` exit 0):
- Remove src/background/index.ts VIDEO_CHUNK + VIDEO_CHUNK_SAVED case
  branches (refs to deleted union members).
- Remove now-unreferenced loadChunkFromIndexedDB / openIndexedDB
  (only reachable from the deleted VIDEO_CHUNK_SAVED branch).
- Remove now-unreferenced addVideoChunkFromBlob / cleanupVideoBuffer
  / firstChunkSaved / VIDEO_BUFFER_DURATION_MS constant (the SW-side
  ring buffer now lives in src/offscreen/recorder.ts per D-16).
- Keep SW-side `videoBuffer: VideoChunk[] = []` as a placeholder; Plan
  04 wires it to fetch from offscreen over the keepalive port. The
  remaining `getVideoBuffer` + `saveArchive` callers continue to
  compile against the empty array until Plan 04 lands.
- Plan 05 owns the broader SW shell cleanup.

Verification (post-commit):
- npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts → 6/6 PASS
- npx tsc --noEmit → exit 0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:37:58 +02:00
555eb0543f chore: import broken Phase-1 extension as received
Snapshot of /home/parf/Downloads/manifest.zip as delivered, before any
GSD-driven remediation. Contains a partially-broken first attempt at the
Russian SPEC "Тз расширение фаза1.md" (Phase 1 of operator-session-recorder).

Source layout:
- manifest.json — MV3 declaration with tabCapture/activeTab/downloads/etc.
- src/background/index.ts — service worker (video buffer + archive packaging)
- src/content/index.ts — rrweb + user-event logger
- src/popup/{index.html,index.ts,style.css} — Russian popup UI
- offscreen/{index.html,index.ts} — orphaned offscreen (see audit)
- vite.config.ts — inline plugin emitting a separate live offscreen.js
- generate-icons.js, icons/ — minimal PNG icons
- "Тз расширение фаза1.md" — authoritative Russian SPEC

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:16:23 +02:00