Conversation
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 Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A browser that stays open and opens many contexts never frees any of them. Two references outlive
BrowserContext::close():Browser::newContext()appends every context toBrowser::$contexts, and nothing ever removes it.BrowserContextandPageregister themselves inJsonRpcTransport::$eventDispatchersfrom 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
PlaywrightKernelClientkeeps 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). LikeaddEventDispatcher(), it is not added toTransportInterface, so there is no BC break. Callers guard it withmethod_exists().BrowserContext::close()unregisters the context and its pages from the transport, clears its pages and route handlers, and removes itself from theBrowserthat created it throughBrowser::forgetContext(), which is marked@internal.Browser::contexts()still returns a list. The cleanup runs in afinally, so a failed close command doesn't leave the context retained. The server already cleans up the same way (closeContext()inbin/lib/handlers.js).saveAutoTrace()stays outside thetry: if it fails, the context was never closed.Page::close()unregisters the page and removes it from its context, sopages()no longer lists it. This only happens when the close command succeeds, as in the server'sclosePage(). If it fails, the page is released when its context closes. ThepageClosed/page-closedcontext events also unregister the page.Page::waitForPopup()are now added to their context, asBrowserContext::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()andBrowserContext::forgetPage(), are marked@internal.objectIdtakes 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 customBrowserInterfaceorBrowserContextInterfaceimplementation 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 fromBrowser::contexts(), butBrowser::context()still returns it.Out of scope, worth separate issues:
ProcessJsonRpcClient::$responsesalso 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 byroute().JsonRpcTransport::$pendingCallbacksonly drops the action closure passed towaitForPopup()oncecallback.continuesucceeds. 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 newPagefor an id that already has one, which replaces the original page's event dispatcher in the transport.