Skip to content

Commit de54177

Browse files
committed
fix: keep MV3 worker alive during Deep Dive and on-demand lenses
Deep Dive and every other on-demand lens run their AI work in the MV3 background service worker AFTER the initial scan's keepalive port has been released. With nothing keeping the worker warm, Chrome suspends it during a provider rate-limit/backoff sleep — most reliably before the second model call ("Mapping causal lineage") — freezing the status with no error and no recovery, so the tab spins forever. Restore protection by mirroring the proven scan keepalive across all flows: - background-activity.js: pure, tested detector (anyLensInFlight / lensActivitySignature) for in-flight work across Deep Dive, Systems, Ideate, Prioritize, Synergies, Combinator, Versus, SKTPG, Docs Quality, Maintenance, Fits-Stack, and Ask. - deep-dive-lifecycle.js: pure transition helper for the Deep Dive watchdog. - output-tab.js: a single reference-counted keepalive driven off the session entry's activity signature, bounded so a never-settling flow can't pin the worker. The top-level scan is excluded (already covered by init() on load; retries reload the page). Deep Dive keeps a tighter 180s stall watchdog that persists an error so the existing "Deep Dive failed -> Try again" UI surfaces instead of an endless spinner. Adds 16 unit tests; full suite 943 passing. Browser-runtime behavior not yet verified (no extension runtime available locally).
1 parent ea4febf commit de54177

5 files changed

Lines changed: 393 additions & 6 deletions

File tree

src/background-activity.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Detect whether a session entry has any on-demand lens flow running in the MV3 background
2+
// service worker, so the output tab can keep the worker warm for its full duration. Every
3+
// post-scan lens (Deep Dive, Systems/Ideate/Prioritize, Synergies, Combinator, Versus,
4+
// SKTPG, Docs Quality, Maintenance, Fits-Stack, Ask) fires AI work AFTER the scan keepalive
5+
// is released — without this they share Deep Dive's "worker suspended mid-run → frozen
6+
// forever" bug. The top-level scan is intentionally excluded: it is covered by the keepalive
7+
// acquired in init() on page load, and every RERUN reloads the page.
8+
//
9+
// Pure + testable; the output tab wires the side effects (port connect/disconnect, timers).
10+
11+
import { isDeepDiveInFlight } from './deep-dive-lifecycle.js';
12+
13+
// Slots whose value is { status, ... }; in flight while status is set and not settled.
14+
const STATUS_SLOTS = [
15+
'synergies',
16+
'combinator',
17+
'versus',
18+
'sktpg',
19+
'docsQuality',
20+
'maintenance',
21+
'fitsStack',
22+
];
23+
// Slots whose value is a lens-runs object { runs: { [framework]: { status } } }.
24+
const LENS_RUN_SLOTS = ['systems', 'ideate', 'prioritize'];
25+
26+
function statusInFlight(status) {
27+
return !!status && status !== 'done' && status !== 'error';
28+
}
29+
30+
/**
31+
* A compact, order-independent string of every in-flight slot's status. The output tab
32+
* compares successive signatures: a change means work PROGRESSED (reset the stall bound),
33+
* an unchanged non-empty signature means it may be STALLED.
34+
* @returns {string}
35+
*/
36+
export function lensActivitySignature(entry) {
37+
if (!entry || typeof entry !== 'object') return '';
38+
const parts = [];
39+
40+
const dd = entry.deepDive?.status;
41+
if (isDeepDiveInFlight(dd)) parts.push(`deepDive:${dd}`);
42+
43+
for (const slot of STATUS_SLOTS) {
44+
const s = entry[slot]?.status;
45+
if (statusInFlight(s)) parts.push(`${slot}:${s}`);
46+
}
47+
48+
for (const slot of LENS_RUN_SLOTS) {
49+
const runs = entry[slot]?.runs || {};
50+
for (const [fw, run] of Object.entries(runs)) {
51+
if (statusInFlight(run?.status)) parts.push(`${slot}.${fw}:${run.status}`);
52+
}
53+
}
54+
55+
const ask = entry.askRepo?.pending?.status;
56+
if (statusInFlight(ask)) parts.push(`ask:${ask}`);
57+
58+
return parts.sort().join('|');
59+
}
60+
61+
/** True when any on-demand lens flow is running. */
62+
export function anyLensInFlight(entry) {
63+
return lensActivitySignature(entry) !== '';
64+
}

