From e25e6e2fca346e99d1043bd9d1abd82108dc6338 Mon Sep 17 00:00:00 2001 From: Philipp Noack Date: Tue, 24 Mar 2026 09:18:38 +0100 Subject: [PATCH] Fix tab leak: add closure timeout and reaper recovery for stuck tabs Tab closures could hang indefinitely when CDP WebSocket calls stall (dead connection, Chrome overloaded), leaving tabs permanently stuck with closing=true. The reaper sweep unconditionally skipped these tabs, so they were never cleaned up. Three fixes: - Wrap perform_tab_closure with a 15s timeout so hangs trigger the existing retry logic instead of blocking forever - Track closing_since timestamp; reaper resets stuck closures after 30s so they become eligible for force-close on the next sweep - Only nullify browser/tab references on successful closure so retries can still use the primary close path --- cdp.js | 48 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/cdp.js b/cdp.js index 86800f1..6b8a96e 100644 --- a/cdp.js +++ b/cdp.js @@ -30,6 +30,9 @@ const open_tabs = new Map(); let tab_reaper_timer = null; const cdp_logger = logger.get_logger(); +const TAB_CLOSURE_TIMEOUT_MS = 15000; +const TAB_CLOSURE_STALE_THRESHOLD_MS = 30000; + ensure_unexpected_response_logging(); const MAX_CAPTURED_UNEXPECTED_RESPONSE_BODY_BYTES = 256 * 1024; @@ -279,6 +282,21 @@ function is_transient_close_error(err) { return false; } +function with_timeout(promise, ms, label) { + let timer; + const timeout_promise = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(`${label} timed out after ${ms}ms`)); + }, ms); + if (timer && typeof timer.unref === 'function') { + timer.unref(); + } + }); + return Promise.race([promise, timeout_promise]).finally(() => { + clearTimeout(timer); + }); +} + function is_unexpected_http_response_error(err) { if (!err) { return false; @@ -459,6 +477,7 @@ function register_open_tab(browser, tab, target_id) { target_id: target_id, created_at: Date.now(), closing: false, + closing_since: null, disconnect_handler: null, retry_count: 0 }; @@ -627,8 +646,10 @@ async function perform_tab_closure(tab_info, reason) { } } - tab_info.browser = null; - tab_info.tab = null; + if (target_closed) { + tab_info.browser = null; + tab_info.tab = null; + } return target_closed; } @@ -655,12 +676,17 @@ async function release_tracked_tab(tab_info, reason) { } tab_info.closing = true; + tab_info.closing_since = Date.now(); detach_disconnect_handler(tab_info); let closed = false; try { - closed = await perform_tab_closure(tab_info, reason); + closed = await with_timeout( + perform_tab_closure(tab_info, reason), + TAB_CLOSURE_TIMEOUT_MS, + `perform_tab_closure(${tab_info.target_id})` + ); } catch (err) { cdp_logger.error('Unexpected error while closing tab.', { target_id: tab_info.target_id, @@ -677,6 +703,7 @@ async function release_tracked_tab(tab_info, reason) { tab_info.retry_count = (tab_info.retry_count || 0) + 1; tab_info.closing = false; + tab_info.closing_since = null; attach_disconnect_handler(tab_info); schedule_tab_retry(tab_info, reason); ensure_tab_reaper(); @@ -689,7 +716,20 @@ function run_tab_reaper_sweep() { for (const tab_info of open_tabs.values()) { if (tab_info.closing) { - continue; + // Detect stuck closures: if closing has been true for too long, + // reset it so the tab can be reaped on this or the next sweep. + if (tab_info.closing_since && (now - tab_info.closing_since) >= TAB_CLOSURE_STALE_THRESHOLD_MS) { + cdp_logger.warn('Tab closure appears stuck; resetting for reaper retry.', { + target_id: tab_info.target_id, + closing_since: tab_info.closing_since, + stuck_for_ms: now - tab_info.closing_since + }); + tab_info.closing = false; + tab_info.closing_since = null; + // Fall through to the lifetime check below + } else { + continue; + } } if ((now - tab_info.created_at) >= config.TAB_MAX_LIFETIME_MS) {