Skip to content

Commit 9d398ed

Browse files
committed
Fix transcript handoff barrier race
1 parent 7f5aa15 commit 9d398ed

7 files changed

Lines changed: 384 additions & 70 deletions

File tree

src/chrome/src/background.js

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,21 @@ function getContextMenuPromptStore() {
165165
}
166166

167167
const contextMenuStorage = createContextMenuStorage(getContextMenuPromptStore);
168-
const tabChatHandoff = createTabChatHandoffCoordinator(chrome.storage.session);
168+
const tabChatHandoff = createTabChatHandoffCoordinator(chrome.storage.session, {
169+
requestHandoff: async (tabId, { ownerId, generation }) => {
170+
try {
171+
return await chrome.runtime.sendMessage({
172+
target: 'sidepanel',
173+
action: 'tab_chat_handoff_request',
174+
tabId,
175+
ownerId,
176+
generation,
177+
});
178+
} catch {
179+
return null;
180+
}
181+
},
182+
});
169183

170184
function createContextMenus() {
171185
if (!chrome.contextMenus?.create) return;
@@ -2658,11 +2672,15 @@ async function handleMessage(msg, sender) {
26582672
}
26592673

26602674
case 'persist_tab_chat':
2661-
return await tabChatHandoff.save(msg.tabId || sender.tab?.id, msg.html);
2675+
return await tabChatHandoff.save(msg.tabId || sender.tab?.id, msg.html, {
2676+
ownerId: msg.handoffOwnerId,
2677+
handoffGeneration: msg.handoffGeneration,
2678+
});
26622679

26632680
case 'load_tab_chat':
26642681
return await tabChatHandoff.load(msg.tabId || sender.tab?.id, {
26652682
waitForHandoff: msg.waitForHandoff === true,
2683+
claimantId: msg.handoffOwnerId,
26662684
});
26672685

26682686
case 'clear_tab_chat':

src/chrome/src/ui/sidepanel.js

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1538,6 +1538,10 @@ function isSuccessfulAskCompletion(mode, response) {
15381538
// conversation survives the side panel being closed and reopened.
15391539
const tabChats = new Map();
15401540
const tabChatOperations = new Map();
1541+
const tabChatHandoffGenerations = new Map();
1542+
const tabChatHandoffOwnerId = `sidepanel-chat-${
1543+
globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`
1544+
}`;
15411545
const tabInputDrafts = new Map();
15421546
const permissionSkipCommandContextsByTab = new Map();
15431547
const queuedComposerMessagesByTab = new Map();
@@ -1568,7 +1572,12 @@ async function loadTabChat(tabId, { waitForHandoff = false } = {}) {
15681572
const stored = await sendToBackground('load_tab_chat', {
15691573
tabId: queuedTabId,
15701574
waitForHandoff,
1575+
handoffOwnerId: tabChatHandoffOwnerId,
15711576
});
1577+
if (stored?.handoffOwnerId === tabChatHandoffOwnerId
1578+
&& Number.isFinite(Number(stored?.handoffGeneration))) {
1579+
tabChatHandoffGenerations.set(queuedTabId, Number(stored.handoffGeneration));
1580+
}
15721581
const html = stored?.found ? stored.html : null;
15731582
if (typeof html === 'string') {
15741583
tabChats.set(queuedTabId, html);
@@ -1587,18 +1596,25 @@ function persistTabChat(tabId, html, { allowHidden = false } = {}) {
15871596
}
15881597
const numericTabId = Number(tabId);
15891598
if (!Number.isFinite(numericTabId)) return Promise.resolve({ ok: false, error: 'No tab ID' });
1599+
const handoffGeneration = tabChatHandoffGenerations.get(numericTabId);
1600+
const payload = {
1601+
tabId: numericTabId,
1602+
html,
1603+
handoffOwnerId: tabChatHandoffOwnerId,
1604+
};
1605+
if (Number.isFinite(handoffGeneration)) payload.handoffGeneration = handoffGeneration;
15901606
if (document.visibilityState === 'hidden' && allowHidden) {
15911607
// Do not wait behind this document's local queue. The shared background
15921608
// coordinator orders this authoritative handoff behind any earlier write
15931609
// and ahead of the newly visible document's restore.
15941610
tabChats.set(numericTabId, html);
1595-
return sendToBackground('persist_tab_chat', { tabId: numericTabId, html });
1611+
return sendToBackground('persist_tab_chat', payload);
15961612
}
15971613
return enqueueTabChatOperation(tabId, async (numericTabId) => {
15981614
// Keep the live transcript lossless. The background may compact only the
15991615
// storage.session copy when the shared quota requires it.
16001616
tabChats.set(numericTabId, html);
1601-
return sendToBackground('persist_tab_chat', { tabId: numericTabId, html });
1617+
return sendToBackground('persist_tab_chat', payload);
16021618
});
16031619
}
16041620

@@ -1626,6 +1642,7 @@ function clearCachedTabChat(tabId) {
16261642
persistTimerTabId = null;
16271643
}
16281644
tabChats.delete(tabId);
1645+
tabChatHandoffGenerations.delete(Number(tabId));
16291646
return enqueueTabChatOperation(tabId, async (numericTabId) => {
16301647
tabChats.delete(numericTabId);
16311648
try {
@@ -7468,6 +7485,21 @@ chrome.runtime.onMessage.addListener((msg) => {
74687485
acceptContextMenuPrompt(msg.prompt || msg);
74697486
});
74707487

7488+
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
7489+
if (msg?.target !== 'sidepanel'
7490+
|| msg.action !== 'tab_chat_handoff_request'
7491+
|| String(msg.ownerId || '') !== tabChatHandoffOwnerId) return;
7492+
const snapshot = lastVisibleTabChatSnapshot;
7493+
if (!snapshot || !sameTabId(snapshot.tabId, msg.tabId)) return;
7494+
sendResponse({
7495+
ok: true,
7496+
tabId: Number(snapshot.tabId),
7497+
ownerId: tabChatHandoffOwnerId,
7498+
generation: Number(msg.generation),
7499+
html: snapshot.html,
7500+
});
7501+
});
7502+
74717503
document.addEventListener('visibilitychange', () => {
74727504
if (document.visibilityState === 'hidden') {
74737505
visibleStateRefreshPending = false;

src/chrome/src/ui/tab-chat-persistence.js

Lines changed: 100 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -192,25 +192,25 @@ export async function persistTabChatToSession(storageArea, key, html, warn = con
192192
}
193193
}
194194

195-
export const TAB_CHAT_HANDOFF_SETTLE_MS = 25;
195+
export const TAB_CHAT_HANDOFF_PREFIX = 'tabChatHandoff:';
196196

197197
/**
198198
* Serialize tab-chat reads and writes in the background's shared JavaScript
199-
* realm. The short settle window lets the outgoing panel enqueue its final
200-
* visibility snapshot even if the newly visible panel's message is delivered
201-
* first.
199+
* realm. Coordinated loads explicitly request and acknowledge the outgoing
200+
* document's final snapshot before assigning a new handoff generation.
202201
*
203202
* @param {chrome.storage.StorageArea | browser.storage.StorageArea} storageArea
204203
* @param {{
205204
* persist?: typeof persistTabChatToSession,
206-
* settleHandoff?: () => Promise<void>,
205+
* requestHandoff?: (tabId: number, handoff: { ownerId: string, generation: number }) => Promise<object | null>,
207206
* }} options
208207
*/
209208
export function createTabChatHandoffCoordinator(storageArea, {
210209
persist = persistTabChatToSession,
211-
settleHandoff = () => new Promise(resolve => setTimeout(resolve, TAB_CHAT_HANDOFF_SETTLE_MS)),
210+
requestHandoff = async () => null,
212211
} = {}) {
213212
const operations = new Map();
213+
const handoffOperations = new Map();
214214
const latestHtml = new Map();
215215

216216
function normalizeTabId(tabId) {
@@ -232,34 +232,110 @@ export function createTabChatHandoffCoordinator(storageArea, {
232232
return operation;
233233
}
234234

235-
function save(tabId, html) {
235+
function enqueueHandoff(tabId, fn) {
236+
const previous = handoffOperations.get(tabId) || Promise.resolve();
237+
const operation = previous.catch(() => {}).then(fn);
238+
handoffOperations.set(tabId, operation);
239+
operation.finally(() => {
240+
if (handoffOperations.get(tabId) === operation) handoffOperations.delete(tabId);
241+
}).catch(() => {});
242+
return operation;
243+
}
244+
245+
async function readHandoffState(tabId) {
246+
const key = TAB_CHAT_HANDOFF_PREFIX + tabId;
247+
const stored = await storageArea.get(key);
248+
const state = stored?.[key];
249+
const ownerId = String(state?.ownerId || '');
250+
const generation = Number(state?.generation);
251+
return ownerId && Number.isFinite(generation) && generation > 0
252+
? { ownerId, generation }
253+
: null;
254+
}
255+
256+
async function readLatest(tabId) {
257+
if (latestHtml.has(tabId)) {
258+
return { ok: true, found: true, html: latestHtml.get(tabId) };
259+
}
260+
const key = TAB_CHAT_PREFIX + tabId;
261+
const stored = await storageArea.get(key);
262+
const html = stored?.[key];
263+
if (typeof html === 'string') {
264+
latestHtml.set(tabId, html);
265+
return { ok: true, found: true, html };
266+
}
267+
return { ok: true, found: false, html: null };
268+
}
269+
270+
function save(tabId, html, { ownerId = '', handoffGeneration = null } = {}) {
236271
const numericTabId = normalizeTabId(tabId);
237272
if (numericTabId == null) return Promise.resolve({ ok: false, error: 'No tab ID' });
238273
const source = String(html || '');
239274
return enqueue(numericTabId, async (queuedTabId) => {
275+
const normalizedOwnerId = String(ownerId || '');
276+
const normalizedGeneration = Number(handoffGeneration);
277+
if (normalizedOwnerId && Number.isFinite(normalizedGeneration) && normalizedGeneration > 0) {
278+
const state = await readHandoffState(queuedTabId);
279+
if (!state
280+
|| state.ownerId !== normalizedOwnerId
281+
|| state.generation !== normalizedGeneration) {
282+
return { ok: true, skipped: true, reason: 'stale-handoff' };
283+
}
284+
}
240285
// Retain the lossless copy even if persistence has to compact the
241286
// storage.session value for quota recovery.
242287
latestHtml.set(queuedTabId, source);
243288
return persist(storageArea, TAB_CHAT_PREFIX + queuedTabId, source);
244289
});
245290
}
246291

247-
async function load(tabId, { waitForHandoff = false } = {}) {
292+
async function load(tabId, { waitForHandoff = false, claimantId = '' } = {}) {
248293
const numericTabId = normalizeTabId(tabId);
249294
if (numericTabId == null) return { ok: false, found: false, error: 'No tab ID' };
250-
if (waitForHandoff) await settleHandoff();
251-
return enqueue(numericTabId, async (queuedTabId) => {
252-
if (latestHtml.has(queuedTabId)) {
253-
return { ok: true, found: true, html: latestHtml.get(queuedTabId) };
254-
}
255-
const key = TAB_CHAT_PREFIX + queuedTabId;
256-
const stored = await storageArea.get(key);
257-
const html = stored?.[key];
258-
if (typeof html === 'string') {
259-
latestHtml.set(queuedTabId, html);
260-
return { ok: true, found: true, html };
295+
if (!waitForHandoff) {
296+
return enqueue(numericTabId, queuedTabId => readLatest(queuedTabId));
297+
}
298+
299+
return enqueueHandoff(numericTabId, async () => {
300+
const outgoing = await enqueue(numericTabId, queuedTabId => readHandoffState(queuedTabId));
301+
if (outgoing) {
302+
let acknowledgement = null;
303+
try {
304+
acknowledgement = await requestHandoff(numericTabId, outgoing);
305+
} catch { /* an unavailable outgoing document has no snapshot to acknowledge */ }
306+
if (acknowledgement?.ok
307+
&& String(acknowledgement.ownerId || '') === outgoing.ownerId
308+
&& Number(acknowledgement.generation) === outgoing.generation
309+
&& typeof acknowledgement.html === 'string') {
310+
await save(numericTabId, acknowledgement.html, {
311+
ownerId: outgoing.ownerId,
312+
handoffGeneration: outgoing.generation,
313+
});
314+
}
261315
}
262-
return { ok: true, found: false, html: null };
316+
317+
return enqueue(numericTabId, async (queuedTabId) => {
318+
const current = await readHandoffState(queuedTabId);
319+
const generation = Math.max(
320+
Number(outgoing?.generation || 0),
321+
Number(current?.generation || 0),
322+
) + 1;
323+
const normalizedClaimantId = String(claimantId || '');
324+
if (normalizedClaimantId) {
325+
await storageArea.set({
326+
[TAB_CHAT_HANDOFF_PREFIX + queuedTabId]: {
327+
ownerId: normalizedClaimantId,
328+
generation,
329+
},
330+
});
331+
}
332+
const result = await readLatest(queuedTabId);
333+
return {
334+
...result,
335+
handoffOwnerId: normalizedClaimantId || null,
336+
handoffGeneration: normalizedClaimantId ? generation : null,
337+
};
338+
});
263339
});
264340
}
265341

@@ -268,7 +344,10 @@ export function createTabChatHandoffCoordinator(storageArea, {
268344
if (numericTabId == null) return Promise.resolve({ ok: false, error: 'No tab ID' });
269345
return enqueue(numericTabId, async (queuedTabId) => {
270346
latestHtml.delete(queuedTabId);
271-
await storageArea.remove(TAB_CHAT_PREFIX + queuedTabId);
347+
await storageArea.remove([
348+
TAB_CHAT_PREFIX + queuedTabId,
349+
TAB_CHAT_HANDOFF_PREFIX + queuedTabId,
350+
]);
272351
return { ok: true };
273352
});
274353
}

src/firefox/src/background.js

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,21 @@ function getContextMenuPromptStore() {
126126
}
127127

128128
const contextMenuStorage = createContextMenuStorage(getContextMenuPromptStore);
129-
const tabChatHandoff = createTabChatHandoffCoordinator(browser.storage.session);
129+
const tabChatHandoff = createTabChatHandoffCoordinator(browser.storage.session, {
130+
requestHandoff: async (tabId, { ownerId, generation }) => {
131+
try {
132+
return await browser.runtime.sendMessage({
133+
target: 'sidepanel',
134+
action: 'tab_chat_handoff_request',
135+
tabId,
136+
ownerId,
137+
generation,
138+
});
139+
} catch {
140+
return null;
141+
}
142+
},
143+
});
130144

131145
function createContextMenus() {
132146
const api = getContextMenuApi();
@@ -2345,11 +2359,15 @@ async function handleMessage(msg, sender) {
23452359
}
23462360

23472361
case 'persist_tab_chat':
2348-
return await tabChatHandoff.save(msg.tabId || sender.tab?.id, msg.html);
2362+
return await tabChatHandoff.save(msg.tabId || sender.tab?.id, msg.html, {
2363+
ownerId: msg.handoffOwnerId,
2364+
handoffGeneration: msg.handoffGeneration,
2365+
});
23492366

23502367
case 'load_tab_chat':
23512368
return await tabChatHandoff.load(msg.tabId || sender.tab?.id, {
23522369
waitForHandoff: msg.waitForHandoff === true,
2370+
claimantId: msg.handoffOwnerId,
23532371
});
23542372

23552373
case 'clear_tab_chat':

0 commit comments

Comments
 (0)