Skip to content

Commit 9a5e473

Browse files
Nima21claude
andcommitted
feat: auto-close idle Chrome pages after 5 minutes of inactivity
Agents open pages via new_page but never close them, causing tab accumulation over 20-30h sessions. This adds a two-tier idle timeout: 1. Track last activity timestamp per page. Every MCP tool call that targets a page updates its timestamp (via touchPage). Page selection and page creation also record activity. 2. A setInterval reaper runs every 60s, scanning all pages and closing any page idle for 5+ minutes via page.close(). 3. When zero pages remain after reaping, closeBrowser() is called to kill Chrome entirely, freeing all resources. 4. On the next tool call, getContext() → ensureBrowserLaunched will relaunch Chrome and open a new page transparently. Puppeteer has no built-in API for stale page management — confirmed by checking all Browser/Page APIs. This is a custom implementation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6da0ba9 commit 9a5e473

2 files changed

Lines changed: 123 additions & 1 deletion

File tree

src/McpContext.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,13 @@ export class McpContext implements Context {
138138
#locatorClass: typeof Locator;
139139
#options: McpContextOptions;
140140

141+
// Idle page reaper: tracks last activity time per page.
142+
static readonly PAGE_IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
143+
static readonly IDLE_CHECK_INTERVAL_MS = 60 * 1000; // 60 seconds
144+
#pageLastActivity = new Map<Page, number>();
145+
#idleReaperTimer?: ReturnType<typeof setInterval>;
146+
#onIdleBrowserEmpty?: () => void;
147+
141148
#uniqueBackendNodeIdToMcpId = new Map<string, string>();
142149

143150
private constructor(
@@ -177,11 +184,110 @@ export class McpContext implements Context {
177184
}
178185

179186
dispose() {
187+
this.stopIdlePageReaper();
180188
this.#networkCollector.dispose();
181189
this.#consoleCollector.dispose();
182190
this.#devtoolsUniverseManager.dispose();
183191
}
184192

193+
/**
194+
* Record activity on a page, resetting its idle timer.
195+
*/
196+
touchPage(page: Page): void {
197+
this.#pageLastActivity.set(page, Date.now());
198+
}
199+
200+
/**
201+
* Record activity on all currently tracked pages.
202+
*/
203+
touchAllPages(): void {
204+
const now = Date.now();
205+
for (const page of this.#pages) {
206+
this.#pageLastActivity.set(page, now);
207+
}
208+
}
209+
210+
/**
211+
* Start the periodic idle page reaper.
212+
* @param onBrowserEmpty - called when all pages have been closed by the reaper.
213+
*/
214+
startIdlePageReaper(onBrowserEmpty?: () => void): void {
215+
this.#onIdleBrowserEmpty = onBrowserEmpty;
216+
// Initialize activity timestamps for all existing pages.
217+
const now = Date.now();
218+
for (const page of this.#pages) {
219+
if (!this.#pageLastActivity.has(page)) {
220+
this.#pageLastActivity.set(page, now);
221+
}
222+
}
223+
this.#idleReaperTimer = setInterval(() => {
224+
void this.#reapIdlePages();
225+
}, McpContext.IDLE_CHECK_INTERVAL_MS);
226+
// Don't keep the process alive just for the reaper.
227+
this.#idleReaperTimer.unref();
228+
}
229+
230+
stopIdlePageReaper(): void {
231+
if (this.#idleReaperTimer) {
232+
clearInterval(this.#idleReaperTimer);
233+
this.#idleReaperTimer = undefined;
234+
}
235+
}
236+
237+
async #reapIdlePages(): Promise<void> {
238+
const now = Date.now();
239+
const idleTimeout = McpContext.PAGE_IDLE_TIMEOUT_MS;
240+
// Refresh page list from browser to get accurate state.
241+
let pages: Page[];
242+
try {
243+
pages = await this.createPagesSnapshot();
244+
} catch {
245+
return; // Browser may be disconnected.
246+
}
247+
248+
const pagesToClose: Page[] = [];
249+
for (const page of pages) {
250+
const lastActivity = this.#pageLastActivity.get(page);
251+
// Pages without a recorded timestamp get one now (first seen).
252+
if (lastActivity === undefined) {
253+
this.#pageLastActivity.set(page, now);
254+
continue;
255+
}
256+
if (now - lastActivity > idleTimeout) {
257+
pagesToClose.push(page);
258+
}
259+
}
260+
261+
if (pagesToClose.length === 0) {
262+
return;
263+
}
264+
265+
// Close idle pages. If ALL pages are idle, close them all.
266+
for (const page of pagesToClose) {
267+
try {
268+
this.logger(
269+
`Closing idle page ${this.#pageIdMap.get(page)}: ${page.url()} (idle ${Math.round((now - (this.#pageLastActivity.get(page) ?? now)) / 1000)}s)`,
270+
);
271+
this.#pageLastActivity.delete(page);
272+
await page.close({runBeforeUnload: false});
273+
} catch {
274+
// Page may already be closed.
275+
}
276+
}
277+
278+
// Refresh after closing.
279+
try {
280+
pages = await this.createPagesSnapshot();
281+
} catch {
282+
pages = [];
283+
}
284+
285+
if (pages.length === 0 && this.#onIdleBrowserEmpty) {
286+
this.logger('All pages closed by idle reaper, triggering browser cleanup');
287+
this.#onIdleBrowserEmpty();
288+
}
289+
}
290+
185291
static async from(
186292
browser: Browser,
187293
logger: Debugger,
@@ -265,7 +371,7 @@ export class McpContext implements Context {
265371
async newPage(background?: boolean): Promise<Page> {
266372
const page = await this.browser.newPage({background});
267373
await this.createPagesSnapshot();
268-
this.selectPage(page);
374+
this.selectPage(page); // Also touches the page via selectPage.
269375
this.#networkCollector.addPage(page);
270376
this.#consoleCollector.addPage(page);
271377
return page;
@@ -426,6 +532,7 @@ export class McpContext implements Context {
426532
});
427533
}
428534
this.#selectedPage = newPage;
535+
this.touchPage(newPage);
429536
newPage.on('dialog', this.#dialogHandler);
430537
this.#updateSelectedPageTimeouts();
431538
void newPage.emulateFocusedPage(true).catch(error => {

src/main.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,20 @@ async function getContext(): Promise<McpContext> {
116116
});
117117

118118
if (context?.browser !== browser) {
119+
// Stop the old reaper before creating a new context.
120+
context?.stopIdlePageReaper();
119121
context = await McpContext.from(browser, logger, {
120122
experimentalDevToolsDebugging: devtools,
121123
experimentalIncludeAllPages: args.experimentalIncludeAllPages,
122124
performanceCrux: args.performanceCrux,
123125
});
126+
// Start idle page reaper: closes pages idle for 5+ minutes.
127+
// When all pages are closed, kill the browser entirely.
128+
// On the next tool call, getContext() will relaunch transparently.
129+
context.startIdlePageReaper(() => {
130+
logger('Idle reaper: all pages closed, shutting down browser');
131+
void closeBrowser();
132+
});
124133
}
125134
return context;
126135
}
@@ -201,6 +210,12 @@ function registerTool(tool: ToolDefinition): void {
201210
logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`);
202211
const context = await getContext();
203212
logger(`${tool.name} context: resolved`);
213+
// Mark the selected page as active to prevent idle reaper from closing it.
214+
try {
215+
context.touchPage(context.getSelectedPage());
216+
} catch {
217+
// No page selected yet — that's fine.
218+
}
204219
await context.detectOpenDevToolsWindows();
205220
const response = new McpResponse();
206221
await tool.handler(

0 commit comments

Comments
 (0)