Живучесть: сокет умирает честно, сторож поднимает браузер сам

Три вещи, и первая - настоящая дыра, а не удобство.

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:
Alex Cube
2026-08-26 14:52:13 +03:00
co-authored by Claude Opus 5
parent b6f45db8dc
commit 52de3b91eb
2 changed files with 181 additions and 10 deletions
+99 -5
View File
@@ -16,7 +16,7 @@ import { readFile } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { configure, ask, probe, browserAlive, ROLES } from './browser.mjs';
import { configure, ask, probe, revive, browserAlive, ROLES } from './browser.mjs';
// fileURLToPath, а не разбор pathname вручную: путь проекта может содержать «#»,
// и в URL он приезжает как %23 — самодельный разбор ломается именно на нём.
@@ -31,6 +31,15 @@ const cfg = {
chatUrl: 'https://chat.deepseek.com/',
defaultRole: 'think',
timeout: 300,
// Сторож: поднимать браузер и вкладку сам, не дожидаясь вопроса. Иначе
// восстановление происходит внутри запроса, и клиент платит за него
// 15-60 секундами своего времени ожидания.
watchdog: true,
watchdogSec: 30,
// Потолок отсрочки. Если браузер не поднимается (обычно профиль уже открыт
// другим окном без отладочного порта), проверки разрежаются вдвое каждый
// раз — иначе сторож плодил бы процессы каждые полминуты без конца.
watchdogMaxSec: 300,
...JSON.parse(await readFile(join(HERE, 'config.json'), 'utf8').catch(() => '{}')),
// Личные пути — сюда: этот файл в репозиторий не попадает, и обновление
// проекта не затрёт настройки конкретной машины.
@@ -43,17 +52,67 @@ configure(cfg);
const MODELS = ROLES.map(r => 'deepseek:' + r);
// Состояние для индикаторов. Обновляется сторожем и запросами; отдаётся
// по /api/state, НЕ трогая вкладку — на него можно смотреть хоть раз в секунду.
const state = {
browser: false, // отвечает ли отладочный порт
ready: false, // браузер поднят и вкладка с чатом на месте
busy: false, // прямо сейчас идёт запрос
queue: 0, // сколько ждёт в очереди
since: null, // когда начался текущий запрос
error: null, // последняя беда, понятным текстом
checked: null, // когда сторож смотрел в последний раз
};
// Вкладка одна, и два запроса разом затрут друг другу поле ввода. Поэтому
// очередь: запросы идут строго по одному.
// ponytail: очередь без ограничения длины — если клиент завалит сервис
// запросами, они просто будут ждать. Появится нужда — резать по длине очереди.
let chain = Promise.resolve();
function serial(fn) {
state.queue++;
const next = chain.then(fn, fn);
chain = next.catch(() => {});
next.then(() => { state.queue--; }, () => { state.queue--; });
return next;
}
// ------------------------------------------------------------------ сторож
// Сторож ходит через ТУ ЖЕ очередь, что и запросы. Иначе он полез бы в браузер
// в тот момент, когда DeepSeek дописывает ответ, — а вкладка одна.
let watchTimer = null;
let watchDelay = 0;
function scheduleWatch(delaySec) {
if (!cfg.watchdog) return;
clearTimeout(watchTimer);
watchDelay = delaySec;
watchTimer = setTimeout(runWatch, delaySec * 1000);
watchTimer.unref?.(); // сторож не должен держать процесс живым
}
async function runWatch() {
// Идёт запрос — он сам всё поднимет и проверит. Не мешаем и не ждём очереди:
// на длинном ответе сторож простоял бы минуты без всякой пользы.
if (state.busy) return scheduleWatch(cfg.watchdogSec);
const r = await serial(() => revive(m => console.log(` сторож: ${m}`)));
state.checked = now();
state.browser = r.browser;
state.ready = r.ok;
state.error = r.ok ? null : r.error;
if (r.ok) return scheduleWatch(cfg.watchdogSec);
// Не поднялось — разрежаем проверки. Чаще всего причина не лечится сама:
// профиль открыт другим окном без отладочного порта, и человеку надо это окно
// закрыть. Долбиться каждые полминуты бессмысленно и плодит процессы.
const next = Math.min((watchDelay || cfg.watchdogSec) * 2, cfg.watchdogMaxSec);
console.error(`Сторож: ${r.error} Следующая попытка через ${next} с.`);
scheduleWatch(next);
}
// ------------------------------------------------------------------ Ollama
// Роль из имени модели: "deepseek:search" → "search". Незнакомое имя (клиент
@@ -98,6 +157,22 @@ async function readBody(req) {
const now = () => new Date().toISOString();
// Обёртка вокруг одного запроса: держит state в правде, что бы ни случилось.
async function track(fn) {
state.busy = true; state.since = Date.now(); state.error = null;
try {
const out = await fn();
state.ready = true; state.browser = true;
return out;
} catch (e) {
state.error = e.message;
state.ready = false;
throw e;
} finally {
state.busy = false; state.since = null;
}
}
// Клиенты Ollama по умолчанию просят поток и ждут не один объект, а несколько
// строк JSON подряд. Настоящего потока у нас нет — веб-чат отдаёт ответ целиком,
// — но формат соблюсти обязаны, иначе клиент не разберёт ответ вообще.
@@ -136,6 +211,17 @@ const server = createServer(async (req, res) => {
return json(res, 200, { version: 'deepseek-api' });
}
// Дешёвый снимок состояния для индикаторов: ни браузер, ни вкладку
// не трогает, поэтому спрашивать можно хоть раз в секунду.
if (req.method === 'GET' && url.pathname === '/api/state') {
return json(res, 200, {
...state,
seconds: state.busy && state.since ? Math.round((Date.now() - state.since) / 1000) : 0,
host: cfg.host, port: cfg.port, cdpPort: cfg.cdpPort,
watchdog: !!cfg.watchdog,
});
}
// Своё, сверх протокола Ollama: что видно во вкладке прямо сейчас.
if (req.method === 'GET' && url.pathname === '/api/probe') {
return json(res, 200, await serial(() => probe()));
@@ -149,10 +235,10 @@ const server = createServer(async (req, res) => {
const role = imageBytes ? 'vision' : roleOf(body.model);
const started = Date.now();
console.log(`[${role}] ${prompt.length} симв.${imageBytes ? ' + картинка' : ''}`);
const answer = await serial(() => ask({
const answer = await serial(() => track(() => ask({
prompt, role, imageBytes, timeout: cfg.timeout,
log: m => console.log(` ${m}`),
}));
})));
console.log(`[${role}] ответ ${answer.length} симв. за ${
Math.round((Date.now() - started) / 1000)} с`);
const model = body.model || ('deepseek:' + role);
@@ -173,9 +259,9 @@ const server = createServer(async (req, res) => {
const prompt = [body.system, body.prompt].filter(Boolean).join('\n\n').trim();
if (!prompt) return json(res, 400, { error: 'пустой запрос' });
const role = roleOf(body.model);
const answer = await serial(() => ask({
const answer = await serial(() => track(() => ask({
prompt, role, timeout: cfg.timeout, log: m => console.log(` ${m}`),
}));
})));
const head = { model: body.model || ('deepseek:' + role), created_at: now() };
if (body.stream === false) {
return json(res, 200, { ...head, response: answer, done: true, done_reason: 'stop' });
@@ -212,6 +298,14 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
if (cfg.host !== '0.0.0.0') console.log(`Слушаем только ${cfg.host}`);
console.log(`Модели: ${MODELS.join(', ')}`);
console.log(`Браузер: порт ${cfg.cdpPort}, профиль ${cfg.profileDir || '(не задан)'}`);
if (cfg.watchdog) {
console.log(`Сторож: проверка каждые ${cfg.watchdogSec} с`);
// Первая проверка сразу: если браузер уже открыт, вкладка подхватится,
// а если нет — поднимется, пока никто не ждёт ответа.
scheduleWatch(1);
} else {
console.log('Сторож выключен: браузер поднимется на первом запросе');
}
console.log('Остановить — Ctrl+C');
});
}