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:
1 parent
da24380870
commit
838aea3ebd
19 files changed
+977
-45
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user