Repository navigation
Description
Version / environment
- Playwright Extension 0.4.0, source at Playwright v1.63.0.
- Also reproduced against current upstream main, commit
5b8c4a0f524d916ca3a2e3e401a9cfb07b4ea42a. - Node.js 24.18.0 on Windows.
- This is a deterministic simulation of the official
RelayConnectionimplementation with mocked Chrome APIs and timers, not a real-browser reproduction. No downstream extension implementation is used by the reproducer.
Summary
If a recovery chrome.debugger.attach request is already awaiting Chrome when the 2500 ms confirmation timeout expires, the extension closes the relay connection. When that attach subsequently succeeds, it leaves a debugger attached to a closed connection with its event listeners already removed.
Reproduction sequence
- Attach tab 23.
- Emit
chrome.debugger.onDetach({ tabId: 23 }, 'target_closed')while the tab still exists. - Advance the 150 ms recovery delay. The extension re-announces the tab.
- Deliver the relay's resulting
chrome.debugger.attachcommand, but defer Chrome's attach completion. - Advance the 2500 ms confirmation timeout: the connection closes with no registered attached tabs, so cleanup does not call detach.
- Resolve the deferred attach successfully.
Both tested revisions produce:
{"closed":true,"liveDebuggers":[23],"trackedTabs":[23],"detachCalls":0}Minimal runnable reproducer
In a Playwright checkout with its TypeScript development dependency available, save the following as repro.cjs in the checkout root, then run:
node repro.cjs v1.63.0
node repro.cjs 5b8c4a0f524d916ca3a2e3e401a9cfb07b4ea42aBoth commands fail the final assertion, as expected for the bug. The script fetches only the official source at the requested revision, transpiles it in memory, and drives its real command/event handlers.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ts = require('node:module').createRequire(path.resolve('package.json'))('typescript');
(async () => {
const ref = process.argv[2] || 'v1.63.0';
const response = await fetch(
'https://raw.githubusercontent.com/microsoft/playwright/' + ref +
'/packages/extension/src/relayConnection.ts'
);
assert.equal(response.status, 200);
const source = await response.text();
const js = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
}).outputText;
const event = () => ({
listeners: new Set(),
addListener(fn) { this.listeners.add(fn); },
removeListener(fn) { this.listeners.delete(fn); },
emit(...args) { for (const fn of [...this.listeners]) fn(...args); }
});
const timers = [];
const liveDebuggers = new Set();
let completeAttach;
let detachCalls = 0;
const chrome = {
debugger: {
onEvent: event(), onDetach: event(),
attach: async ({ tabId }) => { liveDebuggers.add(tabId); },
detach: async ({ tabId }) => { detachCalls++; liveDebuggers.delete(tabId); }
},
tabs: {
onCreated: event(), onRemoved: event(),
get: async id => ({ id, windowId: 1, url: 'https://example.com/' })
}
};
class Socket {
static OPEN = 1;
readyState = 1;
send() {}
close() { this.readyState = 3; }
}
const context = {
exports: {}, chrome, WebSocket: Socket, console,
setTimeout: (callback, delay) => { timers.push({ callback, delay }); },
clearTimeout() {}
};
vm.runInNewContext(js, context);
const connection = new context.exports.RelayConnection(new Socket());
const attach = () => connection._onMessageAsync({
data: JSON.stringify({
id: 1, method: 'chrome.debugger.attach', params: [{ tabId: 23 }, '1.3']
})
});
const runTimer = async delay => {
const index = timers.findIndex(timer => timer.delay === delay);
assert.ok(index >= 0, 'Expected timer: ' + delay);
await timers.splice(index, 1)[0].callback();
await Promise.resolve();
await Promise.resolve();
};
await attach();
liveDebuggers.delete(23);
chrome.debugger.onDetach.emit({ tabId: 23 }, 'target_closed');
await runTimer(150);
// The relay processes the extension's re-announced tab and requests attach.
// Hold Chrome's completion until after the extension's recovery timeout.
chrome.debugger.attach = ({ tabId }) => new Promise(resolve => {
completeAttach = () => { liveDebuggers.add(tabId); resolve(); };
});
const pendingAttach = attach();
await runTimer(2500);
assert.equal(connection._closed, true);
completeAttach();
await pendingAttach;
console.log(JSON.stringify({
ref, closed: connection._closed,
liveDebuggers: [...liveDebuggers],
trackedTabs: [...connection.attachedTabs], detachCalls
}));
assert.equal(liveDebuggers.size, 0, 'Debugger must be released after a late attach');
})().catch(error => { console.error(error); process.exitCode = 1; });Expected behavior
After the connection closes, an attach which finishes late should release the debugger it acquired and should not re-register the tab or call the closed connection's attachment callbacks. Closing/disconnecting must also prevent new commands from acquiring resources for that connection.
Actual behavior and source locations
In relayConnection.ts at v1.63.0:
_tryReattachlines 227–234 closes after the confirmation timeout._onCloselines 154–167 only detaches tabs already present in_attachedTabs, then becomes idempotent._handleCommandawaitsinvokeChromeMethod, then calls_notifyTabAttachedfor successful attach without checking whether the connection closed during that await._notifyTabAttachedlines 131–136 registers the tab and invokes callbacks even when_closedis already true.
The close checks surrounding the earlier chrome.tabs.get await do not cover the later asynchronous debugger acquisition. This also appears applicable to disconnecting while any attach command is pending.
Impact
A stale debugger attachment can retain the browser debugging indicator and interfere with a subsequent client's attempt to attach to the same tab. Those user-facing consequences are inferred from the leaked resource; the directly reproduced result is the mocked Chrome debugger remaining attached after official connection teardown.
A regression test should defer chrome.debugger.attach across timeout/disconnect and verify cleanup after its eventual successful completion, including when another client is active.
Activity
simonether commentedon Sep 30, 2026
Hi, I'd like to fix this if you're open to a community PR.
_handleCommandawaitschrome.debugger.attachand then calls_notifyTabAttachedwithout checking_closed. If_onCloseruns while the attach is pending, it has already detached everything in_attachedTabs, so the tab is added afterwards and never detached. This isn't limited to recovery: any attach that completes after the relay closes ends up the same way.My plan is to detach the tab right away and skip
_notifyTabAttachedwhen the connection is already closed after the attach resolves, with a test intests/extension/that holdschrome.debugger.attachin the service worker until the connection closes. Can I take it?