server/browser-lifecycle-cleanup.ts
Rama main · réplica de lectura del repositorio original.
import { randomUUID } from "node:crypto";
import { readdirSync, readFileSync } from "node:fs";
import { dirname } from "node:path";
import { z } from "zod";
import { writeFileAtomic } from "./atomic.ts";
import { browserDaemonFile, browserProfilesRoot, closeBrowserSession, runningBrowserSessionNames } from "./browser-engine.ts";
import { orphanedBrowsers, stopBrowserProcesses } from "./browser-processes.ts";
import { browserRuntimeEnv } from "./browser-runtime.ts";
const BOT_ID = /^[A-Za-z0-9_-]{1,120}$/;
const PROFILE_ID = /^[a-z0-9_-]{1,40}$/;
const PROFILE_PARTITION_ID = /^[A-Za-z0-9_-]{1,40}$/;
const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const MAX_PENDING = 512;
const missingFileErrorSchema = z.looseObject({ code: z.literal("ENOENT") });
const browserCleanupTargetSchema = z.discriminatedUnion("kind", [
z.object({
requestId: z.string().regex(REQUEST_ID),
kind: z.literal("bot"),
id: z.string().regex(BOT_ID),
phase: z.enum(["prepared", "committed"]),
}).strict(),
z.object({
requestId: z.string().regex(REQUEST_ID),
kind: z.literal("profile"),
id: z.string().regex(PROFILE_ID).refine((id) => id !== "guest"),
partitionId: z.string().regex(PROFILE_PARTITION_ID).refine((id) => id !== "guest"),
phase: z.enum(["prepared", "committed"]),
}).strict(),
]);
const browserCleanupJournalSchema = z.array(browserCleanupTargetSchema).max(MAX_PENDING).superRefine((entries, ctx) => {
const requestIds = new Set<string>();
for (const [index, entry] of entries.entries()) {
if (!requestIds.has(entry.requestId)) {
requestIds.add(entry.requestId);
continue;
}
ctx.addIssue({
code: "custom",
path: [index, "requestId"],
message: "duplicate browser cleanup request id",
});
}
});
const browserCleanupResultSchema = z.object({
type: z.literal("openmausbot:browser-lifecycle-result"),
requestId: z.string().regex(REQUEST_ID),
ok: z.boolean(),
}).strict();
export type BrowserCleanupKind = "bot" | "profile";
export type BrowserCleanupRequest = z.infer<typeof browserCleanupTargetSchema>;
export type BrowserCleanupWireRequest = {
type: "openmausbot:browser-bot-deleted" | "openmausbot:browser-profile-deleted";
requestId: string;
botId?: string;
partitionId?: string;
};
export interface BrowserCleanupIncomingMessage {
type?: string;
requestId?: string;
ok?: boolean;
}
type CleanupJournalWriter = (path: string, data: string, options: { mode?: number }) => void;
/** Bookkeeping failures are reported only after the mandatory runtime effects ran, so a revoked bearer or fleet never stays live until restart. */
export async function finalizeBrowserCleanupMutation<T>(options: {
requests: readonly BrowserCleanupRequest[];
referenceError?: unknown;
commit: (request: BrowserCleanupRequest) => BrowserCleanupRequest;
ensure: (request: BrowserCleanupRequest) => Promise<boolean>;
mandatory: () => Promise<T>;
}): Promise<{ value: T; acknowledgements: boolean[] }> {
let firstError: unknown | null = options.referenceError ?? null;
const pendingAcknowledgements: Array<Promise<boolean>> = [];
if (firstError === null) {
for (const request of options.requests) {
try {
const committed = options.commit(request);
pendingAcknowledgements.push(options.ensure(committed));
} catch (error) {
firstError = error;
break;
}
}
}
let value!: T;
try {
value = await options.mandatory();
} catch (error) {
if (firstError === null) firstError = error;
}
const settled = await Promise.allSettled(pendingAcknowledgements);
const acknowledgements = settled.map((result) => result.status === "fulfilled" && result.value);
const rejected = settled.find((result): result is PromiseRejectedResult => result.status === "rejected");
if (firstError === null && rejected) firstError = rejected.reason;
if (firstError !== null) throw firstError;
return { value, acknowledgements };
}
function unavailableJournalError(error: Error): Error & { status: number } {
return Object.assign(new Error(
"The browser cleanup journal could not be read safely. Browser profile reuse and deletion are blocked "
+ `until the journal is repaired (${error.message}).`,
), { status: 503, cause: error });
}
export function requireBrowserCleanupAcknowledged(ok: boolean, target: string): void {
if (ok) return;
const error = Object.assign(new Error(
`${target} was removed, but OpenMausBot could not confirm its local browser data was erased. `
+ "Restart the desktop app before reusing it; cleanup will retry automatically.",
), { status: 503 });
throw error;
}
type Waiter = {
resolve: (ok: boolean) => void;
timer: ReturnType<typeof setTimeout>;
};
function validTarget(kind: BrowserCleanupKind, id: string, partitionId: string): boolean {
return kind === "bot"
? BOT_ID.test(id)
: PROFILE_ID.test(id) && id !== "guest" && PROFILE_PARTITION_ID.test(partitionId) && partitionId !== "guest";
}
/** Crash-safe handoff to Electron: a deletion is journaled as prepared before its config/store mutation and committed after;
* only committed entries are dispatched, so a crash can block id reuse but never trigger a wipe from an ambiguous snapshot. */
export class BrowserCleanupCoordinator {
readonly #file: string;
readonly #send: (message: BrowserCleanupWireRequest) => boolean;
readonly #timeoutMs: number;
readonly #retryMs: readonly number[];
readonly #write: CleanupJournalWriter;
readonly #pending = new Map<string, BrowserCleanupRequest>();
readonly #waiters = new Map<string, Waiter>();
readonly #inflight = new Map<string, Promise<boolean>>();
readonly #retryTimers = new Map<string, ReturnType<typeof setTimeout>>();
#loadFailure: (Error & { status: number }) | null = null;
constructor(options: {
file: string;
send: (message: BrowserCleanupWireRequest) => boolean;
timeoutMs?: number;
retryMs?: readonly number[];
write?: CleanupJournalWriter;
}) {
this.#file = options.file;
this.#send = options.send;
this.#timeoutMs = Math.max(10, options.timeoutMs ?? 10_000);
this.#retryMs = options.retryMs?.length ? options.retryMs : [1_000, 5_000, 30_000, 120_000];
this.#write = options.write ?? writeFileAtomic;
this.#load();
}
#load(): void {
try {
const raw: unknown = JSON.parse(readFileSync(this.#file, "utf8"));
const journal = browserCleanupJournalSchema.safeParse(raw);
if (!journal.success) {
throw new Error(`invalid browser cleanup journal: ${journal.error.issues[0]?.message ?? "invalid entry"}`);
}
for (const request of journal.data) this.#pending.set(request.requestId, request);
} catch (caught) {
// Only a missing journal is empty; replacing any unreadable state could resurrect a deleted login partition.
if (missingFileErrorSchema.safeParse(caught).success) return;
const error = caught instanceof Error ? caught : new Error(String(caught));
this.#loadFailure = unavailableJournalError(error);
}
}
#assertHealthy(): void {
if (this.#loadFailure) throw this.#loadFailure;
}
#save(): void {
this.#assertHealthy();
this.#write(this.#file, JSON.stringify([...this.#pending.values()], null, 2), { mode: 0o600 });
}
prepare(kind: BrowserCleanupKind, id: string, partitionId = id): BrowserCleanupRequest {
this.#assertHealthy();
if (!validTarget(kind, id, partitionId)) throw new Error(`invalid browser ${kind} cleanup target`);
const existing = [...this.#pending.values()].find((request) => request.kind === kind && request.id === id);
if (existing) return existing;
if (this.#pending.size >= MAX_PENDING) throw new Error("too many pending browser data cleanups");
const request: BrowserCleanupRequest = kind === "bot"
? { requestId: randomUUID(), kind, id, phase: "prepared" }
: { requestId: randomUUID(), kind, id, partitionId, phase: "prepared" };
this.#pending.set(request.requestId, request);
try {
this.#save();
} catch (error) {
this.#pending.delete(request.requestId);
throw error;
}
return request;
}
/** This marker is the only authority startup replay uses: in-memory loaders recover parse failures as empty state. */
commit(request: BrowserCleanupRequest): BrowserCleanupRequest {
this.#assertHealthy();
const current = this.#pending.get(request.requestId);
if (!current || current.kind !== request.kind || current.id !== request.id) {
throw new Error("unknown browser cleanup request");
}
if (current.phase === "committed") return current;
const committed = { ...current, phase: "committed" as const } satisfies BrowserCleanupRequest;
this.#pending.set(request.requestId, committed);
try {
this.#save();
} catch (error) {
this.#pending.set(request.requestId, current);
throw error;
}
return committed;
}
abort(request: BrowserCleanupRequest): void {
this.#assertHealthy();
if (!this.#pending.has(request.requestId)) return;
if (this.#pending.get(request.requestId)?.phase === "committed") {
throw new Error("cannot abort a committed browser cleanup");
}
this.#pending.delete(request.requestId);
try {
this.#save();
} catch (error) {
this.#pending.set(request.requestId, request);
throw error;
}
}
pending(): BrowserCleanupRequest[] {
this.#assertHealthy();
return [...this.#pending.values()];
}
hasPendingProfile(profileId: string, partitionId = profileId): boolean {
this.#assertHealthy();
const foldedPartitionId = partitionId.toLowerCase();
return [...this.#pending.values()].some((request) =>
request.kind === "profile"
&& (request.id === profileId || request.partitionId.toLowerCase() === foldedPartitionId));
}
/** Prepared entries are excluded: their primary config deletion may not have committed. */
committedProfileIds(): string[] {
this.#assertHealthy();
return [...new Set(
[...this.#pending.values()]
.filter((request): request is Extract<BrowserCleanupRequest, { kind: "profile" }> =>
request.kind === "profile" && request.phase === "committed")
.map((request) => request.id),
)];
}
/** A late success still clears the durable journal even after the request's timeout fired. */
receive(message: BrowserCleanupIncomingMessage | undefined): boolean {
if (message?.type !== "openmausbot:browser-lifecycle-result") return false;
const parsed = browserCleanupResultSchema.safeParse(message);
if (!parsed.success) throw new Error("invalid browser lifecycle result");
const result = parsed.data;
const completed = result.ok ? this.#finish(result.requestId) : false;
const waiter = this.#waiters.get(result.requestId);
if (waiter) {
clearTimeout(waiter.timer);
this.#waiters.delete(result.requestId);
waiter.resolve(result.ok && completed);
}
return true;
}
#finish(requestId: string): boolean {
const request = this.#pending.get(requestId);
if (!request) return true;
if (request.phase !== "committed") return false;
this.#pending.delete(requestId);
try {
this.#save();
} catch (error) {
// Keep the item when the acknowledgement could not be persisted, so a retry never treats stale credentials as erased.
this.#pending.set(requestId, request);
console.error("browser cleanup: could not persist acknowledgement", error);
return false;
}
const retry = this.#retryTimers.get(requestId);
if (retry) clearTimeout(retry);
this.#retryTimers.delete(requestId);
return true;
}
async #attempt(request: BrowserCleanupRequest): Promise<boolean> {
const current = this.#pending.get(request.requestId);
if (!current) return true;
if (current.phase !== "committed") return false;
const result = new Promise<boolean>((resolve) => {
const prior = this.#waiters.get(request.requestId);
if (prior) {
clearTimeout(prior.timer);
prior.resolve(false);
}
const timer = setTimeout(() => {
if (this.#waiters.get(request.requestId)?.timer !== timer) return;
this.#waiters.delete(request.requestId);
resolve(false);
}, this.#timeoutMs);
timer.unref?.();
this.#waiters.set(request.requestId, { resolve, timer });
});
const sent = this.#send({
type: current.kind === "bot"
? "openmausbot:browser-bot-deleted"
: "openmausbot:browser-profile-deleted",
requestId: current.requestId,
...(current.kind === "bot" ? { botId: current.id } : { partitionId: current.partitionId }),
});
if (!sent) {
const waiter = this.#waiters.get(request.requestId);
if (waiter) {
clearTimeout(waiter.timer);
this.#waiters.delete(request.requestId);
waiter.resolve(false);
}
}
return result;
}
async ensure(request: BrowserCleanupRequest): Promise<boolean> {
this.#assertHealthy();
const current = this.#pending.get(request.requestId);
if (!current) return true;
if (current.phase !== "committed") return false;
const active = this.#inflight.get(request.requestId);
if (active) return active;
const operation = this.#attempt(current).finally(() => {
if (this.#inflight.get(request.requestId) === operation) this.#inflight.delete(request.requestId);
});
this.#inflight.set(request.requestId, operation);
const ok = await operation;
if (!ok && this.#pending.has(request.requestId)) this.#schedule(current, 0);
return ok;
}
#schedule(request: BrowserCleanupRequest, attempt: number): void {
if (
this.#retryTimers.has(request.requestId) ||
this.#pending.get(request.requestId)?.phase !== "committed"
) return;
const delay = this.#retryMs[Math.min(attempt, this.#retryMs.length - 1)]!;
const timer = setTimeout(() => {
this.#retryTimers.delete(request.requestId);
void this.#attempt(request).then((ok) => {
if (!ok && this.#pending.has(request.requestId)) this.#schedule(request, attempt + 1);
});
}, delay);
timer.unref?.();
this.#retryTimers.set(request.requestId, timer);
}
startPending(): void {
if (this.#loadFailure) {
console.error(`browser cleanup: ${this.#loadFailure.message}`);
return;
}
for (const request of this.#pending.values()) {
if (request.phase === "committed") this.#schedule(request, 0);
}
}
}
/** Defaults for config.json's browserEngine.idleTimeoutMinutes (1-60); agent-browser's own backstop closes an idle daemon after an hour. */
export const DEFAULT_BROWSER_IDLE_MINUTES = 10;
export const CLOUD_HOME_BROWSER_IDLE_MINUTES = 3;
export function browserIdleMs(cfg: { browserEngine?: { idleTimeoutMinutes?: number } }, { cloudHome = false }: { cloudHome?: boolean } = {}): number {
return (cfg.browserEngine?.idleTimeoutMinutes ?? (cloudHome ? CLOUD_HOME_BROWSER_IDLE_MINUTES : DEFAULT_BROWSER_IDLE_MINUTES)) * 60_000;
}
/** agent-browser writes one `<session>.pid` per daemon, so with none there is nothing to ask about. */
function daemonsMayRun(env: NodeJS.ProcessEnv): boolean {
try { return readdirSync(dirname(browserDaemonFile({ ...env, AGENT_BROWSER_SESSION: "" }, ".pid"))).some((name) => name.endsWith(".pid") && name !== "dashboard.pid"); }
catch { return false; }
}
/** agent-browser detaches each daemon and its Chrome, so they outlive a crashed or killed server. A browser attached to the
* person's own Chrome, or anything else under the same HOME, is never this data dir's to close. */
export class BrowserSweep {
readonly #engine: () => string | null;
readonly #owns: (session: string) => boolean;
readonly #live: (session: string) => boolean;
readonly #held: (session: string) => boolean;
readonly #closeIdle: (session: string) => Promise<boolean>;
readonly #intervalMs: number;
readonly #warned = new Set<string>();
#timer: ReturnType<typeof setInterval> | undefined;
#pass: Promise<void> | undefined;
#hadDaemons = false;
constructor(options: {
engine: () => string | null;
owns: (session: string) => boolean;
live: (session: string) => boolean;
/** A turn holds it until it finishes: never closed while one does. */
held: (session: string) => boolean;
closeIdle: (session: string) => Promise<boolean>;
intervalMs?: number;
}) {
this.#engine = options.engine;
this.#owns = options.owns;
this.#live = options.live;
this.#held = options.held;
this.#closeIdle = options.closeIdle;
this.#intervalMs = options.intervalMs ?? 60_000;
}
start(): this {
void this.sweep(true);
this.#timer = setInterval(() => void this.sweep(), this.#intervalMs);
this.#timer.unref?.();
return this;
}
sweep(boot = false): Promise<void> {
this.#pass ??= this.#sweep(boot).catch((error) => console.warn("browser sweep:", error)).finally(() => { this.#pass = undefined; });
return this.#pass;
}
async #owned(binaryPath: string, timeoutMs?: number): Promise<string[] | null> {
const env = browserRuntimeEnv({});
if (!daemonsMayRun(env)) return null;
return (await runningBrowserSessionNames(binaryPath, env, timeoutMs))?.filter((session) => this.#owns(session)) ?? null;
}
async #sweep(boot: boolean): Promise<void> {
const binaryPath = this.#engine();
if (!binaryPath) return;
const owned = await this.#owned(binaryPath);
if (boot || owned || this.#hadDaemons) {
for (const { session, tree } of await orphanedBrowsers(browserRuntimeEnv({}).HOME, browserProfilesRoot())) {
if (!this.#owns(session)) continue;
if (await stopBrowserProcesses(tree)) console.info(`browser ${session}: stopped a browser whose engine had been killed under it`);
else console.warn(`browser ${session}: could not stop a browser whose engine had been killed under it`);
}
}
this.#hadDaemons = owned !== null;
for (const session of owned ?? []) {
// Read per browser, after the awaits before it: a turn may have started using it.
if (this.#held(session)) continue;
if (this.#live(session)) {
if (await this.#closeIdle(session)) console.info(`browser ${session}: closed after it sat unused`);
continue;
}
const closed = await closeBrowserSession(binaryPath, browserRuntimeEnv({ AGENT_BROWSER_SESSION: session }));
if (closed) {
this.#warned.delete(session);
console.info(`browser ${session}: closed a browser nothing here uses any more`);
} else if (!this.#warned.has(session)) {
this.#warned.add(session);
console.warn(`browser ${session}: could not close a browser nothing here uses any more; the sweep keeps trying`);
}
}
}
async closeAll(timeoutMs = 4_000): Promise<void> {
clearInterval(this.#timer);
this.#timer = undefined;
const binaryPath = this.#engine();
const owned = binaryPath ? await this.#owned(binaryPath, timeoutMs).catch(() => null) : null;
await Promise.all((owned ?? []).map(async (session) => {
if (!await closeBrowserSession(binaryPath!, browserRuntimeEnv({ AGENT_BROWSER_SESSION: session }), timeoutMs)) {
console.warn(`browser ${session}: could not close its browser at shutdown`);
}
}));
}
}