From b3d767fa14629793ce9b0ad79e83d52d26cba42b Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 4 Sep 2024 09:57:15 +0200 Subject: [PATCH] fix(trace viewer): fix memory leak (#32379) In the `visit` method, we currently cache the rendered HTML for every walked node. This re-use works well for traces that consist mostly of references to earlier snapshots. But for traces that don't share much, this is a large memory overhead and leads to the memory crash documented in https://github.com/microsoft/playwright/issues/32336. For the algocracks amongst you, the current memory usage for an html tree $h$ is $\mathcal{O}(|h| * \text{height}(h))$. This PR removes that cache from the nodes and replaces it with a snapshot-level cache, fixing the memory crash. Traces *without* reference should not see a performance impact from this. Traces *with* references will have slower initial rendering, but re-rendering maintains speed because of the snapshot-level cache. Closes https://github.com/microsoft/playwright/issues/32336 --------- Signed-off-by: Simon Knott Co-authored-by: Max Schmitt --- packages/trace-viewer/src/snapshotRenderer.ts | 168 ++++++++++-------- 1 file changed, 98 insertions(+), 70 deletions(-) diff --git a/packages/trace-viewer/src/snapshotRenderer.ts b/packages/trace-viewer/src/snapshotRenderer.ts index 0b458c0cb5..11e63de04f 100644 --- a/packages/trace-viewer/src/snapshotRenderer.ts +++ b/packages/trace-viewer/src/snapshotRenderer.ts @@ -25,6 +25,34 @@ function isSubtreeReferenceSnapshot(n: NodeSnapshot): n is SubtreeReferenceSnaps return Array.isArray(n) && Array.isArray(n[0]); } +let cacheSize = 0; +const cache = new Map(); +const CACHE_SIZE = 300_000_000; // 300mb + +function lruCache(key: SnapshotRenderer, compute: () => string): string { + if (cache.has(key)) { + const value = cache.get(key)!; + // reinserting makes this the least recently used entry + cache.delete(key); + cache.set(key, value); + return value; + } + + + const result = compute(); + + while (cache.size && cacheSize + result.length > CACHE_SIZE) { + const [firstKey, firstValue] = cache.entries().next().value; + cacheSize -= firstValue.length; + cache.delete(firstKey); + } + + cache.set(key, result); + cacheSize += result.length; + + return result; +} + export class SnapshotRenderer { private _snapshots: FrameSnapshot[]; private _index: number; @@ -51,89 +79,89 @@ export class SnapshotRenderer { } render(): RenderedFrameSnapshot { - const visit = (n: NodeSnapshot, snapshotIndex: number, parentTag: string | undefined, parentAttrs: [string, string][] | undefined): string => { + const result: string[] = []; + const visit = (n: NodeSnapshot, snapshotIndex: number, parentTag: string | undefined, parentAttrs: [string, string][] | undefined) => { // Text node. if (typeof n === 'string') { // Best-effort Electron support: rewrite custom protocol in url() links in stylesheets. // Old snapshotter was sending lower-case. if (parentTag === 'STYLE' || parentTag === 'style') - return rewriteURLsInStyleSheetForCustomProtocol(n); - return escapeHTML(n); + result.push(rewriteURLsInStyleSheetForCustomProtocol(n)); + else + result.push(escapeHTML(n)); + return; } - if (!(n as any)._string) { - if (isSubtreeReferenceSnapshot(n)) { - // Node reference. - const referenceIndex = snapshotIndex - n[0][0]; - if (referenceIndex >= 0 && referenceIndex <= snapshotIndex) { - const nodes = snapshotNodes(this._snapshots[referenceIndex]); - const nodeIndex = n[0][1]; - if (nodeIndex >= 0 && nodeIndex < nodes.length) - (n as any)._string = visit(nodes[nodeIndex], referenceIndex, parentTag, parentAttrs); - } - } else if (isNodeNameAttributesChildNodesSnapshot(n)) { - const [name, nodeAttrs, ...children] = n; - // Element node. - // Note that