@@ -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 => {
0 commit comments