Живучесть: сокет умирает честно, сторож поднимает браузер сам
Три вещи, и первая - настоящая дыра, а не удобство. 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;
|
return chat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сколько ждать ОДИН вызов протокола. Обычные вызовы — чтение DOM, миллисекунды;
|
||||||
|
// самый долгий, Page.captureScreenshot на тяжёлой странице, укладывается в пару
|
||||||
|
// секунд. Минута — потолок, за которым уже не «медленно», а «не ответит никогда».
|
||||||
|
const CDP_CALL_TIMEOUT = 60;
|
||||||
|
const CDP_CONNECT_TIMEOUT = 10;
|
||||||
|
|
||||||
class Session {
|
class Session {
|
||||||
#ws; #id = 1; #pending = new Map();
|
#ws; #id = 1; #pending = new Map(); #dead = null;
|
||||||
|
|
||||||
static connect(wsUrl) {
|
static connect(wsUrl) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
const s = new Session();
|
const s = new Session();
|
||||||
s.#ws = ws;
|
s.#ws = ws;
|
||||||
ws.addEventListener('open', () => resolve(s), { once: true });
|
const giveUp = setTimeout(
|
||||||
ws.addEventListener('error', e => reject(new Error(`WebSocket: ${e?.message ?? e}`)), { once: true });
|
() => 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 => {
|
ws.addEventListener('message', ev => {
|
||||||
let m; try { m = JSON.parse(ev.data); } catch { return; }
|
let m; try { m = JSON.parse(ev.data); } catch { return; }
|
||||||
if (m.id === undefined) 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 = {}) {
|
send(method, params = {}) {
|
||||||
|
if (this.#dead) return Promise.reject(this.#dead);
|
||||||
const id = this.#id++;
|
const id = this.#id++;
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.#pending.set(id, { resolve, reject });
|
const giveUp = setTimeout(() => {
|
||||||
this.#ws.send(JSON.stringify({ id, method, params }));
|
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); // дать чату прогрузиться до первого вопроса
|
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"> у протокола нет.
|
// способа наполнить <input type="file"> у протокола нет.
|
||||||
async function attachImage(s, bytes, log) {
|
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';
|
if (!ROLES.includes(role)) role = 'think';
|
||||||
await ensureBrowser(log);
|
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) => {
|
return withChat(async (s) => {
|
||||||
await pruneChats(s, log); // сайдбар держим чистым
|
await pruneChats(s, log); // сайдбар держим чистым
|
||||||
|
|
||||||
|
|||||||
+99
-5
@@ -16,7 +16,7 @@ import { readFile } from 'node:fs/promises';
|
|||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
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 вручную: путь проекта может содержать «#»,
|
// fileURLToPath, а не разбор pathname вручную: путь проекта может содержать «#»,
|
||||||
// и в URL он приезжает как %23 — самодельный разбор ломается именно на нём.
|
// и в URL он приезжает как %23 — самодельный разбор ломается именно на нём.
|
||||||
@@ -31,6 +31,15 @@ const cfg = {
|
|||||||
chatUrl: 'https://chat.deepseek.com/',
|
chatUrl: 'https://chat.deepseek.com/',
|
||||||
defaultRole: 'think',
|
defaultRole: 'think',
|
||||||
timeout: 300,
|
timeout: 300,
|
||||||
|
// Сторож: поднимать браузер и вкладку сам, не дожидаясь вопроса. Иначе
|
||||||
|
// восстановление происходит внутри запроса, и клиент платит за него
|
||||||
|
// 15-60 секундами своего времени ожидания.
|
||||||
|
watchdog: true,
|
||||||
|
watchdogSec: 30,
|
||||||
|
// Потолок отсрочки. Если браузер не поднимается (обычно профиль уже открыт
|
||||||
|
// другим окном без отладочного порта), проверки разрежаются вдвое каждый
|
||||||
|
// раз — иначе сторож плодил бы процессы каждые полминуты без конца.
|
||||||
|
watchdogMaxSec: 300,
|
||||||
...JSON.parse(await readFile(join(HERE, 'config.json'), 'utf8').catch(() => '{}')),
|
...JSON.parse(await readFile(join(HERE, 'config.json'), 'utf8').catch(() => '{}')),
|
||||||
// Личные пути — сюда: этот файл в репозиторий не попадает, и обновление
|
// Личные пути — сюда: этот файл в репозиторий не попадает, и обновление
|
||||||
// проекта не затрёт настройки конкретной машины.
|
// проекта не затрёт настройки конкретной машины.
|
||||||
@@ -43,17 +52,67 @@ configure(cfg);
|
|||||||
|
|
||||||
const MODELS = ROLES.map(r => 'deepseek:' + r);
|
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: очередь без ограничения длины — если клиент завалит сервис
|
// ponytail: очередь без ограничения длины — если клиент завалит сервис
|
||||||
// запросами, они просто будут ждать. Появится нужда — резать по длине очереди.
|
// запросами, они просто будут ждать. Появится нужда — резать по длине очереди.
|
||||||
let chain = Promise.resolve();
|
let chain = Promise.resolve();
|
||||||
function serial(fn) {
|
function serial(fn) {
|
||||||
|
state.queue++;
|
||||||
const next = chain.then(fn, fn);
|
const next = chain.then(fn, fn);
|
||||||
chain = next.catch(() => {});
|
chain = next.catch(() => {});
|
||||||
|
next.then(() => { state.queue--; }, () => { state.queue--; });
|
||||||
return next;
|
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
|
// ------------------------------------------------------------------ Ollama
|
||||||
|
|
||||||
// Роль из имени модели: "deepseek:search" → "search". Незнакомое имя (клиент
|
// Роль из имени модели: "deepseek:search" → "search". Незнакомое имя (клиент
|
||||||
@@ -98,6 +157,22 @@ async function readBody(req) {
|
|||||||
|
|
||||||
const now = () => new Date().toISOString();
|
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 по умолчанию просят поток и ждут не один объект, а несколько
|
// Клиенты Ollama по умолчанию просят поток и ждут не один объект, а несколько
|
||||||
// строк JSON подряд. Настоящего потока у нас нет — веб-чат отдаёт ответ целиком,
|
// строк JSON подряд. Настоящего потока у нас нет — веб-чат отдаёт ответ целиком,
|
||||||
// — но формат соблюсти обязаны, иначе клиент не разберёт ответ вообще.
|
// — но формат соблюсти обязаны, иначе клиент не разберёт ответ вообще.
|
||||||
@@ -136,6 +211,17 @@ const server = createServer(async (req, res) => {
|
|||||||
return json(res, 200, { version: 'deepseek-api' });
|
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: что видно во вкладке прямо сейчас.
|
// Своё, сверх протокола Ollama: что видно во вкладке прямо сейчас.
|
||||||
if (req.method === 'GET' && url.pathname === '/api/probe') {
|
if (req.method === 'GET' && url.pathname === '/api/probe') {
|
||||||
return json(res, 200, await serial(() => 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 role = imageBytes ? 'vision' : roleOf(body.model);
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
console.log(`[${role}] ${prompt.length} симв.${imageBytes ? ' + картинка' : ''}`);
|
console.log(`[${role}] ${prompt.length} симв.${imageBytes ? ' + картинка' : ''}`);
|
||||||
const answer = await serial(() => ask({
|
const answer = await serial(() => track(() => ask({
|
||||||
prompt, role, imageBytes, timeout: cfg.timeout,
|
prompt, role, imageBytes, timeout: cfg.timeout,
|
||||||
log: m => console.log(` ${m}`),
|
log: m => console.log(` ${m}`),
|
||||||
}));
|
})));
|
||||||
console.log(`[${role}] ответ ${answer.length} симв. за ${
|
console.log(`[${role}] ответ ${answer.length} симв. за ${
|
||||||
Math.round((Date.now() - started) / 1000)} с`);
|
Math.round((Date.now() - started) / 1000)} с`);
|
||||||
const model = body.model || ('deepseek:' + role);
|
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();
|
const prompt = [body.system, body.prompt].filter(Boolean).join('\n\n').trim();
|
||||||
if (!prompt) return json(res, 400, { error: 'пустой запрос' });
|
if (!prompt) return json(res, 400, { error: 'пустой запрос' });
|
||||||
const role = roleOf(body.model);
|
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}`),
|
prompt, role, timeout: cfg.timeout, log: m => console.log(` ${m}`),
|
||||||
}));
|
})));
|
||||||
const head = { model: body.model || ('deepseek:' + role), created_at: now() };
|
const head = { model: body.model || ('deepseek:' + role), created_at: now() };
|
||||||
if (body.stream === false) {
|
if (body.stream === false) {
|
||||||
return json(res, 200, { ...head, response: answer, done: true, done_reason: 'stop' });
|
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}`);
|
if (cfg.host !== '0.0.0.0') console.log(`Слушаем только ${cfg.host}`);
|
||||||
console.log(`Модели: ${MODELS.join(', ')}`);
|
console.log(`Модели: ${MODELS.join(', ')}`);
|
||||||
console.log(`Браузер: порт ${cfg.cdpPort}, профиль ${cfg.profileDir || '(не задан)'}`);
|
console.log(`Браузер: порт ${cfg.cdpPort}, профиль ${cfg.profileDir || '(не задан)'}`);
|
||||||
|
if (cfg.watchdog) {
|
||||||
|
console.log(`Сторож: проверка каждые ${cfg.watchdogSec} с`);
|
||||||
|
// Первая проверка сразу: если браузер уже открыт, вкладка подхватится,
|
||||||
|
// а если нет — поднимется, пока никто не ждёт ответа.
|
||||||
|
scheduleWatch(1);
|
||||||
|
} else {
|
||||||
|
console.log('Сторож выключен: браузер поднимется на первом запросе');
|
||||||
|
}
|
||||||
console.log('Остановить — Ctrl+C');
|
console.log('Остановить — Ctrl+C');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user