Skip to content

Release closed browser contexts - #181

Open
Jean-Beru wants to merge 1 commit into
playwright-php:mainfrom
Jean-Beru:fix/release-closed-contexts
Open

Jean-Beru wants to merge 1 commit into
playwright-php:mainfrom
Jean-Beru:fix/release-closed-contexts

Conversation

@Jean-Beru

@Jean-Beru Jean-Beru commented Oct 1, 2026 •

Copy link
Copy Markdown

A browser that stays open and opens many contexts never frees any of them. Two references outlive BrowserContext::close():

  • Browser::newContext() appends every context to Browser::$contexts, and nothing ever removes it.
  • BrowserContext and Page register themselves in JsonRpcTransport::$eventDispatchers from their constructors, and there is no way to unregister them.

A closed context stays reachable, so its pages stay too, along with their route handlers and everything those closures capture. In a Behat suite that launches Chromium once and opens one context per scenario, peak memory grew by about 2 MB per scenario: 189 MB for 80 browser scenarios, 24 MB once the references are dropped. With playwright-symfony, the captured PlaywrightKernelClient keeps each test's whole Symfony kernel and container alive. The same shape against a real Chromium (30 contexts, each with a route handler capturing 2 MB) leaves 30 contexts alive and peaks at 63 MB before this change, and 0 alive at 5 MB after it.

What changes:

  • JsonRpcTransport::removeEventDispatcher(string $id). Like addEventDispatcher(), it is not added to TransportInterface, so there is no BC break. Callers guard it with method_exists().
  • BrowserContext::close() unregisters the context and its pages from the transport, clears its pages and route handlers, and removes itself from the Browser that created it through Browser::forgetContext(), which is marked @internal. Browser::contexts() still returns a list. The cleanup runs in a finally, so a failed close command doesn't leave the context retained. The server already cleans up the same way (closeContext() in bin/lib/handlers.js). saveAutoTrace() stays outside the try: if it fails, the context was never closed.
  • Page::close() unregisters the page and removes it from its context, so pages() no longer lists it. This only happens when the close command succeeds, as in the server's closePage(). If it fails, the page is released when its context closes. The pageClosed / page-closed context events also unregister the page.
  • Popups returned by Page::waitForPopup() are now added to their context, as BrowserContext::waitForPopup() already did. Before this, they were registered in the transport but not in the context, so closing the context never released them. Both hooks, BrowserContext::registerPage() and BrowserContext::forgetPage(), are marked @internal.
  • Dispatchers are removed only after the close command has been sent. An event that arrives later for an unregistered objectId takes the existing path: it is logged at debug level and dropped.

The pruning relies on the concrete classes. Like removeEventDispatcher(), the new hooks are not added to the interfaces, so a custom BrowserInterface or BrowserContextInterface implementation is not pruned.

Visible changes: code that holds on to a closed context now sees an empty pages() list. Closing the default context removes it from Browser::contexts(), but Browser::context() still returns it.

$browser = Playwright::chromium()->browser();

foreach ($scenarios as $scenario) {
    $context = $browser->newContext();
    $context->newPage()->route('**/*', fn (Route $route) => $client->handle($route));
    // ...
    $context->close(); // the context, its pages and $client can now be collected
}

Out of scope, worth separate issues:

  • ProcessJsonRpcClient::$responses also keeps the acknowledgements of async requests that nobody waits for ({"requestId":"req_async_...","success":true}). That is about 80 bytes each, and about 2,500 of them pile up over a suite.
  • Page::unroute() only sends the command. It never removes the PHP-side handler registered by route().
  • JsonRpcTransport::$pendingCallbacks only drops the action closure passed to waitForPopup() once callback.continue succeeds. When the action or the second request throws, the closure stays registered, along with the page it usually captures. Preserve native popup wait errors #177 fixes it.
  • Page::opener() builds a new Page for an id that already has one, which replaces the original page's event dispatcher in the transport.

A long-lived browser kept every context it ever created: Browser::$contexts
never dropped closed contexts, and JsonRpcTransport kept the event
dispatcher of every context and page for its whole lifetime. Each closed
context stayed reachable with its pages, their route handlers and whatever
those closures captured.

BrowserContext::close() now unregisters itself and its pages from the
transport, clears its pages and route handlers, and asks its Browser to
forget it. The cleanup also runs when the close command fails, as the
server does. Page::close() and the pageClosed event unregister the page,
and Page::close() removes it from its context. Popups returned by
Page::waitForPopup() are now tracked by their context, so they are
released with it.

Dispatchers are removed after the close command has been sent; late events
for an unregistered objectId are only logged at debug level.

JsonRpcTransport::removeEventDispatcher() is not added to
TransportInterface, like addEventDispatcher(), and callers guard it with
method_exists().

Assisted-by: Claude:claude-opus-5-5 [gh]
@codecov

codecov Bot commented Oct 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant