Files
Mark 05d00509bf docs(01-04): complete offscreen port keepalive + OFFSCREEN_READY handshake plan
- Add 01-04-SUMMARY.md with TDD RED/GREEN/REFACTOR gate records,
  acceptance grep gates, threat mitigations (T-1-04, T-1-NEW-04-01),
  Plan 05 SW-side handoff (REQUIRED sender.id === chrome.runtime.id
  check), and 1 Rule 1 deviation documented
- Advance STATE.md Plan counter 4 → 5, progress 43% → 57%
- Append 3 decisions to STATE.md Accumulated Context
- Update ROADMAP.md: 01-04-PLAN checkbox → [x], phase progress row 3/7 → 4/7

REQ-video-ring-buffer NOT marked complete — still pending Plan 07
ffprobe D-12 gate per the requirement's traceability.
2026-05-15 17:54:04 +02:00

219 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 01-stabilize-video-pipeline
plan: 04
subsystem: offscreen-recorder
tags: [chrome-extension, mv3, service-worker-keepalive, long-lived-port, offscreen-document, handshake, tdd]
# Dependency graph
requires:
- phase: 01-stabilize-video-pipeline
provides: "Plan 02 RED tests for OFFSCREEN_READY handshake + port reconnect contracts; Plan 03 stub bootstrap + PortMessage types in shared/types.ts"
provides:
- "Full long-lived port lifecycle in src/offscreen/recorder.ts — connect → ping (25 s) → REQUEST_BUFFER handler → pre-emptive reconnect (290 s) → onDisconnect synchronous reconnect"
- "OFFSCREEN_READY handshake confirmed to fire exactly once after listener registration (Pattern 4 ordering)"
- "T-1-04 mitigation: explicit message-shape switch in onPortMessage (defense-in-depth); sender.id === chrome.runtime.id contract documented for Plan 05's SW-side onConnect listener"
affects: [01-05-sw-shrink, 01-06-vite-config, 01-07-ffprobe-gate]
# Tech tracking
tech-stack:
added: []
patterns:
- "Long-lived port keepalive with synchronous reconnect-on-disconnect (Pitfall 4 mitigation against Chrome's ~5 min port lifetime cap)"
- "Idempotent connectPort(): teardown timers → fresh connect → re-attach all listeners. Reentrant via onDisconnect → connectPort()"
- "Defense-in-depth on inbound port messages: typeof-check, type-switch on known PortMessageType union, silent drop of unknown shapes"
key-files:
created: []
modified:
- "src/offscreen/recorder.ts"
key-decisions:
- "Kept Plan 03's defensive bootstrap() guard (typeof chrome / per-API checks) so the pure ring-buffer + codec-check tests can import the module without a full chrome stub. Plan 04's verbatim block in the PLAN.md assumed chrome was always present — applying it as-is regressed ring-buffer + codec-check (Rule 1)."
- "REFACTOR pass: trimmed three stale 'Plan 04 wires this' comments and replaced them with the actual D-17 / Pattern 5 citations now that Plan 04 has landed."
- "T-1-04 sender-id check is documented in 4 places in recorder.ts but the SW-side enforcement is Plan 05's responsibility (the offscreen INITIATES the port; the SW is the trusting party that must validate sender)."
patterns-established:
- "connectPort() is the single re-entry point for the port lifecycle. teardownPortTimers() runs first, fresh connect second, listeners third, timers fourth. The onDisconnect handler calls connectPort() recursively — synchronous reconnect path the Plan 02 test pins."
- "PORT_PING_MS = 25_000 keeps SW idle timer alive (< 30 s threshold). PORT_RECONNECT_MS = 290_000 pre-empts the ~5 min port lifetime cap before Chrome closes it on us."
requirements-completed: [] # REQ-video-ring-buffer is STILL pending Plan 07 ffprobe gate; do NOT mark complete.
# Metrics
duration: 4min
completed: 2026-05-15
---
# Phase 01 Plan 04: Port Keepalive + Handshake TDD GREEN Summary
**Long-lived `chrome.runtime.connect` port keepalive with 25 s PING loop, 290 s pre-emptive reconnect, synchronous reconnect-on-disconnect, and REQUEST_BUFFER → BUFFER response handler — flipped Plan 02 port reconnect test from RED to GREEN.**
## Performance
- **Duration:** ~4 min
- **Started:** 2026-05-15T15:44:22Z
- **Completed:** 2026-05-15T15:47:56Z
- **Tasks:** 3 (Task 1 verify-only, Task 2 GREEN commit, Task 3 REFACTOR commit)
- **Files modified:** 1
## Accomplishments
- **GREEN gate cleared:** `tests/offscreen/port.test.ts > reconnects when port disconnects` now passes. All 4 test files in `tests/offscreen/` are GREEN (9 tests total: 4 ring-buffer + 2 codec-check + 1 handshake + 2 port).
- **Full port lifecycle:** `connectPort()` opens the port, registers `onMessage` (REQUEST_BUFFER → BUFFER) and `onDisconnect` (synchronous reconnect), schedules a 25 s PING postMessage interval, and arms a 290 s pre-emptive reconnect timer. Idempotent: reentry via `onDisconnect` tears down all timers cleanly before fresh connect.
- **D-17 contract fulfilled** at the offscreen side: long-lived port now serves as the SW keepalive (replacing the deleted `chrome.alarms` from D-18).
- **T-1-04 defense-in-depth in place:** `onPortMessage` does an explicit `typeof message === 'object'` + type-switch before destructuring. Any unknown port message shape is silently dropped. The SW-side `sender.id === chrome.runtime.id` enforcement contract is documented inline at 4 locations in `recorder.ts` for Plan 05's executor.
- **Strict-type hygiene preserved:** zero `as any`, zero `@ts-ignore`, zero fallback codec strings. `npx tsc --noEmit` exits 0.
## TDD Gate Sequence
| Gate | Command | Result |
|------|---------|--------|
| **RED** (pre-implementation) | `npx vitest run tests/offscreen/handshake.test.ts tests/offscreen/port.test.ts` | 1 failed / 2 passed (3 tests) — `reconnects when port disconnects` FAILS as expected. Captured to `/tmp/01-04-red.log`. |
| **GREEN** (post-implementation) | `npx vitest run` (full suite) | exit 0, 9/9 PASS across 4 test files. Captured to `/tmp/01-04-green.log`. |
| **REFACTOR** | comment cleanup in `recorder.ts` (header + PORT_NAME + keepalivePort) | 9/9 still PASS, `tsc --noEmit` clean — committed as `refactor(01-04): ...` |
### RED log excerpt
```
FAIL tests/offscreen/port.test.ts > port reconnect > reconnects when port disconnects
AssertionError: expected 1 to be greater than or equal to 2
tests/offscreen/port.test.ts:87:26
86| disconnectListeners.forEach((fn) => fn());
87| expect(connectCount).toBeGreaterThanOrEqual(2);
| ^
Test Files 1 failed | 1 passed (2)
Tests 1 failed | 2 passed (3)
```
### GREEN test output (9/9 PASS)
```
RUN v4.1.6 /home/parf/projects/work/repremium
Test Files 4 passed (4)
Tests 9 passed (9)
Start at 17:47:46
Duration 294ms
```
Test names now all passing:
1. `ring buffer > first chunk is header`
2. `ring buffer > second chunk is NOT header`
3. `ring buffer > trim 30s — keeps header, evicts aged tail`
4. `ring buffer > trim with empty buffer does not throw`
5. `codec strict mode > throws on unsupported vp9 and emits RECORDING_ERROR`
6. `codec strict mode > does not throw when vp9 IS supported`
7. `OFFSCREEN_READY handshake > sends OFFSCREEN_READY after listener registration`
8. `port reconnect > connects on module load`
9. `port reconnect > reconnects when port disconnects` ← flipped to GREEN by Plan 04
## Task Commits
| # | Task | Type | Commit |
|---|------|------|--------|
| 1 | RED-verify (no commit per plan — verify-only) | — | — |
| 2 | GREEN — wire offscreen port keepalive + OFFSCREEN_READY handshake | feat | **b064a21** |
| 3 | REFACTOR — remove stale 'Plan 04 wires this' comments | refactor | **b0f4adc** |
Final metadata commit will follow after STATE.md / ROADMAP.md updates.
## Final `src/offscreen/recorder.ts` Shape
- **Total lines:** 270 (was 215 after Plan 03 → +55 lines net from Plan 04)
- **Bootstrap structure:** `bootstrap()` defensive-guards each chrome.runtime sub-API, registers `onMessage` listener, calls `connectPort()`, sends `OFFSCREEN_READY`. Order matches Pattern 4 (listener-then-ready) so the SW can safely send START_RECORDING the moment OFFSCREEN_READY arrives.
- **Port lifecycle exports** (none — all internal): `connectPort`, `onPortMessage`, `teardownPortTimers`, plus the inline `onDisconnect` handler. Plan 05 grep-tests against the wire contract (PORT_NAME = 'video-keepalive', PortMessage types from `shared/types.ts`), not against this module's internal symbols.
## Plan Acceptance Grep Gates (all PASS)
| Gate | Expected | Actual |
|------|----------|--------|
| `function connectPort` | 1 | 1 ✓ |
| `PORT_PING_MS = 25_000` | 1 | 1 ✓ |
| `PORT_RECONNECT_MS = 290_000` | 1 | 1 ✓ |
| `REQUEST_BUFFER` | ≥1 | 1 ✓ |
| `'BUFFER'` | ≥1 | 1 ✓ |
| `isFromOwnExtension` | ≥1 | 2 ✓ (definition + call) |
| `as any` | 0 | 0 ✓ |
| `@ts-ignore` | 0 | 0 ✓ |
| `void keepalivePort` | 0 | 0 ✓ (no-unused-locals workaround removed) |
| `T-1-04` or `sender.id === chrome.runtime.id` | ≥1 | 4 ✓ |
| `chrome.runtime.connect` | ≥1 | 2 ✓ |
| `onDisconnect` | ≥1 | 2 ✓ |
## Threat Mitigations Verified
- **T-1-04 (port hijack from another extension):** Offscreen INITIATES the port, so the offscreen is the trusting party and the SW (Plan 05) is the listening / validating party. Plan 04 lays down: (a) an explicit `typeof message === 'object'` check before any destructure inside `onPortMessage`; (b) a type-switch on inbound `PortMessageType` that silently drops any unknown shape; (c) inline-comment documentation at 4 locations of `recorder.ts` flagging the SW-side `sender.id === chrome.runtime.id` requirement for Plan 05.
- **T-1-NEW-04-01 (port reconnect storm):** The reconnect path is idempotent: `teardownPortTimers()` clears interval + timeout, then `chrome.runtime.connect()` opens a fresh port wrapped in a `try / catch` so a transient connect failure becomes a logged warning instead of a thrown exception. The 290 s pre-emptive timer continues to retry on schedule even if a single reconnect attempt failed.
## Decisions Made
1. **Kept Plan 03's defensive bootstrap guard.** Plan 04's verbatim PLAN.md replacement assumed `chrome.runtime.{onMessage,connect,sendMessage}` were always present and called them unconditionally at top-level. Applying that as written regressed `tests/offscreen/ring-buffer.test.ts` (no chrome stub at all) and `tests/offscreen/codec-check.test.ts` (only `sendMessage` stubbed). Plan 04's `<acceptance_criteria>` explicitly requires all 4 test files to remain GREEN, so the fix was to wrap the bootstrap in the same `bootstrap()` function Plan 03 used, keeping the per-API existence checks. Production behavior is unchanged (Chrome always populates the full surface). See Deviations §1 (Rule 1).
2. **REFACTOR pass NOT skipped.** Three stale `// Plan 04 ...` comments on `PORT_NAME`, `keepalivePort`, and the module header were now misleading (they pointed forward to work that has landed). Replaced them with the actual citations (D-17 / Pattern 5 / Pattern 4). This satisfies the plan's "Comments that became stale after the bootstrap refactor" target. Plan 03's REFACTOR was skipped; Plan 04's is a meaningful (if small) tidy.
3. **T-1-04 SW-side contract documented redundantly.** The offscreen-side mitigation is defense-in-depth only; the actual enforcement lives in Plan 05. Documenting in 4 places (module header comment, port-name constant, threat-mitigation comment near `bootstrap()`, and inline at `connectPort()`) creates redundant signals so Plan 05's executor cannot miss the requirement when grepping for `T-1-04`.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Plan 04's verbatim bootstrap block regressed ring-buffer + codec-check tests**
- **Found during:** Task 2 — first GREEN run after applying the plan's verbatim block as written
- **Issue:** Plan 04's verbatim replacement (lines 219-304 of `01-04-PLAN.md`) puts `chrome.runtime.onMessage.addListener(...)`, `connectPort()`, and `chrome.runtime.sendMessage(...)` at top-level (no guard). The pure ring-buffer test (`tests/offscreen/ring-buffer.test.ts`) imports the module without providing any `chrome` stub at all, so `chrome.runtime.onMessage.addListener(...)` at top-level throws `ReferenceError: chrome is not defined`. The codec-check test stubs only `chrome.runtime.sendMessage`, so it throws `TypeError: Cannot read properties of undefined (reading 'addListener')` on the same line. Plan 04's acceptance gate explicitly requires "ALL test files in tests/offscreen/ passing (8 tests total across 4 files)" — applying the verbatim block as written fails 4 of those 9 tests (2 in ring-buffer, 2 in codec-check).
- **Fix:** Wrapped the same bootstrap content in the `bootstrap()` function Plan 03 used, preserving (a) the `typeof chrome === 'undefined'` top-level guard and (b) the `typeof chrome.runtime.X === 'function'` per-API checks. Production behavior is unchanged because production always has the full chrome surface. The full port lifecycle (`connectPort`, `onPortMessage`, `teardownPortTimers`, ping interval, pre-emptive reconnect timer, REQUEST_BUFFER handler) is identical to the plan's verbatim block — only the outer guarding shell differs.
- **Files modified:** `src/offscreen/recorder.ts` (in Task 2 commit b064a21)
- **Verification:** `npx vitest run` → 9/9 PASS across 4 test files; `npx tsc --noEmit` exits 0; grep gates all return their expected values.
---
**Total deviations:** 1 auto-fixed (1× Rule 1 bug — plan's verbatim block was inconsistent with prior test stubs)
**Impact on plan:** Single localized fix; no architectural change; no scope creep. The plan's `<verification>` `<success_criteria>` (all 4 test files green, T-1-04 mitigations in place, no `as any` / `@ts-ignore`) are all satisfied. The verbatim block was authored without re-checking the stubs in the existing passing tests (`ring-buffer.test.ts` provides no chrome stub at all; `codec-check.test.ts` provides only `sendMessage`); restoring the guard preserves both Plan 02's RED contract and Plan 04's new GREEN contract.
## Issues Encountered
- **None beyond the Deviation §1 case above.** Tests, tsc, and grep gates all behaved as the plan predicted on the second attempt (after applying the guard fix).
## Threat Flags
None — no new security-relevant surface beyond what the plan's `<threat_model>` already enumerated (T-1-04 and T-1-NEW-04-01). T-1-04 enforcement on the SW side is Plan 05's explicit responsibility and is documented at 4 places in `recorder.ts`.
## Plan 05 Handoff (CRITICAL — do not skip the sender check)
**Plan 05 (`src/background/index.ts`) MUST implement:**
1. **`chrome.runtime.onConnect.addListener` filter:**
```typescript
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== 'video-keepalive') return;
// T-1-04 sender check (REQUIRED — offscreen documents this contract):
if (port.sender?.id !== chrome.runtime.id) {
port.disconnect();
return;
}
// ... attach onMessage / onDisconnect ...
});
```
2. **On `SAVE_ARCHIVE`:** send `{ type: 'REQUEST_BUFFER' }` over the kept-open port; resolve the `saveArchive()` Promise inside the `port.onMessage` handler when `{ type: 'BUFFER', chunks: [...] }` arrives.
3. **Handle SW unload window:** the offscreen reconnects on disconnect; SW should NOT cache the port reference across unloads — re-bind in `onConnect` each time.
4. **Delete `setupKeepalive` + alarms code** (audit P1 #8 / D-18) — Plan 03 left it intact; Plan 05 owns its removal.
## Next Phase Readiness
- REQ-video-ring-buffer remains incomplete pending **Plan 07 ffprobe gate** (a fresh-export sample must pass `ffprobe -v error -f matroska -i last_30sec.webm` exit 0 — D-12). Do NOT mark the requirement complete.
- Plan 05 (SW-side port host + SW shrink) can now proceed against a stable offscreen-side contract: `PORT_NAME = 'video-keepalive'`, inbound `PortMessage` = REQUEST_BUFFER, outbound `PortMessage` = PING / BUFFER.
## Self-Check: PASSED
- `[x] src/offscreen/recorder.ts` exists at expected path (`test -f` exits 0; 270 lines)
- `[x] Commit b064a21` exists in git log (`git log --oneline | grep b064a21` returns 1)
- `[x] Commit b0f4adc` exists in git log (`git log --oneline | grep b0f4adc` returns 1)
- `[x] All 4 test files pass` (verified by `/tmp/01-04-green.log` + live re-run)
- `[x] tsc --noEmit exits 0`
- `[x] T-1-04 SW-side contract` documented in 4 places (grep count = 4)
- `[x] void keepalivePort` no-unused-locals workaround removed (grep count = 0)
- `[x] No as any / @ts-ignore` introduced (both counts = 0)
---
*Phase: 01-stabilize-video-pipeline*
*Completed: 2026-05-15*