src/deep-dive-lifecycle.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Deep Dive lifecycle — pure decisions for the output tab's keepalive + stall watchdog.
2+
//
3+
// Deep Dive runs a long, multi-stage pipeline (source fetch → atoms → lineage → feynman)
4+
// inside the MV3 background service worker. MV3 suspends an idle worker after ~30s, and the
5+
// keepalive port that protects the initial scan is released the moment that scan finishes —
6+
// long before Deep Dive is triggered on demand. With nothing keeping the worker warm, it is
7+
// reaped during a provider rate-limit/backoff sleep (most reliably before the 2nd model call,
8+
// "Mapping causal lineage"), freezing `deepDive.status` with no error and no recovery.
9+
//
10+
// The output tab fixes this by (a) holding the keepalive for every in-flight stage and
11+
// (b) arming a stall watchdog so a frozen run surfaces an actionable error instead of
12+
// spinning forever. This module keeps the *decisions* pure so they are unit-testable; the
13+
// tab wires the side effects (port connect/disconnect, setTimeout, DOM).
14+
15+
const IN_FLIGHT = new Set(['fetching', 'atoms', 'lineage', 'feynman']);
16+
17+
/** A Deep Dive status that represents active background work (not settled / not absent). */
18+
export function isDeepDiveInFlight(status) {
19+
return IN_FLIGHT.has(status);
20+
}
21+
22+
/**
23+
* Decide what the keepalive + watchdog should do given a status transition.
24+
*
25+
* @param {string|null|undefined} prevStatus - last observed deepDive.status
26+
* @param {string|null|undefined} nextStatus - newly observed deepDive.status
27+
* @returns {{
28+
* changed: boolean, // did the status actually change?
29+
* holdKeepalive: boolean, // desired keepalive state (true while work is in flight)
30+
* resetWatchdog: boolean, // (re)arm a fresh stall timer — only on a transition INTO a stage
31+
* clearWatchdog: boolean, // disarm the stall timer — once settled (done/error/absent)
32+
* }}
33+
*/
34+
export function deepDiveLifecycleAction(prevStatus, nextStatus) {
35+
const changed = prevStatus !== nextStatus;
36+
const inFlight = isDeepDiveInFlight(nextStatus);
37+
return {
38+
changed,
39+
holdKeepalive: inFlight,
40+
// Reset only on a real transition so unrelated session-storage writes (other lenses
41+
// re-render Deep Dive too) can't keep postponing the stall detector indefinitely.
42+
resetWatchdog: changed && inFlight,
43+
clearWatchdog: !inFlight,
44+
};
45+
}

src/output-tab.js

Lines changed: 130 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { lineageSvg, loopSvg } from './diagram.js';
1111
import { explainerFor, SCAN_EXPLAINERS } from './explainers.js';
1212
import { deriveFit, firstSentence, verdictCopyText } from './verdict.js';
1313
import { pingRunner } from './runner.js';
14+
import { deepDiveLifecycleAction } from './deep-dive-lifecycle.js';
15+
import { lensActivitySignature } from './background-activity.js';
1416
import { emptyLens, runOf } from './lens-runs.js';
1517
import { spine, flow, ranked, matrix2x2, optionMatrix } from './layouts.js';
1618
import { guideFor } from './lens-guide.js';
@@ -98,15 +100,17 @@ const sessionKey = params.get('key');
98100

99101
let keepalivePort = null;
100102
let keepaliveTimer = null;
101-
function startScanKeepalive() {
103+
let keepaliveRefs = 0;
104+
105+
function openKeepalivePort() {
102106
if (!sessionKey || keepalivePort) return;
103107
try {
104108
keepalivePort = chrome.runtime.connect({ name: 'repolens-scan-keepalive' });
105109
const ping = () => {
106110
try {
107111
keepalivePort?.postMessage({ type: 'PING', sessionKey, at: Date.now() });
108112
} catch {
109-
/* the worker may be restarting; the next scan/reload reconnects */
113+
/* the worker may be restarting; the next tick/reload reconnects */
110114
}
111115
};
112116
ping();
@@ -115,13 +119,17 @@ function startScanKeepalive() {
115119
keepalivePort = null;
116120
if (keepaliveTimer) clearInterval(keepaliveTimer);
117121
keepaliveTimer = null;
122+
// Work still in flight (refs > 0) means the worker cycled mid-run — reopen so the
123+
// pings keep resetting its idle timer. A deliberate release sets refs to 0 first,
124+
// so this no-ops on intentional teardown.
125+
if (keepaliveRefs > 0) openKeepalivePort();
118126
});
119127
} catch {
120128
/* keepalive is best-effort; polling still works without it */
121129
}
122130
}
123131

