Session-2 (continuation of d614462 INCONCLUSIVE) executed disambiguation
plan and converged on a definitive verdict. Three independent observations
ruled out ALL architectural-failure hypotheses:
Step A: race-tolerant offscreen target attach (committed separately;
enabled visibility into the offscreen recorder + remux pipeline).
Step B: pre-kill and post-kill segment-count probes via the existing
`__mokoshOffscreenQuery 'get-segment-count'` bridge op (no new
test-only symbols introduced; FORBIDDEN_HOOK_STRINGS inventory
unchanged at 12 entries). Observed segments.length transition:
POST-PRIME=0 → PRE-KILL=3 → POST-KILL=3
Segments structurally survive the SW kill (offscreen still responds
to bridge query post-kill). Hypothesis A (architectural RAM loss
across SW termination) REFUTED.
Step C: SPIKE_SKIP_SW_KILL=1 env-var mode skips worker.close(). The
resulting videoSize is IDENTICAL to the canonical run (8505 bytes).
Hypothesis C (CDP-induced offscreen collateral teardown) REFUTED.
Since SW was not killed, its console listener stayed connected,
exposing the full Remux pipeline output:
[SW:Remux] Segment ts=1: 0 frames, duration=0ms, trackInfo=320x180
[SW:Remux] Segment ts=2: 0 frames, duration=0ms, trackInfo=320x180
[SW:Remux] Segment ts=3: 0 frames, duration=0ms, trackInfo=320x180
[SW:Remux] Remux complete: 0 frames, total timeline=0ms, output=8505 bytes
Each segment Blob has a valid track header (PixelWidth/Height parsed
successfully) but ZERO VP9 frames. Hypothesis B (canvas-captureStream
throttling in headless idle) CONFIRMED.
VERDICT: REFUTED-architecture (canvas-captureStream issue).
The architecture (offscreen-RAM `segments: Blob[] = []`) works
correctly; the spike's test methodology is invalid. The
`installFakeDisplayMedia` synthetic stream (canvas.captureStream(30)
on a hidden -9999px-offset 320x180 canvas) cannot sustain frame
production during a 5-min headless idle window despite the
`setInterval(drawFrame, 33ms)` belt-and-suspenders mitigation. This
matches the documented Chromium throttling of MediaRecorder on
invisible-canvas sources (Chrome bug 653548; auto-throttled-screen-capture
design doc; sendrec.eu blog "Why Canvas Breaks Your Screen Recorder").
ROUTING RECOMMENDATION (out of scope for this debug session):
- Do NOT proceed with the IndexedDB persistence plan-fix proposed by
Plan 04-04 SUMMARY. The plan-fix would NOT close SC #1 because the
spike would STILL produce 8505 bytes after IDB lands — the failure
is in the test's fake stream, not in segment persistence.
- Open a new plan slot (likely Plan 04-08 or a Phase 5 plan) that
reframes SC #1 verification methodology. Options:
(a) real getDisplayMedia in non-headless Puppeteer with
--auto-select-desktop-capture-source;
(b) video-file-backed MediaStream source (HTMLVideoElement
playing a bundled WebM) — bypasses canvas-captureStream
throttling entirely;
(c) reduce SC #1 wall-clock idle threshold to a value short
enough that canvas-captureStream survives (e.g., 30s) AND
add a separate manual operator-empirical test for 5-min.
ROADMAP SC #1 status: REMAINS OPEN. The architecture is sound; the
empirical verification gate is broken. Plan 04-04 SUMMARY's
characterization ("spike FAILED → architectural plan-fix needed") is
TECHNICALLY CORRECT on the first clause but INCORRECT on the second —
the spike's failure mode is in test infrastructure, not in production
code.
Files in this commit:
- tests/uat/spike-a33-sw-persistence.ts: added probeSegmentCount
helper using existing __mokoshOffscreenQuery bridge op; 3
checkpoints (POST-PRIME / PRE-KILL / POST-KILL); SPIKE_SKIP_SW_KILL=1
env-var skips worker.close() for Step C disambiguation.
- .planning/debug/sw-offscreen-persistence-investigation-session-2.md:
NEW session-2 debug note documenting full evidence trail + verdict
derivation + routing recommendation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
337 lines
16 KiB
TypeScript
337 lines
16 KiB
TypeScript
// tests/uat/spike-a33-sw-persistence.ts — Plan 04-04 Wave 0 SPIKE +
|
|
// Plan-04-04 debug session-2 disambiguation probes.
|
|
//
|
|
// One-shot empirical investigation: does the offscreen document survive
|
|
// a 5-min idle window followed by Puppeteer-driven force-termination
|
|
// of the MV3 service worker? Per RESEARCH Q2 (sub-question b), the
|
|
// canonical architecture analysis says yes (offscreen has its own
|
|
// lifecycle, anchored by active MediaRecorder); RESEARCH confidence
|
|
// is MEDIUM because the Chrome docs leave the offscreen-vs-SW interplay
|
|
// implicit. This spike is the empirical hedge.
|
|
//
|
|
// Session-2 disambiguation (committed alongside debug
|
|
// session-2 note .planning/debug/sw-offscreen-persistence-investigation-session-2.md):
|
|
// - get-segment-count probes at 3 checkpoints (post-prime, pre-kill,
|
|
// post-kill-pre-SAVE) — distinguishes "segments empty pre-kill
|
|
// (canvas-captureStream throttled)" from "segments existed pre-kill
|
|
// but lost post-kill (architectural)". Probes ride the production
|
|
// __mokoshOffscreenQuery bridge op (already in FORBIDDEN_HOOK_STRINGS
|
|
// inventory); no new test-only symbols introduced.
|
|
// - SPIKE_SKIP_SW_KILL=1 env-var skips the worker.close() call —
|
|
// distinguishes "SW kill via CDP causes the failure (CDP artifact)"
|
|
// from "5-min idle alone causes the failure (architectural OR
|
|
// canvas-throttling-without-SW-involvement)".
|
|
//
|
|
// Outcome decision tree:
|
|
// - videoSize > 100_000 bytes → SPIKE PASSED. Plan 04-04 Wave 1
|
|
// proceeds: A33 is a verification-only harness assertion that
|
|
// wraps the spike methodology. ROADMAP SC #1 satisfied by the
|
|
// CURRENT architecture; no persistence layer needed.
|
|
// - videoSize <= 100_000 bytes OR throw → SPIKE FAILED. Plan 04-04
|
|
// STOPS execution; SUMMARY documents the failure mode; the next
|
|
// planning event proposes IndexedDB persistence work (out of
|
|
// scope for this plan per the spike-first contract).
|
|
//
|
|
// Architectural reuse:
|
|
// - `setupFreshRecording` is module-internal inside
|
|
// `extension-page-harness.ts` (NOT exposed on `__mokoshHarness`).
|
|
// The exposed `assertA2` method is the canonical "prime fresh
|
|
// recording" entrypoint — it calls `ensureOffscreen + startRecording`
|
|
// and waits for `badge === 'REC'`. After `assertA2` returns,
|
|
// `MediaRecorder.state === 'recording'` and the offscreen is the
|
|
// "compelling reason" Chrome keeps it alive for. This matches the
|
|
// existing precedent: A3+A4 chain off A2's REC state.
|
|
// - SAVE_ARCHIVE dispatch uses `chrome.runtime.sendMessage` directly
|
|
// from the harness page realm (which is an extension-page realm
|
|
// with full chrome.* access). This matches the canonical pattern
|
|
// used by 9 existing assertA* methods (A5/A11/A12/A13/A26/A28/
|
|
// A29/A30/A31). The __mokoshHarness surface is assertA1..A31 +
|
|
// getManifestVersion only (no SAVE-dispatch helper) — Option B
|
|
// per Plan 04-04 REVISION iter-2.
|
|
//
|
|
// References:
|
|
// - Plan 04-04 PLAN.md <tasks> Task 1
|
|
// - .planning/phases/04-harden-clean-up-optional/04-RESEARCH.md Q2
|
|
// - https://developer.chrome.com/docs/extensions/how-to/test/test-serviceworker-termination-with-puppeteer
|
|
//
|
|
// Operation: tsx tests/uat/spike-a33-sw-persistence.ts (HEADLESS=1 for CI).
|
|
// Expected wall-clock: ~6-7 minutes (5 min idle + 1-2 min orchestration).
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
|
|
import JSZip from 'jszip';
|
|
|
|
import { launchHarnessBrowser } from './lib/launch';
|
|
import { findLatestZip, stopServiceWorker } from './lib/harness-page-driver';
|
|
|
|
/** Wall-clock idle window. 5 min matches ROADMAP SC #1's "5+ min idle". */
|
|
const SPIKE_IDLE_WAIT_MS = 5 * 60 * 1000;
|
|
/** Post-`worker.close()` settle so the SW teardown finishes. */
|
|
const SPIKE_NEW_SW_BOOT_MS = 500;
|
|
/** SAVE_ARCHIVE round-trip timeout. */
|
|
const SPIKE_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
|
|
/** Post-SAVE settle so chrome.downloads finishes writing the zip. */
|
|
const SPIKE_DOWNLOAD_SETTLE_MS = 5_000;
|
|
/** Pass/fail floor: real archives are 1-3 MB; 100 KB is a sanity floor
|
|
* above any "near-empty" failure mode (a near-empty zip with just
|
|
* meta.json + screenshot is ~50 KB; anything above 100 KB means the
|
|
* video buffer actually contained segments). */
|
|
const SPIKE_VIDEO_SIZE_FLOOR_BYTES = 100_000;
|
|
/** Session-2: probe op timeout (5s; matches the harness's offscreenQuery default). */
|
|
const SPIKE_PROBE_TIMEOUT_MS = 5_000;
|
|
|
|
/**
|
|
* Session-2 disambiguation probe — query the offscreen recorder's live
|
|
* `segments.length` via the production `__mokoshOffscreenQuery`
|
|
* `get-segment-count` bridge op (src/test-hooks/offscreen-hooks.ts:493).
|
|
*
|
|
* Returns the segment count (number of fully-rotated 10s WebM segments
|
|
* in the in-memory ring buffer), or `-1` on bridge error.
|
|
*
|
|
* The bridge op rides chrome.runtime.sendMessage from the harness page
|
|
* realm → offscreen-hooks's onMessage listener responds synchronously
|
|
* via sendResponse({count: number}). No new test-hook symbol; the
|
|
* `get-segment-count` op is already in the 12-entry FORBIDDEN_HOOK_STRINGS
|
|
* inventory (Plan 01-13 Wave 3D A11 contract).
|
|
*
|
|
* Used at 3 checkpoints in the spike:
|
|
* 1. post-prime: confirms baseline segment-count ≈ 0 (no rotations yet)
|
|
* — sanity check; if NON-zero here, the prime did unexpected work.
|
|
* 2. pre-kill (just BEFORE stopServiceWorker): the critical probe —
|
|
* if zero here, hypothesis B (canvas-throttling) confirmed;
|
|
* if non-zero here but archive empty post-SAVE, hypothesis A
|
|
* (architectural RAM loss across SW termination) confirmed.
|
|
* 3. post-kill-pre-SAVE: confirms offscreen still respawns + can
|
|
* respond — if the offscreen target itself died with the SW kill,
|
|
* this probe would throw or time out; that result distinguishes
|
|
* "offscreen survives kill but loses state" from "offscreen dies
|
|
* with the SW kill (collateral teardown per Puppeteer #9995)".
|
|
*
|
|
* @param page - The harness page (has chrome.runtime access).
|
|
* @returns The live segment count, or `-1` on probe error.
|
|
*/
|
|
async function probeSegmentCount(
|
|
page: import('puppeteer').Page,
|
|
label: string,
|
|
): Promise<number> {
|
|
try {
|
|
const result = await page.evaluate(
|
|
(timeoutMs: number) =>
|
|
new Promise<{ count?: number; error?: string }>((resolve) => {
|
|
const timer = setTimeout(() => {
|
|
resolve({ error: `get-segment-count timed out after ${timeoutMs}ms` });
|
|
}, timeoutMs);
|
|
chrome.runtime.sendMessage(
|
|
{ type: '__mokoshOffscreenQuery', op: 'get-segment-count' },
|
|
(response: unknown) => {
|
|
clearTimeout(timer);
|
|
if (chrome.runtime.lastError !== undefined) {
|
|
resolve({ error: String(chrome.runtime.lastError.message) });
|
|
return;
|
|
}
|
|
resolve(response as { count?: number; error?: string });
|
|
},
|
|
);
|
|
}),
|
|
SPIKE_PROBE_TIMEOUT_MS,
|
|
);
|
|
if (typeof result.count === 'number') {
|
|
process.stdout.write(
|
|
`SPIKE PROBE [${label}]: segments.length=${result.count}\n`,
|
|
);
|
|
return result.count;
|
|
}
|
|
process.stdout.write(
|
|
`SPIKE PROBE [${label}]: ERROR ${result.error ?? '(unknown)'}\n`,
|
|
);
|
|
return -1;
|
|
} catch (err) {
|
|
process.stdout.write(
|
|
`SPIKE PROBE [${label}]: THREW ${err instanceof Error ? err.message : String(err)}\n`,
|
|
);
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Spike entrypoint. Returns a Node exit code (0 = PASSED, 1 = FAILED).
|
|
*/
|
|
async function main(): Promise<number> {
|
|
// Session-2 mode switch: SPIKE_SKIP_SW_KILL=1 = run Step C variant
|
|
// (5-min idle + SAVE, NO worker.close()). Distinguishes "SW kill is
|
|
// the cause" from "5-min idle alone is the cause".
|
|
const skipSwKill = process.env.SPIKE_SKIP_SW_KILL === '1';
|
|
|
|
process.stdout.write('\nMokosh Plan 04-04 Wave 0 SPIKE — SW state persistence empirical test\n');
|
|
process.stdout.write('='.repeat(72) + '\n');
|
|
process.stdout.write(
|
|
`SPIKE: idle window = ${SPIKE_IDLE_WAIT_MS}ms (${SPIKE_IDLE_WAIT_MS / 60_000} min); ` +
|
|
`pass floor = ${SPIKE_VIDEO_SIZE_FLOOR_BYTES} bytes\n`,
|
|
);
|
|
process.stdout.write(
|
|
`SPIKE: mode = ${skipSwKill ? 'STEP-C (skip-sw-kill: 5-min idle + SAVE, no worker.close)' : 'CANONICAL (5-min idle + worker.close + SAVE)'}\n\n`,
|
|
);
|
|
|
|
const t0 = Date.now();
|
|
const handles = await launchHarnessBrowser();
|
|
process.stdout.write(`SPIKE: launched browser; extensionId=${handles.extensionId}\n`);
|
|
process.stdout.write(`SPIKE: downloadsDir=${handles.downloadsDir}\n\n`);
|
|
|
|
let videoSize = 0;
|
|
let spikeError: string | null = null;
|
|
|
|
try {
|
|
// Step 1 — prime recording via assertA2. assertA2 is the canonical
|
|
// exposed "go to REC state" method on __mokoshHarness (read_first
|
|
// verified: __mokoshHarness has assertA1..A31 + getManifestVersion;
|
|
// assertA1 is read-only state inspection, NOT recording bootstrap;
|
|
// setupFreshRecording is module-internal, NOT exposed). assertA2
|
|
// does ensureOffscreen + startRecording + waitFor(badge==='REC').
|
|
process.stdout.write('SPIKE Step 1: prime recording via __mokoshHarness.assertA2\n');
|
|
const a2Result = await handles.harnessPage.evaluate(async () => {
|
|
const harness = (
|
|
window as unknown as {
|
|
__mokoshHarness: { assertA2: () => Promise<{ passed: boolean; error?: string }> };
|
|
}
|
|
).__mokoshHarness;
|
|
return harness.assertA2();
|
|
});
|
|
process.stdout.write(
|
|
`SPIKE Step 1 result: assertA2.passed=${a2Result.passed}` +
|
|
(a2Result.error !== undefined ? `, error=${a2Result.error}` : '') +
|
|
'\n',
|
|
);
|
|
if (!a2Result.passed) {
|
|
throw new Error(`assertA2 priming failed: ${a2Result.error ?? '(no error)'}`);
|
|
}
|
|
|
|
// Session-2 PROBE 1: post-prime baseline. Expected: 0 (no segment
|
|
// rotation has happened yet; A2's prime takes <1s + first rotation
|
|
// is at +10s).
|
|
await probeSegmentCount(handles.harnessPage, 'POST-PRIME');
|
|
|
|
// Step 2 — 5-min wall-clock idle. The whole point of the spike.
|
|
process.stdout.write(`\nSPIKE Step 2: waiting ${SPIKE_IDLE_WAIT_MS}ms for SW idle window...\n`);
|
|
process.stdout.write(`SPIKE Step 2: ETA ${new Date(Date.now() + SPIKE_IDLE_WAIT_MS).toISOString()}\n`);
|
|
await new Promise((res) => setTimeout(res, SPIKE_IDLE_WAIT_MS));
|
|
process.stdout.write('SPIKE Step 2 OK — 5-min idle elapsed\n');
|
|
|
|
// Session-2 PROBE 2: pre-kill — THE critical disambiguation point.
|
|
// If count==0 here: hypothesis B (canvas-captureStream throttled in
|
|
// headless idle; segments never accumulated). Architecture NOT broken.
|
|
// If count>=3 here: hypothesis A (architectural RAM loss across SW
|
|
// termination); the test is correct; segments existed pre-kill.
|
|
// Then check post-SAVE archive: if also empty, IDB persistence needed.
|
|
// If count==-1 (probe failed): offscreen is unresponsive — either
|
|
// collateral-killed already (unlikely; no worker.close yet) or
|
|
// SW had to broker and SW is also unresponsive.
|
|
const segCountPreKill = await probeSegmentCount(handles.harnessPage, 'PRE-KILL');
|
|
|
|
if (skipSwKill) {
|
|
// Step C variant — skip the SW kill entirely. Use the same 500ms
|
|
// settle as the canonical path so the timing between SAVE dispatch
|
|
// and zip mtime stays consistent across runs.
|
|
process.stdout.write(
|
|
'\nSPIKE Step 3: SKIPPED (SPIKE_SKIP_SW_KILL=1) — testing 5-min idle alone\n',
|
|
);
|
|
} else {
|
|
// Step 3 — force-terminate the SW via Puppeteer CDP worker.close().
|
|
process.stdout.write('\nSPIKE Step 3: stopServiceWorker(browser, extensionId)\n');
|
|
await stopServiceWorker(handles.browser, handles.extensionId);
|
|
process.stdout.write('SPIKE Step 3 OK — SW terminated via worker.close()\n');
|
|
}
|
|
|
|
// Step 4 — brief settle for SW teardown.
|
|
await new Promise((res) => setTimeout(res, SPIKE_NEW_SW_BOOT_MS));
|
|
|
|
// Session-2 PROBE 3: post-kill (or post-skip-kill) — confirms
|
|
// offscreen is still responsive after worker.close. If count drops
|
|
// from pre-kill value, the SW kill collaterally destroyed offscreen
|
|
// state. If count is same (or grows by 1 due to a rotation between
|
|
// probes), offscreen survived.
|
|
const segCountPostKill = await probeSegmentCount(handles.harnessPage, skipSwKill ? 'POST-SKIP-KILL' : 'POST-KILL');
|
|
process.stdout.write(
|
|
`SPIKE: segment-count transition: pre=${segCountPreKill}, post=${segCountPostKill}\n`,
|
|
);
|
|
|
|
// Step 5 — dispatch SAVE_ARCHIVE via chrome.runtime.sendMessage from
|
|
// the harness page realm. The first message after worker.close()
|
|
// wakes the SW back up (event-driven respawn — canonical MV3 wakeup
|
|
// path). SAVE_ARCHIVE then triggers saveArchive() which calls
|
|
// getVideoBufferFromOffscreen() — if the offscreen survived, the
|
|
// segments array still has the accumulated 30s of webm chunks.
|
|
process.stdout.write('\nSPIKE Step 5: chrome.runtime.sendMessage({type: SAVE_ARCHIVE}) inline\n');
|
|
const saveResult = await handles.harnessPage.evaluate(
|
|
(timeoutMs: number) =>
|
|
new Promise<{ success: boolean; error?: string }>((resolve) => {
|
|
const timer = setTimeout(() => {
|
|
resolve({ success: false, error: `SAVE_ARCHIVE timed out after ${timeoutMs}ms` });
|
|
}, timeoutMs);
|
|
chrome.runtime.sendMessage({ type: 'SAVE_ARCHIVE' }, (response: unknown) => {
|
|
clearTimeout(timer);
|
|
if (chrome.runtime.lastError !== undefined) {
|
|
resolve({ success: false, error: String(chrome.runtime.lastError.message) });
|
|
return;
|
|
}
|
|
resolve(response as { success: boolean; error?: string });
|
|
});
|
|
}),
|
|
SPIKE_SAVE_ARCHIVE_TIMEOUT_MS,
|
|
);
|
|
process.stdout.write(`SPIKE Step 5 result: SAVE_ARCHIVE ack -> ${JSON.stringify(saveResult)}\n`);
|
|
|
|
// Step 6 — let chrome.downloads finish writing the file.
|
|
await new Promise((res) => setTimeout(res, SPIKE_DOWNLOAD_SETTLE_MS));
|
|
|
|
// Step 7 — find the produced zip + measure the video entry.
|
|
process.stdout.write('\nSPIKE Step 7: locate zip + inspect video/last_30sec.webm\n');
|
|
const zipPath = findLatestZip(handles.downloadsDir);
|
|
if (zipPath === null) {
|
|
throw new Error(`No zip found in downloadsDir=${handles.downloadsDir}`);
|
|
}
|
|
process.stdout.write(`SPIKE Step 7: zipPath=${zipPath}\n`);
|
|
|
|
const zip = await JSZip.loadAsync(readFileSync(zipPath));
|
|
const videoEntry = zip.file('video/last_30sec.webm');
|
|
videoSize = videoEntry !== null ? (await videoEntry.async('uint8array')).byteLength : 0;
|
|
} catch (err) {
|
|
spikeError = err instanceof Error ? err.message : String(err);
|
|
process.stderr.write(`\nSPIKE THREW: ${spikeError}\n`);
|
|
} finally {
|
|
try {
|
|
await handles.browser.close();
|
|
} catch (closeErr) {
|
|
process.stderr.write(`(non-fatal: browser close threw: ${String(closeErr)})\n`);
|
|
}
|
|
}
|
|
|
|
const tEnd = Date.now();
|
|
const elapsedSec = (tEnd - t0) / 1000;
|
|
|
|
process.stdout.write('\n' + '='.repeat(72) + '\n');
|
|
process.stdout.write(
|
|
`SPIKE RESULT [${skipSwKill ? 'STEP-C-NO-KILL' : 'CANONICAL'}]: ` +
|
|
`videoSize=${videoSize} bytes (floor=${SPIKE_VIDEO_SIZE_FLOOR_BYTES}; elapsed=${elapsedSec.toFixed(1)}s)\n`,
|
|
);
|
|
if (spikeError !== null) {
|
|
process.stdout.write(`SPIKE OUTCOME: FAILED (spike threw: ${spikeError})\n`);
|
|
process.stdout.write('='.repeat(72) + '\n');
|
|
return 1;
|
|
}
|
|
if (videoSize > SPIKE_VIDEO_SIZE_FLOOR_BYTES) {
|
|
process.stdout.write('SPIKE OUTCOME: PASSED (offscreen SURVIVED the 5-min idle + SW kill)\n');
|
|
process.stdout.write(' ROADMAP SC #1 hypothesis empirically confirmed.\n');
|
|
process.stdout.write(' Plan 04-04 Wave 1 proceeds: A33 verification-only assertion.\n');
|
|
process.stdout.write('='.repeat(72) + '\n');
|
|
return 0;
|
|
}
|
|
process.stdout.write('SPIKE OUTCOME: FAILED (offscreen DIED — videoSize below floor)\n');
|
|
process.stdout.write(' Plan 04-04 Wave 1 BLOCKED: IndexedDB persistence work needed.\n');
|
|
process.stdout.write(' Route via plan-fix ceremony per saved-memory contract.\n');
|
|
process.stdout.write('='.repeat(72) + '\n');
|
|
return 1;
|
|
}
|
|
|
|
const code = await main();
|
|
process.exit(code);
|