Skip to content

Commit de2be85

Browse files
Adam RaineDevtools-frontend LUCI CQ
authored andcommitted
[LiveMetrics] Release objects to prevent memory leaks
The CDP client will retain objects in memory until they are explicitly released. This CL releases objects so that they don't cause any memory leaks. The frontend link to the elements panel needs to be created before the object is released so this CL also moves the logic for creating node links into LiveMetrics.ts. This CL also uses WeakRef in the injected code to prevent memory leaks caused by the permanent node list. Bug: 376777343 Change-Id: I809ae85802842c480f38a75c13097b575ee64f97 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6098570 Reviewed-by: Connor Clark <[email protected]> Commit-Queue: Adam Raine <[email protected]>
1 parent ec445a7 commit de2be85

File tree

4 files changed

+72
-60
lines changed

4 files changed

+72
-60
lines changed

front_end/models/live-metrics/LiveMetrics.ts

Lines changed: 51 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,7 @@ export class LiveMetrics extends Common.ObjectWrapper.ObjectWrapper<EventTypes>
159159
* DOM nodes can't be sent over a runtime binding, so we have to retrieve
160160
* them separately.
161161
*/
162-
async #resolveDomNode(index: number, executionContextId: Protocol.Runtime.ExecutionContextId):
163-
Promise<SDK.DOMModel.DOMNode|null> {
162+
async #resolveNodeRef(index: number, executionContextId: Protocol.Runtime.ExecutionContextId): Promise<NodeRef|null> {
164163
if (!this.#target) {
165164
return null;
166165
}
@@ -184,15 +183,21 @@ export class LiveMetrics extends Common.ObjectWrapper.ObjectWrapper<EventTypes>
184183
return null;
185184
}
186185

187-
const remoteObject = runtimeModel.createRemoteObject(result);
188-
return domModel.pushObjectAsNodeToFrontend(remoteObject);
189-
}
186+
let remoteObject;
187+
try {
188+
remoteObject = runtimeModel.createRemoteObject(result);
189+
const node = await domModel.pushObjectAsNodeToFrontend(remoteObject);
190+
if (!node) {
191+
return null;
192+
}
190193

191-
async #refreshNode(domModel: SDK.DOMModel.DOMModel, node: SDK.DOMModel.DOMNode):
192-
Promise<SDK.DOMModel.DOMNode|undefined> {
193-
const backendNodeId = node.backendNodeId();
194-
const nodes = await domModel.pushNodesByBackendIdsToFrontend(new Set([backendNodeId]));
195-
return nodes?.get(backendNodeId) || undefined;
194+
const link = await Common.Linkifier.Linkifier.linkify(node);
195+
return {node, link};
196+
} catch {
197+
return null;
198+
} finally {
199+
remoteObject?.release();
200+
}
196201
}
197202

198203
#sendStatusUpdate(): void {
@@ -221,24 +226,29 @@ export class LiveMetrics extends Common.ObjectWrapper.ObjectWrapper<EventTypes>
221226
async #onDocumentUpdate(event: Common.EventTarget.EventTargetEvent<SDK.DOMModel.DOMModel>): Promise<void> {
222227
const domModel = event.data;
223228

