Skip to content

Commit 8059af9

Browse files
znetstarclaude
andcommitted
fix: improve connection reliability with retry logic and stale target handling
Add resilience to browser connection and page enumeration: - Retry puppeteer.connect with exponential backoff on transient failures - Extract readDevToolsActivePort helper with I/O retry (fail fast on validation errors) - Clear stale browser reference before reconnecting - Dispose previous McpContext before creating a new one - Wrap browser.pages() in try/catch with a single retry for stale targets - Guard detectOpenDevToolsWindows() so failures don't break tool calls - Catch per-page hasDevTools()/openDevTools() errors individually Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e6b7a09 commit 8059af9

3 files changed

Lines changed: 109 additions & 41 deletions

File tree

src/McpContext.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -582,9 +582,18 @@ export class McpContext implements Context {
582582
isolatedContextNames: Map<Page, string>;
583583
}> {
584584
const defaultCtx = this.browser.defaultBrowserContext();
585-
const allPages = await this.browser.pages(
586-
this.#options.experimentalIncludeAllPages,
587-
);
585+
let allPages: Page[];
586+
try {
587+
allPages = await this.browser.pages(
588+
this.#options.experimentalIncludeAllPages,
589+
);
590+
} catch (error) {
591+
this.logger('browser.pages() failed, retrying:', error);
592+
await new Promise(resolve => setTimeout(resolve, 100));
593+
allPages = await this.browser.pages(
594+
this.#options.experimentalIncludeAllPages,
595+
);
596+
}
588597

589598
const allTargets = this.browser.targets();
590599
const extensionTargets = allTargets.filter(target => {
@@ -657,9 +666,15 @@ export class McpContext implements Context {
657666
return;
658667
}
659668

660-
if (await page.hasDevTools()) {
661-
mcpPage.devToolsPage = await page.openDevTools();
662-
} else {
669+
try {
670+
if (await page.hasDevTools()) {
671+
mcpPage.devToolsPage = await page.openDevTools();
672+
} else {
673+
mcpPage.devToolsPage = undefined;
674+
}
675+
} catch (error) {
676+
// Page may have closed between getAllPages() and this check.
677+
this.logger('Error detecting DevTools for page, skipping:', error);
663678
mcpPage.devToolsPage = undefined;
664679
}
665680
}),

src/browser.ts

Lines changed: 82 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ export async function ensureBrowserConnected(options: {
5757
return browser;
5858
}
5959

60+
// Clear stale browser reference to force fresh reconnection.
61+
if (browser) {
62+
logger('Browser disconnected, clearing stale reference');
63+
browser = undefined;
64+
}
65+
6066
const connectOptions: Parameters<typeof puppeteer.connect>[0] = {
6167
targetFilter: makeTargetFilter(enableExtensions),
6268
defaultViewport: null,
@@ -75,35 +81,7 @@ export async function ensureBrowserConnected(options: {
7581
const userDataDir = options.userDataDir;
7682
if (userDataDir) {
7783
autoConnect = true;
78-
// TODO: re-expose this logic via Puppeteer.
79-
const portPath = path.join(userDataDir, 'DevToolsActivePort');
80-
try {
81-
const fileContent = await fs.promises.readFile(portPath, 'utf8');
82-
const [rawPort, rawPath] = fileContent
83-
.split('\n')
84-
.map(line => {
85-
return line.trim();
86-
})
87-
.filter(line => {
88-
return !!line;
89-
});
90-
if (!rawPort || !rawPath) {
91-
throw new Error(`Invalid DevToolsActivePort '${fileContent}' found`);
92-
}
93-
const port = parseInt(rawPort, 10);
94-
if (isNaN(port) || port <= 0 || port > 65535) {
95-
throw new Error(`Invalid port '${rawPort}' found`);
96-
}
97-
const browserWSEndpoint = `ws://127.0.0.1:${port}${rawPath}`;
98-
connectOptions.browserWSEndpoint = browserWSEndpoint;
99-
} catch (error) {
100-
throw new Error(
101-
`Could not connect to Chrome in ${userDataDir}. Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.`,
102-
{
103-
cause: error,
104-
},
105-
);
106-
}
84+
await readDevToolsActivePort(userDataDir, connectOptions);
10785
} else {
10886
if (!channel) {
10987
throw new Error('Channel must be provided if userDataDir is missing');
@@ -119,18 +97,88 @@ export async function ensureBrowserConnected(options: {
11997
}
12098

12199
logger('Connecting Puppeteer to ', JSON.stringify(connectOptions));
122-
try {
123-
browser = await puppeteer.connect(connectOptions);
124-
} catch (err) {
100+
const retryDelays = [500, 1000, 2000];
101+
let lastConnectError: unknown;
102+
for (let attempt = 0; attempt <= retryDelays.length; attempt++) {
103+
try {
104+
browser = await puppeteer.connect(connectOptions);
105+
lastConnectError = undefined;
106+
break;
107+
} catch (err) {
108+
lastConnectError = err;
109+
if (attempt < retryDelays.length) {
110+
logger(
111+
`Connection attempt ${attempt + 1} failed, retrying in ${retryDelays[attempt]}ms...`,
112+
);
113+
await new Promise(resolve =>
114+
setTimeout(resolve, retryDelays[attempt]),
115+
);
116+
// Re-read DevToolsActivePort in case Chrome restarted with a new port.
117+
if (autoConnect && options.userDataDir) {
118+
try {
119+
await readDevToolsActivePort(options.userDataDir, connectOptions);
120+
} catch {
121+
// Will retry connection with existing endpoint.
122+
}
123+
}
124+
}
125+
}
126+
}
127+
if (lastConnectError) {
125128
throw new Error(
126129
`Could not connect to Chrome. ${autoConnect ? `Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.` : `Check if Chrome is running.`}`,
127130
{
128-
cause: err,
131+
cause: lastConnectError,
129132
},
130133
);
131134
}
132135
logger('Connected Puppeteer');
133-
return browser;
136+
// browser is guaranteed to be set here: either puppeteer.connect succeeded
137+
// or lastConnectError was thrown above.
138+
return browser!;
139+
}
140+
141+
async function readDevToolsActivePort(
142+
userDataDir: string,
143+
connectOptions: Parameters<typeof puppeteer.connect>[0],
144+
): Promise<void> {
145+
// TODO: re-expose this logic via Puppeteer.
146+
const portPath = path.join(userDataDir, 'DevToolsActivePort');
147+
let lastError: unknown;
148+
for (let attempt = 0; attempt < 5; attempt++) {
149+
try {
150+
const fileContent = await fs.promises.readFile(portPath, 'utf8');
151+
const [rawPort, rawPath] = fileContent
152+
.split('\n')
153+
.map(line => line.trim())
154+
.filter(line => !!line);
155+
if (!rawPort || !rawPath) {
156+
throw new Error(`Invalid DevToolsActivePort '${fileContent}' found`);
157+
}
158+
const port = parseInt(rawPort, 10);
159+
if (isNaN(port) || port <= 0 || port > 65535) {
160+
throw new Error(`Invalid port '${rawPort}' found`);
161+
}
162+
connectOptions.browserWSEndpoint = `ws://127.0.0.1:${port}${rawPath}`;
163+
return;
164+
} catch (error) {
165+
// Validation errors (invalid port/format) won't self-resolve — fail immediately.
166+
if (error instanceof Error && (error.message.startsWith('Invalid port') || error.message.startsWith('Invalid DevToolsActivePort'))) {
167+
throw error;
168+
}
169+
lastError = error;
170+
if (attempt < 4) {
171+
logger(
172+
`DevToolsActivePort read attempt ${attempt + 1} failed, retrying in 1s...`,
173+
);
174+
await new Promise(resolve => setTimeout(resolve, 1000));
175+
}
176+
}
177+
}
178+
throw new Error(
179+
`Could not connect to Chrome in ${userDataDir}. Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.`,
180+
{cause: lastError},
181+
);
134182
}
135183

136184
interface McpLaunchOptions {

src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export async function createMcpServer(
9797
});
9898

9999
if (context?.browser !== browser) {
100+
context?.dispose();
100101
context = await McpContext.from(browser, logger, {
101102
experimentalDevToolsDebugging: devtools,
102103
experimentalIncludeAllPages: serverArgs.experimentalIncludeAllPages,
@@ -174,7 +175,11 @@ export async function createMcpServer(
174175
logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`);
175176
const context = await getContext();
176177
logger(`${tool.name} context: resolved`);
177-
await context.detectOpenDevToolsWindows();
178+
try {
179+
await context.detectOpenDevToolsWindows();
180+
} catch (error) {
181+
logger('detectOpenDevToolsWindows failed, continuing:', error);
182+
}
178183
const response = serverArgs.slim
179184
? new SlimMcpResponse(serverArgs)
180185
: new McpResponse(serverArgs);

0 commit comments

Comments
 (0)