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
143 changes: 143 additions & 0 deletions apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -955,3 +955,146 @@ describe("document-bound references", () => {
cdp.dispose();
});
});
describe("controlled background execution", () => {
it("does not emulate passive reads and releases control while retaining passive attachments", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
cdp.trackSessionTab("reader", 4);
await cdp.send(4, "Runtime.evaluate", {});
expect(api.sendCommand).not.toHaveBeenCalledWith(
{ tabId: 4 },
"Emulation.setFocusEmulationEnabled",
expect.anything(),
);
await cdp.acquireBackgroundExecution("agent", 4);
await cdp.acquireBackgroundExecution("agent", 4);
await cdp.releaseSessionTab("agent", 4);
expect(api.sendCommand).toHaveBeenCalledWith(
{ tabId: 4 },
"Emulation.setFocusEmulationEnabled",
{ enabled: false },
);
expect(api.detach).not.toHaveBeenCalled();
await cdp.detachSession("reader");
expect(api.detach).toHaveBeenCalledOnce();
cdp.dispose();
});

it("restores desired execution on reattach before sending page commands", async () => {
const { api, onDetach } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.acquireBackgroundExecution("agent", 4);
onDetach.fire({ tabId: 4 }, "canceled_by_user");
vi.mocked(api.sendCommand).mockClear();
await cdp.send(4, "Runtime.evaluate", {});
const calls = vi.mocked(api.sendCommand).mock.calls.map((call) => call[1]);
expect(calls.indexOf("Emulation.setFocusEmulationEnabled")).toBeLessThan(
calls.indexOf("Runtime.evaluate"),
);
await cdp.detachSession("agent");
expect(api.detach).toHaveBeenCalled();
cdp.dispose();
});

it("does not disable another controller and never applies to a different tab", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.acquireBackgroundExecution("one", 4);
await cdp.acquireBackgroundExecution("two", 4);
await cdp.send(5, "Runtime.evaluate", {});
await cdp.releaseSessionTab("one", 4);
expect(api.sendCommand).not.toHaveBeenCalledWith(
{ tabId: 4 },
"Emulation.setFocusEmulationEnabled",
{ enabled: false },
);
expect(api.sendCommand).not.toHaveBeenCalledWith(
{ tabId: 5 },
"Emulation.setFocusEmulationEnabled",
expect.anything(),
);
await cdp.releaseSessionTab("two", 4);
expect(api.sendCommand).toHaveBeenCalledWith(
{ tabId: 4 },
"Emulation.setFocusEmulationEnabled",
{ enabled: false },
);
cdp.dispose();
});

it("undoes an in-flight enable when stop wins the race", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
let finish!: () => void;
vi.mocked(api.sendCommand).mockImplementation(async (_target, method, params) => {
if (
method === "Emulation.setFocusEmulationEnabled" &&
(params as { enabled: boolean }).enabled
) {
await new Promise<void>((resolve) => {
finish = resolve;
});
}
return {};
});
const acquiring = cdp.acquireBackgroundExecution("agent", 4);
const rejected = expect(acquiring).rejects.toThrow("released");
await vi.waitFor(() => expect(finish).toBeTypeOf("function"));
const releasing = cdp.detachSession("agent");
finish();
await rejected;
await releasing;
expect(api.sendCommand).toHaveBeenCalledWith(
{ tabId: 4 },
"Emulation.setFocusEmulationEnabled",
{ enabled: false },
);
expect(cdp.isAttached(4)).toBe(false);
cdp.dispose();
});

it("reports unsupported emulation and leaves no desired policy to restore", async () => {
const { api, onDetach } = fakeApi();
const cdp = new ChromiumCdp(api);
vi.mocked(api.sendCommand).mockImplementation(async (_target, method) => {
if (method === "Emulation.setFocusEmulationEnabled") throw new Error("Method not found");
return {};
});
await expect(cdp.acquireBackgroundExecution("agent", 4)).rejects.toThrow("Method not found");
onDetach.fire({ tabId: 4 }, "canceled_by_user");
vi.mocked(api.sendCommand).mockClear();
await cdp.send(4, "Runtime.evaluate", {});
expect(api.sendCommand).not.toHaveBeenCalledWith(
{ tabId: 4 },
"Emulation.setFocusEmulationEnabled",
expect.anything(),
);
await cdp.detachSession("agent");
cdp.dispose();
});
});