224-
const allLayoutAffectedNodes = this.#layoutShifts.flatMap(shift => shift.affectedNodes);
225-
const toRefresh: Array<{node?: SDK.DOMModel.DOMNode}> =
226-
[this.#lcpValue || {}, ...this.#interactions.values(), ...allLayoutAffectedNodes];
229+
const toRefresh = [
230+
this.#lcpValue?.nodeRef,
231+
...this.#interactions.values().map(i => i.nodeRef),
232+
...this.#layoutShifts.flatMap(shift => shift.affectedNodeRefs),
233+
].filter((nodeRef): nodeRef is NodeRef => Boolean(nodeRef));
227234

228-
const allPromises = toRefresh.map(item => {
229-
const node = item.node;
230-
if (node === undefined) {
235+
const idsToRefresh = new Set(toRefresh.map(nodeRef => nodeRef.node.backendNodeId()));
236+
const nodes = await domModel.pushNodesByBackendIdsToFrontend(idsToRefresh);
237+
if (!nodes) {
238+
return;
239+
}
240+
241+
const allPromises = toRefresh.map(async nodeRef => {
242+
const refreshedNode = nodes.get(nodeRef.node.backendNodeId());
243+
244+
// It is possible for the refreshed node to be undefined even though it was defined previously.
245+
// We should keep the affected nodes consistent from the user perspective, so we will just keep the stale node instead of removing it.
246+
if (!refreshedNode) {
231247
return;
232248
}
233249

234-
return this.#refreshNode(domModel, node).then(refreshedNode => {
235-
// In theory, it is possible for `node` to be undefined even though it was defined previously.
236-
// We should keep the affected nodes consistent from the user perspective, so we will just keep the stale node instead of removing it.
237-
// This is unlikely to happen in practice.
238-
if (refreshedNode) {
239-
item.node = refreshedNode;
240-
}
241-
});
250+
nodeRef.node = refreshedNode;
251+
nodeRef.link = await Common.Linkifier.Linkifier.linkify(refreshedNode);
242252
});
243253

244254
await Promise.all(allPromises);
@@ -257,9 +267,9 @@ export class LiveMetrics extends Common.ObjectWrapper.ObjectWrapper<EventTypes>
257267
warnings,
258268
};
259269
if (webVitalsEvent.nodeIndex !== undefined) {
260-
const node = await this.#resolveDomNode(webVitalsEvent.nodeIndex, executionContextId);
261-
if (node) {
262-
lcpEvent.node = node;
270+
const nodeRef = await this.#resolveNodeRef(webVitalsEvent.nodeIndex, executionContextId);
271+
if (nodeRef) {
272+
lcpEvent.nodeRef = nodeRef;
263273
}
264274
}
265275

@@ -321,26 +331,25 @@ export class LiveMetrics extends Common.ObjectWrapper.ObjectWrapper<EventTypes>
321331
}
322332

323333
if (webVitalsEvent.nodeIndex !== undefined) {
324-
const node = await this.#resolveDomNode(webVitalsEvent.nodeIndex, executionContextId);
334+
const node = await this.#resolveNodeRef(webVitalsEvent.nodeIndex, executionContextId);
325335
if (node) {
326-
interaction.node = node;
336+
interaction.nodeRef = node;
327337
}
328338
}
329339
break;
330340
}
331341
case 'LayoutShift': {
332342
const nodePromises = webVitalsEvent.affectedNodeIndices.map(nodeIndex => {
333-
return this.#resolveDomNode(nodeIndex, executionContextId);
343+
return this.#resolveNodeRef(nodeIndex, executionContextId);
334344
});
335345

336-
const affectedNodes = (await Promise.all(nodePromises))
337-
.filter((node): node is SDK.DOMModel.DOMNode => Boolean(node))
338-
.map(node => ({node}));
346+
const affectedNodes =
347+
(await Promise.all(nodePromises)).filter((nodeRef): nodeRef is NodeRef => Boolean(nodeRef));
339348

340349
const layoutShift: LayoutShift = {
341350
score: webVitalsEvent.score,
342351
uniqueLayoutShiftId: webVitalsEvent.uniqueLayoutShiftId,
343-
affectedNodes,
352+
affectedNodeRefs: affectedNodes,
344353
};
345354
this.#layoutShifts.push(layoutShift);
346355
break;
@@ -572,9 +581,14 @@ export interface MetricValue {
572581
warnings?: string[];
573582
}
574583

584+
export interface NodeRef {
585+
node: SDK.DOMModel.DOMNode;
586+
link: Node;
587+
}
588+
575589
export interface LCPValue extends MetricValue {
576590
phases: Spec.LCPPhases;
577-
node?: SDK.DOMModel.DOMNode;
591+
nodeRef?: NodeRef;
578592
}
579593

580594
export interface INPValue extends MetricValue {
@@ -589,7 +603,7 @@ export interface CLSValue extends MetricValue {
589603
export interface LayoutShift {
590604
score: number;
591605
uniqueLayoutShiftId: Spec.UniqueLayoutShiftId;
592-
affectedNodes: Array<{node: SDK.DOMModel.DOMNode}>;
606+
affectedNodeRefs: NodeRef[];
593607
}
594608

595609
export interface Interaction {
@@ -601,7 +615,7 @@ export interface Interaction {
601615
nextPaintTime: number;
602616
phases: Spec.INPPhases;
603617
longAnimationFrameTimings: Spec.PerformanceLongAnimationFrameTimingJSON[];
604-
node?: SDK.DOMModel.DOMNode;
618+
nodeRef?: NodeRef;
605619
}
606620

607621
export interface StatusEvent {

front_end/models/live-metrics/web-vitals-injected/web-vitals-injected.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,11 @@ function sendEventToDevTools(event: Spec.WebVitalsEvent): void {
7777
window[Spec.EVENT_BINDING_NAME](payload);
7878
}
7979

80-
const nodeList: Node[] = [];
80+
const nodeList: WeakRef<Node>[] = [];
8181

8282
function establishNodeIndex(node: Node): number {
8383
const index = nodeList.length;
84-
nodeList.push(node);
84+
nodeList.push(new WeakRef(node));
8585
return index;
8686
}
8787

@@ -95,7 +95,7 @@ function establishNodeIndex(node: Node): number {
9595
* for the specified index.
9696
*/
9797
window.getNodeForIndex = (index: number): Node|undefined => {
98-
return nodeList[index];
98+
return nodeList[index].deref();
9999
};
100100

101101
function limitScripts(loafs: Spec.PerformanceLongAnimationFrameTimingJSON[]):

front_end/panels/timeline/components/LiveMetricsView.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -398,9 +398,9 @@ describeWithMockConnection('LiveMetricsView', () => {
398398
},
399399
interactions: new Map(),
400400
layoutShifts: [
401-
{score: 0.05, affectedNodes: [], uniqueLayoutShiftId: 'layout-shift-1-1'},
402-
{score: 0.1, affectedNodes: [], uniqueLayoutShiftId: 'layout-shift-1-2'},
403-
{score: 0.01, affectedNodes: [], uniqueLayoutShiftId: 'layout-shift-1-3'},
401+
{score: 0.05, affectedNodeRefs: [], uniqueLayoutShiftId: 'layout-shift-1-1'},
402+
{score: 0.1, affectedNodeRefs: [], uniqueLayoutShiftId: 'layout-shift-1-2'},
403+
{score: 0.01, affectedNodeRefs: [], uniqueLayoutShiftId: 'layout-shift-1-3'},
404404
],
405405
});
406406
await coordinator.done();
@@ -430,9 +430,9 @@ describeWithMockConnection('LiveMetricsView', () => {
430430
},
431431
interactions: new Map(),
432432
layoutShifts: [
433-
{score: 0.05, affectedNodes: [], uniqueLayoutShiftId: 'layout-shift-1-1'},
434-
{score: 0.1, affectedNodes: [], uniqueLayoutShiftId: 'layout-shift-1-2'},
435-
{score: 0.01, affectedNodes: [], uniqueLayoutShiftId: 'layout-shift-1-3'},
433+
{score: 0.05, affectedNodeRefs: [], uniqueLayoutShiftId: 'layout-shift-1-1'},
434+
{score: 0.1, affectedNodeRefs: [], uniqueLayoutShiftId: 'layout-shift-1-2'},
435+
{score: 0.01, affectedNodeRefs: [], uniqueLayoutShiftId: 'layout-shift-1-3'},
436436
],
437437
});
438438
await coordinator.done();
@@ -449,9 +449,9 @@ describeWithMockConnection('LiveMetricsView', () => {
449449
},
450450
interactions: new Map(),
451451
layoutShifts: [
452-
{score: 0.05, affectedNodes: [], uniqueLayoutShiftId: 'layout-shift-1-1'},
453-
{score: 0.1, affectedNodes: [], uniqueLayoutShiftId: 'layout-shift-1-2'},
454-
{score: 0.01, affectedNodes: [], uniqueLayoutShiftId: 'layout-shift-1-3'},
452+
{score: 0.05, affectedNodeRefs: [], uniqueLayoutShiftId: 'layout-shift-1-1'},
453+
{score: 0.1, affectedNodeRefs: [], uniqueLayoutShiftId: 'layout-shift-1-2'},
454+
{score: 0.01, affectedNodeRefs: [], uniqueLayoutShiftId: 'layout-shift-1-3'},
455455
],
456456
});
457457
await coordinator.done();
@@ -586,7 +586,7 @@ describeWithMockConnection('LiveMetricsView', () => {
586586
},
587587
]),
588588
layoutShifts: [
589-
{score: 0.1, affectedNodes: [], uniqueLayoutShiftId: 'layout-shift-1-1'},
589+
{score: 0.1, affectedNodeRefs: [], uniqueLayoutShiftId: 'layout-shift-1-1'},
590590
],
591591
});
592592
await coordinator.done();
@@ -643,7 +643,7 @@ describeWithMockConnection('LiveMetricsView', () => {
643643
},
644644
]),
645645
layoutShifts: [
646-
{score: 0.1, affectedNodes: [], uniqueLayoutShiftId: 'layout-shift-1-1'},
646+
{score: 0.1, affectedNodeRefs: [], uniqueLayoutShiftId: 'layout-shift-1-1'},
647647
],
648648
});
649649
await coordinator.done();

