Skip to content

fix(mcp): drop the backend after browser_close with a shared browser - #42365

Open
om singhal (Om-singhaI) wants to merge 5 commits into
microsoft:mainfrom
Om-singhaI:fix/mcp-shared-context-close-invalidates-backend
Open

fix(mcp): drop the backend after browser_close with a shared browser#42365
om singhal (Om-singhaI) wants to merge 5 commits into
microsoft:mainfrom
Om-singhaI:fix/mcp-shared-context-close-invalidates-backend

Conversation

@Om-singhaI

Copy link
Copy Markdown

Fixes #42363

What happens

With the HTTP transport and --shared-browser-context, browser_close disposes the calling client's BrowserBackend: the Context is cleared and the dispose callback only decrements the client count, because another client keeps the shared browser alive. server.ts caches backendPromise per session and clears it only on the backend's disconnected event, which BrowserBackend emits only when the browser context closes or the browser disconnects. Neither happens here, so the next tool call from that client reuses the disposed backend: ensureBrowserContext returns the already resolved promise without re registering the page listener, newTab finds no tab, and browser_tabs (new) or browser_navigate fail with

TypeError: Cannot read properties of undefined (reading 'checkUrlAndNavigate')
TypeError: Cannot read properties of undefined (reading 'waitForInitialized')

until the server restarts. In the CLI server, non shared and isolated modes are unaffected because they close the context or the browser and the event fires.

The change

BrowserBackend.dispose() now emits disconnected (guarded so it fires once) after the context and the dispose callback have run. server.ts already drops the cached backend in its existing once('disconnected') listener, so the next call creates a fresh backend through the factory. The listener also calls backend.dispose() again, which is a no op through the existing _disposed guard.

This was chosen over special casing the close result in server.ts: callTool already strips isClose from the result before returning it, so the server cannot see a close without widening the ServerBackend contract, and the event approach makes every disposal path invalidate the cache, not only browser_close. No debug line is logged from dispose, so the tests that count browser disconnected lines are unaffected.

The library entry point (createConnection in mcp/index.ts) goes through the same createServer, so it picks up the same behaviour: after browser_close the next call now drops the disposed backend and asks the factory for a new one (a fresh BrowserBackend on the context the getter returns when a contextGetter is supplied), instead of failing with the TypeError. Without a getter the factory launches a second browser while the first stays open, because createConnection passes no dispose callback and browser_close never closed the browser in library mode before or after this change; that is pre existing. The other BrowserBackend consumers (cli-daemon, traceSnapshot, the test backend under playwright/src/mcp) register no disconnected listener, and an emit with no listener is harmless.

Test

New http transport shared context survives browser_close in tests/mcp/http.spec.ts, modelled on the neighbouring shared context test: two HTTP clients with --shared-browser-context, both navigate, client 1 calls browser_close then browser_tabs (new) and must get a snapshot, client 2's browser_snapshot must still work, and the log counts must match (create context 3, close browser 1).

# chromium project, tests/mcp/http.spec.ts
with the change:                     20 passed, 1 failed
source reverted, test kept:          the new test fails with TypeError ... 'checkUrlAndNavigate' at the browser_tabs assertion
# chromium project, tests/mcp/launch.spec.ts and cdp.spec.ts (the tests that count browser disconnected lines)
with the change:                     19 passed

The one failure in http.spec.ts is should close session when heartbeat ping is not answered, and it fails identically with the source reverted, so it is unrelated to this change: on the machine used here the default chrome channel takes 7 to 8 seconds to launch a fresh persistent profile, and the heartbeat only starts once the backend exists, so the session delete lands after expect.poll's default 5 second timeout. With --browser=chromium the same scenario reaps the session in under 3 seconds.

The issue's three step scenario was also run end to end against a built mcp.js with two streamable HTTP clients before and after the change; before, step 3 returns the TypeError above and the second client keeps working; after, all steps succeed.

eslint, tsc, lint-tests and check-deps pass. Only the chromium project was run locally; chrome, firefox and webkit were not.

With the HTTP transport and the shared browser context option, browser_close
disposes the backend of the calling client while the browser stays alive for
the other clients. The server only drops its cached backend on the
disconnected event, which never fires in that case, so the next tool call of
that client runs against a disposed context and fails with a TypeError.

Emit the disconnected event from BrowserBackend.dispose so that any disposal
invalidates the cached backend and the next call creates a fresh one.

Fixes microsoft#42363
this._disposed = true;
await this._context?.dispose().catch(e => debug('pw:tools:error')(e));
await this._disposeCallback?.().catch(e => debug('pw:tools:error')(e));
// The browser may outlive this backend, e.g. when other clients share it,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

disposing a backend while the shared browser survives leaves the constructor's close and disconnected listeners attached

repeating browser_close therefore retains every disposed backend until the shared browser exits, so please detach those listeners during disposal

// so tell the server to stop handing out the disposed backend.
if (!this._disconnected) {
this._disconnected = true;
this.emit('disconnected');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createConnection() without a contextGetter owns the browser it launches but supplies no dispose callback, so the next tool call launches another browser while the previous process remains open

please close factory owned browsers before invalidating and recreating this backend

Review feedback on the previous commit.

BrowserBackend attaches markDisconnected to the browser context close event and
to the browser disconnected event, and dispose removed neither. When the browser
outlives the backend, as it does with a shared browser or with a caller supplied
context, repeating browser_close leaves one listener per disposed backend on
objects that stay alive, so every disposed backend stays reachable. Keep the
handler on the instance and detach both listeners in dispose.

createConnection without a contextGetter launches its own browser and passed no
dispose callback, so browser_close invalidated the backend and left the browser
running. The next tool call then launched another one, and against a persistent
profile it failed outright with "Browser is already in use". Give that path a
dispose callback that closes the context and the browser, in the shape program.ts
already uses. With a contextGetter the caller owns the context, so that path
still gets no callback.
@Om-singhaI

Copy link
Copy Markdown
Author

Both fixed in the commit on top.

The handler is kept on the instance now and removed in dispose, so a disposed backend no longer sits on the context close list or the browser disconnected list. New test repeats browser_close three times against a caller supplied context and asserts both counts come back to baseline; without the change they grow by one per cycle.

createConnection now passes a dispose callback where it launched the browser itself. One thing I did beyond what you asked: createBrowserWithInfo already returns an ownership flag, so the callback only closes when that is own. A CDP or remote endpoint, or the extension, stays open, since the next call re attaches to it rather than launching a second process. Closing those looked like collateral you had not asked for, but say the word if you would rather it closed unconditionally.

The second test asserts the profile lock clears after browser_close and that the next call gets a working browser. Before, it failed with "Browser is already in use for ...".

Both tests are in tests/mcp/library.spec.ts. traceSnapshot.ts and daemon.ts do not have either problem.

// whoever we connected to, and the next call re attaches to it.
return new BrowserBackend(config, context, tools, async () => {
await context.close().catch(() => {});
if (ownership === 'own')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ownership === 'attached' means the browser is externally owned, but this factory still owns its Playwright connection

with an isolated CDP or remote endpoint, context.close() leaves that connection alive, so please call browser.close() for both ownership modes

@github-actions

This comment has been minimized.

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "MCP"

2 failed
❌ [chromium] › mcp/library.spec.ts:69 › createConnection closes the browser it launched when the backend is disposed @mcp-ubuntu-latest-chromium
❌ [firefox] › mcp/cli-core.spec.ts:140 › uncheck @mcp-windows-latest-firefox

7555 passed, 1359 skipped


Merge workflow run.

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.

[MCP]: browser_close caches a disposed backend with shared HTTP clients

2 participants