Skip to content

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

Merged
Devin Rousso (dcrousso) merged 8 commits into
microsoft:mainfrom
Om-singhaI:fix/mcp-shared-context-close-invalidates-backend
Aug 28, 2026
Merged

fix(mcp): drop the backend after browser_close with a shared browser#42365
Devin Rousso (dcrousso) merged 8 commits into
microsoft:mainfrom
Om-singhaI:fix/mcp-shared-context-close-invalidates-backend

Conversation

@Om-singhaI

Copy link
Copy Markdown
Contributor

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.
Fixesmicrosoft#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
ContributorAuthor

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

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done, closing unconditionally now.

You are right that the gate guarded the wrong thing. browser.close() on a connected browser only clears the contexts this connection created and drops the connection itself; the external browser keeps running. That is what the Browser.close docs say and what _shouldCloseConnectionOnClose does in client/connect.ts. So the attached case was leaking one Playwright connection per disposed backend, and the next call stacked a fresh one on top. The dispose callback now closes the browser in both ownership modes, same shape as program.ts.

New test in tests/mcp/library.spec.ts attaches to a launchServer browser through a small counting TCP proxy, so the connection the factory holds is visible from outside. After browser_close the open count has to drop to zero while the server stays up and still accepts a fresh connect. With the old gate it stays at one and the test times out waiting for zero.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

Review feedback on the previous commit. The dispose callback only closed the
browser when this factory had launched it, on the theory that an attached
browser belongs to whoever we connected to. That guarded the wrong thing. For
a browser reached over a CDP or remote endpoint, close() clears the contexts
this connection created and drops the connection itself, and the external
browser keeps running. Skipping the close therefore leaked one connection per
disposed backend. Close in both ownership modes, matching what program.ts
already does.
The new test attaches to a browser server through a counting TCP proxy, so
the connection the factory holds is observable from the outside. Disposing
the backend has to drop the count to zero while the server stays up and
accepts a fresh connection.
@github-actions

This comment has been minimized.

@Om-singhaI

Copy link
Copy Markdown
ContributorAuthor

The two red checks are not the fix. The ubuntu one is my test: it builds a config with browserName: 'chromium' and no channel, and validateBrowserConfig turns the sandbox on for that case, so the bundled build cannot launch on the runner. BrowserType.launch defaults it off, which is why the neighbouring tests pass. da7c657 passes chromiumSandbox: false so the test launches the same way. The macos one is annotate.spec.ts starting its dashboard, which is unrelated to this change.

Worth a separate look: on linux that line reads channel !== 'chromium' && channel !== 'chrome-for-testing', so an undefined channel, meaning the bundled browser, gets the sandbox enabled while both named channels for the same binary get it disabled. Happy to open something for it if you agree it is a gap.

@dcrousso

Copy link
Copy Markdown
Contributor

yes please open another issue for that :)

@Om-singhaI

Copy link
Copy Markdown
ContributorAuthor

Opened #42452 for it.

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "MCP"

8305 passed, 1367 skipped


Merge workflow run.

@dcrousso
Devin Rousso (dcrousso) merged commit e65229f into microsoft:mainAug 28, 2026
17 of 18 checks passed
@Om-singhaI

Copy link
Copy Markdown
ContributorAuthor

Thanks for the reviews on this one 😊

Was the sandbox issue useful? Happy to put up the change if #42452 looks right to you.

Sign up for freeto 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

@Om-singhaI@dcrousso