Skip to content

Commit 2314a34

Browse files
author
maihaopeng
committed
fix: manage third-party element handle lifetimes
1 parent 5b25370 commit 2314a34

4 files changed

Lines changed: 493 additions & 59 deletions

File tree

src/McpPage.ts

Lines changed: 105 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import {
7777
type WebMCPTool,
7878
type Protocol,
7979
type Page,
80+
type Frame,
8081
type ConsoleMessage,
8182
type HTTPRequest,
8283
type DevTools,
@@ -103,6 +104,7 @@ import {
103104
type WaitForEventsResult,
104105
type DialogAction,
105106
} from './WaitForHelper.js';
107+
type DisposableStackInstance = InstanceType<typeof DisposableStack>;
106108

107109
/**
108110
* Per-page state wrapper. Consolidates dialog, snapshot, emulation,
@@ -120,6 +122,9 @@ export class McpPage implements ContextPage {
120122
textSnapshot: TextSnapshot | null = null;
121123
uniqueBackendNodeIdToMcpId = new Map<string, string>();
122124
extraHandles: ElementHandle[] = [];
125+
#extraHandleResources = new DisposableStack();
126+
#extraHandleGeneration = 0;
127+
#disposed = false;
123128

124129
// Emulation
125130
emulationSettings: EmulationSettings = {};
@@ -131,6 +136,7 @@ export class McpPage implements ContextPage {
131136
// Dialog
132137
#dialog?: Dialog;
133138
#dialogHandler: (dialog: Dialog) => void;
139+
#frameNavigatedHandler: (frame: Frame) => void;
134140

135141
thirdPartyDeveloperTools: ToolGroups = [];
136142

@@ -157,7 +163,13 @@ export class McpPage implements ContextPage {
157163
this.#dialogHandler = (dialog: Dialog): void => {
158164
this.#dialog = dialog;
159165
};
166+
this.#frameNavigatedHandler = (frame: Frame): void => {
167+
if (frame === page.mainFrame()) {
168+
this.#invalidateExtraHandles();
169+
}
170+
};
160171
page.on('dialog', this.#dialogHandler);
172+
page.on('framenavigated', this.#frameNavigatedHandler);
161173

162174
this.networkCollector = new NetworkCollector(page);
163175
this.consoleCollector = new ConsoleCollector(page, collect => {
@@ -428,20 +440,70 @@ export class McpPage implements ContextPage {
428440
}
429441

430442
dispose(): void {
443+
if (this.#disposed) {
444+
return;
445+
}
446+
this.#disposed = true;
431447
this.pptrPage.off('dialog', this.#dialogHandler);
448+
this.pptrPage.off('framenavigated', this.#frameNavigatedHandler);
432449
this.networkCollector.dispose();
433450
this.consoleCollector.dispose();
451+
this.#invalidateExtraHandles();
452+
}
453+
454+
#invalidateExtraHandles(): void {
455+
this.#extraHandleGeneration++;
456+
this.#clearExtraHandles();
457+
}
458+
459+
#clearExtraHandles(): void {
460+
this.#extraHandleResources.dispose();
461+
this.#extraHandleResources = new DisposableStack();
462+
this.extraHandles = [];
463+
}
464+
465+
#replaceExtraHandles(
466+
handles: ElementHandle[],
467+
resources: DisposableStackInstance,
468+
generation: number,
469+
): void {
470+
if (this.#disposed) {
471+
resources.dispose();
472+
throw new Error(
473+
`Page ${this.id} was disposed before retaining element handles.`,
474+
);
475+
}
476+
if (generation !== this.#extraHandleGeneration) {
477+
resources.dispose();
478+
throw new Error(
479+
`Page ${this.id} changed before retaining element handles.`,
480+
);
481+
}
482+
const previousResources = this.#extraHandleResources;
483+
this.#extraHandleResources = resources;
484+
this.extraHandles = handles;
485+
previousResources.dispose();
486+
}
487+
488+
async #clearStashedElements(): Promise<void> {
489+
await this.pptrPage.evaluate(() => {
490+
if (window.__dtmcp) {
491+
window.__dtmcp.stashedElements = [];
492+
}
493+
});
434494
}
435495

436496
async executeThirdPartyDeveloperTool(
437497
toolName: string,
438498
params: Record<string, unknown>,
439499
response: Response,
440500
): Promise<void> {
501+
const extraHandleGeneration = this.#extraHandleGeneration;
441502
// Creates array of ElementHandles from the UIDs in the params.
442503
// We do not replace the uids with the ElementsHandles yet, because
443504
// the `evaluate` function only turns them into DOM elements if they
444505
// are passed as non-nested arguments.
506+
using inputHandleResources = new DisposableStack();
445507
const handles: ElementHandle[] = [];
446508
for (const value of Object.values(params)) {
447509
if (
@@ -450,11 +512,13 @@ export class McpPage implements ContextPage {
450512
typeof value.uid === 'string' &&
451513
Object.keys(value).length === 1
452514
) {
453-
handles.push(await this.getElementByUid(value.uid));
515+
handles.push(
516+
inputHandleResources.use(await this.getElementByUid(value.uid)),
517+
);
454518
}
455519
}
456520

457-
const result = await this.pptrPage.evaluate(
521+
const toolExecution = this.pptrPage.evaluate(
458522
async (name, args, ...elements) => {
459523
// Replace the UIDs with DOM elements.
460524
for (const [key, value] of Object.entries(args)) {
@@ -471,6 +535,7 @@ export class McpPage implements ContextPage {
471535
if (!window.__dtmcp?.executeTool) {
472536
throw new Error('No tools found on the page');
473537
}
538+
window.__dtmcp.stashedElements = [];
474539
const toolResult = await window.__dtmcp.executeTool(name, args);
475540

476541
const stashDOMElement = (el: Element) => {
@@ -548,27 +613,51 @@ export class McpPage implements ContextPage {
548613
params,
549614
...handles,
550615
);
616+
let result: Awaited<typeof toolExecution>;
617+
try {
618+
result = await toolExecution;
619+
} catch (error) {
620+
try {
621+
await this.#clearStashedElements();
622+
} catch (clearError) {
623+
logger?.('Failed to clear stashed elements', clearError);
624+
}
625+
throw error;
626+
}
551627

628+
using extraHandleResources = new DisposableStack();
552629
const elementHandles: ElementHandle[] = [];
553-
for (let i = 0; i < (result.stashed ?? 0); i++) {
554-
const elementHandle = await this.pptrPage.evaluateHandle(index => {
555-
const el = window.__dtmcp?.stashedElements?.[index];
556-
if (!el) {
557-
throw new Error(`Stashed element at index ${index} not found`);
558-
}
559-
return el;
560-
}, i);
561-
elementHandles.push(elementHandle);
630+
try {
631+
for (let i = 0; i < (result.stashed ?? 0); i++) {
632+
const elementHandle = await this.pptrPage.evaluateHandle(index => {
633+
const el = window.__dtmcp?.stashedElements?.[index];
634+
if (!el) {
635+
throw new Error(`Stashed element at index ${index} not found`);
636+
}
637+
return el;
638+
}, i);
639+
elementHandles.push(extraHandleResources.use(elementHandle));
640+
}
641+
} catch (error) {
642+
try {
643+
await this.#clearStashedElements();
644+
} catch (clearError) {
645+
logger?.('Failed to clear stashed elements', clearError);
646+
}
647+
throw error;
562648
}
649+
await this.#clearStashedElements();
563650

564651
if (elementHandles.length) {
565-
using stack = new DisposableStack();
566-
for (const handle of elementHandles) {
567-
stack.use(handle);
568-
}
569-
this.textSnapshot = await TextSnapshot.create(this, {
652+
const textSnapshot = await TextSnapshot.create(this, {
570653
extraHandles: elementHandles,
571654
});
655+
this.#replaceExtraHandles(
656+
elementHandles,
657+
extraHandleResources.move(),
658+
extraHandleGeneration,
659+
);
660+
this.textSnapshot = textSnapshot;
572661
response.includeSnapshot();
573662
}
574663

src/TextSnapshot.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export class TextSnapshot {
112112

113113
const rootNodeWithId = assignIds(rootNode);
114114

115+
const extraHandles = options.extraHandles ?? page.extraHandles;
115116
await TextSnapshot.insertExtraNodes(
116117
page,
117118
idToNode,
@@ -120,7 +121,7 @@ export class TextSnapshot {
120121
idCounter,
121122
rootNodeWithId,
122123
seenBackendNodeIds,
123-
options.extraHandles ?? [],
124+
extraHandles,
124125
);
125126

126127
const snapshot = new TextSnapshot({
@@ -206,14 +207,15 @@ export class TextSnapshot {
206207
}
207208
seenUniqueIds.add(uniqueBackendId);
208209

209-
const tagHandle = await handle.getProperty('localName');
210+
using tagHandle = await handle.getProperty('localName');
210211
const tagValue = await tagHandle.jsonValue();
211212
const extraNode: TextSnapshotNode = {
212213
role: tagValue,
213214
id,
214215
backendNodeId,
215216
children: [],
216-
elementHandle: async () => handle,
217+
elementHandle: async () =>
218+
await handle.evaluateHandle((element: Element) => element),
217219
};
218220
return extraNode;
219221
};
@@ -317,16 +319,13 @@ export class TextSnapshot {
317319
: 0;
318320
};
319321

320-
if (extraHandles.length) {
321-
page.extraHandles = extraHandles;
322-
}
323322
const reorgInfo: Array<{
324323
extraNode: TextSnapshotNode;
325324
attachTarget: TextSnapshotNode;
326325
descendantIds: Set<number>;
327326
}> = [];
328327

329-
for (const handle of page.extraHandles) {
328+
for (const handle of extraHandles) {
330329
const extraNode = await createExtraNode(handle);
331330
if (!extraNode) {
332331
continue;

tests/TextSnapshot.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,17 @@ describe('TextSnapshot', () => {
8888
);
8989

9090
// Now take snapshot with extra handle
91+
const getPropertySpy = sinon.spy(middleHandle, 'getProperty');
9192
const snapshot = await TextSnapshot.create(page, {
9293
verbose: false,
9394
extraHandles: [middleHandle],
9495
});
96+
assert.strictEqual(getPropertySpy.callCount, 1);
97+
const tagHandle = await getPropertySpy.firstCall.returnValue;
98+
await assert.rejects(
99+
tagHandle.evaluate(value => value),
100+
/disposed/,
101+
);
95102

96103
// Find the extra node in idToNode
97104
let extraNode: TextSnapshotNode | undefined;

0 commit comments

Comments
 (0)