server/browser-lifecycle-cleanup.test.ts
Rama main · réplica de lectura del repositorio original.
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
BrowserCleanupCoordinator,
BrowserSweep,
browserIdleMs,
finalizeBrowserCleanupMutation,
requireBrowserCleanupAcknowledged,
} from "./browser-lifecycle-cleanup.ts";
import { closeBrowserSession, runningBrowserSessionNames } from "./browser-engine.ts";
import { orphanedBrowsers, stopBrowserProcesses, type BrowserProcessTree } from "./browser-processes.ts";
vi.mock("./browser-engine.ts", async (original) => ({
...await original<typeof import("./browser-engine.ts")>(),
closeBrowserSession: vi.fn(async () => true),
runningBrowserSessionNames: vi.fn(async () => []),
}));
vi.mock("./browser-processes.ts", async (original) => ({
...await original<typeof import("./browser-processes.ts")>(),
orphanedBrowsers: vi.fn(async () => []),
stopBrowserProcesses: vi.fn(async () => true),
}));
const folders: string[] = [];
const journal = () => {
const folder = mkdtempSync(join(tmpdir(), "openmaus-browser-cleanup-"));
folders.push(folder);
return join(folder, "browser-cleanups.json");
};
afterEach(() => {
for (const folder of folders.splice(0)) rmSync(folder, { recursive: true, force: true });
});
describe("durable browser lifecycle cleanup", () => {
it("keeps a deletion journaled until Electron acknowledges the wipe", async () => {
const file = journal();
let coordinator!: BrowserCleanupCoordinator;
coordinator = new BrowserCleanupCoordinator({
file,
timeoutMs: 50,
retryMs: [60_000],
send(message) {
const { requestId } = message;
queueMicrotask(() => coordinator.receive({
type: "openmausbot:browser-lifecycle-result",
requestId,
ok: true,
}));
return true;
},
});
const prepared = coordinator.prepare("profile", "work");
expect(coordinator.hasPendingProfile("work")).toBe(true);
const request = coordinator.commit(prepared);
await expect(coordinator.ensure(request)).resolves.toBe(true);
expect(coordinator.pending()).toEqual([]);
expect(JSON.parse(readFileSync(file, "utf8"))).toEqual([]);
});
it("does not report completion without an ACK and blocks profile-id reuse across restart", async () => {
const file = journal();
const coordinator = new BrowserCleanupCoordinator({
file,
timeoutMs: 10,
retryMs: [60_000],
send: () => true,
});
const request = coordinator.commit(coordinator.prepare("profile", "client_1"));
const acknowledged = await coordinator.ensure(request);
expect(acknowledged).toBe(false);
expect(() => requireBrowserCleanupAcknowledged(acknowledged, "The browser profile"))
.toThrow(expect.objectContaining({ status: 503 }));
expect(coordinator.hasPendingProfile("client_1")).toBe(true);
const afterRestart = new BrowserCleanupCoordinator({
file,
timeoutMs: 10,
retryMs: [60_000],
send: () => false,
});
expect(afterRestart.hasPendingProfile("client_1")).toBe(true);
expect(afterRestart.pending()).toEqual([request]);
});
it("locks the canonical profile id while wiping its exact legacy partition", async () => {
const file = journal();
let sentProfileId = "";
let coordinator!: BrowserCleanupCoordinator;
coordinator = new BrowserCleanupCoordinator({
file,
timeoutMs: 50,
retryMs: [60_000],
send(message) {
sentProfileId = message.partitionId ?? "";
queueMicrotask(() => coordinator.receive({
type: "openmausbot:browser-lifecycle-result",
requestId: message.requestId,
ok: true,
}));
return true;
},
});
const request = coordinator.commit(coordinator.prepare("profile", "work-2", "Work"));
expect(coordinator.hasPendingProfile("work-2")).toBe(true);
expect(coordinator.hasPendingProfile("different-id", "work")).toBe(true);
expect(coordinator.committedProfileIds()).toEqual(["work-2"]);
await expect(coordinator.ensure(request)).resolves.toBe(true);
expect(sentProfileId).toBe("Work");
expect(coordinator.hasPendingProfile("work-2")).toBe(false);
expect(coordinator.committedProfileIds()).toEqual([]);
});
it("does not dispatch an ambiguous prepared intent after a crash", async () => {
const file = journal();
const coordinator = new BrowserCleanupCoordinator({ file, send: () => false });
const request = coordinator.prepare("profile", "client-crash");
let sends = 0;
const afterRestart = new BrowserCleanupCoordinator({
file,
timeoutMs: 10,
retryMs: [10],
send: () => {
sends += 1;
return true;
},
});
afterRestart.startPending();
await new Promise((resolve) => setTimeout(resolve, 30));
expect(sends).toBe(0);
expect(afterRestart.pending()).toEqual([request]);
expect(afterRestart.hasPendingProfile("client-crash")).toBe(true);
expect(afterRestart.committedProfileIds()).toEqual([]);
await expect(afterRestart.ensure(request)).resolves.toBe(false);
});
it("replays only an explicitly committed intent after a crash", async () => {
const file = journal();
const beforeCrash = new BrowserCleanupCoordinator({ file, send: () => false });
const request = beforeCrash.commit(beforeCrash.prepare("profile", "client-committed"));
let afterRestart!: BrowserCleanupCoordinator;
afterRestart = new BrowserCleanupCoordinator({
file,
timeoutMs: 50,
retryMs: [10],
send(message) {
queueMicrotask(() => afterRestart.receive({
type: "openmausbot:browser-lifecycle-result",
requestId: message.requestId,
ok: true,
}));
return true;
},
});
await expect(afterRestart.ensure(request)).resolves.toBe(true);
expect(afterRestart.pending()).toEqual([]);
});
it("treats malformed journal JSON as unknown state and blocks profile reuse", () => {
const file = journal();
writeFileSync(file, "{ definitely not json");
const coordinator = new BrowserCleanupCoordinator({ file, send: () => false });
expect(() => coordinator.hasPendingProfile("work")).toThrow(expect.objectContaining({
status: 503,
message: expect.stringMatching(/could not be read safely.*blocked/i),
}));
expect(() => coordinator.prepare("profile", "work")).toThrow(expect.objectContaining({ status: 503 }));
expect(() => coordinator.pending()).toThrow(expect.objectContaining({ status: 503 }));
});
it("rejects a syntactically valid journal containing an invalid entry", () => {
const file = journal();
writeFileSync(file, JSON.stringify([{
requestId: "00000000-0000-4000-8000-000000000000",
kind: "profile",
id: "work",
phase: "maybe",
}]));
const coordinator = new BrowserCleanupCoordinator({ file, send: () => false });
expect(() => coordinator.hasPendingProfile("work")).toThrow(expect.objectContaining({
status: 503,
message: expect.stringMatching(/invalid browser cleanup journal/i),
}));
});
it("uses ENOENT alone as the empty-journal state", () => {
const file = journal();
const coordinator = new BrowserCleanupCoordinator({ file, send: () => false });
expect(coordinator.pending()).toEqual([]);
expect(coordinator.hasPendingProfile("work")).toBe(false);
});
it("runs mandatory post-config effects when the commit journal write fails", async () => {
const file = journal();
let writes = 0;
const events: string[] = [];
const coordinator = new BrowserCleanupCoordinator({
file,
send: () => false,
write(path, data, options) {
writes += 1;
if (writes === 2) throw new Error("simulated commit journal failure");
writeFileSync(path, data, options);
},
});
const request = coordinator.prepare("profile", "work");
await expect(finalizeBrowserCleanupMutation({
requests: [request],
commit(entry) {
events.push("commit");
return coordinator.commit(entry);
},
ensure(entry) {
events.push("ensure");
return coordinator.ensure(entry);
},
async mandatory() {
events.push("mandatory");
return "status";
},
})).rejects.toThrow("simulated commit journal failure");
expect(events).toEqual(["commit", "mandatory"]);
expect(coordinator.pending()).toEqual([request]);
expect(JSON.parse(readFileSync(file, "utf8"))).toEqual([request]);
});
});
describe("closing this data dir's browsers nothing uses", () => {
/** A HOME whose agent-browser run directory holds a pid file per daemon. */
function home(daemons: string[]) {
const folder = mkdtempSync(join(tmpdir(), "openmaus-browser-sweep-"));
folders.push(folder);
mkdirSync(join(folder, "agent-browser"));
for (const session of daemons) writeFileSync(join(folder, "agent-browser", `${session}.pid`), "0");
vi.stubEnv("XDG_RUNTIME_DIR", folder);
vi.mocked(runningBrowserSessionNames).mockClear().mockResolvedValue(daemons);
vi.mocked(closeBrowserSession).mockClear().mockResolvedValue(true);
vi.mocked(orphanedBrowsers).mockClear().mockResolvedValue([]);
vi.mocked(stopBrowserProcesses).mockClear().mockResolvedValue(true);
}
function sweep(options: { live?: string[]; held?: string[]; idle?: (session: string) => Promise<boolean> } = {}) {
const closeIdle = vi.fn(options.idle ?? (async () => false));
const value = new BrowserSweep({
engine: () => "/engine/agent-browser",
owns: (session) => session === "bot-a" || session === "bot-b" || session.startsWith("guest-0f0f0f0f-"),
live: (session) => options.live?.includes(session) ?? false,
held: (session) => options.held?.includes(session) ?? false,
closeIdle,
});
return { value, closeIdle, closed: () => vi.mocked(closeBrowserSession).mock.calls.map(([binary, env]) => `${binary} ${env.AGENT_BROWSER_SESSION}`) };
}
afterEach(() => { vi.unstubAllEnvs(); vi.useRealTimers(); });
it("closes the browsers an earlier run left, and nothing else under the same HOME", async () => {
home(["bot-a", "guest-0f0f0f0f-1234", "someone-elses", "work"]);
const { value, closeIdle, closed } = sweep();
await value.sweep();
expect(closed()).toEqual(["/engine/agent-browser bot-a", "/engine/agent-browser guest-0f0f0f0f-1234"]);
expect(closeIdle).not.toHaveBeenCalled();
});
it("closes a browser this run uses only once it sat unused", async () => {
home(["bot-a", "bot-b"]);
const { value, closeIdle, closed } = sweep({ live: ["bot-a"] });
await value.sweep();
expect(closeIdle.mock.calls).toEqual([["bot-a"]]);
expect(closed()).toEqual(["/engine/agent-browser bot-b"]);
});
it("never closes a browser a turn holds, however long it went unused", async () => {
home(["bot-a", "bot-b"]);
const { value, closeIdle, closed } = sweep({ live: ["bot-a"], held: ["bot-a", "bot-b"], idle: async () => true });
await value.sweep();
expect(closeIdle).not.toHaveBeenCalled();
expect(closed()).toEqual([]);
});
it("asks the engine nothing while no daemon has a pid file, or there is no engine", async () => {
home([]);
writeFileSync(join(process.env.XDG_RUNTIME_DIR!, "agent-browser", "dashboard.pid"), "0");
await sweep().value.sweep();
expect(runningBrowserSessionNames).not.toHaveBeenCalled();
home(["bot-a"]);
await new BrowserSweep({ engine: () => null, owns: () => true, live: () => false, held: () => false, closeIdle: async () => false }).sweep();
expect(runningBrowserSessionNames).not.toHaveBeenCalled();
});
it("runs one pass at a time, and keeps trying a browser it could not close with one warning", async () => {
home(["bot-a"]);
vi.mocked(closeBrowserSession).mockResolvedValue(false);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { value, closed } = sweep();
await Promise.all([value.sweep(), value.sweep()]);
await value.sweep();
expect(closed()).toEqual(["/engine/agent-browser bot-a", "/engine/agent-browser bot-a"]);
expect(warn).toHaveBeenCalledTimes(1);
warn.mockRestore();
});
it("sweeps at boot and every minute, and at shutdown closes every browser of this data dir, used or not", async () => {
vi.useFakeTimers();
home(["bot-a", "bot-b", "someone-elses"]);
const { value, closeIdle, closed } = sweep({ live: ["bot-a", "bot-b"] });
value.start();
await vi.advanceTimersByTimeAsync(0);
expect(closeIdle).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(60_000);
expect(closeIdle).toHaveBeenCalledTimes(4);
await value.closeAll(1_000);
expect(closed()).toEqual(["/engine/agent-browser bot-a", "/engine/agent-browser bot-b"]);
expect(vi.mocked(closeBrowserSession).mock.calls.every(([, , timeout]) => timeout === 1_000)).toBe(true);
await vi.advanceTimersByTimeAsync(120_000);
expect(closeIdle).toHaveBeenCalledTimes(4);
});
it("stops a browser of this data dir whose engine was killed, at boot and while daemons run, and no one else's", async () => {
const orphan = (session: string): { session: string; tree: BrowserProcessTree } => ({ session, tree: { daemon: 42, members: new Map([[42, "t"]]), groups: [42] } });
home([]);
vi.mocked(orphanedBrowsers).mockResolvedValue([orphan("bot-a"), orphan("someone-elses")]);
const info = vi.spyOn(console, "info").mockImplementation(() => {});
await sweep().value.sweep();
expect(orphanedBrowsers).not.toHaveBeenCalled(); // no pid file: nothing died since the last pass
await sweep().value.sweep(true);
expect(vi.mocked(stopBrowserProcesses).mock.calls).toEqual([[orphan("bot-a").tree]]);
home(["bot-b"]);
vi.mocked(orphanedBrowsers).mockResolvedValue([orphan("guest-0f0f0f0f-1")]);
const running = sweep({ live: ["bot-b"] }).value;
await running.sweep();
expect(vi.mocked(stopBrowserProcesses).mock.calls).toEqual([[orphan("guest-0f0f0f0f-1").tree]]);
rmSync(join(process.env.XDG_RUNTIME_DIR!, "agent-browser", "bot-b.pid"));
await running.sweep();
expect(orphanedBrowsers).toHaveBeenCalledTimes(2);
await running.sweep();
expect(orphanedBrowsers).toHaveBeenCalledTimes(2);
info.mockRestore();
});
it("closes an unused browser after ten minutes, three on a Cloud home, unless config.json says otherwise", () => {
expect(browserIdleMs({})).toBe(10 * 60_000);
expect(browserIdleMs({}, { cloudHome: true })).toBe(3 * 60_000);
expect(browserIdleMs({ browserEngine: { idleTimeoutMinutes: 2 } })).toBe(2 * 60_000);
expect(browserIdleMs({ browserEngine: { idleTimeoutMinutes: 20 } }, { cloudHome: true })).toBe(20 * 60_000);
});
});