From c39874591f7a1f675fbc5f822344c6fa43da1406 Mon Sep 17 00:00:00 2001 From: Ljy-0827 Date: Tue, 8 Sep 2026 19:51:03 +0800 Subject: [PATCH] perf(observation): measure sibling iframe geometry with bounded concurrency --- .../src/tools/vom/__tests__/capture.test.ts | 145 ++++++++++++++++++ apps/extension/src/tools/vom/capture.ts | 84 ++++++---- 2 files changed, 203 insertions(+), 26 deletions(-) diff --git a/apps/extension/src/tools/vom/__tests__/capture.test.ts b/apps/extension/src/tools/vom/__tests__/capture.test.ts index 3c6c1b4..3f26c10 100644 --- a/apps/extension/src/tools/vom/__tests__/capture.test.ts +++ b/apps/extension/src/tools/vom/__tests__/capture.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { OVERLAY_HOST_MARKER_ATTR, OVERLAY_HOST_NAME } from "../../../lib/overlay-bridge"; +import type { CdpRunner } from "../../shared"; import { captureViewModel, collectOverlayExcludedBackendIds, probeHoverSurfaces } from "../capture"; // Minimal but format-accurate captureSnapshot reply: a body with one @@ -1262,3 +1263,147 @@ describe("captureViewModel", () => { expect(excluded).toEqual(new Set([200, 201, 202])); }); }); + +// Six sibling frames and one nested frame exercise scheduling through the real capture entry. +function siblingCaptureFixture( + beforeReply: (method: string, params: Record) => Promise = async () => {}, +) { + const document = (id: number, owners: number[], childIndexes: number[]) => ({ + frameId: `frame-${id}`, + nodes: { + parentIndex: [-1, ...owners.map(() => 0)], + nodeName: [0, ...owners.map(() => 1)], + backendNodeId: [1000 + id, ...owners], + attributes: [[], ...owners.map(() => [])], + contentDocumentIndex: { index: owners.map((_, i) => i + 1), value: childIndexes }, + }, + layout: { + nodeIndex: [0, ...owners.map((_, i) => i + 1)], + bounds: [[0, 0, 200, 100], ...owners.map(() => [0, 0, 200, 100])], + }, + }); + const snapshot = { + strings: ["body", "iframe"], + documents: [ + document(0, [100, 101, 102, 103, 104, 105], [1, 2, 3, 4, 5, 6]), + document(1, [200], [7]), + ...Array.from({ length: 6 }, (_, i) => document(i + 2, [], [])), + ], + }; + let active = 0; + let peak = 0; + const send = vi.fn(async (_tabId: number, method: string, params: object = {}) => { + const args = params as Record; + active++; + peak = Math.max(peak, active); + try { + await beforeReply(method, args); + if (method === "DOMSnapshot.captureSnapshot") return snapshot; + if (method === "Page.getLayoutMetrics") + return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 } }; + if (method === "DOM.getBoxModel") + return { model: { content: [0, 0, 200, 0, 200, 100, 0, 100] } }; + if (method === "DOM.resolveNode") return { object: { objectId: String(args.backendNodeId) } }; + if (method === "Runtime.callFunctionOn") + return { result: { value: { width: 200, height: 100 } } }; + return {}; + } finally { + active--; + } + }); + return { + cdp: { send: send as CdpRunner["send"] }, + send, + peak: () => peak, + active: () => active, + measured: () => + send.mock.calls + .filter(([, method]) => method === "DOM.getBoxModel") + .map(([, , params]) => (params as { backendNodeId: number }).backendNodeId), + }; +} + +describe("sibling frame measurement scheduling", () => { + it("bounds concurrent reads, fills free slots and preserves depth-first output despite reordered replies", async () => { + const pending = new Map void>(); + const fixture = siblingCaptureFixture(async (method, params) => { + if (method === "DOM.getBoxModel" && Number(params.backendNodeId) < 200) + await new Promise((resolve) => pending.set(Number(params.backendNodeId), resolve)); + }); + const capture = captureViewModel(fixture.cdp, 4); + await vi.waitFor(() => expect(pending.size).toBe(4)); + expect(fixture.measured()).toEqual([100, 101, 102, 103]); + pending.get(103)!(); + await vi.waitFor(() => expect(pending.has(104)).toBe(true)); + pending.get(104)!(); + await vi.waitFor(() => expect(pending.has(105)).toBe(true)); + expect(fixture.measured()).not.toContain(200); + for (const id of [105, 102, 101, 100]) pending.get(id)!(); + const captured = await capture; + expect(fixture.peak()).toBe(4); + expect(fixture.active()).toBe(0); + expect(fixture.measured()).toEqual([100, 101, 102, 103, 104, 105, 200]); + expect([...captured.iframeNodes.keys()]).toEqual([100, 200, 101, 102, 103, 104, 105]); + expect(captured.iframeNodes.get(200)?.[0].rect).toEqual({ x: 0, y: 0, w: 200, h: 100 }); + for (const method of [ + "DOM.getBoxModel", + "DOM.resolveNode", + "Runtime.callFunctionOn", + "Runtime.releaseObject", + ]) + expect(fixture.send.mock.calls.filter(([, name]) => name === method)).toHaveLength(7); + }); + + it("does not measure descendants of a failed owner and retains sibling geometry and local nodes", async () => { + const fixture = siblingCaptureFixture(async (method, params) => { + if (method === "DOM.resolveNode" && params.backendNodeId === 100) + throw new Error("owner replaced"); + }); + const captured = await captureViewModel(fixture.cdp, 4); + expect(fixture.measured()).not.toContain(200); + expect(captured.iframeNodes.get(200)?.[0]).toMatchObject({ + tag: "body", + rect: null, + localRect: { x: 0, y: 0, w: 200, h: 100 }, + }); + expect(captured.iframeNodes.get(101)?.[0].rect).toEqual({ x: 0, y: 0, w: 200, h: 100 }); + expect([...captured.iframeNodes.keys()]).toEqual([100, 200, 101, 102, 103, 104, 105]); + }); + + it("stops scheduling on cancellation and waits for resolved objects to be released", async () => { + const controller = new AbortController(); + const resolutions = new Map void>(); + const releases = new Map void>(); + const fixture = siblingCaptureFixture(async (method, params) => { + if (method === "DOM.resolveNode") + await new Promise((resolve) => + resolutions.set(String(params.backendNodeId), resolve), + ); + if (method === "Runtime.releaseObject") + await new Promise((resolve) => releases.set(String(params.objectId), resolve)); + }); + let settled = false; + const capture = captureViewModel(fixture.cdp, 4, { signal: controller.signal }); + const rejected = expect(capture).rejects.toMatchObject({ name: "AbortError" }); + void capture.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await vi.waitFor(() => expect(resolutions.size).toBe(4)); + controller.abort(); + for (const resolve of resolutions.values()) resolve(); + await vi.waitFor(() => expect(releases.size).toBe(4)); + expect(settled).toBe(false); + expect(fixture.measured()).toEqual([100, 101, 102, 103]); + for (const release of releases.values()) release(); + await rejected; + expect(fixture.active()).toBe(0); + expect(fixture.send.mock.calls.some(([, method]) => method === "Runtime.callFunctionOn")).toBe( + false, + ); + }); +}); diff --git a/apps/extension/src/tools/vom/capture.ts b/apps/extension/src/tools/vom/capture.ts index 5271f7a..c341da3 100644 --- a/apps/extension/src/tools/vom/capture.ts +++ b/apps/extension/src/tools/vom/capture.ts @@ -976,6 +976,7 @@ async function parseChildFrameDocuments( parentDocIndex: number, parentContext: FrameContext, geometry: GeometryContext, + signal?: AbortSignal, visited = new Set(), ): Promise { const iframeNodes: CapturedIframeNodes = new Map(); @@ -990,34 +991,57 @@ async function parseChildFrameDocuments( const parentBackendIds = parentDoc?.nodes?.backendNodeId ?? []; - for (const [nodeArrayIdx, childDocIndex] of cdi) { - if (visited.has(childDocIndex)) continue; + const children = [...cdi].flatMap(([nodeArrayIdx, childDocIndex]) => { const childDoc = documents[childDocIndex]; - if (!childDoc) continue; const iframeBackendId = parentBackendIds[nodeArrayIdx]; - if (iframeBackendId === undefined) continue; - let projection: SnapshotProjection | null = null; - const source = { target: parentContext.target, frameId: snapshotFrameId(childDoc, strings) }; - const parentProjection = parentContext.projection?.geometry; - if (parentProjection) { - // Each owner quad is target-relative; do not apply the parent's transform twice. - const edge = parentProjection.edges[0]; - const clips = edge - ? [edge.destinationQuad, ...(edge.destinationClips ?? [])] - : parentProjection.sourceClips; - try { - projection = await geometry.snapshotProjection( - source, - iframeBackendId, - clips, - parentProjection.topViewport, - ); - } catch (error) { - if (isAbortError(error)) throw error; - console.debug("[bsk capture] frame owner geometry unavailable", error); - } - } + if (visited.has(childDocIndex) || !childDoc || iframeBackendId === undefined) return []; + return [ + { + childDocIndex, + childDoc, + iframeBackendId, + source: { target: parentContext.target, frameId: snapshotFrameId(childDoc, strings) }, + projection: null as SnapshotProjection | null, + }, + ]; + }); + const parentProjection = parentContext.projection?.geometry; + if (parentProjection) { + // Measure only direct siblings. Recursion starts after these workers finish, + // so descendants never hold or recursively acquire a measurement slot. + const edge = parentProjection.edges[0]; + const clips = edge + ? [edge.destinationQuad, ...(edge.destinationClips ?? [])] + : parentProjection.sourceClips; + let cursor = 0; + let aborted: unknown; + await Promise.all( + Array.from({ length: Math.min(4, children.length) }, async () => { + while (cursor < children.length && !signal?.aborted && !aborted) { + const child = children[cursor++]; + try { + // Owner quads are target-relative; do not apply the parent transform twice. + child.projection = await geometry.snapshotProjection( + child.source, + child.iframeBackendId, + clips, + parentProjection.topViewport, + ); + } catch (error) { + if (isAbortError(error)) aborted = error; + else console.debug("[bsk capture] frame owner geometry unavailable", error); + } + } + }), + ); + // Join all active reads, including object cleanup, before propagating abort. + if (aborted) throw aborted; + } + throwIfAborted(signal); + // Completion order must not change document traversal or result insertion order. + for (const { childDocIndex, childDoc, iframeBackendId, source, projection } of children) { + throwIfAborted(signal); const childContext: FrameContext = { frameId: source.frameId, target: source.target, @@ -1042,6 +1066,7 @@ async function parseChildFrameDocuments( childDocIndex, childContext, geometry, + signal, nextVisited, ); for (const [nestedFrameId, nestedNodes] of nested.iframeNodes) { @@ -1116,7 +1141,14 @@ export async function captureViewModel( const nodes = mainParsed.nodes; const excludedBackendNodeIds = new Set(mainParsed.excludedBackendNodeIds); - const frameParsed = await parseChildFrameDocuments(documents, strings, 0, topContext, geometry); + const frameParsed = await parseChildFrameDocuments( + documents, + strings, + 0, + topContext, + geometry, + options.signal, + ); const iframeNodes = frameParsed.iframeNodes; for (const id of frameParsed.excludedBackendNodeIds) { excludedBackendNodeIds.add(id);