mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
fix(observation): await capture task cleanup before propagating cancellation
This commit is contained in:
@@ -170,6 +170,104 @@ describe("captureObservationFacts", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"cancel",
|
||||||
|
"worker-abort",
|
||||||
|
] as const)("joins target cleanup and stops claiming targets after %s", async (mode) => {
|
||||||
|
const frames = Array.from({ length: 5 }, (_, i) => ({
|
||||||
|
frameId: `f${i}`,
|
||||||
|
target: { tabId: 4, ...(i ? { sessionId: `s${i}` } : {}) },
|
||||||
|
}));
|
||||||
|
const { cdp } = fixture({ frames });
|
||||||
|
const original = cdp.sendToTarget!;
|
||||||
|
const pending = new Map<string, { resolve: () => void; reject: (error: Error) => void }>();
|
||||||
|
const calls: Array<{ target: string; method: string }> = [];
|
||||||
|
let release: (() => void) | undefined;
|
||||||
|
let released = false;
|
||||||
|
const send: NonNullable<CdpRunner["sendToTarget"]> = async <T>(
|
||||||
|
target: CdpTarget,
|
||||||
|
method: string,
|
||||||
|
params?: object,
|
||||||
|
) => {
|
||||||
|
calls.push({ target: target.sessionId ?? "main", method });
|
||||||
|
if (method === "DOMSnapshot.captureSnapshot" && target.sessionId) {
|
||||||
|
await new Promise<void>((resolve, reject) =>
|
||||||
|
pending.set(target.sessionId!, { resolve, reject }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (method === "Runtime.releaseObjectGroup") {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
released = true;
|
||||||
|
}
|
||||||
|
const result = await original<T>(target, method, params);
|
||||||
|
if (method === "DOMSnapshot.captureSnapshot" && !target.sessionId)
|
||||||
|
(result as { strings: string[] }).strings[2] = "input";
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
cdp.sendToTarget = send;
|
||||||
|
cdp.send = (tabId, method, params) => send({ tabId }, method, params);
|
||||||
|
const controller = new AbortController();
|
||||||
|
let settled = false;
|
||||||
|
const capture = captureObservationFacts(cdp, 4, controller.signal);
|
||||||
|
const rejected = expect(capture).rejects.toMatchObject({ name: "AbortError" });
|
||||||
|
void capture.then(
|
||||||
|
() => {
|
||||||
|
settled = true;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
settled = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(pending.size).toBe(3);
|
||||||
|
expect(release).toBeDefined();
|
||||||
|
});
|
||||||
|
if (mode === "cancel") controller.abort();
|
||||||
|
// Cancellation may race with an ordinary transport rejection. Without an
|
||||||
|
// external signal, an escaping AbortError still terminates the worker pool.
|
||||||
|
pending
|
||||||
|
.get("s1")!
|
||||||
|
.reject(
|
||||||
|
mode === "cancel"
|
||||||
|
? new Error("transport closed")
|
||||||
|
: new DOMException("target aborted", "AbortError"),
|
||||||
|
);
|
||||||
|
pending.get("s2")!.resolve();
|
||||||
|
pending.get("s3")!.resolve();
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
if (mode === "worker-abort")
|
||||||
|
expect(
|
||||||
|
calls.some(
|
||||||
|
(call) => call.target === "s3" && call.method === "Accessibility.getFullAXTree",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
else
|
||||||
|
expect(
|
||||||
|
calls.some(
|
||||||
|
(call) => call.target === "s3" && call.method === "DOMSnapshot.captureSnapshot",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
// Allow worker rejection and the outer promise chain to run while cleanup
|
||||||
|
// is deliberately held. The old fail-fast pool settles here.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
expect(released).toBe(false);
|
||||||
|
expect(calls.some((call) => call.target === "s4")).toBe(false);
|
||||||
|
if (mode === "cancel")
|
||||||
|
expect(
|
||||||
|
calls.some(
|
||||||
|
(call) => call.method === "Accessibility.enable" || call.method === "DOM.getDocument",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
release!();
|
||||||
|
await rejected;
|
||||||
|
expect(released).toBe(true);
|
||||||
|
expect(calls.some((call) => call.target === "s4")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("bounds collection across many targets and stops scheduling after cancellation", async () => {
|
it("bounds collection across many targets and stops scheduling after cancellation", async () => {
|
||||||
const frames = Array.from({ length: 12 }, (_, i) => ({
|
const frames = Array.from({ length: 12 }, (_, i) => ({
|
||||||
frameId: `f${i}`,
|
frameId: `f${i}`,
|
||||||
|
|||||||
@@ -41,15 +41,25 @@ async function collectTargets<T>(
|
|||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
|
let failed = false;
|
||||||
|
let failure: unknown;
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Array.from({ length: Math.min(4, items.length) }, async () => {
|
Array.from({ length: Math.min(4, items.length) }, async () => {
|
||||||
while (cursor < items.length) {
|
try {
|
||||||
throwCaptureAborted(signal);
|
while (cursor < items.length && !failed && !signal?.aborted) {
|
||||||
const item = items[cursor++];
|
const item = items[cursor++];
|
||||||
await collect(item);
|
await collect(item);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!failed) failure = error;
|
||||||
|
failed = true;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// A worker's rejection must not let the capture outlive its own task scope.
|
||||||
|
// In-flight reads and their finally blocks settle before cancellation/failure.
|
||||||
|
throwCaptureAborted(signal);
|
||||||
|
if (failed) throw failure;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** When topology discovery is unavailable, snapshot documents still carry
|
/** When topology discovery is unavailable, snapshot documents still carry
|
||||||
@@ -91,6 +101,7 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isCaptureAbort(error)) throw error;
|
if (isCaptureAbort(error)) throw error;
|
||||||
}
|
}
|
||||||
|
throwCaptureAborted(signal);
|
||||||
const geometry = new GeometryContext(cdp, tabId, graph, signal);
|
const geometry = new GeometryContext(cdp, tabId, graph, signal);
|
||||||
const issues: CaptureIssue[] = [];
|
const issues: CaptureIssue[] = [];
|
||||||
const groups = new Map<string, TargetBatch<T>>();
|
const groups = new Map<string, TargetBatch<T>>();
|
||||||
@@ -144,19 +155,19 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
|||||||
issues,
|
issues,
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
if (
|
const formsAvailable = await enrichFormControlStates(
|
||||||
!(await enrichFormControlStates(
|
scoped,
|
||||||
scoped,
|
tabId,
|
||||||
tabId,
|
batch.documents.map((doc) => doc.domNodes),
|
||||||
batch.documents.map((doc) => doc.domNodes),
|
signal,
|
||||||
signal,
|
);
|
||||||
))
|
throwCaptureAborted(signal);
|
||||||
)
|
if (!formsAvailable) unavailable(batch, "forms");
|
||||||
unavailable(batch, "forms");
|
|
||||||
const present = new Set(batch.documents.map((doc) => doc.frame.frameId));
|
const present = new Set(batch.documents.map((doc) => doc.frame.frameId));
|
||||||
for (const frame of batch.frames)
|
for (const frame of batch.frames)
|
||||||
if (!present.has(frame.frameId)) unavailable(batch, "dom", frame.frameId);
|
if (!present.has(frame.frameId)) unavailable(batch, "dom", frame.frameId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
throwCaptureAborted(signal);
|
||||||
if (isCaptureAbort(error)) throw error;
|
if (isCaptureAbort(error)) throw error;
|
||||||
firstFailure ??= error;
|
firstFailure ??= error;
|
||||||
unavailable(batch, "dom");
|
unavailable(batch, "dom");
|
||||||
@@ -168,6 +179,7 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
|||||||
throwCaptureAborted(signal);
|
throwCaptureAborted(signal);
|
||||||
await scoped.send(tabId, "Accessibility.enable", {});
|
await scoped.send(tabId, "Accessibility.enable", {});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
throwCaptureAborted(signal);
|
||||||
if (isCaptureAbort(error)) throw error;
|
if (isCaptureAbort(error)) throw error;
|
||||||
firstFailure ??= error;
|
firstFailure ??= error;
|
||||||
unavailable(batch, "ax");
|
unavailable(batch, "ax");
|
||||||
@@ -181,8 +193,10 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
|||||||
"Accessibility.getFullAXTree",
|
"Accessibility.getFullAXTree",
|
||||||
frame.frameId === "root" ? {} : { frameId: frame.frameId },
|
frame.frameId === "root" ? {} : { frameId: frame.frameId },
|
||||||
);
|
);
|
||||||
|
throwCaptureAborted(signal);
|
||||||
batch.ax.push({ frame, nodes: result.nodes ?? [] });
|
batch.ax.push({ frame, nodes: result.nodes ?? [] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
throwCaptureAborted(signal);
|
||||||
if (isCaptureAbort(error)) throw error;
|
if (isCaptureAbort(error)) throw error;
|
||||||
firstFailure ??= error;
|
firstFailure ??= error;
|
||||||
unavailable(batch, "ax", frame.frameId);
|
unavailable(batch, "ax", frame.frameId);
|
||||||
|
|||||||
@@ -150,5 +150,6 @@ export async function enrichFormControlStates(
|
|||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
await cdp.send(tabId, "Runtime.releaseObjectGroup", { objectGroup }).catch(() => undefined);
|
await cdp.send(tabId, "Runtime.releaseObjectGroup", { objectGroup }).catch(() => undefined);
|
||||||
|
throwIfAborted(signal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user