Skip to content

[MCP]: Extension recovery timeout leaks debugger when pending attach completes after close #43005

Closed

Description

@zhangweiooy

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 RelayConnection implementation 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

  1. Attach tab 23.
  2. Emit chrome.debugger.onDetach({ tabId: 23 }, 'target_closed') while the tab still exists.
  3. Advance the 150 ms recovery delay. The extension re-announces the tab.
  4. Deliver the relay's resulting chrome.debugger.attach command, but defer Chrome's attach completion.
  5. Advance the 2500 ms confirmation timeout: the connection closes with no registered attached tabs, so cleanup does not call detach.
  6. 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 5b8c4a0f524d916ca3a2e3e401a9cfb07b4ea42a

Both 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:

  • _tryReattach lines 227–234 closes after the confirmation timeout.
  • _onClose lines 154–167 only detaches tabs already present in _attachedTabs, then becomes idempotent.
  • _handleCommand awaits invokeChromeMethod, then calls _notifyTabAttached for successful attach without checking whether the connection closed during that await.
  • _notifyTabAttached lines 131–136 registers the tab and invokes callbacks even when _closed is 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

  1. simonether commented on Sep 30, 2026

    @simonether
    Contributor

    Hi, I'd like to fix this if you're open to a community PR.

    _handleCommand awaits chrome.debugger.attach and then calls _notifyTabAttached without checking _closed. If _onClose runs 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 _notifyTabAttached when the connection is already closed after the attach resolves, with a test in tests/extension/ that holds chrome.debugger.attach in the service worker until the connection closes. Can I take it?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions