Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions apps/server/src/preview/ServerBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1952,3 +1952,164 @@ it.live("profiles reported by one client survive another client's report", () =>
}),
).pipe(Effect.provide(layer)),
);

it.live.each([
{ budget: "agent", limit: 8 },
{ budget: "server", limit: 32 },
])("opens and popups reserve the $budget budget before session creation", ({ budget, limit }) =>
Effect.scoped(
Effect.gen(function* () {
const { broker, browser, tabId } = yield* ready;
const manager = yield* Manager.PreviewManager;
const open = (target = scope) =>
broker.invoke<PreviewAutomationStatus>({
scope: target,
operation: "open",
input: { reuseExistingTab: false, show: false },
});
for (let index = 1; index < limit - 3; index++) {
yield* open(budget === "agent" ? scope : asSession(`other-${Math.floor(index / 8)}`));
}

const sessionGate = Promise.withResolvers<void>();
const launchGate = (contextGate = Promise.withResolvers<void>());
const sessionStarted = Promise.withResolvers<void>();
const realOpen = manager.open;
const delayedOpen = vi.spyOn(manager, "open").mockImplementation((input) =>
Effect.promise(() => {
sessionStarted.resolve();
return sessionGate.promise;
}).pipe(Effect.andThen(realOpen(input))),
);
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
sessionGate.resolve();
launchGate.resolve();
delayedOpen.mockRestore();
}),
);
const events = yield* manager.subscribeEvents;
const first = yield* open().pipe(Effect.result, Effect.forkScoped);
yield* Effect.promise(() => sessionStarted.promise);
const popup = makeContext();
const resized = Promise.withResolvers<void>();
const setViewportSize = popup.page.setViewportSize;
popup.page.setViewportSize = vi.fn(async (size) => {
await setViewportSize(size);
if (size?.width === 900) resized.resolve();
});
const newCDPSession = popup.newCDPSession;
popup.newCDPSession = async (target) => {
await launchGate.promise;
return newCDPSession(target);
};
contexts[0]!.page.emit("popup", popup.page);
const last = yield* open().pipe(Effect.result, Effect.forkScoped);
const overflow = yield* open().pipe(Effect.result, Effect.forkScoped);
// The host processes this after the opens, while manager creation is blocked.
const reused = yield* broker.invoke<PreviewAutomationStatus>({
scope,
tabId,
operation: "open",
input: { show: false },
});
expect(reused.tabId).toBe(tabId);
expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toHaveLength(
limit - 3,
);

sessionGate.resolve();
for (let count = 0; count < 3; count++) {
let event = yield* PubSub.take(events);
while (event.type !== "opened") event = yield* PubSub.take(events);
yield* manager.resize({
threadId: scope.thread.threadId,
tabId: event.tabId,
viewport: { _tag: "freeform", width: 900, height: 700 },
});
}
// The same slots must stay reserved after publication, while pages are still opening.
const duringLaunch = yield* open().pipe(Effect.result, Effect.forkScoped);
const rejectedPopup = makeContext();
contexts[0]!.page.emit("popup", rejectedPopup.page);
yield* broker.invoke({ scope, tabId, operation: "status", input: {} });
launchGate.resolve();

expect((yield* Fiber.join(first))._tag).toBe("Success");
expect((yield* Fiber.join(last))._tag).toBe("Success");
for (const fiber of [overflow, duringLaunch]) {
expect(yield* Fiber.join(fiber)).toMatchObject({
_tag: "Failure",
failure: { _tag: "PreviewAutomationControlInterruptedError", reason: "tabLimit" },
});
}
yield* Effect.promise(() => resized.promise);
const { sessions } = yield* manager.list({ threadId: scope.thread.threadId });
for (const session of sessions)
yield* browser.attachViewer(viewerInput(session.tabId, false));
expect(sessions).toHaveLength(limit);
expect(popup.page.isClosed()).toBe(false);
expect(rejectedPopup.page.isClosed()).toBe(true);
const status = yield* broker.invoke<PreviewAutomationStatus>({
scope,
tabId,
operation: "status",
input: {},
});
expect(status.tabs).toHaveLength(limit);
}),
).pipe(Effect.provide(layer)),
);

it.live("failed session and browser launches release capacity, as does closing a tab", () =>
Effect.scoped(
Effect.gen(function* () {
const { broker, tabId } = yield* ready;
const manager = yield* Manager.PreviewManager;
const open = broker.invoke<PreviewAutomationStatus>({
scope,
operation: "open",
input: { reuseExistingTab: false, show: false },
});
for (let count = 1; count < 7; count++) yield* open;
const failSession = vi
.spyOn(manager, "open")
.mockImplementationOnce(() => Effect.die(new Error("session creation failed")));
yield* Effect.addFinalizer(() => Effect.sync(() => failSession.mockRestore()));
expect(yield* open.pipe(Effect.result)).toMatchObject({ _tag: "Failure" });
failSession.mockRestore();
const events = yield* manager.subscribeEvents;
contextFailure = new Error("Chromium launch failed");
expect(yield* open.pipe(Effect.result)).toMatchObject({ _tag: "Failure" });
let failedSession = yield* PubSub.take(events);
while (failedSession.type !== "opened") failedSession = yield* PubSub.take(events);
// A failed launch must not leave a session a viewer can relaunch past the budget.
expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toHaveLength(7);
contextFailure = null;
expect((yield* open).available).toBe(true);
expect(yield* open.pipe(Effect.flip)).toMatchObject({ reason: "tabLimit" });
expect(
yield* broker
.invoke<PreviewAutomationStatus>({
scope,
tabId: PreviewTabId.make(failedSession.tabId),
operation: "open",
input: { show: false },
})
.pipe(Effect.flip),
).toMatchObject({ _tag: "PreviewAutomationTabNotFoundError" });
const browser = yield* ServerBrowser.ServerBrowser;
expect(
yield* browser.attachViewer(viewerInput(failedSession.tabId, false)).pipe(Effect.flip),
).toMatchObject({ _tag: "ServerBrowserTabNotFoundError" });
yield* broker.invoke({ scope, tabId, operation: "close", input: {} });
expect((yield* open).available).toBe(true);
const status = yield* broker.invoke<PreviewAutomationStatus>({
scope,
operation: "status",
input: {},
});
expect(status.tabs).toHaveLength(8);
}),
).pipe(Effect.provide(layer)),
);
107 changes: 78 additions & 29 deletions apps/server/src/preview/ServerBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,10 @@ const make = Effect.gen(function* () {
const launchersDir = NodePath.join(config.stateDir, "server-browser", "launchers");

const tabs = new Map<string, ServerTab>();
const pendingTabs = new Map<string, Promise<ServerTab>>();
const pendingTabs = new Map<
string,
{ readonly agentId: string | null; promise?: Promise<ServerTab> }
>();

const closedPendingTabs = new Set<string>();
const adoptedPages = new Map<string, { readonly page: Page; readonly openerTabId: string }>();
Expand Down Expand Up @@ -1292,8 +1295,7 @@ const make = Effect.gen(function* () {
};

const adoptPopup = async (opener: ServerTab, popup: Page) => {
const agentId = opener.control.agentId;
if (opener.closing || (agentId !== null && atTabLimit(agentId))) {
if (opener.closing) {
await popup.close().catch(constVoid);
return;
}
Expand All @@ -1303,7 +1305,7 @@ const make = Effect.gen(function* () {
(viewer) => viewer.id === opener.control.controller,
);
await runPromise(
manager.open({
openTab({
threadId: opener.threadId,
...(/^https?:/i.test(url) ? { url } : {}),
runtime: "server",
Expand All @@ -1318,9 +1320,8 @@ const make = Effect.gen(function* () {
}),
}),
).then(
(snapshot) => {
if (opener.control.agentId === null)
controller?.push({ _tag: "popup", tabId: snapshot.tabId });
(tab) => {
if (opener.control.agentId === null) controller?.push({ _tag: "popup", tabId: tab.tabId });
},
async () => {
await popup.close().catch(constVoid);
Expand All @@ -1331,7 +1332,7 @@ const make = Effect.gen(function* () {
const ensureTab = (snapshot: PreviewSessionSnapshot): Promise<ServerTab> => {
const key = tabKey(snapshot.threadId, snapshot.tabId);
const pending = pendingTabs.get(key);
if (pending) return pending;
if (pending?.promise) return pending.promise;
const existing = tabs.get(key);
if (existing) return Promise.resolve(existing);
const opening = createTab(snapshot)
Expand All @@ -1348,7 +1349,7 @@ const make = Effect.gen(function* () {
pendingTabs.delete(key);
closedPendingTabs.delete(key);
});
pendingTabs.set(key, opening);
pendingTabs.set(key, { agentId: snapshot.automationOwner ?? null, promise: opening });
return opening;
};

Expand All @@ -1366,10 +1367,15 @@ const make = Effect.gen(function* () {
});
});

const atTabLimit = (agentSessionId: string) =>
tabs.size + pendingTabs.size >= SERVER_TAB_LIMIT ||
[...tabs.values()].filter((tab) => tab.control.agentId === agentSessionId).length >=
AGENT_TAB_LIMIT;
const atTabLimit = (agentSessionId: string) => {
const opening = [...pendingTabs.entries()].filter(([key]) => !tabs.has(key));
return (
tabs.size + opening.length >= SERVER_TAB_LIMIT ||
[...tabs.values()].filter((tab) => tab.control.agentId === agentSessionId).length +
opening.filter(([, pending]) => pending.agentId === agentSessionId).length >=
AGENT_TAB_LIMIT
);
};

const assertTabCapacity = (agentSessionId: string) => {
if (atTabLimit(agentSessionId))
Expand All @@ -1379,6 +1385,52 @@ const make = Effect.gen(function* () {
);
};

// A Playwright launch must settle before its capacity can be released.
const openTab = Effect.fn("ServerBrowser.openTab")(function* (
input: Parameters<typeof manager.open>[0],
) {
let key: string = NodeCrypto.randomUUID();
const reservation = { agentId: input.automationOwner ?? null };
return yield* Effect.acquireUseRelease(
Effect.try({
try: () => {
if (input.automationOwner !== undefined) assertTabCapacity(input.automationOwner);
pendingTabs.set(key, reservation);
},
catch: ServerBrowserPage.toOperationError,
}),
() =>
manager
.open({
...input,
beforePublish: (snapshot) => {
pendingTabs.delete(key);
key = tabKey(snapshot.threadId, snapshot.tabId);
pendingTabs.set(key, reservation);
input.beforePublish?.(snapshot);
},
})
.pipe(
Effect.flatMap((snapshot) =>
Effect.tryPromise({
try: () => ensureTab(snapshot),
catch: ServerBrowserPage.toOperationError,
}).pipe(
Effect.onError(() =>
manager
.close({ threadId: ThreadId.make(snapshot.threadId), tabId: snapshot.tabId })
.pipe(Effect.ignore),
),
),
),
),
() =>
Effect.sync(() => {
pendingTabs.delete(key);
}),
);
}, Effect.uninterruptible);

// Desktop browsing bypasses streamed viewers and server input tracking,
// so these idle signals only apply to headless tabs.
const closeIdleAgentTabs = () => {
Expand Down Expand Up @@ -1917,23 +1969,18 @@ const make = Effect.gen(function* () {
: undefined;
const navigationTimeout = Math.min(request.timeoutMs, NAVIGATION_TIMEOUT_MS);
const profileId = existing ? undefined : resolveOpenProfile(open.profileId);
if (!existing) {
closeIdleAgentTabs();
assertTabCapacity(request.agentSessionId);
}
if (!existing) closeIdleAgentTabs();
const tab =
existing ??
(await ensureTab(
await runPromise(
manager.open({
threadId: request.threadId,
...(url ? { url } : {}),
...(profileId === undefined ? {} : { profileId }),
runtime: "server",
reveal: false,
automationOwner: request.agentSessionId,
}),
),
(await runPromise(
openTab({
threadId: request.threadId,
...(url ? { url } : {}),
...(profileId === undefined ? {} : { profileId }),
runtime: "server",
reveal: false,
automationOwner: request.agentSessionId,
}),
));
if (existing?.dialog)
throw new BrowserControlInterrupted(
Expand Down Expand Up @@ -2195,7 +2242,9 @@ const make = Effect.gen(function* () {
const mirrorManagerEvent = (event: PreviewEvent) =>
Effect.promise(async () => {
if (event.type === "opened" && event.snapshot.runtime === "server") {
await ensureTab(event.snapshot).catch(constVoid);
const key = tabKey(event.snapshot.threadId, event.snapshot.tabId);
if (event.snapshot.automationOwner === undefined || pendingTabs.has(key) || tabs.has(key))
await ensureTab(event.snapshot).catch(constVoid);
return;
}
const key = tabKey(event.threadId, event.tabId);
Expand Down
Loading