feat(web-test): таймаут теста реально прерывает зависший await

Прогон вставал намертво на зависшем Playwright-действии (~29 мин, без движения),
при --format=allure отчёт терялся целиком — результаты писались только в конце.

Promise.race с таймером был и раньше, но не лечил: race не отменяет t.fn (промис
отменить нельзя), а весь путь после него шёл без единого таймаута. Паттерн
`try { await x } catch {}` ловит reject, но не «никогда не завершится» — а
page.evaluate не имеет таймаута в принципе, поэтому заблокированный JS-поток
рендерера вешал раннер навсегда.

Что сделано:

1. Прерывание. При таймауте движок опрашивает контекст (probeContext) и при
   вердикте hang/browser-dead уничтожает зависшее: abortContext закрывает
   страницу с runBeforeUnload:false, повисший await отваливается «Target closed»,
   следующий тест поднимает контекст лениво. Зависший тест не ретраится.

2. Диагноз в отчёте. hang (браузер жив, рендерер не отвечает) против slow
   (просто не уложился → поднять timeout). Разделитель — асимметрия пробников:
   вызов в browser-процесс отвечает за 1 мс при мёртвом рендерере, evaluate — нет.

3. Лицензии. Штатный logout идёт fetch-ем изнутри страницы и на зависшей
   странице невозможен. abortContext шлёт POST /e1cib/logout из Node: замерено —
   сеанс опознаётся seanceId в URL, кук у клиента нет вообще, после запроса
   клиент пишет «сеанс был завершен». Каскад: node → page → соседняя страница.

4. Дедлайны на весь путь очистки (deadline.mjs) — пробой печатается строкой,
   молча зависнуть больше нельзя. Попутно: disconnect слал logout по зависшей
   странице дважды (модульный `page` после multi-context ветки).

5. Инкрементальный Allure: результат теста пишется сразу по его завершении —
   зависание больше не уничтожает уже собранное.

6. --global-timeout: потолок на прогон, работает и внутри зависшего теста
   (неразрешённый промис не блокирует event loop). Коды выхода: 2 — потолок
   сработал, 3 — зависло само сворачивание. Внешний watchdog больше не нужен.

