Compare commits

...

1 Commits

Author SHA1 Message Date
jackwener 32eb8eb8ee fix(extension): preserve network capture across ensureAttached re-attach
A forced detach inside ensureAttached's re-attach loop fires
chrome.debugger.onDetach, whose handler deletes the tab's armed
networkCaptures state; the detach also disables the CDP Network domain,
and re-attach only re-issued Runtime.enable. So any non-navigate command
that triggered a re-attach (a stale-attach health-check failure during SPA
navigation, or third-party debugger interference) left
network-capture-read returning [] even though requests fired — the
recorded "0 captures" symptom.

Snapshot the capture before the re-attach and, on success, re-enable the
Network domain and restore the accumulated state (restored last so it
wins over the onDetach handler's delete). Adds a regression test that
fails without the restore.
2026-07-01 01:29:27 +08:00
3 changed files with 104 additions and 0 deletions
+8
View File
@@ -41,6 +41,7 @@ async function ensureAttached(tabId, aggressiveRetry = false) {
const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2;
const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500;
let lastError = "";
const preservedNetworkCapture = networkCaptures.get(tabId);
for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) {
try {
try {
@@ -85,6 +86,13 @@ async function ensureAttached(tabId, aggressiveRetry = false) {
await chrome.debugger.sendCommand({ tabId }, "Runtime.enable");
} catch {
}
if (preservedNetworkCapture) {
try {
await chrome.debugger.sendCommand({ tabId }, "Network.enable");
networkCaptures.set(tabId, preservedNetworkCapture);
} catch {
}
}
}
async function evaluate(tabId, expression, aggressiveRetry = false) {
const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2;
+72
View File
@@ -393,3 +393,75 @@ describe('cdp download waits', () => {
});
});
});
describe('cdp network capture survives forced re-attach', () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
});
function createReattachMock() {
const onDetachListeners: Array<(source: { tabId?: number }) => void> = [];
let failNextHealthCheck = false;
let networkEnableCount = 0;
const debuggerApi = {
attach: vi.fn(async () => {}),
detach: vi.fn(async ({ tabId }: { tabId?: number }) => {
// Chrome fires onDetach whenever the debugger detaches from a tab.
for (const fn of onDetachListeners) fn({ tabId });
}),
sendCommand: vi.fn(async (_target: unknown, method: string, params?: any) => {
if (method === 'Runtime.evaluate' && params?.expression === '1') {
if (failNextHealthCheck) {
failNextHealthCheck = false;
throw new Error('Inspected target navigated or closed');
}
return { result: { value: '1' } };
}
if (method === 'Network.enable') {
networkEnableCount += 1;
return {};
}
return {};
}),
onDetach: { addListener: vi.fn((fn: (s: { tabId?: number }) => void) => { onDetachListeners.push(fn); }) },
onEvent: { addListener: vi.fn() },
};
const tabs = {
get: vi.fn(async () => ({ id: 1, windowId: 1, url: 'https://x.com/home' })),
onRemoved: { addListener: vi.fn() },
onUpdated: { addListener: vi.fn() },
};
return {
chrome: { tabs, debugger: debuggerApi, scripting: {}, runtime: { id: 'opencli-test' } },
debuggerApi,
failNextHealthCheck: () => { failNextHealthCheck = true; },
networkEnableCount: () => networkEnableCount,
};
}
it('preserves armed network capture and re-enables Network across a forced re-attach', async () => {
const mock = createReattachMock();
vi.stubGlobal('chrome', mock.chrome);
const mod = await import('./cdp');
// Wire the onDetach handler that wipes networkCaptures on detach.
mod.registerListeners();
await mod.startNetworkCapture(1);
expect(mod.hasActiveNetworkCapture(1)).toBe(true);
const enablesAfterStart = mock.networkEnableCount();
// The next ensureAttached health-check throws, forcing a detach + re-attach.
// The detach fires onDetach (which deletes the capture) and disables the
// Network domain — the capture must be restored, not silently dropped.
mock.failNextHealthCheck();
await mod.ensureAttached(1);
expect(mod.hasActiveNetworkCapture(1)).toBe(true);
expect(mock.networkEnableCount()).toBeGreaterThan(enablesAfterStart);
});
});
+24
View File
@@ -101,6 +101,15 @@ export async function ensureAttached(tabId: number, aggressiveRetry: boolean = f
const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500;
let lastError = '';
// The forced detach below fires chrome.debugger.onDetach, whose handler wipes
// this tab's armed network-capture state; detaching also disables the CDP
// Network domain. Snapshot the capture so we can restore it after a successful
// re-attach instead of silently dropping in-flight capture — otherwise any
// non-navigate command that triggers a re-attach (a stale-attach health-check
// failure during SPA navigation or third-party debugger interference) leaves
// network-capture-read returning [] even though requests fired.
const preservedNetworkCapture = networkCaptures.get(tabId);
for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) {
try {
// Force detach first to clear any stale state from other extensions
@@ -153,6 +162,21 @@ export async function ensureAttached(tabId: number, aggressiveRetry: boolean = f
} catch {
// Some pages may not need explicit enable
}
// Restore network capture that the re-attach (detach + onDetach) tore down.
// The detach always disables the CDP Network domain, so re-enable it and put
// the accumulated capture state back unconditionally. Done last (after the
// awaits above) so it wins over the onDetach handler's delete, which fires
// while those awaits yield to the event loop.
if (preservedNetworkCapture) {
try {
await chrome.debugger.sendCommand({ tabId }, 'Network.enable');
networkCaptures.set(tabId, preservedNetworkCapture);
} catch {
// Leave capture cleared rather than arm a half-attached Network domain;
// the next start-capture re-arms cleanly.
}
}
}
export async function evaluate(tabId: number, expression: string, aggressiveRetry: boolean = false): Promise<unknown> {