front_end/panels/timeline/components/LiveMetricsView.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,7 @@ import type {MetricCardData} from './MetricCard.js';
3232
import metricValueStyles from './metricValueStyles.css.js';
3333
import {CLS_THRESHOLDS, INP_THRESHOLDS, renderMetricValue} from './Utils.js';
3434

35-
const {html, nothing, Directives} = LitHtml;
36-
const {until} = Directives;
35+
const {html, nothing} = LitHtml;
3736

3837
type DeviceOption = CrUXManager.DeviceScope|'AUTO';
3938

@@ -409,7 +408,7 @@ export class LiveMetricsView extends LegacyWrapper.LegacyWrapper.WrappableCompon
409408

410409
#renderLcpCard(): LitHtml.LitTemplate {
411410
const fieldData = this.#cruxManager.getSelectedFieldMetricData('largest_contentful_paint');
412-
const node = this.#lcpValue?.node;
411+
const nodeLink = this.#lcpValue?.nodeRef?.link;
413412
const phases = this.#lcpValue?.phases;
414413

415414
// clang-format off
@@ -428,10 +427,10 @@ export class LiveMetricsView extends LegacyWrapper.LegacyWrapper.WrappableCompon
428427
[i18nString(UIStrings.elementRenderDelay), phases.elementRenderDelay],
429428
],
430429
} as MetricCardData}>
431-
${node ? html`
430+
${nodeLink ? html`
432431
<div class="related-info" slot="extra-info">
433432
<span class="related-info-label">${i18nString(UIStrings.lcpElement)}</span>
434-
<span class="related-info-link">${until(Common.Linkifier.Linkifier.linkify(node))}</span>
433+
<span class="related-info-link">${nodeLink}</span>
435434
</div>
436435
`
437436
: nothing}
@@ -906,8 +905,7 @@ export class LiveMetricsView extends LegacyWrapper.LegacyWrapper.WrappableCompon
906905
html`<span class="interaction-inp-chip" title=${i18nString(UIStrings.inpInteraction)}>INP</span>`
907906
: nothing}
908907
</span>
909-
<span class="interaction-node">${
910-
interaction.node && until(Common.Linkifier.Linkifier.linkify(interaction.node))}</span>
908+
<span class="interaction-node">${interaction.nodeRef?.link}</span>
911909
${isP98Excluded ? html`<devtools-icon
912910
class="interaction-info"
913911
name="info"
@@ -1011,8 +1009,8 @@ export class LiveMetricsView extends LegacyWrapper.LegacyWrapper.WrappableCompon
10111009
<li id=${layoutShift.uniqueLayoutShiftId} class="log-item layout-shift" tabindex="-1">
10121010
<div class="layout-shift-score">Layout shift score: ${metricValue}</div>
10131011
<div class="layout-shift-nodes">
1014-
${layoutShift.affectedNodes.map(({node}) => html`
1015-
<div class="layout-shift-node">${until(Common.Linkifier.Linkifier.linkify(node))}</div>
1012+
${layoutShift.affectedNodeRefs.map(({link}) => html`
1013+
<div class="layout-shift-node">${link}</div>
10161014
`)}
10171015
</div>
10181016
</li>

0 commit comments

Comments
 (0)