From 52de3b91ebc9b617c4c8895fc63bd902197d3c9e Mon Sep 17 00:00:00 2001 From: Alex Cube Date: Wed, 26 Aug 2026 14:52:13 +0300 Subject: [PATCH] =?UTF-8?q?=D0=96=D0=B8=D0=B2=D1=83=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=8C:=20=D1=81=D0=BE=D0=BA=D0=B5=D1=82=20=D1=83=D0=BC?= =?UTF-8?q?=D0=B8=D1=80=D0=B0=D0=B5=D1=82=20=D1=87=D0=B5=D1=81=D1=82=D0=BD?= =?UTF-8?q?=D0=BE,=20=D1=81=D1=82=D0=BE=D1=80=D0=BE=D0=B6=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=BD=D0=B8=D0=BC=D0=B0=D0=B5=D1=82=20=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=83=D0=B7=D0=B5=D1=80=20=D1=81=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три вещи, и первая - настоящая дыра, а не удобство. 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 --- browser.mjs | 87 ++++++++++++++++++++++++++++++++++++++++--- server.mjs | 104 +++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 181 insertions(+), 10 deletions(-) diff --git a/browser.mjs b/browser.mjs index b018c47..79e44ca 100644 --- a/browser.mjs +++ b/browser.mjs @@ -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 }; + } +} + // Картинка приезжает к нам в памяти, а странице нужен файл на диске: другого // способа наполнить у протокола нет. 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); // сайдбар держим чистым diff --git a/server.mjs b/server.mjs index 47a04d4..6ee50c7 100644 --- a/server.mjs +++ b/server.mjs @@ -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'); }); }