fix(browser): settle timed out actions

This commit is contained in:
ZacharyZhang-NY committed 2026-05-15 02:17:10 -04:00
1 parent 83faa9629b
commit b5a6a1a9ea
2 files changed
+157 -11

No files matched your search

+23 -11
View File
@@ -179,32 +179,44 @@ export class BrowserManager {
if (!queue || queue.length === 0) return;
this.processing.add(socialAccountId);
while (queue.length > 0) {
const item = queue.shift()!;
try {
const result = await this.runActionWithDeadline(item.action);
item.resolve(result);
} catch (error) {
item.reject(error instanceof Error ? error : new Error(String(error)));
try {
while (queue.length > 0) {
const item = queue.shift()!;
try {
const result = await this.runActionWithDeadline(item.action);
item.resolve(result);
} catch (error) {
item.reject(error instanceof Error ? error : new Error(String(error)));
}
}
} finally {
this.processing.delete(socialAccountId);
if ((this.queues.get(socialAccountId)?.length ?? 0) === 0) {
this.queues.delete(socialAccountId);
}
}
this.processing.delete(socialAccountId);
}
private runActionWithDeadline(action: BrowserAction): Promise<BrowserActionResult> {
// cancelAction reuses the same cancelledActionIds set + closes the active
// window, which runAction observes to return a cancelled result. Mirror
// that path on timeout so queued follow-ups are unblocked.
return new Promise<BrowserActionResult>((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
this.cancelledActionIds.add(action.id);
const activeWindow = this.activeActionWindows.get(action.id);
if (activeWindow && !activeWindow.isDestroyed()) {
activeWindow.close();
}
this.activeActionWindows.delete(action.id);
log.warn(`[browser] action ${action.id} exceeded ${ACTION_TIMEOUT_MS}ms timeout`);
resolve({
success: false,
cancelled: true,
summary: "Action timed out and was cancelled.",
screenshotPath: null,
error: "Action timed out.",
});
}, ACTION_TIMEOUT_MS);
runAction(
+134
View File
@@ -0,0 +1,134 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { BrowserAction, BrowserActionResult } from "../../src/main/browser-manager";
import { BrowserManager } from "../../src/main/browser-manager";
const mocks = vi.hoisted(() => ({
runAction: vi.fn(),
fromPartition: vi.fn(),
logWarn: vi.fn(),
}));
vi.mock("electron", () => ({
BrowserWindow: vi.fn(),
session: {
fromPartition: mocks.fromPartition,
},
}));
vi.mock("electron-log/main.js", () => ({
default: {
info: vi.fn(),
warn: mocks.logWarn,
error: vi.fn(),
},
}));
vi.mock("../../src/main/browser/browser-action-executor", () => ({
assertAllowedUrl: vi.fn(),
attachWindowSecurity: vi.fn(),
getPlatformRules: vi.fn(() => ({
loginUrl: "https://x.com/i/flow/login",
cookieOrigins: [],
authenticatedCookieNames: [],
})),
waitForLoad: vi.fn(),
runAction: mocks.runAction,
}));
const ACTION_TIMEOUT_MS = 5 * 60 * 1000;
const tempDirs: string[] = [];
function createSession() {
return {
setPermissionCheckHandler: vi.fn(),
setPermissionRequestHandler: vi.fn(),
clearStorageData: vi.fn(),
clearCache: vi.fn(),
clearAuthCache: vi.fn(),
cookies: {
get: vi.fn(async () => []),
},
};
}
function createAction(id: string): BrowserAction {
return {
id,
socialAccountId: "social-1",
platform: "twitter",
actionType: "post",
payload: { content: "hello" },
};
}
function createActionWindow() {
const win = {
destroyed: false,
close: vi.fn(() => {
win.destroyed = true;
}),
isDestroyed: vi.fn(() => win.destroyed),
};
return win;
}
async function createProfileDir() {
const dir = await mkdtemp(join(tmpdir(), "agentcompany-browser-timeout-"));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
vi.useRealTimers();
mocks.runAction.mockReset();
mocks.fromPartition.mockReset();
mocks.logWarn.mockReset();
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
describe("browser manager action timeout", () => {
it("settles a hung action and starts the next queued action", async () => {
const profileDir = await createProfileDir();
const firstWindow = createActionWindow();
const secondResult: BrowserActionResult = {
success: true,
cancelled: false,
summary: "second action completed",
screenshotPath: null,
error: null,
};
mocks.fromPartition.mockReturnValue(createSession());
mocks.runAction
.mockImplementationOnce((action, _screenshotDir, _session, _cancelledActionIds, activeActionWindows) => {
activeActionWindows.set(action.id, firstWindow);
return new Promise(() => {});
})
.mockResolvedValueOnce(secondResult);
vi.useFakeTimers();
const manager = new BrowserManager(profileDir);
const first = manager.executeAction(createAction("action-1"));
await Promise.resolve();
const second = manager.executeAction(createAction("action-2"));
expect(mocks.runAction).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(ACTION_TIMEOUT_MS);
await expect(first).resolves.toMatchObject({
success: false,
cancelled: true,
summary: "Action timed out and was cancelled.",
screenshotPath: null,
error: "Action timed out.",
});
await expect(second).resolves.toEqual(secondResult);
expect(firstWindow.close).toHaveBeenCalledTimes(1);
expect(mocks.runAction).toHaveBeenCalledTimes(2);
});
});