fix(ab-connect): recover a churned-tabId session by stable CDP targetId (0.4.9, #24)

Live-reproduced #24 on 0.4.8 driving the Mercari signin token-exchange hop
(login.jp.mercari.com): the cross-process nav gives the tab a NEW Chrome tabId
while the CDP targetId stays the same. So cb-tab-<oldTabId> can't be recovered —
recoverSessionTab parsed the old tabId, chrome.tabs.get(oldTabId) failed (gone),
and it gave up → permanent 'stale sessionId ... its tab is gone' until the page
settled ~6s later and something re-attached. current/tab <targetId>/daemon
restart all failed because the relay still mapped the targetId to the dead
session.

Fix: remember each session's targetId across detach (sessionTargets map). When
recoverSessionTab can't recover by the encoded tabId, fall back to the STABLE
targetId — chrome.debugger.getTargets() to find the tab now hosting that target,
attach it, and ALIAS the dead cb-tab-<oldTabId> session to the live tab so the
daemon's session id keeps resolving. Longer retry window (~6s) since this hop
takes seconds to settle. Builds on 0.4.6/0.4.8 reattach; covers the tabId-churn
case those missed.

Needs dogfood on the real Mercari flow (can't repro the tabId churn synthetically).
This commit is contained in:
leeguooooo
2026-06-15 12:08:32 +09:00
parent 3d82f11ff2
commit 4e295ce139
4 changed files with 54 additions and 11 deletions
Binary file not shown.
Binary file not shown.
+53 -10
View File
@@ -30,6 +30,16 @@ const tabs = new Map()
const sessionToTab = new Map() const sessionToTab = new Map()
/** child (OOPIF/worker) sessionId -> tabId */ /** child (OOPIF/worker) sessionId -> tabId */
const childSessionToTab = new Map() const childSessionToTab = new Map()
/** sessionId -> CDP targetId, kept ACROSS detach so a dead `cb-tab-<oldTabId>`
* session can be recovered by its stable targetId when the cross-process nav
* gave the tab a new Chrome tabId (issue #24). Capped to bound memory. */
const sessionTargets = new Map()
function rememberSessionTarget(sessionId, targetId) {
if (!sessionId || !targetId) return
sessionTargets.delete(sessionId)
sessionTargets.set(sessionId, targetId)
if (sessionTargets.size > 256) sessionTargets.delete(sessionTargets.keys().next().value)
}
/** tab-group name -> chrome tabGroups id (best-effort cache) */ /** tab-group name -> chrome tabGroups id (best-effort cache) */
const groupIdByName = new Map() const groupIdByName = new Map()
@@ -172,17 +182,49 @@ function tabIdFromSession(sessionId) {
// (closed / restricted). (issues #20.1, #23) // (closed / restricted). (issues #20.1, #23)
async function recoverSessionTab(sessionId) { async function recoverSessionTab(sessionId) {
const tabId = tabIdFromSession(sessionId) const tabId = tabIdFromSession(sessionId)
if (tabId == null) return null // 1) Fast path: the encoded Chrome tabId still exists — re-attach it (covers
for (let i = 0; i < 3; i++) { // the common renderer-process swap where the tabId is preserved, #23).
const tab = await chrome.tabs.get(tabId).catch(() => null) if (tabId != null) {
if (!eligible(tab)) return null for (let i = 0; i < 3; i++) {
try { const tab = await chrome.tabs.get(tabId).catch(() => null)
await attachTab(tabId) if (!eligible(tab)) break // tabId is gone — fall through to targetId recovery
if (tabs.has(tabId)) return tabId try {
} catch { await attachTab(tabId)
// mid-swap: the tab exists but isn't attachable yet — back off and retry. if (tabs.has(tabId)) return tabId
} catch {
// mid-swap: tab exists but isn't attachable yet — back off and retry.
}
await new Promise((r) => setTimeout(r, 120 + i * 150))
}
}
// 2) The Chrome tabId is gone, but the CDP targetId is STABLE across the nav.
// Some cross-process hops (Mercari's signin token exchange) give the tab a
// NEW tabId while keeping the same target, so `cb-tab-<oldTabId>` can't be
// recovered by tabId. Find the tab now hosting our remembered targetId via
// chrome.debugger.getTargets(), attach it, and ALIAS the dead session to it
// so the daemon's session id keeps resolving. Longer window: this hop can
// take several seconds to settle (issue #24).
const targetId = sessionTargets.get(sessionId)
if (targetId) {
for (let i = 0; i < 6; i++) {
const targets = await chrome.debugger.getTargets().catch(() => null)
const t = targets && targets.find((x) => x.id === targetId && x.tabId != null)
if (t && t.tabId != null) {
const tab = await chrome.tabs.get(t.tabId).catch(() => null)
if (eligible(tab)) {
try {
await attachTab(t.tabId)
if (tabs.has(t.tabId)) {
sessionToTab.set(sessionId, t.tabId) // alias dead session -> live tab
return t.tabId
}
} catch {
// not attachable yet — keep waiting for the swap to settle.
}
}
}
await new Promise((r) => setTimeout(r, 300 + i * 300))
} }
await new Promise((r) => setTimeout(r, 120 + i * 150))
} }
return null return null
} }
@@ -343,6 +385,7 @@ async function attachTab(tabId) {
const entry = { sessionId, targetId } const entry = { sessionId, targetId }
tabs.set(tabId, entry) tabs.set(tabId, entry)
sessionToTab.set(sessionId, tabId) sessionToTab.set(sessionId, tabId)
rememberSessionTarget(sessionId, targetId)
setBadge(tabId, port ? 'on' : 'connecting') setBadge(tabId, port ? 'on' : 'connecting')
postToHost({ postToHost({
method: 'forwardCDPEvent', method: 'forwardCDPEvent',
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "chrome-use", "name": "chrome-use",
"version": "0.4.8", "version": "0.4.9",
"description": "Let chrome-use drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.", "description": "Let chrome-use drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
"icons": { "icons": {