From 5170b3574467eea3e0841b25e33a92b40568675a Mon Sep 17 00:00:00 2001 From: Matteo Benedetto Date: Wed, 12 Aug 2026 16:14:04 +0200 Subject: [PATCH] v0.3: fail-fast timeout, retry con jitter, recupero sessione, messaggi di errore chiari --- extensions/firefox-bidi.ts | 166 ++++++++++++++++++++++++++++--------- 1 file changed, 127 insertions(+), 39 deletions(-) diff --git a/extensions/firefox-bidi.ts b/extensions/firefox-bidi.ts index d027d52..f14c265 100644 --- a/extensions/firefox-bidi.ts +++ b/extensions/firefox-bidi.ts @@ -39,6 +39,10 @@ const BRIDGE_PY = const DEFAULT_TIMEOUT_MS = 120_000; // comandi BiDi lunghi (navigate wait:"complete") const STATUS_TIMEOUT_MS = 5_000; +const EVAL_TIMEOUT_MS = 30_000; // fail-fast per operazioni veloci +const SCREENSHOT_TIMEOUT_MS = 30_000; +const EVENTS_TIMEOUT_MS = 10_000; +const SEND_TIMEOUT_MS = 60_000; const MAX_BRIDGE_CYCLES = 2; // cicli di riavvio bridge+firefox su sessione orfana let bridgeProcess: ReturnType | null = null; @@ -68,6 +72,14 @@ async function fetchWithTimeout( const timer = setTimeout(() => ac.abort(), timeoutMs); try { return await fetch(url, { ...init, signal: ac.signal }); + } catch (err) { + if (ac.signal.aborted) { + throw new Error( + `timeout dopo ${timeoutMs}ms: ${url} non ha risposto (bridge bloccato o Firefox non risponde). ` + + `Verifica lo stato con browser_status.`, + ); + } + throw err; } finally { clearTimeout(timer); } @@ -78,17 +90,26 @@ async function bridge( init?: RequestInit, timeoutMs: number = DEFAULT_TIMEOUT_MS, ): Promise { - const res = await fetchWithTimeout( - `${BRIDGE_URL}${path}`, - { - headers: { - "content-type": "application/json", - authorization: `Bearer ${BRIDGE_TOKEN}`, + let res: Response; + try { + res = await fetchWithTimeout( + `${BRIDGE_URL}${path}`, + { + headers: { + "content-type": "application/json", + authorization: `Bearer ${BRIDGE_TOKEN}`, + }, + ...init, }, - ...init, - }, - timeoutMs, - ); + timeoutMs, + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error( + `bridge non raggiungibile (${BRIDGE_URL}): ${msg}. ` + + `Avvia il bridge con browser_start prima di usare i comandi browser_*.`, + ); + } const body = await res.json().catch(() => ({})); if (!res.ok) throw new BridgeError(res.status, path, body); return body; @@ -97,7 +118,14 @@ async function bridge( function isTransient(err: unknown): boolean { if (err instanceof BridgeError) return err.status >= 500; const msg = err instanceof Error ? err.message : String(err); - return /ECONNREFUSED|fetch failed|abort|timeout|socket hang up|session not created/i.test(msg); + // NOTA: "session not created" NON è transitorio: richiede il riavvio + // della sessione (gestito da withSessionRecovery), non un semplice retry. + return /ECONNREFUSED|fetch failed|abort|timeout|socket hang up/i.test(msg); +} + +function isSessionError(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err); + return /session not created|sessione BiDi chiusa|session\.end/i.test(msg); } async function withRetry(fn: () => Promise, label: string, retries = 2): Promise { @@ -108,12 +136,16 @@ async function withRetry(fn: () => Promise, label: string, retries = 2): P } catch (err) { lastErr = err; if (!isTransient(err) || attempt === retries) break; - const delay = 500 * 2 ** attempt; + // backoff esponenziale con jitter (±50%) per evitare thundering herd + const base = 500 * 2 ** attempt; + const delay = Math.round(base * (0.5 + Math.random() * 0.5)); log(`${label}: tentativo ${attempt + 1} fallito (${(err as Error).message}), retry tra ${delay}ms`); await new Promise((r) => setTimeout(r, delay)); } } - throw lastErr; + throw new Error( + `${label} fallito dopo ${retries + 1} tentativi: ${(lastErr as Error).message}`, + ); } // --------------------------------------------------------------------------- @@ -151,11 +183,15 @@ async function waitBridgeReady(timeoutMs = 15_000): Promise { * Garantisce bridge attivo. Se non risponde: avvia Firefox (profilo scelto) + * bridge. Se il bridge parte ma muore (es. sessione BiDi orfana dopo un kill * senza session.end), riavvia Firefox e riprova fino a MAX_BRIDGE_CYCLES. + * Con forceRestart=true riavvia comunque (usato dopo errori di sessione). */ -async function ensureBridge(profile: "dedicated" | "main" = "dedicated"): Promise { - if (await bridgeAlive()) return; +async function ensureBridge( + profile: "dedicated" | "main" = "dedicated", + opts: { forceRestart?: boolean } = {}, +): Promise { + if (!opts.forceRestart && (await bridgeAlive())) return; for (let cycle = 0; cycle <= MAX_BRIDGE_CYCLES; cycle++) { - if (cycle > 0) { + if (cycle > 0 || opts.forceRestart) { log(`ciclo ${cycle}: riavvio Firefox (possibile sessione BiDi orfana)`); await execFileAsync(SCRIPT, ["stop"]).catch(() => {}); await new Promise((r) => setTimeout(r, 1_500)); @@ -173,6 +209,22 @@ async function ensureBridge(profile: "dedicated" | "main" = "dedicated"): Promis ); } +/** + * Esegue fn; se fallisce per sessione BiDi non valida (es. "session not + * created", "sessione BiDi chiusa"), riavvia Firefox+bridge e riprova una + * volta. Evita di lasciare l'utente con errori criptici dopo un crash. + */ +async function withSessionRecovery(fn: () => Promise, label: string): Promise { + try { + return await fn(); + } catch (err) { + if (!isSessionError(err)) throw err; + log(`${label}: sessione BiDi non valida (${(err as Error).message}), riavvio Firefox+bridge`); + await ensureBridge("dedicated", { forceRestart: true }); + return await fn(); + } +} + export default function (pi: ExtensionAPI) { // ------------------------------------------------------------------------- // Ciclo di vita del browser @@ -253,12 +305,20 @@ export default function (pi: ExtensionAPI) { }), async execute(_id, params) { await ensureBridge(); - const r = await withRetry( + const r = await withSessionRecovery( () => - bridge("/navigate", { - method: "POST", - body: JSON.stringify({ url: params.url }), - }), + withRetry( + () => + bridge( + "/navigate", + { + method: "POST", + body: JSON.stringify({ url: params.url }), + }, + DEFAULT_TIMEOUT_MS, + ), + "navigate", + ), "navigate", ); return { @@ -282,12 +342,20 @@ export default function (pi: ExtensionAPI) { }), async execute(_id, params) { await ensureBridge(); - const r = await withRetry( + const r = await withSessionRecovery( () => - bridge("/screenshot", { - method: "POST", - body: JSON.stringify({ out: params.path, fullPage: params.fullPage ?? false }), - }), + withRetry( + () => + bridge( + "/screenshot", + { + method: "POST", + body: JSON.stringify({ out: params.path, fullPage: params.fullPage ?? false }), + }, + SCREENSHOT_TIMEOUT_MS, + ), + "screenshot", + ), "screenshot", ); return { @@ -311,12 +379,20 @@ export default function (pi: ExtensionAPI) { }), async execute(_id, params) { await ensureBridge(); - const r = await withRetry( + const r = await withSessionRecovery( () => - bridge("/eval", { - method: "POST", - body: JSON.stringify(params), - }), + withRetry( + () => + bridge( + "/eval", + { + method: "POST", + body: JSON.stringify(params), + }, + EVAL_TIMEOUT_MS, + ), + "eval", + ), "eval", ); return { @@ -338,10 +414,18 @@ export default function (pi: ExtensionAPI) { }), async execute(_id, params) { await ensureBridge(); - const r = await bridge("/cmd", { - method: "POST", - body: JSON.stringify(params), - }); + const r = await withSessionRecovery( + () => + bridge( + "/cmd", + { + method: "POST", + body: JSON.stringify(params), + }, + SEND_TIMEOUT_MS, + ), + "browser_send", + ); return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }], details: r, @@ -370,10 +454,14 @@ export default function (pi: ExtensionAPI) { }), async execute(_id, params) { await ensureBridge(); - const r = await bridge("/events", { - method: "POST", - body: JSON.stringify(params), - }); + const r = await bridge( + "/events", + { + method: "POST", + body: JSON.stringify(params), + }, + EVENTS_TIMEOUT_MS, + ); return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }], details: r,