Живучесть: сокет умирает честно, сторож поднимает браузер сам
Три вещи, и первая - настоящая дыра, а не удобство. 1. Смерть вкладки посреди запроса вешала сервис насмерть. У Session не было ни обработчика close, ни таймаута на вызов: закрылась вкладка в момент ожидания - обещание не разрешалось НИКОГДА. Запрос висел, очередь за ним стояла, сервис при этом выглядел живым и молчал. Теперь close отклоняет всё висящее, у каждого вызова протокола потолок 60 с, у подключения 10 с, а мёртвый сокет отказывает сразу, а не копит вызовы. 2. ask() повторяет запрос один раз, если вкладка пропала посреди работы. Повтор безопасен именно из-за правки первого сообщения: вопрос не добавляется к чату, а заменяет его содержимое, поэтому дважды он не задастся. 3. Сторож: раз в 30 с проверяет браузер и вкладку и поднимает, не дожидаясь вопроса. Раньше восстановление жило внутри запроса, и клиент платил за него 15-60 секундами своего времени. Ходит через ТУ ЖЕ очередь, что и запросы, иначе полез бы в браузер, пока DeepSeek дописывает ответ. Пропускает свой черёд, если запрос уже идёт. При неудаче отсрочка удваивается до 300 с: обычная причина (профиль открыт другим окном без отладочного порта) сама не лечится, и долбиться каждые полминуты значит плодить процессы. Выключается ключом watchdog в config.json. Добавлен GET /api/state - дешёвый снимок для индикаторов: браузер, готовность, занятость, длина очереди, секунды текущего запроса, последняя ошибка и время последней проверки. Вкладку не трогает, спрашивать можно хоть раз в секунду. ВАЖНО для будущего окна с индикаторами: снимок обновляет сторож, поэтому он устаревает на срок до watchdogSec - смотреть на поле checked, а не только на ready. Проверено вживую: сервис поднялся, /api/state отдал ready; браузер убит дважды - в логе "сторож: Браузер не отвечает, поднимаю...", checked идёт каждые 30 с, состояние возвращается в ready. НЕ проверено вживую: восстановление после смерти вкладки ПОСРЕДИ запроса (пункты 1 и 2). Код компилируется, разбор формата проходит, но подгадать момент не пробовал. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b6f45db8dc
commit
52de3b91eb
+82
-5
@@ -64,16 +64,36 @@ async function findChatTab() {
|
||||
return chat;
|
||||
}
|
||||
|
||||
// Сколько ждать ОДИН вызов протокола. Обычные вызовы — чтение DOM, миллисекунды;
|
||||
// самый долгий, Page.captureScreenshot на тяжёлой странице, укладывается в пару
|
||||
// секунд. Минута — потолок, за которым уже не «медленно», а «не ответит никогда».
|
||||
const CDP_CALL_TIMEOUT = 60;
|
||||
const CDP_CONNECT_TIMEOUT = 10;
|
||||
|
||||
class Session {
|
||||
#ws; #id = 1; #pending = new Map();
|
||||
#ws; #id = 1; #pending = new Map(); #dead = null;
|
||||
|
||||
static connect(wsUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const s = new Session();
|
||||
s.#ws = ws;
|
||||
ws.addEventListener('open', () => resolve(s), { once: true });
|
||||
ws.addEventListener('error', e => reject(new Error(`WebSocket: ${e?.message ?? e}`)), { once: true });
|
||||
const giveUp = setTimeout(
|
||||
() => reject(new Error(`вкладка не отозвалась за ${CDP_CONNECT_TIMEOUT} с`)),
|
||||
CDP_CONNECT_TIMEOUT * 1000);
|
||||
ws.addEventListener('open', () => { clearTimeout(giveUp); resolve(s); }, { once: true });
|
||||
ws.addEventListener('error', e => {
|
||||
clearTimeout(giveUp);
|
||||
const err = new Error(`WebSocket: ${e?.message ?? e}`);
|
||||
s.#fail(err); reject(err);
|
||||
}, { once: true });
|
||||
// Вкладку закрыли — все висящие вызовы обязаны отвалиться. Без этого
|
||||
// обработчика они не разрешались НИКОГДА: запрос висел вечно, очередь
|
||||
// за ним стояла, а сервис при этом выглядел живым и молчал.
|
||||
ws.addEventListener('close', () => {
|
||||
clearTimeout(giveUp);
|
||||
s.#fail(new Error('вкладка DeepSeek закрылась во время работы'));
|
||||
}, { once: true });
|
||||
ws.addEventListener('message', ev => {
|
||||
let m; try { m = JSON.parse(ev.data); } catch { return; }
|
||||
if (m.id === undefined) return;
|
||||
@@ -85,11 +105,33 @@ class Session {
|
||||
});
|
||||
}
|
||||
|
||||
// Сокет умер: разрешить всё, что ждёт, и запомнить причину — следующие
|
||||
// вызовы должны падать сразу, а не копиться на мёртвом сокете.
|
||||
#fail(err) {
|
||||
this.#dead = err;
|
||||
for (const p of this.#pending.values()) p.reject(err);
|
||||
this.#pending.clear();
|
||||
}
|
||||
|
||||
send(method, params = {}) {
|
||||
if (this.#dead) return Promise.reject(this.#dead);
|
||||
const id = this.#id++;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#pending.set(id, { resolve, reject });
|
||||
this.#ws.send(JSON.stringify({ id, method, params }));
|
||||
const giveUp = setTimeout(() => {
|
||||
this.#pending.delete(id);
|
||||
reject(new Error(`вкладка не ответила на ${method} за ${CDP_CALL_TIMEOUT} с`));
|
||||
}, CDP_CALL_TIMEOUT * 1000);
|
||||
this.#pending.set(id, {
|
||||
resolve: v => { clearTimeout(giveUp); resolve(v); },
|
||||
reject: e => { clearTimeout(giveUp); reject(e); },
|
||||
});
|
||||
try {
|
||||
this.#ws.send(JSON.stringify({ id, method, params }));
|
||||
} catch (e) {
|
||||
clearTimeout(giveUp);
|
||||
this.#pending.delete(id);
|
||||
reject(new Error(`не удалось отправить ${method}: ${e.message}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1032,6 +1074,24 @@ export async function ensureBrowser(log = () => {}) {
|
||||
await sleep(4000); // дать чату прогрузиться до первого вопроса
|
||||
}
|
||||
|
||||
// Приметы того, что вкладка или браузер пропали посреди работы. По ним ask()
|
||||
// решает, что дело не в вопросе, и стоит восстановиться и повторить один раз.
|
||||
function isLostTab(e) {
|
||||
return /вкладка DeepSeek закрылась|вкладка не ответила на|вкладка не отозвалась|WebSocket|Вкладка chat\.deepseek\.com не открыта|Браузер с отладочным портом/i
|
||||
.test(String(e && e.message || e));
|
||||
}
|
||||
|
||||
// Поднять браузер и вкладку, ничего не спрашивая. Отдаёт состояние — им живёт
|
||||
// сторож и по нему рисуются индикаторы.
|
||||
export async function revive(log = () => {}) {
|
||||
try {
|
||||
await ensureBrowser(log);
|
||||
return { ok: true, browser: true, tab: true };
|
||||
} catch (e) {
|
||||
return { ok: false, browser: await browserAlive(), tab: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Картинка приезжает к нам в памяти, а странице нужен файл на диске: другого
|
||||
// способа наполнить <input type="file"> у протокола нет.
|
||||
async function attachImage(s, bytes, log) {
|
||||
@@ -1063,6 +1123,23 @@ export async function ask({ prompt, role = 'think', imageBytes = null,
|
||||
if (!ROLES.includes(role)) role = 'think';
|
||||
await ensureBrowser(log);
|
||||
|
||||
try {
|
||||
return await askOnce({ prompt, role, imageBytes, timeout, log });
|
||||
} catch (e) {
|
||||
if (!isLostTab(e)) throw e;
|
||||
// Вкладку закрыли или браузер уронили посреди работы. Поднимаем заново
|
||||
// и спрашиваем ещё раз — ровно один.
|
||||
//
|
||||
// Повтор безопасен именно из-за правки первого сообщения: вопрос не
|
||||
// добавляется к чату, а заменяет его содержимое. Даже если первая попытка
|
||||
// успела отправить вопрос, вторая его перепишет, а не задаст дважды.
|
||||
log(`связь с вкладкой потеряна (${e.message}); поднимаю и повторяю`);
|
||||
await ensureBrowser(log);
|
||||
return askOnce({ prompt, role, imageBytes, timeout, log });
|
||||
}
|
||||
}
|
||||
|
||||
async function askOnce({ prompt, role, imageBytes, timeout, log }) {
|
||||
return withChat(async (s) => {
|
||||
await pruneChats(s, log); // сайдбар держим чистым
|
||||
|
||||
|
||||
Reference in New Issue
Block a user