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
@@ -119,6 +119,13 @@ export type MockCamofox = MockHttp & {
|
||||
expireLogin(userId?: string): void;
|
||||
/** 模拟上游回收标签页(空闲超时 / 重启) */
|
||||
dropTab(tabId: string): void;
|
||||
/**
|
||||
* 模拟 camofox 容器重启(BR-3):全部标签页消失,旧 tabId 之后返回 410 browser_restarted(与 1.17.0 实测一致),
|
||||
* 实例标识(`GET /umt/instance`)换新。loseProfiles = true 时连 profile 的 Cookie / 登录态一起丢(SIGKILL 未落盘)。
|
||||
*/
|
||||
restart(opts?: { loseProfiles?: boolean }): void;
|
||||
/** 当前实例标识 */
|
||||
readonly instanceId: string;
|
||||
};
|
||||
|
||||
/** 1x1 透明 PNG */
|
||||
@@ -325,6 +332,9 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
const loggedIn = new Set<string>();
|
||||
const cookieJars = new Map<string, MockCamofoxCookie[]>();
|
||||
const cookieApi = opts.cookieApi ?? "plugin";
|
||||
let instanceId = `inst-${crypto.randomUUID()}`;
|
||||
/** 重启前存在过的 tabId:之后访问返回 410 */
|
||||
const restartedTabs = new Set<string>();
|
||||
const site = opts.site;
|
||||
const putCookie = (userId: string, c: MockCamofoxCookie) => {
|
||||
const jar = (cookieJars.get(userId) ?? []).filter(
|
||||
@@ -784,6 +794,7 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
return { json: { downloads: list } };
|
||||
},
|
||||
],
|
||||
[/^\/umt\/instance$/, () => (cookieApi === "plugin" ? { json: { instanceId } } : err(404, "no route"))],
|
||||
[
|
||||
/^\/umt\/sessions\/([^/]+)\/cookies$/,
|
||||
(m, q, req, b) => {
|
||||
@@ -829,12 +840,20 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
guard: (req) => {
|
||||
const path = new URL(req.url ?? "/", "http://mock").pathname;
|
||||
// 与上游一致:导入端点有自己的口令(CAMOFOX_API_KEY),不走访问凭据
|
||||
if (!opts.accessKey || path === "/health" || /^\/sessions\/[^/]+\/cookies$/.test(path)) return null;
|
||||
return req.headers.authorization === `Bearer ${opts.accessKey}` ? null : err(401, "Unauthorized");
|
||||
const gone = /^\/tabs\/([^/]+)/.exec(path)?.[1];
|
||||
const restarted =
|
||||
gone && restartedTabs.has(decodeURIComponent(gone))
|
||||
? {
|
||||
status: 410,
|
||||
json: { error: "Tab no longer exists (browser was restarted).", code: "browser_restarted" },
|
||||
}
|
||||
: null;
|
||||
if (!opts.accessKey || path === "/health" || /^\/sessions\/[^/]+\/cookies$/.test(path)) return restarted;
|
||||
return req.headers.authorization === `Bearer ${opts.accessKey}` ? restarted : err(401, "Unauthorized");
|
||||
},
|
||||
},
|
||||
);
|
||||
return Object.assign(mock, {
|
||||
const out = Object.assign(mock, {
|
||||
tabs,
|
||||
actions,
|
||||
loggedIn,
|
||||
@@ -846,5 +865,17 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
dropTab(tabId: string) {
|
||||
tabs.delete(tabId);
|
||||
},
|
||||
restart(o: { loseProfiles?: boolean } = {}) {
|
||||
for (const id of tabs.keys()) restartedTabs.add(id);
|
||||
tabs.clear();
|
||||
instanceId = `inst-${crypto.randomUUID()}`;
|
||||
if (o.loseProfiles) {
|
||||
loggedIn.clear();
|
||||
cookieJars.clear();
|
||||
}
|
||||
},
|
||||
});
|
||||
// Object.assign 会把 getter 取值成常量:单独定义
|
||||
Object.defineProperty(out, "instanceId", { get: () => instanceId, enumerable: true });
|
||||
return out as MockCamofox;
|
||||
}
|
||||
Reference in new issue
Block a user