it("detaches on failed policy release even when a passive reader remains", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.acquireBackgroundExecution("agent", 4);
cdp.trackSessionTab("reader", 4);
vi.mocked(api.sendCommand).mockImplementation(async (_target, method, params) => {
if (
method === "Emulation.setFocusEmulationEnabled" &&
!(params as { enabled: boolean }).enabled
)
throw new Error("disable failed");
return {};
});
await expect(cdp.releaseSessionTab("agent", 4)).rejects.toThrow("disable failed");
expect(api.detach).toHaveBeenCalledWith({ tabId: 4 });
vi.mocked(api.sendCommand).mockClear();
await cdp.send(4, "Runtime.evaluate", {});
expect(api.sendCommand).not.toHaveBeenCalledWith(
{ tabId: 4 },
"Emulation.setFocusEmulationEnabled",
expect.anything(),
);
cdp.dispose();
});
70 changes: 70 additions & 0 deletions apps/extension/src/browser-driver/background-execution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/** Desired control is independent of a debugger attachment (which Chrome can drop).
* Serialize toggles per tab so a late enable cannot outlive its last owner. */
export class BackgroundExecution {
private readonly owners = new Map<number, Set<string>>();
private readonly applied = new Map<number, { attachment: string; enabled: boolean }>();
private readonly pending = new Map<number, Promise<void>>();

constructor(
private readonly attachment: (tabId: number) => string | undefined,
private readonly toggle: (tabId: number, enabled: boolean) => Promise<unknown>,
) {}

retain(sessionId: string, tabId: number): void {
const owners = this.owners.get(tabId) ?? new Set<string>();
owners.add(sessionId);
this.owners.set(tabId, owners);
}

has(sessionId: string, tabId: number): boolean {
return this.owners.get(tabId)?.has(sessionId) ?? false;
}

release(sessionId: string, tabId: number): void {
const owners = this.owners.get(tabId);
owners?.delete(sessionId);
if (owners?.size === 0) this.owners.delete(tabId);
}

forget(tabId: number): void {
this.owners.delete(tabId);
this.applied.delete(tabId);
}

invalidate(tabId: number): void {
this.applied.delete(tabId);
}

clear(): void {
this.owners.clear();
this.applied.clear();
}

async synchronize(tabId: number): Promise<void> {
// Join the preceding toggle, but retry a failed toggle on a subsequent call.
const previous = this.pending.get(tabId);
const next = (async () => {
await previous?.catch(() => {});
for (;;) {
const attachment = this.attachment(tabId);
if (!attachment) return;
const enabled = this.owners.has(tabId);
const applied = this.applied.get(tabId);
if (applied?.attachment === attachment && applied.enabled === enabled) return;
if (!enabled && applied?.attachment !== attachment) return;
await this.toggle(tabId, enabled);
if (this.attachment(tabId) !== attachment) {
throw new Error("Background execution attachment changed during setup");
}
this.applied.set(tabId, { attachment, enabled });
// A release may have arrived while Chrome processed the toggle.
}
})();
this.pending.set(tabId, next);
try {
await next;
} finally {
if (this.pending.get(tabId) === next) this.pending.delete(tabId);
}
}
}
66 changes: 54 additions & 12 deletions apps/extension/src/browser-driver/chromium-cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type {
NetworkEntryKind,
NetworkResult,
} from "@/transport/types";
import { BackgroundExecution } from "./background-execution";
import {
buildFrameGraph,
type CdpFrameGraph,
Expand Down Expand Up @@ -167,6 +168,11 @@ export class ChromiumCdp {
private readonly attachmentIds = new Map<number, string>();
private readonly attachInFlight = new Map<number, Promise<void>>();
private readonly detachInFlight = new Map<number, Promise<void>>();
private readonly backgroundExecution = new BackgroundExecution(
(tabId) => this.attachmentIds.get(tabId),
(tabId, enabled) =>
this.api.sendCommand({ tabId }, "Emulation.setFocusEmulationEnabled", { enabled }),
);
private readonly tabOwners = new Map<number, Set<string>>();
private readonly dialogBuffers = new Map<number, JavaScriptDialogInfo[]>();
private readonly dialogSequences = new Map<number, number>();
Expand Down Expand Up @@ -208,6 +214,30 @@ export class ChromiumCdp {

/** Attach to `tabId` if we haven't already in this driver. */
async ensureAttached(tabId: number): Promise<void> {
await this.ensureRawAttached(tabId);
await this.backgroundExecution.synchronize(tabId);
}

/** Only explicit automation control may retain the focus/visibility override. */
async acquireBackgroundExecution(sessionId: string, tabId: number): Promise<void> {
const retained = this.backgroundExecution.has(sessionId, tabId);
this.trackSessionTab(sessionId, tabId);
this.backgroundExecution.retain(sessionId, tabId);
try {
await this.ensureAttached(tabId);
if (!this.backgroundExecution.has(sessionId, tabId)) {
throw new Error("Background execution was released during setup");
}
} catch (error) {
if (!retained) {
this.backgroundExecution.release(sessionId, tabId);
await this.backgroundExecution.synchronize(tabId).catch(() => {});
}
throw error;
}
}

private async ensureRawAttached(tabId: number): Promise<void> {
// Returning a tab clears the cache before Chrome finishes detaching.
// New observers must wait before opening the next connection to that tab.
const detaching = this.detachInFlight.get(tabId);
Expand Down Expand Up @@ -263,9 +293,7 @@ export class ChromiumCdp {
* `chrome.runtime.lastError`.
*/
async send<T = unknown>(tabId: number, method: string, params?: object): Promise<T> {
if (!this.attachedTabs.has(tabId)) {
await this.ensureAttached(tabId);
}
await this.ensureAttached(tabId);
try {
const result = await this.api.sendCommand({ tabId }, method, params ?? {});
return result as T;
Expand All @@ -275,9 +303,7 @@ export class ChromiumCdp {
}

async sendToTarget<T = unknown>(target: CdpTarget, method: string, params?: object): Promise<T> {
if (!this.attachedTabs.has(target.tabId)) {
await this.ensureAttached(target.tabId);
}
await this.ensureAttached(target.tabId);
try {
return (await this.api.sendCommand(target, method, params ?? {})) as T;
} catch (err) {
Expand Down Expand Up @@ -455,6 +481,7 @@ export class ChromiumCdp {
this.attachedTabs.delete(tabId);
this.attachmentIds.delete(tabId);
this.options.onDocumentChanged?.(tabId);
this.backgroundExecution.invalidate(tabId);
this.clearDialogState(tabId);
this.clearConsoleState(tabId);
this.clearNetworkState(tabId);
Expand Down Expand Up @@ -488,13 +515,23 @@ export class ChromiumCdp {

/** Release one session's claim, preserving attachments still used by another. */
async releaseSessionTab(sessionId: string, tabId: number): Promise<void> {
this.backgroundExecution.release(sessionId, tabId);
const owners = this.tabOwners.get(tabId);
if (!owners?.delete(sessionId) || owners.size > 0) return;
this.tabOwners.delete(tabId);
// An observation may still be attaching when the tab is returned. Wait
// for it so detach cannot miss the attachment or remove a new owner's claim.
owners?.delete(sessionId);
if (owners?.size === 0) this.tabOwners.delete(tabId);
// Remove the old claim before yielding: a new acquisition must survive this
// cleanup, including when it uses the same session id.
await this.attachInFlight.get(tabId)?.catch(() => {});
if (!this.tabOwners.has(tabId)) await this.detach(tabId);
try {
await this.backgroundExecution.synchronize(tabId);
} catch (error) {
// A failed disable must not leave a returned user page emulated just
// because a passive reader still owns the debugger. Readers can reattach.
await this.detach(tabId);
throw error;
} finally {
if (!this.tabOwners.has(tabId)) await this.detach(tabId);
}
}

/** Subscribe to all CDP events. Returned disposable removes the listener. */
Expand All @@ -512,6 +549,7 @@ export class ChromiumCdp {
const tabs = Array.from(this.attachedTabs);
this.attachInFlight.clear();
this.tabOwners.clear();
this.backgroundExecution.clear();
this.attachedTabs.clear();
this.attachmentIds.clear();
for (const tabId of tabs) this.options.onDocumentChanged?.(tabId);
Expand Down Expand Up @@ -877,7 +915,11 @@ export class ChromiumCdp {
this.attachedTabs.delete(source.tabId);
this.attachmentIds.delete(source.tabId);
this.attachInFlight.delete(source.tabId);
this.tabOwners.delete(source.tabId);
this.backgroundExecution.invalidate(source.tabId);
if (_reason === "target_closed") {
this.tabOwners.delete(source.tabId);
this.backgroundExecution.forget(source.tabId);
}
this.clearDialogState(source.tabId);
this.clearConsoleState(source.tabId);
this.clearNetworkState(source.tabId);
Expand Down
Loading
Loading