feat: first extension with active tabs keeps connection instead of being replaced

- Server rejects new extension (code 4002) if existing has active tabs
- Idle extensions (no tabs) can still be replaced
- Extension properly enters polling mode when rejected during handshake
- /extension/status now returns activeTargets count for smarter reconnection
This commit is contained in:
Tommy D. Rossi
2026-01-21 19:09:37 +01:00
parent c3a6003fa9
commit 93fdcb6b35
7 changed files with 87 additions and 17 deletions
+11
View File
@@ -1,5 +1,16 @@
# Changelog
## 0.0.69
### Features
- **First extension keeps connection**: When multiple Playwriter extensions are installed, the actively-used one (with tabs) now keeps the connection. New extensions are rejected with code 4002 instead of taking over.
- **Smarter reconnection**: Extension now polls `/extension/status` for `activeTargets` count and only attempts reconnection when the other extension has no active tabs.
### Bug Fixes
- **Proper state handling for 4002 rejection**: Fixed issue where extension would keep retrying forever when rejected during WebSocket handshake. Now correctly enters `extension-replaced` polling state.
## 0.0.68
### Bug Fixes
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Playwriter MCP",
"version": "0.0.67",
"version": "0.0.69",
"description": "Automate your Browser using Cursor, Claude, VS Code. More capable and context efficient than Playwright MCP.",
"permissions": ["debugger", "tabGroups", "contextMenus", "tabs"],
"host_permissions": ["<all_urls>"],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mcp-extension",
"version": "0.0.67",
"version": "0.0.69",
"description": "Playwright MCP Browser Extension",
"private": true,
"repository": {
+52 -11
View File
@@ -110,7 +110,12 @@ class ConnectionManager {
if (settled) return
settled = true
clearTimeout(timeout)
reject(new Error(`WebSocket closed: ${event.reason || event.code}`))
// Normalize 4002 rejection to consistent error message for callers to detect
if (event.code === 4002 || event.reason === 'Extension Already In Use') {
reject(new Error('Extension Already In Use'))
} else {
reject(new Error(`WebSocket closed: ${event.reason || event.code}`))
}
}
})
@@ -221,9 +226,10 @@ class ConnectionManager {
this.ws = null
// Only one extension can connect to the relay server at a time.
// This happens when another Playwriter extension (dev/prod, or another Chrome profile) connects.
// Code 4001: Another extension replaced this one (this extension was idle)
// Code 4002: This extension tried to connect but another is actively in use
if (reason === 'Extension Replaced' || code === 4001) {
logger.debug('Disconnected: another Playwriter extension connected to the relay server')
logger.debug('Disconnected: another Playwriter extension connected (this one was idle)')
store.setState({
tabs: new Map(),
connectionState: 'extension-replaced',
@@ -232,6 +238,16 @@ class ConnectionManager {
return
}
if (reason === 'Extension Already In Use' || code === 4002) {
logger.debug('Rejected: another Playwriter extension is actively in use')
store.setState({
tabs: new Map(),
connectionState: 'extension-replaced',
errorText: 'Another Playwriter extension is actively in use',
})
return
}
// For normal disconnects, set tabs to 'connecting' state and let maintain loop handle reconnect
store.setState((state) => {
const newTabs = new Map(state.tabs)
@@ -249,16 +265,18 @@ class ConnectionManager {
continue
}
// When another Playwriter extension took over, poll until slot is free
// When another Playwriter extension took over, poll until slot is free.
// Slot is free when: no extension connected, OR connected but no active tabs.
if (store.getState().connectionState === 'extension-replaced') {
try {
const response = await fetch(`http://127.0.0.1:${RELAY_PORT}/extension/status`, { method: 'GET', signal: AbortSignal.timeout(2000) })
const data = await response.json()
if (!data.connected) {
const data = await response.json() as { connected: boolean; activeTargets: number }
const slotAvailable = !data.connected || data.activeTargets === 0
if (slotAvailable) {
store.setState({ connectionState: 'idle', errorText: undefined })
logger.debug('Extension slot is free, cleared error state')
logger.debug('Extension slot is free (connected:', data.connected, 'activeTargets:', data.activeTargets, '), cleared error state')
} else {
logger.debug('Extension slot still taken, will retry...')
logger.debug('Extension slot still taken (activeTargets:', data.activeTargets, '), will retry...')
}
} catch {
logger.debug('Server not available, will retry...')
@@ -317,7 +335,15 @@ class ConnectionManager {
}
} catch (error: any) {
logger.debug('Connection attempt failed:', error.message)
store.setState({ connectionState: 'idle' })
// Check if rejected because another extension is actively in use
if (error.message === 'Extension Already In Use') {
store.setState({
connectionState: 'extension-replaced',
errorText: 'Another Playwriter extension is actively in use',
})
} else {
store.setState({ connectionState: 'idle' })
}
}
await sleep(3000)
@@ -828,13 +854,28 @@ async function connectTab(tabId: number): Promise<void> {
// Distinguish between WS connection errors and tab-specific errors
// WS errors: keep in 'connecting' state, maintainLoop will retry when WS is available
// Tab errors: show 'error' state (e.g., restricted page, debugger attach failed)
// Extension in use: set global 'extension-replaced' state to enter polling mode
const isExtensionInUse =
error.message === 'Extension Already In Use' ||
error.message === 'Another Playwriter extension is already connected'
const isWsError =
error.message === 'Server not available' ||
error.message === 'Connection timeout' ||
error.message === 'Another Playwriter extension is already connected' ||
error.message.startsWith('WebSocket')
if (isWsError) {
if (isExtensionInUse) {
logger.debug(`Another extension is in use, entering polling mode`)
store.setState((state) => {
const newTabs = new Map(state.tabs)
newTabs.delete(tabId)
return {
tabs: newTabs,
connectionState: 'extension-replaced',
errorText: 'Another Playwriter extension is actively in use',
}
})
} else if (isWsError) {
logger.debug(`WS connection failed, keeping tab ${tabId} in connecting state for retry`)
// Tab stays in 'connecting' state - maintainLoop will retry when WS becomes available
} else {