fix(browser): settle timed out actions
This commit is contained in:
1 parent
83faa9629b
commit
b5a6a1a9ea
2 files changed
+157
-11
No files matched your search
+23
-11
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user