Проверено вживую на стенде: фикстура tests/web-test/_hang (заблокированный
JS-поток) — падение за 17.6с с verdict: hang вместо вечного зависания,
следующий тест зелёный, оба результата в allure-results; --global-timeout
срабатывает посреди зависания и сохраняет отчёт. Полный регресс 25 тестов:
пробоев дедлайнов ноль.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-07-15 17:37:18 +03:00
co-authored by Claude Opus 4.8
parent 3889c7279f
commit 037d000f20
11 changed files with 669 additions and 62 deletions
@@ -0,0 +1,48 @@
// web-test engine/core/deadline v1.0 — wall-clock bounds for calls that can hang
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
//
// Why this exists: `try { await x } catch {}` guards against a REJECTION, not against
// a promise that never settles. A Playwright call against a wedged renderer (page.evaluate
// has no timeout at all) does exactly that — it neither resolves nor rejects, and the runner
// waits forever. Every such call must be bounded by a wall-clock timer instead.
//
// Neither helper CANCELS the underlying work — that is impossible for a promise. They only
// stop *waiting* on it. Whoever breaks a deadline must also destroy the thing that hung
// (see session.abortContext) or the pending call keeps holding its resources.
export class DeadlineError extends Error {
constructor(label, ms) {
super(`${label} timed out after ${ms}ms`);
this.name = 'DeadlineError';
this.label = label;
this.ms = ms;
}
}
/**
* Await `promise`, but give up after `ms`.
* @throws {DeadlineError} when the deadline is reached first.
*/
export function withDeadline(promise, ms, label = 'operation') {
let timer;
return Promise.race([
Promise.resolve(promise),
new Promise((_, reject) => { timer = setTimeout(() => reject(new DeadlineError(label, ms)), ms); }),
]).finally(() => clearTimeout(timer));
}
/**
* Best-effort variant: never throws, reports what happened instead.
* Use it where the old code said `try { await x } catch {}` — the point is that a breach
* becomes VISIBLE (callers are expected to log `err`) rather than silently swallowed.
* @returns {Promise<{ok: boolean, value?: any, err?: Error, timedOut: boolean, ms: number}>}
*/
export async function softDeadline(promise, ms, label = 'operation') {
const t0 = Date.now();
try {
const value = await withDeadline(promise, ms, label);
return { ok: true, value, timedOut: false, ms: Date.now() - t0 };
} catch (err) {
return { ok: false, err, timedOut: err instanceof DeadlineError, ms: Date.now() - t0 };
}
}
@@ -1,7 +1,8 @@
// web-test core/session v1.17 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry.
// web-test core/session v1.18 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { chromium } from 'playwright';
import { softDeadline } from './deadline.mjs';
import { statSync, mkdirSync, readdirSync, rmSync } from 'fs';
import { join as pathJoin } from 'path';
import { tmpdir } from 'os';
@@ -138,15 +139,21 @@ async function logoutSlot(slot, waitMs = 500) {
* Sends POST /e1cib/logout to release the license before closing.
*/
export async function disconnect() {
const wasMultiContext = contexts.size > 0;
// Multi-context path: stop recording + logout each slot before closing browser
if (contexts.size > 0) {
if (wasMultiContext) {
saveActiveSlot();
// Recorder is global — one stop covers all contexts
if (recorder) {
try { await stopRecording(); } catch {}
await softDeadline(stopRecording(), 40000, 'stopRecording');
}
for (const [, slot] of contexts.entries()) {
await logoutSlot(slot);
// Deadline-bounded: an unresponsive slot must not hold up the shutdown of the others.
// nodeLogout is the fallback that needs no renderer — it is what keeps the license
// from leaking when the page is the thing that died.
const own = await softDeadline(logoutSlot(slot), 3000, 'logoutSlot');
if (!own.ok) await nodeLogout(slot, 3000);
}
contexts.clear();
setActiveContextName(null);
@@ -155,13 +162,20 @@ export async function disconnect() {
// Single-session path (connect): auto-stop recording if active
if (recorder) {
try { await stopRecording(); } catch {}
await softDeadline(stopRecording(), 40000, 'stopRecording');
}
if (browser) {
// Graceful logout — release the 1C license (single-session connect path)
await logoutSlot({ page, sessionPrefix, seanceId }, 1000);
await browser.close().catch(() => {});
// Graceful logout — release the 1C license (single-session connect path).
// Skipped after the multi-context path: `page` still mirrors the last active slot, which
// was just logged out above — re-sending it would pay a hung page's cost a second time.
if (!wasMultiContext) {
await softDeadline(logoutSlot({ page, sessionPrefix, seanceId }, 1000), 4000, 'logoutSlot');
}
await softDeadline(browser.close(), 10000, 'browser.close');
// Floor: if Chromium ignored close(), take the process out — never leave an orphan.
try { browser.browser?.()?.process?.()?.kill('SIGKILL'); } catch {}
try { browser.process?.()?.kill('SIGKILL'); } catch {}
setBrowser(null);
setPage(null);
setSessionPrefix(null);
@@ -241,7 +255,22 @@ function activateSlot(name) {
/** Attach 1C session listeners to a page, writing into the given slot. */
function attachSessionListeners(pg, slot, name) {
pg.on('dialog', dialog => dialog.accept().catch(() => {}));
// Network counters feed the hang/slow diagnosis (see probeContext). These events are
// emitted by the BROWSER process, so they keep flowing even when the page's JS thread is
// wedged and page.evaluate() can no longer answer. Supporting colour only, not a verdict:
// a wedged renderer cannot issue requests, so "quiet" looks the same as "idle waiting".
// Counters only — never buffer URLs, this runs for the whole suite.
slot.net = { lastEventAt: Date.now(), inFlight: 0, requests: 0, responses: 0 };
const settled = () => { slot.net.inFlight = Math.max(0, slot.net.inFlight - 1); slot.net.lastEventAt = Date.now(); };
pg.on('requestfinished', settled);
pg.on('requestfailed', settled);
pg.on('response', () => { slot.net.responses++; slot.net.lastEventAt = Date.now(); });
pg.on('request', req => {
slot.net.requests++;
slot.net.inFlight++;
slot.net.lastEventAt = Date.now();
if (slot.seanceId) return;
const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
if (m) {
@@ -402,3 +431,211 @@ export async function closeContext(name) {
}
contexts.delete(name);
}
/**
* Release a 1C seance straight from Node — no renderer, no CDP, no browser involved.
*
* Measured on the webtest stand: the seance is identified by `seanceId` in the URL and the
* client holds NO cookies at all (context.cookies() → []), so this request is equivalent to
* the one logoutSlot makes from inside the page. Verified end-to-end: after this call the
* web client reports "сеанс был завершен" on its next action.
*
* This is the only logout that still works when the renderer is wedged — which is exactly
* when a license would otherwise leak until the server-side seance timeout.
*
* @returns {Promise<boolean>} true only on a 2xx answer (a 401/404 must not pass for success).
*/
async function nodeLogout(slot, ms = 3000) {
if (!slot?.sessionPrefix || !slot?.seanceId) return false;
try {
const res = await fetch(`${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{"root":{}}',
signal: AbortSignal.timeout(ms),
});
return res.status >= 200 && res.status < 300;
} catch {
return false;
}
}
/**
* Is this context's browser alive, and is its renderer still answering?
*
* The two probes separate the failure modes that look identical from the outside:
* browserAlive && !rendererAlive → the page's JS thread is wedged: a hang. Nothing that
* goes through the renderer (evaluate, screenshot, clicks) can ever come back.
* both alive → nothing is broken; the test simply outran its timeout.
*
* cookies() is served by the browser process (measured: 1ms against a wedged renderer),
* page.evaluate() is not — that asymmetry is the whole trick.
*
* @returns {Promise<{browserAlive: boolean, rendererAlive: boolean, browserMs: number, rendererMs: number, pageClosed: boolean}>}
*/
export async function probeContext(name, { ms = 2000 } = {}) {
const slot = contexts.get(name);
if (!slot?.page) return { browserAlive: false, rendererAlive: false, browserMs: 0, rendererMs: 0, pageClosed: true };
if (slot.page.isClosed()) return { browserAlive: false, rendererAlive: false, browserMs: 0, rendererMs: 0, pageClosed: true };
const [b, r] = await Promise.all([
softDeadline(slot.page.context().cookies(), ms, 'browser probe'),
softDeadline(slot.page.evaluate(() => 1), ms, 'renderer probe'),
]);
return {
browserAlive: b.ok,
rendererAlive: r.ok,
browserMs: b.ms,
rendererMs: r.ms,
pageClosed: false,
};
}
/** Read-only view of a slot's network activity. Never hands out the slot itself. */
export function getContextDiagnostics(name) {
const slot = contexts.get(name);
if (!slot) return null;
const net = slot.net || { lastEventAt: 0, inFlight: 0, requests: 0, responses: 0 };
return {
name,
isolation: activeMode,
net: { ...net },
msSinceLastNetEvent: net.lastEventAt ? Date.now() - net.lastEventAt : null,
pageClosed: slot.page ? slot.page.isClosed() : true,
};
}
/**
* Force-release an unresponsive context — including the ACTIVE one, which closeContext
* refuses to touch. Every step is wall-clock bounded, so this path is never at the mercy
* of the thing that hung: it is bounded by timers, never by browser cooperation.
*
* Ordering matters: logout FIRST (a dead page can't release its own license), close second.
*
* @param {string} name
* @param {object} [opts]
* @param {number} [opts.logoutMs=3000] budget per logout attempt
* @param {number} [opts.closeMs=5000] budget for page.close()/context.close()
* @param {string} [opts.parkOn] context to activate afterwards (default: any survivor)
* @returns {Promise<{name, logout: 'node'|'page'|'sibling'|'failed'|'skipped', closed: 'page'|'context'|'browser-killed'|'failed', escalated: boolean, notes: string[]}>}
*/
export async function abortContext(name, { logoutMs = 3000, closeMs = 5000, parkOn } = {}) {
const out = { name, logout: 'skipped', closed: 'failed', escalated: false, notes: [] };
const slot = contexts.get(name);
if (!slot) { out.notes.push('not registered'); return out; }
// The recorder follows the active page; if we are about to close that page, stop it first
// or the CDP screencast is dead for the rest of the run.
if (recorder && activeContextName === name) {
const r = await softDeadline(stopRecording(), 10000, 'stopRecording');
if (!r.ok) out.notes.push(`stopRecording: ${r.err.message.split('\n')[0]}`);
}
// ── Logout cascade: first success wins. node first — it needs neither renderer nor CDP.
if (slot.seanceId && slot.sessionPrefix) {
if (await nodeLogout(slot, logoutMs)) {
out.logout = 'node';
} else {
const own = await softDeadline(logoutSlot(slot, 0), logoutMs, 'logoutSlot');
if (own.ok) {
out.logout = 'page';
} else {
// Same origin ⇒ same seance namespace; a live sibling can post the logout for us.
const sibling = [...contexts.entries()].find(([n, s]) =>
n !== name && s.page && !s.page.isClosed() && s.sessionPrefix === slot.sessionPrefix);
if (sibling) {
const url = `${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`;
const sib = await softDeadline(
sibling[1].page.evaluate(async (u) => {
const r = await fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"root":{}}' });
return r.status;
}, url), logoutMs, 'sibling logout');
if (sib.ok && sib.value >= 200 && sib.value < 300) out.logout = 'sibling';
else out.logout = 'failed';
} else {
out.logout = 'failed';
}
}
}
if (out.logout === 'failed') out.notes.push('license may leak until the 1C seance times out');
}
// In tab mode `browser` is a persistent BrowserContext (see createContext): closing its LAST
// page leaves Chromium with no windows and it exits, so the next createContext() would fail
// with "Failed to open a new tab". When this is the only slot, tear the browser down on
// purpose and reset state — the next createContext() then relaunches cleanly.
const lastPageInTabMode = activeMode === 'tab' && contexts.size === 1;
// ── Close. runBeforeUnload:false is what survives a wedged renderer: the browser process
// tears the target down instead of asking the page's JS to agree.
if (activeMode === 'tab') {
// tab mode: slot.context IS the shared browser — closing it would kill every context.
const c = await softDeadline(slot.page.close({ runBeforeUnload: false }), closeMs, 'page.close');
if (c.ok) out.closed = 'page';
} else {
const c = await softDeadline(slot.context.close(), closeMs, 'context.close');
if (c.ok) out.closed = 'context';
}
// ── Escalate: if even the close hung, the browser itself is suspect. Kill it and reset
// state, otherwise the next createContext() would call newPage() on a dead object and
// every remaining test would fail.
if (out.closed === 'failed') {
out.escalated = true;
out.notes.push('close breached its deadline — killing the browser');
const b = browser;
await softDeadline(Promise.resolve(b?.close?.()), 5000, 'browser.close');
try { b?.browser?.()?.process?.()?.kill('SIGKILL'); } catch {}
try { b?.process?.()?.kill('SIGKILL'); } catch {}
out.closed = 'browser-killed';
contexts.clear();
setBrowser(null);
setPage(null);
setSessionPrefix(null);
setSeanceId(null);
setActiveContextName(null);
setActiveMode(null);
if (persistentUserDataDir) {
try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {}
setPersistentUserDataDir(null);
}
return out;
}
contexts.delete(name);
if (lastPageInTabMode) {
out.notes.push('last tab closed — browser torn down, next createContext relaunches it');
await softDeadline(Promise.resolve(browser?.close?.()), 5000, 'browser.close');
try { browser?.browser?.()?.process?.()?.kill('SIGKILL'); } catch {}
setBrowser(null);
setPage(null);
setSessionPrefix(null);
setSeanceId(null);
setActiveContextName(null);
setActiveMode(null);
if (persistentUserDataDir) {
try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {}
setPersistentUserDataDir(null);
}
return out;
}
// ── Park the active pointer on a survivor (or nothing). Deliberately NOT via
// setActiveContext()/saveActiveSlot() — those would write the dying page back into a slot.
if (activeContextName === name) {
const survivor = (parkOn && contexts.has(parkOn)) ? parkOn : [...contexts.keys()][0];
if (survivor) {
activateSlot(survivor);
if (recorder) { try { await recorder._attachPage(page); } catch { /* recording is best-effort */ } }
} else {
// No slots left: isConnected() goes false and engine calls fail fast until the next
// ensureContext() recreates one. That is the intended contract, not a leak.
setPage(null);
setSessionPrefix(null);
setSeanceId(null);
setActiveContextName(null);
}
}
return out;
}