Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 16 additions & 13 deletions src/session/browser-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import { CONFIG } from "../config.js";
import { log } from "../utils/logger.js";
import type { SessionInfo, ProgressCallback } from "../types.js";
import { RateLimitError } from "../errors.js";
import {
browserErrorMessage,
isRecoverableBrowserError,
} from "../utils/browser-errors.js";

export class BrowserSession {
public readonly sessionId: string;
Expand Down Expand Up @@ -74,14 +78,13 @@ export class BrowserSession {
// Create new page (tab) in the shared context (with auto-recovery)
try {
this.page = await this.context.newPage();
} catch (e: any) {
const msg = String(e?.message || e);
if (/has been closed|Target .* closed|Browser has been closed|Context .* closed/i.test(msg)) {
log.warning(" ♻️ Context was closed. Recreating and retrying newPage...");
} catch (error) {
if (isRecoverableBrowserError(error)) {
log.warning(" ♻️ Context unavailable or unresponsive. Recreating and retrying newPage...");
this.context = await this.sharedContextManager.getOrCreateContext();
this.page = await this.context.newPage();
} else {
throw e;
throw error;
}
}
log.success(` ✅ Created new page`);
Expand Down Expand Up @@ -440,10 +443,10 @@ export class BrowserSession {

try {
return await askOnce();
} catch (error: any) {
const msg = String(error?.message || error);
if (/has been closed|Target .* closed|Browser has been closed|Context .* closed/i.test(msg)) {
log.warning(` ♻️ Detected closed page/context. Recovering session and retrying ask...`);
} catch (error) {
const msg = browserErrorMessage(error);
if (isRecoverableBrowserError(error)) {
log.warning(` ♻️ Detected unavailable/unresponsive page/context. Recovering session and retrying ask...`);
try {
this.initialized = false;
if (this.page) { try { await this.page.close(); } catch {} }
Expand Down Expand Up @@ -620,10 +623,10 @@ export class BrowserSession {

try {
await resetOnce();
} catch (error: any) {
const msg = String(error?.message || error);
if (/has been closed|Target .* closed|Browser has been closed|Context .* closed/i.test(msg)) {
log.warning(` ♻️ Detected closed page/context during reset. Recovering and retrying...`);
} catch (error) {
const msg = browserErrorMessage(error);
if (isRecoverableBrowserError(error)) {
log.warning(` ♻️ Detected unavailable/unresponsive page/context during reset. Recovering and retrying...`);
this.initialized = false;
if (this.page) { try { await this.page.close(); } catch {} }
this.page = null;
Expand Down
23 changes: 23 additions & 0 deletions src/utils/browser-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Shared browser error helpers.
*
* These patterns cover common Playwright/Patchright failures for closed,
* disconnected, crashed, or unresponsive browser/page/context states.
*/

const RECOVERABLE_BROWSER_ERROR_PATTERN =
/has been closed|Target .* closed|Browser has been closed|Context .* closed|Target page, context or browser has been closed|Target page, context or browser has been disconnected|Browser closed|Browser disconnected|Page crashed|page crashed|Protocol error|Execution context was destroyed|Session closed|unresponsive|health check timed out/i;

export function browserErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
return String(error);
}

export function isRecoverableBrowserError(error: unknown): boolean {
return RECOVERABLE_BROWSER_ERROR_PATTERN.test(browserErrorMessage(error));
}
140 changes: 124 additions & 16 deletions src/utils/page-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
* Based on the Python implementation from page_utils.py
*/

import { setTimeout as delay } from "node:timers/promises";
import type { Page } from "patchright";
import {
browserErrorMessage,
isRecoverableBrowserError,
} from "./browser-errors.js";
import { log } from "./logger.js";

// ============================================================================
Expand All @@ -37,6 +42,14 @@ const RESPONSE_SELECTORS = [
"[role='listitem'][data-message-author]",
];

const MIN_POLL_INTERVAL_MS = 100;
const POLL_GUARD_MULTIPLIER = 5;
const MIN_POLL_GUARD = 120;
const FAST_POLL_DRIFT_THRESHOLD_MS = 50;
const MAX_FAST_POLL_STREAK = 5;
const HEALTH_CHECK_INTERVAL_POLLS = 10;
const HEALTH_CHECK_TIMEOUT_MS = 2000;


// ============================================================================
// Helper Functions
Expand All @@ -55,6 +68,55 @@ function hashString(str: string): number {
return hash;
}

function throwIfRecoverableBrowserError(error: unknown, context: string): void {
if (isRecoverableBrowserError(error)) {
throw new Error(`${context}: ${browserErrorMessage(error)}`);
}
}

async function assertPageResponsive(page: Page, timeoutMs: number): Promise<void> {
const timeoutPromise = delay(timeoutMs).then(() => {
throw new Error(`health check timed out after ${timeoutMs}ms`);
});

try {
await Promise.race([page.evaluate(() => true), timeoutPromise]);
} catch (error) {
throw new Error(`Browser page unresponsive: ${browserErrorMessage(error)}`);
}
}

async function waitForPollInterval(
page: Page,
pollIntervalMs: number,
fastPollStreakRef: { value: number },
debug: boolean
): Promise<void> {
const startedAt = Date.now();
try {
await page.waitForTimeout(pollIntervalMs);
} catch (error) {
throwIfRecoverableBrowserError(error, "Browser page unavailable during poll wait");
throw error;
}

const waitedMs = Date.now() - startedAt;
const remainingMs = pollIntervalMs - waitedMs;

if (remainingMs > FAST_POLL_DRIFT_THRESHOLD_MS) {
fastPollStreakRef.value++;
if (debug) {
log.warning(
`⚠️ [DEBUG] Poll wait returned early (${waitedMs}ms < ${pollIntervalMs}ms), using fallback sleep`
);
}
await delay(remainingMs);
return;
}

fastPollStreakRef.value = 0;
}


// ============================================================================
// Main Functions
Expand Down Expand Up @@ -165,6 +227,9 @@ export async function waitForLatestAnswer(
debug = false,
} = options;

const safePollIntervalMs = Math.max(MIN_POLL_INTERVAL_MS, pollIntervalMs);
const expectedPolls = Math.ceil(timeoutMs / safePollIntervalMs);
const maxPolls = Math.max(MIN_POLL_GUARD, expectedPolls * POLL_GUARD_MULTIPLIER);
const deadline = Date.now() + timeoutMs;
const sanitizedQuestion = question.trim().toLowerCase();

Expand All @@ -186,10 +251,15 @@ export async function waitForLatestAnswer(
let lastCandidate: string | null = null;
let stableCount = 0; // Track how many times we see the same text
const requiredStablePolls = 3; // Text must be stable for 3 consecutive polls
const fastPollStreakRef = { value: 0 };

while (Date.now() < deadline) {
while (Date.now() < deadline && pollCount < maxPolls) {
pollCount++;

if (pollCount % HEALTH_CHECK_INTERVAL_POLLS === 0) {
await assertPageResponsive(page, HEALTH_CHECK_TIMEOUT_MS);
}

// Check if NotebookLM is still "thinking" (most reliable indicator)
try {
const thinkingElement = await page.$('div.thinking-message');
Expand All @@ -199,21 +269,25 @@ export async function waitForLatestAnswer(
if (debug && pollCount % 5 === 0) {
log.debug("🔍 [DEBUG] NotebookLM still thinking (div.thinking-message visible)...");
}
await page.waitForTimeout(pollIntervalMs);
await waitForPollInterval(
page,
safePollIntervalMs,
fastPollStreakRef,
debug
);
continue;
}
}
} catch {
} catch (error) {
throwIfRecoverableBrowserError(
error,
"Browser page unavailable while checking thinking indicator"
);
// Ignore errors checking thinking state
}

// Extract latest NEW text
const candidate = await extractLatestText(
page,
knownHashes,
debug,
pollCount
);
const candidate = await extractLatestText(page, knownHashes, debug, pollCount);

if (candidate) {
const normalized = candidate.trim();
Expand All @@ -226,7 +300,12 @@ export async function waitForLatestAnswer(
log.debug("🔍 [DEBUG] Found question echo, ignoring");
}
knownHashes.add(hashString(normalized)); // Mark as seen
await page.waitForTimeout(pollIntervalMs);
await waitForPollInterval(
page,
safePollIntervalMs,
fastPollStreakRef,
debug
);
continue;
}

Expand Down Expand Up @@ -262,7 +341,18 @@ export async function waitForLatestAnswer(
}
}

await page.waitForTimeout(pollIntervalMs);
await waitForPollInterval(page, safePollIntervalMs, fastPollStreakRef, debug);

if (fastPollStreakRef.value >= MAX_FAST_POLL_STREAK) {
await assertPageResponsive(page, HEALTH_CHECK_TIMEOUT_MS);
fastPollStreakRef.value = 0;
}
}

if (pollCount >= maxPolls && Date.now() < deadline) {
throw new Error(
`Polling guard triggered after ${pollCount} polls before timeout; browser page may be unresponsive`
);
}

if (debug) {
Expand Down Expand Up @@ -336,7 +426,11 @@ async function extractLatestText(
empty++;
}
}
} catch {
} catch (error) {
throwIfRecoverableBrowserError(
error,
"Browser page unavailable while reading response container"
);
continue;
}
}
Expand All @@ -354,6 +448,7 @@ async function extractLatestText(
}
}
} catch (error) {
throwIfRecoverableBrowserError(error, "Browser page unavailable in primary extraction");
log.error(`❌ [EXTRACT] Primary selector failed: ${error}`);
}

Expand Down Expand Up @@ -382,19 +477,31 @@ async function extractLatestText(
if (closest) {
container = closest.asElement() || element;
}
} catch {
} catch (error) {
throwIfRecoverableBrowserError(
error,
"Browser page unavailable while resolving response container"
);
container = element;
}

const text = await container.innerText();
if (text && text.trim() && !knownHashes.has(hashString(text.trim()))) {
return text.trim();
}
} catch {
} catch (error) {
throwIfRecoverableBrowserError(
error,
"Browser page unavailable while reading fallback response element"
);
continue;
}
}
} catch {
} catch (error) {
throwIfRecoverableBrowserError(
error,
`Browser page unavailable while querying fallback selector (${selector})`
);
continue;
}
}
Expand Down Expand Up @@ -457,7 +564,8 @@ async function extractLatestText(
if (typeof fallbackText === "string" && fallbackText.trim()) {
return fallbackText.trim();
}
} catch {
} catch (error) {
throwIfRecoverableBrowserError(error, "Browser page unavailable during JS fallback extraction");
// Ignore evaluation errors
}

Expand Down