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;
|
if (!queue || queue.length === 0) return;
|
||||||
|
|
||||||
this.processing.add(socialAccountId);
|
this.processing.add(socialAccountId);
|
||||||
while (queue.length > 0) {
|
try {
|
||||||
const item = queue.shift()!;
|
while (queue.length > 0) {
|
||||||
try {
|
const item = queue.shift()!;
|
||||||
const result = await this.runActionWithDeadline(item.action);
|
try {
|
||||||
item.resolve(result);
|
const result = await this.runActionWithDeadline(item.action);
|
||||||
} catch (error) {
|
item.resolve(result);
|
||||||
item.reject(error instanceof Error ? error : new Error(String(error)));
|
} 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> {
|
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) => {
|
return new Promise<BrowserActionResult>((resolve, reject) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
this.cancelledActionIds.add(action.id);
|
this.cancelledActionIds.add(action.id);
|
||||||
const activeWindow = this.activeActionWindows.get(action.id);
|
const activeWindow = this.activeActionWindows.get(action.id);
|
||||||
if (activeWindow && !activeWindow.isDestroyed()) {
|
if (activeWindow && !activeWindow.isDestroyed()) {
|
||||||
activeWindow.close();
|
activeWindow.close();
|
||||||
}
|
}
|
||||||
|
this.activeActionWindows.delete(action.id);
|
||||||
log.warn(`[browser] action ${action.id} exceeded ${ACTION_TIMEOUT_MS}ms timeout`);
|
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);
|
}, ACTION_TIMEOUT_MS);
|
||||||
|
|
||||||
runAction(
|
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