fix(browser): park webviews permanently paintable
Delete the renderer capture prep handshake and keep screenshots on the serialized invalidate/retry path. The capture harness now defaults to the production P1 attach-off parking check.
This commit is contained in:
@@ -13,36 +13,13 @@ import {
|
||||
} from "../browser-webviews/index.js";
|
||||
|
||||
const MAX_CONSOLE_MESSAGES_PER_TAB = 200;
|
||||
const PIXEL_CAPTURE_BRIDGE_TIMEOUT_MS = 5_000;
|
||||
const PIXEL_CAPTURE_PREP_RETRY_INTERVAL_MS = 50;
|
||||
const PIXEL_CAPTURE_PREP_UNAVAILABLE_PREFIX = "Browser screenshot prep_unavailable:";
|
||||
const consoleMessagesByContentsId = new Map<number, BrowserAutomationConsoleLogEntry[]>();
|
||||
const observedContentsIds = new Set<number>();
|
||||
let nextPixelCaptureBridgeRequest = 0;
|
||||
|
||||
interface IpcHandlerRegistry {
|
||||
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void;
|
||||
}
|
||||
|
||||
interface IpcBridgeEvent {
|
||||
sender?: {
|
||||
id?: number;
|
||||
};
|
||||
}
|
||||
|
||||
type IpcListener = (event: IpcBridgeEvent, payload: unknown) => void;
|
||||
|
||||
interface IpcCaptureBridge {
|
||||
on(channel: string, listener: IpcListener): void;
|
||||
removeListener(channel: string, listener: IpcListener): void;
|
||||
}
|
||||
|
||||
interface HostWebContents {
|
||||
readonly id: number;
|
||||
isDestroyed(): boolean;
|
||||
send(channel: string, payload: unknown): void;
|
||||
}
|
||||
|
||||
interface WebContentsDebugger {
|
||||
isAttached(): boolean;
|
||||
attach(protocolVersion?: string): void;
|
||||
@@ -65,7 +42,6 @@ interface ConsoleMessageEmitter {
|
||||
|
||||
interface BrowserAutomationWebContents extends ConsoleMessageEmitter {
|
||||
readonly id: number;
|
||||
readonly hostWebContents: HostWebContents | null;
|
||||
readonly debugger: WebContentsDebugger;
|
||||
getURL(): string;
|
||||
getTitle(): string;
|
||||
@@ -80,34 +56,10 @@ interface BrowserAutomationWebContents extends ConsoleMessageEmitter {
|
||||
reload(): void;
|
||||
capturePage(rect?: Rectangle, options?: { stayHidden?: boolean }): Promise<TabImage>;
|
||||
invalidate(): void;
|
||||
getBackgroundThrottling(): boolean;
|
||||
setBackgroundThrottling(allowed: boolean): void;
|
||||
}
|
||||
|
||||
type PixelCaptureBridgeKind = "prepare" | "restore";
|
||||
|
||||
interface PixelCaptureBridgeSuccess {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
interface PixelCaptureBridgeOptions {
|
||||
ipc?: IpcCaptureBridge;
|
||||
createRequestId?: () => string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface PreparedPixelCapture {
|
||||
browserId: string;
|
||||
host: HostWebContents;
|
||||
}
|
||||
|
||||
export function adaptWebContents(
|
||||
contents: BrowserAutomationWebContents,
|
||||
browserId: string,
|
||||
options?: PixelCaptureBridgeOptions,
|
||||
): TabContents {
|
||||
export function adaptWebContents(contents: BrowserAutomationWebContents): TabContents {
|
||||
observeConsoleMessages(contents);
|
||||
const preparedPixelCapturesByToken = new Map<string, PreparedPixelCapture>();
|
||||
return {
|
||||
id: contents.id,
|
||||
getURL: () => contents.getURL(),
|
||||
@@ -122,34 +74,7 @@ export function adaptWebContents(
|
||||
goForward: () => contents.goForward(),
|
||||
reload: () => contents.reload(),
|
||||
capturePage: (captureOptions) => contents.capturePage(undefined, captureOptions),
|
||||
prepareForPixelCapture: async () => {
|
||||
const result = await preparePixelCaptureBridgeWithRetry({ contents, browserId, options });
|
||||
if (!result.token) {
|
||||
throw new Error("Browser pixel capture preparation did not return a token.");
|
||||
}
|
||||
preparedPixelCapturesByToken.set(result.token, { browserId, host: result.host });
|
||||
return { token: result.token };
|
||||
},
|
||||
restorePixelCapture: async (preparation) => {
|
||||
const prepared = preparedPixelCapturesByToken.get(preparation.token);
|
||||
if (!prepared) {
|
||||
throw new Error("Browser pixel capture preparation is no longer active.");
|
||||
}
|
||||
try {
|
||||
await requestPixelCaptureBridge({
|
||||
host: prepared.host,
|
||||
browserId: prepared.browserId,
|
||||
kind: "restore",
|
||||
options,
|
||||
extraPayload: { token: preparation.token },
|
||||
});
|
||||
} finally {
|
||||
preparedPixelCapturesByToken.delete(preparation.token);
|
||||
}
|
||||
},
|
||||
invalidate: () => contents.invalidate(),
|
||||
isBackgroundThrottlingAllowed: () => contents.getBackgroundThrottling(),
|
||||
setBackgroundThrottling: (allowed) => contents.setBackgroundThrottling(allowed),
|
||||
getConsoleMessages: () => consoleMessagesByContentsId.get(contents.id) ?? [],
|
||||
sendDebugCommand: async (command: string, params?: Record<string, unknown>) => {
|
||||
if (!contents.debugger.isAttached()) {
|
||||
@@ -160,204 +85,6 @@ export function adaptWebContents(
|
||||
};
|
||||
}
|
||||
|
||||
function getPixelCaptureHost(contents: BrowserAutomationWebContents): HostWebContents | null {
|
||||
const host = contents.hostWebContents;
|
||||
if (!host || host.isDestroyed()) {
|
||||
return null;
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
async function preparePixelCaptureBridgeWithRetry(input: {
|
||||
contents: BrowserAutomationWebContents;
|
||||
browserId: string;
|
||||
options: PixelCaptureBridgeOptions | undefined;
|
||||
}): Promise<PixelCaptureBridgeSuccess & { host: HostWebContents }> {
|
||||
const timeoutMs = input.options?.timeoutMs ?? PIXEL_CAPTURE_BRIDGE_TIMEOUT_MS;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastUnavailableReason = "Browser host renderer is not available.";
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const host = getPixelCaptureHost(input.contents);
|
||||
if (!host) {
|
||||
await delayUntilRetry(deadline);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await requestPixelCaptureBridge({
|
||||
host,
|
||||
browserId: input.browserId,
|
||||
kind: "prepare",
|
||||
options: withBridgeTimeout(input.options, Math.max(1, deadline - Date.now())),
|
||||
});
|
||||
return { ...result, host };
|
||||
} catch (error) {
|
||||
if (!isRetryablePrepareUnavailableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
lastUnavailableReason = errorMessage(error);
|
||||
await delayUntilRetry(deadline);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`${PIXEL_CAPTURE_PREP_UNAVAILABLE_PREFIX} ${lastUnavailableReason}`);
|
||||
}
|
||||
|
||||
function withBridgeTimeout(
|
||||
options: PixelCaptureBridgeOptions | undefined,
|
||||
timeoutMs: number,
|
||||
): PixelCaptureBridgeOptions {
|
||||
return {
|
||||
...options,
|
||||
timeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
function isRetryablePrepareUnavailableError(error: unknown): boolean {
|
||||
const message = errorMessage(error);
|
||||
return (
|
||||
message.includes("Browser pixel capture preparation is unavailable.") ||
|
||||
message.includes("Browser host renderer is not available.") ||
|
||||
message.includes("is not mounted.")
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function delayUntilRetry(deadline: number): Promise<void> {
|
||||
const remainingMs = deadline - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, Math.min(PIXEL_CAPTURE_PREP_RETRY_INTERVAL_MS, remainingMs));
|
||||
});
|
||||
}
|
||||
|
||||
function requestPixelCaptureBridge(input: {
|
||||
host: HostWebContents;
|
||||
browserId: string;
|
||||
kind: PixelCaptureBridgeKind;
|
||||
options: PixelCaptureBridgeOptions | undefined;
|
||||
extraPayload?: { token: string };
|
||||
}): Promise<PixelCaptureBridgeSuccess> {
|
||||
const { host, browserId, kind, options, extraPayload } = input;
|
||||
if (host.isDestroyed()) {
|
||||
return Promise.reject(new Error("Browser host renderer is not available."));
|
||||
}
|
||||
|
||||
const ipc = options?.ipc ?? ipcMain;
|
||||
const requestId =
|
||||
options?.createRequestId?.() ?? `browser-pixel-capture-${++nextPixelCaptureBridgeRequest}`;
|
||||
const timeoutMs = options?.timeoutMs ?? PIXEL_CAPTURE_BRIDGE_TIMEOUT_MS;
|
||||
const requestChannel =
|
||||
kind === "prepare" ? "paseo:browser:capture-prepare" : "paseo:browser:capture-restore";
|
||||
const responseChannel =
|
||||
kind === "prepare" ? "paseo:browser:capture-prepared" : "paseo:browser:capture-restored";
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const cleanup = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
ipc.removeListener(responseChannel, listener);
|
||||
};
|
||||
const listener: IpcListener = (event, payload) => {
|
||||
if (readSenderId(event) !== host.id) {
|
||||
return;
|
||||
}
|
||||
const response = readPixelCaptureBridgeResponse(payload, requestId);
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
cleanup();
|
||||
if (response.ok) {
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(new Error(response.message));
|
||||
}
|
||||
};
|
||||
|
||||
ipc.on(responseChannel, listener);
|
||||
timeoutId = setTimeout(() => {
|
||||
cleanup();
|
||||
sendPixelCaptureCancel(host, {
|
||||
requestId,
|
||||
browserId,
|
||||
...(extraPayload ? { token: extraPayload.token } : {}),
|
||||
});
|
||||
reject(new Error(`Browser pixel capture ${kind} timed out.`));
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
host.send(requestChannel, {
|
||||
requestId,
|
||||
browserId,
|
||||
...(extraPayload ? { token: extraPayload.token } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sendPixelCaptureCancel(
|
||||
host: HostWebContents,
|
||||
payload: { browserId: string; requestId?: string; token?: string },
|
||||
): void {
|
||||
if (host.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
host.send("paseo:browser:capture-cancel", payload);
|
||||
} catch {
|
||||
// The original prepare/restore request owns the user-visible error.
|
||||
}
|
||||
}
|
||||
|
||||
function readSenderId(event: IpcBridgeEvent): number | null {
|
||||
const senderId = event.sender?.id;
|
||||
return typeof senderId === "number" ? senderId : null;
|
||||
}
|
||||
|
||||
function readPixelCaptureBridgeResponse(
|
||||
payload: unknown,
|
||||
requestId: string,
|
||||
): ({ ok: true } & PixelCaptureBridgeSuccess) | { ok: false; message: string } | null {
|
||||
if (!isRecord(payload)) {
|
||||
return null;
|
||||
}
|
||||
const record = payload;
|
||||
if (record.requestId !== requestId) {
|
||||
return null;
|
||||
}
|
||||
if (record.ok === true) {
|
||||
return {
|
||||
ok: true,
|
||||
...(typeof record.token === "string" && record.token.length > 0
|
||||
? { token: record.token }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
if (record.ok === false) {
|
||||
const message =
|
||||
typeof record.message === "string" && record.message.length > 0
|
||||
? record.message
|
||||
: "Browser pixel capture bridge failed.";
|
||||
return { ok: false, message };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function observeConsoleMessages(contents: BrowserAutomationWebContents): void {
|
||||
if (observedContentsIds.has(contents.id)) {
|
||||
return;
|
||||
@@ -398,7 +125,7 @@ function createRegistry(): BrowserRegistry {
|
||||
listRegisteredBrowserIdsForWorkspace: listRegisteredPaseoBrowserIdsForWorkspace,
|
||||
getTabContents(browserId: string): TabContents | null {
|
||||
const contents = getPaseoBrowserWebContents(browserId);
|
||||
return contents ? adaptWebContents(contents, browserId) : null;
|
||||
return contents ? adaptWebContents(contents) : null;
|
||||
},
|
||||
getBrowserWorkspaceId: getPaseoBrowserWorkspaceId,
|
||||
getWorkspaceActiveBrowserId: getWorkspaceActivePaseoBrowserId,
|
||||
|
||||
Reference in New Issue
Block a user