124-
function stopScanKeepalive() {
132+
function closeKeepalivePort() {
125133
if (keepaliveTimer) clearInterval(keepaliveTimer);
126134
keepaliveTimer = null;
127135
try {
@@ -132,6 +140,21 @@ function stopScanKeepalive() {
132140
keepalivePort = null;
133141
}
134142

143+
// Reference-counted keepalive. Every long background flow (initial scan, Deep Dive, and
144+
// any on-demand lens) keeps the MV3 service worker warm for its own duration; the port only
145+
// closes once the last holder releases it. Without this, a worker suspended mid-flow leaves
146+
// the session entry frozen forever — the exact failure Deep Dive hit on the lineage stage.
147+
function acquireKeepalive() {
148+
keepaliveRefs += 1;
149+
if (keepaliveRefs === 1) openKeepalivePort();
150+
}
151+
152+
function releaseKeepalive() {
153+
if (keepaliveRefs === 0) return;
154+
keepaliveRefs -= 1;
155+
if (keepaliveRefs === 0) closeKeepalivePort();
156+
}
157+
135158
const loading = document.getElementById('loading-state');
136159
const errorState = document.getElementById('error-state');
137160
const errorMsg = document.getElementById('error-msg');
@@ -587,13 +610,13 @@ async function init() {
587610
errorState.style.display = 'flex';
588611
return;
589612
}
590-
startScanKeepalive();
613+
acquireKeepalive();
591614
let data;
592615
try {
593616
data = await waitForData();
594617
} catch (err) {
595618
loading.style.display = 'none';
596-
stopScanKeepalive();
619+
releaseKeepalive();
597620
try {
598621
const stored = await chrome.storage.session.get(sessionKey);
599622
const stale = stored[sessionKey];
@@ -620,7 +643,7 @@ async function init() {
620643
return;
621644
}
622645
loading.style.display = 'none';
623-
stopScanKeepalive();
646+
releaseKeepalive();
624647

625648
if (data.error) {
626649
errorMsg.textContent = data.error;
@@ -718,6 +741,7 @@ async function init() {
718741
.catch(() => {});
719742
renderThemeSwitcher();
720743
initHeaderActions();
744+
manageKeepalive(data); // re-warm the worker if a lens was already running when the tab loaded
721745
renderDeepDive(data);
722746
renderFrameworkLens(data, SYSTEMS_CFG);
723747
renderFrameworkLens(data, IDEATE_CFG);
@@ -1509,7 +1533,106 @@ function renderUnderstandCheck(host, questions, repoId) {
15091533
drawCard();
15101534
}
15111535

1536+
// ── Deep Dive keepalive + stall watchdog ──
1537+
// ── MV3 keepalive for ALL on-demand lenses ──
1538+
// Every lens that runs AI work after the initial scan (Deep Dive, Systems/Ideate/Prioritize,
1539+
// Synergies, Combinator, Versus, SKTPG, Docs, Maintenance, Fits-Stack, Ask) needs the worker
1540+
// kept warm or it can be suspended mid-run and freeze. Driven off the session entry's
1541+
// activity signature so one place covers them all; the hold is bounded so a flow that never
1542+
// settles can't pin the worker (and drain) indefinitely.
1543+
const LENS_STALL_MS = 300_000; // give up keeping warm after 5 min with no progress at all
1544+
let lensKeepaliveHeld = false;
1545+
let lensSig = '';
1546+
let lensStallTimer = null;
1547+
1548+
function releaseLensKeepalive() {
1549+
if (lensStallTimer) {
1550+
clearTimeout(lensStallTimer);
1551+
lensStallTimer = null;
1552+
}
1553+
if (lensKeepaliveHeld) {
1554+
releaseKeepalive();
1555+
lensKeepaliveHeld = false;
1556+
}
1557+
}
1558+
1559+
function manageKeepalive(entry) {
1560+
const sig = lensActivitySignature(entry);
1561+
if (sig === lensSig) return; // background-work state unchanged → nothing to do
1562+
lensSig = sig;
1563+
const inFlight = sig !== '';
1564+
1565+
if (inFlight && !lensKeepaliveHeld) {
1566+
acquireKeepalive();
1567+
lensKeepaliveHeld = true;
1568+
} else if (!inFlight && lensKeepaliveHeld) {
1569+
releaseKeepalive();
1570+
lensKeepaliveHeld = false;
1571+
}
1572+
1573+
// Progress (a signature change) re-arms the hold; prolonged silence releases it.
1574+
if (lensStallTimer) {
1575+
clearTimeout(lensStallTimer);
1576+
lensStallTimer = null;
1577+
}
1578+
if (inFlight) lensStallTimer = setTimeout(releaseLensKeepalive, LENS_STALL_MS);
1579+
}
1580+
1581+
// ── Deep Dive stall watchdog ──
1582+
// The heaviest flow gets a tighter watchdog with a graceful recovery: if a stage makes no
1583+
// progress within DD_STALL_MS, persist an error so the tab shows "Deep Dive failed → Try
1584+
// again" (and the keepalive driver then releases) instead of an endless spinner. Keepalive
1585+
// itself is handled by manageKeepalive above; this only owns the watchdog + recovery.
1586+
const DD_STALL_MS = 180_000; // longest plausible single stage (runner ~90s, model + retries) ×2
1587+
let ddLastStatus = null;
1588+
let ddWatchdog = null;
1589+
1590+
function clearDdWatchdog() {
1591+
if (ddWatchdog) {
1592+
clearTimeout(ddWatchdog);
1593+
ddWatchdog = null;
1594+
}
1595+
}
1596+
1597+
async function onDeepDiveStalled() {
1598+
ddWatchdog = null;
1599+
ddLastStatus = 'error'; // settle locally so stray re-renders don't re-arm the watchdog
1600+
const msg =
1601+
'Deep Dive stopped responding — the background worker was likely suspended mid-run. ' +
1602+
'Try again; if it repeats, reload RepoLens from chrome://extensions first.';
1603+
try {
1604+
// Persist the error so renderDeepDive shows its normal failed/Try-again state, the result
1605+
// survives a reload, and manageKeepalive sees the run settle and releases the worker.
1606+
const cur = (await chrome.storage.session.get(sessionKey))[sessionKey] || {};
1607+
await chrome.storage.session.set({
1608+
[sessionKey]: { ...cur, deepDive: { ...(cur.deepDive || {}), status: 'error', error: msg } },
1609+
});
1610+
} catch {
1611+
// Storage unavailable — fall back to a local render so the user is never stuck.
1612+
const host = document.getElementById('t10');
1613+
if (host) {
1614+
host.innerHTML = `<div class="dd-cta"><h3>Deep Dive stopped responding</h3><p>${esc(msg)}</p><button class="dd-run" id="dd-run">Try again</button></div>`;
1615+
document.getElementById('dd-run')?.addEventListener('click', () => {
1616+
if (lastData) startDeepDive(lastData);
1617+
});
1618+
}
1619+
}
1620+
}
1621+
1622+
function manageDeepDiveWatchdog(d) {
1623+
const status = d?.deepDive?.status ?? null;
1624+
const action = deepDiveLifecycleAction(ddLastStatus, status);
1625+
if (!action.changed) return;
1626+
ddLastStatus = status;
1627+
if (action.clearWatchdog) clearDdWatchdog();
1628+
if (action.resetWatchdog) {
1629+
clearDdWatchdog();
1630+
ddWatchdog = setTimeout(onDeepDiveStalled, DD_STALL_MS);
1631+
}
1632+
}
1633+
15121634
function renderDeepDive(d) {
1635+
manageDeepDiveWatchdog(d);
15131636
const host = document.getElementById('t10');
15141637
if (!host) return;
15151638
const dd = d.deepDive;
@@ -2580,6 +2703,7 @@ chrome.storage.onChanged.addListener((changes, area) => {
25802703
const nv = changes[sessionKey].newValue;
25812704
const ov = changes[sessionKey].oldValue || {};
25822705
lastData = nv;
2706+
manageKeepalive(nv); // keep the MV3 worker warm while any lens runs in the background
25832707
renderDeepDive(nv);
25842708
renderFrameworkLens(nv, SYSTEMS_CFG);
25852709
renderFrameworkLens(nv, IDEATE_CFG);

0 commit comments

Comments
 (0)