Two doc updates closing the debug session per the resolved pattern this
phase has established (cf. resolved/d12-blob-port-transfer-fails.md and
resolved/webm-playback-freeze.md):
1. **Move debug session to resolved/** with the Resolution section
filled in (root_cause, fix, verification, files_changed). Status
flipped tdd_red_confirmed -> resolved. Original investigation
notes + bisect results + Option C strategy spec all preserved
in-place — the file is the full provenance trail.
2. **Amend 01-CONTEXT.md D-17** with the new port lifecycle commitments.
Append-only (D-17 itself untouched) per the doc cascade rule
established earlier this phase ("amendments append, do not replace,
to preserve SPEC provenance"). The amendment narrates:
- What was Claude's-discretion at Phase 1 plan time has been
specified by Option C.
- The 290 s pre-emptive setTimeout reconnect (Pitfall 4) is RETIRED.
- The architectural commitments added: PING/PONG health probe,
request-id'd REQUEST_BUFFER/BUFFER, SW retry on port replacement,
outer 10 s hard-timeout, operator-visible EmptyVideoBufferError
surface.
- The 4 pinning contracts added (port-health-probe,
request-id-protocol, port-lifecycle-continuous, plus the
refactored port-reconnect-race).
Suite remains 11 files / 53 tests, all GREEN. Quality gates intact.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
18 KiB
Phase 1: Stabilize Video Pipeline — Context
Gathered: 2026-05-15 Status: Ready for planning
## Phase BoundaryStabilize the video-capture pipeline so the extension continuously records the operator's session into a ring buffer and produces a playable WebM on demand. The phase ends when:
- The video buffer is alive end-to-end with one authoritative source of truth for the recorder code (no parallel offscreen implementations).
- The MediaRecorder lifecycle is correct (no shadowed bindings, predictable start/stop).
- The buffer holds at most 30 s of footage at any moment AND concatenating
the retained chunks produces a file that
ffprobe -v erroraccepts as playable. - State (the buffer itself) survives the MV3 service-worker idle unload cycle without losing video.
Scope anchor from ROADMAP.md — Phase 2 (DOM/event privacy), Phase 3 (export UX), Phase 4 (smoke verification), and Phase 5 (P1/P2 hardening) are explicitly out of scope here.
## Implementation DecisionsCapture API — AMENDS DEC-003
This phase REPLACES the SPEC-locked chrome.tabCapture choice with
getDisplayMedia() capture. Done eyes-open: the operator gains broader
capture coverage at the cost of the SPEC §1 "silent operation" property.
The doc cascade is enumerated in the Doc Amendments (precede code)
subsection below.
- D-01: Capture mechanism is
navigator.mediaDevices.getDisplayMedia()invoked inside the offscreen document. No morechrome.tabCapture.getMediaStreamId, no more SW-side gesture juggling. - D-02: Offscreen document is created with
chrome.offscreen.Reason.DISPLAY_MEDIA(replacesUSER_MEDIA). - D-03: One-time source picker on session start; the operator picks "screen" or "window" once. If they later click the Chrome "Stop sharing" banner or the captured source disappears, the offscreen surfaces an error to the SW and the popup re-prompts on next interaction. (Exact error-UX copy is deferred to Phase 3 — see Deferred Ideas.)
- D-04: Operator UX is NOT silent. Chrome's permanent "Sharing your screen" indicator is shown while recording. We accept this as the cost of the API choice.
- D-05:
manifest.jsonpermissions follow the new API:desktopCapturereplacestabCapture;activeTabbecomes unnecessary for the video pipeline but stays forchrome.tabs.captureVisibleTab(screenshot path, Phase 3 concern — kept).
Offscreen source-of-truth location
- D-06: Recorder code lives at
src/offscreen/recorder.tsas a real TypeScript module with strict type-check, source maps, and IDE support. - D-07:
offscreen/index.htmlis rewritten to load the bundled module via crxjs. The runtime path remainsoffscreen/index.html(referenced from SW viachrome.runtime.getURL('offscreen/index.html')). - D-08: DELETE
offscreen/index.ts(orphaned dead code) and the entirecopy-offscreenplugin block invite.config.ts:11-184. crxjs picks up the new TS entry through the HTML reference.
Ring-buffer mechanism
- D-09: Single continuous MediaRecorder for the whole session.
mediaRecorder.start(2000)so chunks land on cluster boundaries per the spec timeslice (DEC-003, SPEC §4.1). No restart strategy at this point. - D-10: Retain the first emitted chunk (the chunk produced by the
first
dataavailableevent afterstart()) indefinitely — it carries the EBML header plus the initial cluster. CON-webm-header-retention. - D-11: Drop later chunks once they are older than 30 s, by chunk
arrival timestamp. Keep header + every chunk newer than
now - 30000 ms. - D-12: Acceptance gate for Phase 1:
ffprobe -v error -f matroska -i <last_30sec.webm>must return exit 0 with no decoder warnings on a fresh-export sample. Plan-checker enforces this as a phase success criterion. - D-13: Fallback if D-12 fails: revise the plan mid-phase to use restart-segments (stop + restart the MediaRecorder every 10 s, keep the 3 most-recent self-contained segments, concat on save). Documented as a known fallback so the planner can pre-stage the alternative structure in PLAN.md.
Tab-switch behavior
- D-14: Not applicable under the new capture API.
getDisplayMedia()captures a screen or window, not a tab — there is nothing to re-attach onchrome.tabs.onActivated. Phase 1 explicitly removes any tab-switch handling fromsrc/background/index.ts. - D-15: Operator switching tabs no longer interrupts the recording — the buffer keeps filling regardless of active tab.
State survival across SW unload
- D-16: Video buffer ownership moves to the offscreen document. The
offscreen survives SW unloads because it holds the
DISPLAY_MEDIA-reason capture; chunks accumulate there. - D-17: A long-lived
chrome.runtime.connectport from offscreen → SW serves as the keepalive (this is the only mechanism that actually resets the SW idle timer —chrome.alarmscallbacks do not, contrary to DEC-010). - D-18: DELETE the
chrome.alarmskeepalive (src/background/index.ts:171-178). DEC-010 and CON-service-worker-keepalive are amended in the doc cascade below. - D-19: On export, SW requests the buffer from offscreen over the port
(or one-shot
chrome.runtime.sendMessage). SW does NOT cache chunks. CON-buffer-storage is honored — buffer is plain JS variable in offscreen memory, nochrome.storage.session, no IndexedDB. The existing IndexedDB code path invite.config.ts:43-104is DELETED along with the inline plugin.
Doc Amendments (precede code)
These document edits MUST ship before any code-touching task in this phase, so downstream phases see a consistent baseline:
- D-A1: Amend
.planning/intel/decisions.mdDEC-003 to record thegetDisplayMediareplacement, with rationale and the explicit silent- operation trade-off. Amend DEC-010 to record port keepalive replacing alarms keepalive. - D-A2: Amend
.planning/intel/constraints.mdto RETIRE CON-tab-capture-binding and CON-service-worker-keepalive. Add new CON-display-capture-binding (one-time picker, "Sharing" indicator). - D-A3: Amend
.planning/PROJECT.mdKey Decisions table (DEC-003, DEC-010) and Constraints section accordingly. - D-A4: Amend
.planning/REQUIREMENTS.mdREQ-video-ring-buffer to remove "active-tab" wording and update API binding. - D-A5: Amend
.planning/ROADMAP.mdPhase 1 description and Success Criterion #2 (drop the "tab re-attach" clause). - D-A6: Amend
manifest.json: swaptabCapture→desktopCaptureinpermissions. KeepactiveTabfor the screenshot path.
Claude's Discretion
- Exact protocol choice for offscreen↔SW messaging (port for keepalive + sendMessage for one-shot vs port-only).
- Codec strictness: enforce
video/webm; codecs=vp9viaMediaRecorder.isTypeSupported; fail loud if unsupported (no fallback chain — current code's vp9→vp8→h264→default fallback is removed). - Internal naming for the new buffer-owning module (offscreen-recorder vs display-recorder etc.).
- Code-style choices around TS strictness within
src/offscreen/(already on"strict": trueper tsconfig).
- "Fail loud" on missing
getDisplayMediasupport — log a clear error to the SW console with the failing user-agent string. No silent fallback. - The ffprobe verification gate is a real CLI invocation in the plan's acceptance, not a hand-wave — Phase 1 success requires producing a sample WebM and running ffprobe against it.
- The "stop sharing" recovery path: when the offscreen detects
MediaStreamTrack.onended, it should clear the buffer, send a RECORDING_STOPPED message to SW, and stop attempting to record. The popup will re-prompt next time the user clicks save (Phase 3 territory). - The
OFFSCREEN_READYMessage type is already declared insrc/shared/types.ts:18but never used. Phase 1 wires up an actual handshake so SW doesn't fireSTART_RECORDINGbefore the offscreen listener is registered (this fixes audit item P1 #12 in passing).
<canonical_refs>
Canonical References
Downstream agents MUST read these before planning or implementing.
Project baseline (current state, will be amended by D-A1..D-A6)
.planning/PROJECT.md— overall project context. Note that DEC-003, DEC-010, and the Constraints section will be amended by this phase..planning/REQUIREMENTS.md§"Video" — REQ-video-ring-buffer wording changes in this phase..planning/ROADMAP.md§"Phase 1" — phase definition and success criteria (Success Criterion #2 changes)..planning/intel/SYNTHESIS.md— entry point for ingested intel..planning/intel/decisions.md§DEC-003, §DEC-009, §DEC-010..planning/intel/constraints.md§CON-video-window, §CON-video-codec, §CON-webm-header-retention, §CON-tab-capture-binding (RETIRED), §CON-service-worker-keepalive (RETIRED), §CON-buffer-storage.
Authoritative SPEC
Тз расширение фаза1.md§2 (stack), §4.1 (video buffer parameters), §7 (manifest permissions), §8 row 3 (tab-capture binding — being AMENDED), §10 acceptance criteria #2, #3, #7.
Audit
/home/parf/.claude/plans/dear-claude-there-is-snazzy-fox.md— full P0 analysis. Key spots: P0-1 (offscreen duality + mediaRecorder shadow,vite.config.ts:113/vite.config.ts:208), P0-2 (WebM ring buffer + 200 ms timeslice issue), P0-3 (always-on capture), P1 #8 (alarms keepalive ineffective), P1 #12 (OFFSCREEN_READY handshake missing).
Chrome APIs (read before researching)
- MDN:
MediaDevices.getDisplayMedia()— capture semantics, picker UX, MediaStreamTrack.onended. - Chrome docs:
chrome.offscreenAPI, especiallyReason.DISPLAY_MEDIA(Chrome 116+). - Chrome docs: Service worker lifecycle in MV3 — confirm
chrome.alarmsvschrome.runtime.connectport behavior with respect to idle timer. - WebM/Matroska EBML format — cluster boundary semantics; needed only if the D-13 fallback (restart-segments) activates.
</canonical_refs>
<code_context>
Existing Code Insights
Reusable assets
src/background/index.ts:16-73—videoBuffer: VideoChunk[]array +cleanupVideoBuffer(). Structural pattern (header retention + age trim) is kept; the implementation is moved tosrc/offscreen/recorder.ts.src/shared/types.ts—Message,VideoChunk,VideoBufferResponse,OFFSCREEN_READY,RECORDING_ERROR. Reuse as-is; wire upOFFSCREEN_READYandRECORDING_ERRORin Phase 1.src/shared/logger.ts—Logger(SW context) andContentLogger(content-script context). Add anOffscreenLoggeror reuseLoggerwith prefix[Offscreen:...].
Established patterns
- Strict TS with
noUnusedLocals/noUnusedParameters(tsconfig.json). New code must comply — noas any, no@ts-ignore(audit item P1 #13 informs this). - Russian comments inline are acceptable per project provenance
(see
Тз расширение фаза1.md, the user-facing UI strings in REQ-popup-ui).
Files to DELETE in this phase
offscreen/index.ts— dead code (audit P2 #18).offscreen/index.html— REPLACED by a crxjs-managed equivalent pointing at the newsrc/offscreen/recorder.ts. (Path stays the same in dist; source moves.)vite.config.ts:11-184— the entirecopy-offscreeninline plugin block including the embedded JS string + IndexedDB plumbing. Replaces with a small note + the standard crx() invocation.src/background/index.ts:171-178—setupKeepalive()and itschrome.alarmsregistration.src/background/index.ts:445-475—loadChunkFromIndexedDB()andopenIndexedDB()(the SW-side IDB plumbing).src/background/index.ts:432-442— theVIDEO_CHUNKmessage handler that expected Blob overchrome.runtime.sendMessage(the fundamentally broken path from audit P0 #2 — never reachable today but documented to delete).
Files to CREATE
src/offscreen/recorder.ts— new module owning getDisplayMedia + MediaRecorder + ring buffer + keepalive port.src/offscreen/index.html(or amendedoffscreen/index.html) — minimal HTML that loads the new module via crxjs.
Files to MODIFY heavily
src/background/index.ts— owns offscreen lifecycle, port handling, export-time buffer fetch. Most of the file shrinks.manifest.json— permissions swap,offscreenalready present.vite.config.ts— collapse to a clean crxjs invocation, no inline plugin.
</code_context>
## Deferred Ideas- Error UX for "user stopped sharing" mid-session. The popup needs a state for this — Phase 3 territory (REQ-popup-ui state machine extension).
- Audio capture.
getDisplayMedia()makes audio capture trivial (audio: true), but SPEC §9 explicitly excludes audio from Phase 1 (Phase 2 work — CAP-01). Capture this as an easier-now-than-before follow-up. - Per-tab silent capture mode as an opt-in via
config.json. Could re-introduce tabCapture for installations that prioritize silent operation over broad coverage. Future phase if there's demand. - Cluster-aware EBML trim (ts-ebml). Not needed for Phase 1 if continuous + age-trim verifies via ffprobe. Keep on the shelf as a third fallback under D-13.
chrome.storage.sessioncold-start recovery. Buffer pointer rehydration after offscreen crash. Phase 5 (Harden + clean up) territory.
Amendment (Phase 01-stabilize-video-pipeline, 2026-05-16) — D-17 port lifecycle
- AMENDED-BY: debug session
empty-archive-port-race(Option C, resolved 2026-05-16) - Replaces nothing. D-17 above stands as the original port-keepalive contract. This amendment narrows the port LIFECYCLE shape that was Claude's-discretion at Phase 1 plan time.
- Background. UAT Test 3 surfaced two coupled defects: 3× Uncaught
"Attempting to use a disconnected port object" in the offscreen console
starting at the 290 s pre-emptive reconnect mark, AND a silent
empty-video archive shipping to the operator. Bisect proved the H1
port race was introduced by Plan 01-04 (commit
b064a21) and the H2 silent-skip increateArchivewas an upstream defect from commit555eb05that the port race amplified from latent to fatal. Full lineage and strategy rationale:.planning/debug/resolved/empty-archive-port-race.md. - Architectural commitments retired:
- The legacy 290 000 ms pre-emptive
setTimeoutreconnect (Pitfall 4) is RETIRED. Its race window between the synchronous.disconnect()and theonDisconnecthandler firing was the proximate cause of H1 — see the bisect notes.
- The legacy 290 000 ms pre-emptive
- Architectural commitments added:
- Port health probe (PING ↔ PONG). The offscreen
PORT_PING_MSinterval doubles as a liveness probe; each PING expects a PONG echo from the SW. The offscreen tracksmissedPongsand reconnects cleanly when the count exceedsMAX_MISSED_PONGS = 3(~75 s of unresponsive SW — well past Chrome's ~30 s idle threshold, so a real disconnect would already have surfaced its ownonDisconnect). The SW echoes PONG on every PING via the onConnect-level message sink. - Request-id'd REQUEST_BUFFER / BUFFER. Every
REQUEST_BUFFERcarries a SW-generatedrequestId(crypto.randomUUID with Math.random fallback). The offscreen echoes the samerequestIdon the BUFFER response. The SW routes BUFFER → pending request via a module-levelMap<requestId, PendingBufferRequest>— port- agnostic, so port replacement does not lose the response. - Retry on port replacement. Every
onConnect(post-bootstrap) scanspendingBufferRequestsand re-issues REQUEST_BUFFER on the fresh port with the SAME requestId. The offscreen posts BUFFER on the CURRENTkeepalivePort, the sink matches by id, and the request resolves. This retires the H2 silent-drop class architecturally. - Outer hard-timeout bumped 2 s → 10 s. The legacy per-port
BUFFER_FETCH_TIMEOUT_MS = 2000was too tight to cover a retry across a reconnect. The new outer budget covers EVERY retry across port replacements; the inner per-port round-trip is still ~100-200 ms. - Operator-visible failure surface.
createArchivethrowsEmptyVideoBufferErrorwhen the video buffer is empty.saveArchivecatches and emits{type:'RECORDING_ERROR', error:'empty-video-buffer'}viachrome.runtime.sendMessagefor the popup, AND returns{success:false, error}in the direct-response path. Replaces the upstream silent-skip branch increateArchivethat shipped a video-less archive in silence.
- Port health probe (PING ↔ PONG). The offscreen
- Pinning contracts added:
tests/offscreen/port-health-probe.test.ts— pins the PING/PONG + request-id'd encode contract on the offscreen side (4 tests).tests/background/request-id-protocol.test.ts— pins the SW-side request-id routing + retry + error-surface contract (5 tests).tests/background/port-lifecycle-continuous.test.ts— continuous 600 s end-to-end simulation: 12 ping/pong cycles + 2 forced reconnects + 3 SAVE_ARCHIVE round-trips, asserts no Uncaught and every BUFFER carries segments.tests/offscreen/port-reconnect-race.test.ts(refactored): H1.b no longer pins the retired 290 s setTimeout path — it now pins the externally-disconnected-port → ping try/catch → reconnect path that the H1 fix delivers.
Phase: 01-stabilize-video-pipeline Context gathered: 2026-05-15 Amended: 2026-05-16 (debug session empty-archive-port-race, Option C)