v0.3: fail-fast timeout, retry con jitter, recupero sessione, messaggi di errore chiari
This commit is contained in:
+127
-39
@@ -39,6 +39,10 @@ const BRIDGE_PY =
|
|||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 120_000; // comandi BiDi lunghi (navigate wait:"complete")
|
const DEFAULT_TIMEOUT_MS = 120_000; // comandi BiDi lunghi (navigate wait:"complete")
|
||||||
const STATUS_TIMEOUT_MS = 5_000;
|
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
|
const MAX_BRIDGE_CYCLES = 2; // cicli di riavvio bridge+firefox su sessione orfana
|
||||||
|
|
||||||
let bridgeProcess: ReturnType<typeof spawn> | null = null;
|
let bridgeProcess: ReturnType<typeof spawn> | null = null;
|
||||||
@@ -68,6 +72,14 @@ async function fetchWithTimeout(
|
|||||||
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
||||||
try {
|
try {
|
||||||
return await fetch(url, { ...init, signal: ac.signal });
|
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 {
|
} finally {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
@@ -78,17 +90,26 @@ async function bridge(
|
|||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const res = await fetchWithTimeout(
|
let res: Response;
|
||||||
`${BRIDGE_URL}${path}`,
|
try {
|
||||||
{
|
res = await fetchWithTimeout(
|
||||||
headers: {
|
`${BRIDGE_URL}${path}`,
|
||||||
"content-type": "application/json",
|
{
|
||||||
authorization: `Bearer ${BRIDGE_TOKEN}`,
|
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(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new BridgeError(res.status, path, body);
|
if (!res.ok) throw new BridgeError(res.status, path, body);
|
||||||
return body;
|
return body;
|
||||||
@@ -97,7 +118,14 @@ async function bridge(
|
|||||||
function isTransient(err: unknown): boolean {
|
function isTransient(err: unknown): boolean {
|
||||||
if (err instanceof BridgeError) return err.status >= 500;
|
if (err instanceof BridgeError) return err.status >= 500;
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
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<T>(fn: () => Promise<T>, label: string, retries = 2): Promise<T> {
|
async function withRetry<T>(fn: () => Promise<T>, label: string, retries = 2): Promise<T> {
|
||||||
@@ -108,12 +136,16 @@ async function withRetry<T>(fn: () => Promise<T>, label: string, retries = 2): P
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastErr = err;
|
lastErr = err;
|
||||||
if (!isTransient(err) || attempt === retries) break;
|
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`);
|
log(`${label}: tentativo ${attempt + 1} fallito (${(err as Error).message}), retry tra ${delay}ms`);
|
||||||
await new Promise((r) => setTimeout(r, delay));
|
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<boolean> {
|
|||||||
* Garantisce bridge attivo. Se non risponde: avvia Firefox (profilo scelto) +
|
* Garantisce bridge attivo. Se non risponde: avvia Firefox (profilo scelto) +
|
||||||
* bridge. Se il bridge parte ma muore (es. sessione BiDi orfana dopo un kill
|
* 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.
|
* 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<void> {
|
async function ensureBridge(
|
||||||
if (await bridgeAlive()) return;
|
profile: "dedicated" | "main" = "dedicated",
|
||||||
|
opts: { forceRestart?: boolean } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
if (!opts.forceRestart && (await bridgeAlive())) return;
|
||||||
for (let cycle = 0; cycle <= MAX_BRIDGE_CYCLES; cycle++) {
|
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)`);
|
log(`ciclo ${cycle}: riavvio Firefox (possibile sessione BiDi orfana)`);
|
||||||
await execFileAsync(SCRIPT, ["stop"]).catch(() => {});
|
await execFileAsync(SCRIPT, ["stop"]).catch(() => {});
|
||||||
await new Promise((r) => setTimeout(r, 1_500));
|
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<T>(fn: () => Promise<T>, label: string): Promise<T> {
|
||||||
|
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) {
|
export default function (pi: ExtensionAPI) {
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Ciclo di vita del browser
|
// Ciclo di vita del browser
|
||||||
@@ -253,12 +305,20 @@ export default function (pi: ExtensionAPI) {
|
|||||||
}),
|
}),
|
||||||
async execute(_id, params) {
|
async execute(_id, params) {
|
||||||
await ensureBridge();
|
await ensureBridge();
|
||||||
const r = await withRetry(
|
const r = await withSessionRecovery(
|
||||||
() =>
|
() =>
|
||||||
bridge("/navigate", {
|
withRetry(
|
||||||
method: "POST",
|
() =>
|
||||||
body: JSON.stringify({ url: params.url }),
|
bridge(
|
||||||
}),
|
"/navigate",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ url: params.url }),
|
||||||
|
},
|
||||||
|
DEFAULT_TIMEOUT_MS,
|
||||||
|
),
|
||||||
|
"navigate",
|
||||||
|
),
|
||||||
"navigate",
|
"navigate",
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -282,12 +342,20 @@ export default function (pi: ExtensionAPI) {
|
|||||||
}),
|
}),
|
||||||
async execute(_id, params) {
|
async execute(_id, params) {
|
||||||
await ensureBridge();
|
await ensureBridge();
|
||||||
const r = await withRetry(
|
const r = await withSessionRecovery(
|
||||||
() =>
|
() =>
|
||||||
bridge("/screenshot", {
|
withRetry(
|
||||||
method: "POST",
|
() =>
|
||||||
body: JSON.stringify({ out: params.path, fullPage: params.fullPage ?? false }),
|
bridge(
|
||||||
}),
|
"/screenshot",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ out: params.path, fullPage: params.fullPage ?? false }),
|
||||||
|
},
|
||||||
|
SCREENSHOT_TIMEOUT_MS,
|
||||||
|
),
|
||||||
|
"screenshot",
|
||||||
|
),
|
||||||
"screenshot",
|
"screenshot",
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -311,12 +379,20 @@ export default function (pi: ExtensionAPI) {
|
|||||||
}),
|
}),
|
||||||
async execute(_id, params) {
|
async execute(_id, params) {
|
||||||
await ensureBridge();
|
await ensureBridge();
|
||||||
const r = await withRetry(
|
const r = await withSessionRecovery(
|
||||||
() =>
|
() =>
|
||||||
bridge("/eval", {
|
withRetry(
|
||||||
method: "POST",
|
() =>
|
||||||
body: JSON.stringify(params),
|
bridge(
|
||||||
}),
|
"/eval",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
},
|
||||||
|
EVAL_TIMEOUT_MS,
|
||||||
|
),
|
||||||
|
"eval",
|
||||||
|
),
|
||||||
"eval",
|
"eval",
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -338,10 +414,18 @@ export default function (pi: ExtensionAPI) {
|
|||||||
}),
|
}),
|
||||||
async execute(_id, params) {
|
async execute(_id, params) {
|
||||||
await ensureBridge();
|
await ensureBridge();
|
||||||
const r = await bridge("/cmd", {
|
const r = await withSessionRecovery(
|
||||||
method: "POST",
|
() =>
|
||||||
body: JSON.stringify(params),
|
bridge(
|
||||||
});
|
"/cmd",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
},
|
||||||
|
SEND_TIMEOUT_MS,
|
||||||
|
),
|
||||||
|
"browser_send",
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: JSON.stringify(r, null, 2) }],
|
content: [{ type: "text", text: JSON.stringify(r, null, 2) }],
|
||||||
details: r,
|
details: r,
|
||||||
@@ -370,10 +454,14 @@ export default function (pi: ExtensionAPI) {
|
|||||||
}),
|
}),
|
||||||
async execute(_id, params) {
|
async execute(_id, params) {
|
||||||
await ensureBridge();
|
await ensureBridge();
|
||||||
const r = await bridge("/events", {
|
const r = await bridge(
|
||||||
method: "POST",
|
"/events",
|
||||||
body: JSON.stringify(params),
|
{
|
||||||
});
|
method: "POST",
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
},
|
||||||
|
EVENTS_TIMEOUT_MS,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: JSON.stringify(r, null, 2) }],
|
content: [{ type: "text", text: JSON.stringify(r, null, 2) }],
|
||||||
details: r,
|
details: r,
|
||||||
|
|||||||
Reference in New Issue
Block a user