feat(browser): self-heal sessions after camofox restarts (BR-3)

- Any session operation that hits 404/410 (or finds no open tab) reconciles
  tabs; if none survive it re-injects the account cookies (BR-2), opens a new
  tab under the same userId/sessionKey, returns to the recorded URL (site home
  otherwise), marks old tabs lost and retries the operation once. Failure
  returns 503 browser_reconnect_failed with the reason, with a 10s backoff.
- Restart detection: umt plugin GET /umt/instance (fallback /health fields);
  a changed instance id marks every open session needsReconnect.
- POST /browser/sessions/{id}/reconnect (idempotent); session view exposes
  needsReconnect and reconnect {status, trigger, at, reason, humanNotice}.
- Controller and lease are kept; reconnecting during takeover asks the human
  to double-check the reloaded page.
- Web: screenshot and desktop modes show "reconnecting", auto-reconnect once,
  pause polling, offer a reconnect button with the reason on failure; desktop
  mode rebuilds the Selkies connection after a reconnect.
- Mock camofox restart(), tests, smoke step and runbook section 10.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 17:29:11 +08:00
1 parent da24380870
commit 838aea3ebd
19 files changed
+977 -45

No files matched your search

+139
View File
@@ -280,6 +280,145 @@ async function main() {
results.push({ path: label, ms: Date.now() - t0, problems: [...new Set(problems)] });
}
// ── 交互:原站视图在浏览器服务重启后自动重连(BR-3;浏览器 API 全部 mock,不连真实 camofox)──
// 截图先 404(标签页不在了)→ 显示"正在重新连接" → 自动调一次 POST .../reconnect → 截图恢复
{
const label = "原站视图 → 浏览器重启后自动重连(mock)";
const sitesRes = await page.request.get(`${BASE}/api/v1/sites?limit=50`);
const site = sitesRes.ok()
? ((await sitesRes.json().catch(() => null))?.data?.items ?? []).find((x) => x.enabled)
: null;
if (!site) skipped.push(`${label}(没有启用的站点)`);
else {
problems = [];
const t0 = Date.now();
const sid = "00000000-0000-4000-8000-00000000b3b3";
const at = new Date().toISOString();
const session = {
id: sid,
providerId: "00000000-0000-4000-8000-00000000b3b4",
siteId: site.id,
accountId: null,
profileKey: `umt-site-${site.id}-acct-anon`,
controller: "automation",
currentUrl: "https://example.invalid/torrents.php",
lastActivityAt: at,
upstreamRef: {},
recoveryState: {},
closedAt: null,
closeReason: null,
createdAt: at,
updatedAt: at,
tabs: [],
currentTabId: "tab-mock",
lease: null,
needsSnapshot: false,
waitingReason: null,
credential: null,
needsReconnect: false,
reconnect: null,
};
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
const ok = (data) => ({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true, data }) });
let reconnects = 0;
let shots = 0;
await page.route("**/api/v1/browser/**", async (route) => {
const u = new URL(route.request().url());
const p = u.pathname.replace(/^\/api\/v1/, "");
if (p === "/browser/providers")
return route.fulfill(
ok({
items: [
{
id: session.providerId,
name: "camofox-mock",
kind: "camofox",
baseUrl: "http://camofox.invalid:9377",
secretRef: null,
managedBy: null,
hasAccessKey: true,
enabled: true,
capabilities: null,
verifiedAt: null,
healthStatus: "healthy",
lastHealthAt: at,
health: null,
},
],
}),
);
if (p === "/browser/sessions" && route.request().method() === "GET")
return route.fulfill(ok({ items: [session] }));
if (p === `/browser/desktop/${sid}/_status`)
return route.fulfill(
ok({
available: false,
reason: "冒烟 mock",
role: "viewer",
leaseExpiresAt: null,
path: `/api/v1/browser/desktop/${sid}/`,
requiresSecureContext: true,
}),
);
if (p === `/browser/sessions/${sid}/screenshot`) {
shots++;
if (reconnects === 0)
return route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ ok: false, error: { code: "not_found", message: "标签页不存在或已关闭" } }),
});
return route.fulfill(
ok({
mimeType: "image/png",
dataBase64: png,
width: 1,
height: 1,
url: session.currentUrl,
modelSafe: true,
capturedAt: new Date().toISOString(),
controller: "automation",
}),
);
}
if (p === `/browser/sessions/${sid}/reconnect`) {
reconnects++;
await new Promise((r) => setTimeout(r, 800));
return route.fulfill(
ok({ ...session, reconnect: { status: "ok", trigger: "tab_gone", at, reason: null, humanNotice: false } }),
);
}
return route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ ok: false, error: { code: "not_found", message: `冒烟 mock 没有 ${p}` } }),
});
});
try {
await page.goto("about:blank");
await page.goto(`${BASE}/console/sites/${encodeURIComponent(site.id)}?tab=origin`, {
waitUntil: "domcontentloaded",
timeout: 30_000,
});
await page.getByText("浏览器已重启,正在重新连接…").waitFor({ timeout: 15_000 });
await page.locator('img[alt="站点页面画面"]').waitFor({ timeout: 15_000 });
await page.waitForTimeout(2_000);
if (reconnects !== 1) problems.push(`自动重连应只调用 1 次,实际 ${reconnects} 次`);
if (shots < 2) problems.push("重连后没有继续轮询截图");
if (await page.getByText("读取画面失败").count()) problems.push("仍显示读取画面失败");
} catch (err) {
problems.push(`交互失败:${String(err?.message ?? err).split("\n")[0]}`);
}
if (SHOTS) await page.screenshot({ path: join(OUT, "site_origin_reconnect.png") }).catch(() => {});
await page.unroute("**/api/v1/browser/**");
// mock 的首个截图 404 是预期内的
const kept = problems.filter(
(x) => !(/HTTP 404/.test(x) && x.includes("/screenshot")) && !/status of 404/.test(x),
);
results.push({ path: label, ms: Date.now() - t0, problems: [...new Set(kept)] });
}
}
// ── 旧链接重定向 ──
for (const r of REDIRECT_ROUTES) {
let from = r.from;