Skip to content

Commit

Permalink
🐛 [RUMF-1449] implement a workaround for Firefox memory leak
Browse files Browse the repository at this point in the history
  • Loading branch information
BenoitZugmeyer committed Dec 7, 2022
1 parent ae1a975 commit 37efd06
Show file tree
Hide file tree
Showing 9 changed files with 181 additions and 33 deletions.
38 changes: 38 additions & 0 deletions packages/core/src/browser/addEventListener.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { stubZoneJs } from '../../test/specHelper'
import { noop } from '../tools/utils'

import { addEventListener, DOM_EVENT, clearGetEventTargetOriginMethodsCache } from './addEventListener'

describe('addEventListener', () => {
describe('Zone.js support', () => {
let zoneJsStub: ReturnType<typeof stubZoneJs>

beforeEach(() => {
zoneJsStub = stubZoneJs()
})

afterEach(() => {
zoneJsStub.restore()
clearGetEventTargetOriginMethodsCache()
})

it('uses the original addEventListener method instead of the method patched by Zone.js', () => {
const zoneJsPatchedAddEventListener = jasmine.createSpy()
zoneJsStub.replaceProperty(EventTarget.prototype, 'addEventListener', zoneJsPatchedAddEventListener)

const eventTarget = document.createElement('div')
addEventListener(eventTarget, DOM_EVENT.CLICK, noop)
expect(zoneJsPatchedAddEventListener).not.toHaveBeenCalled()
})

it('uses the original removeEventListener method instead of the method patched by Zone.js', () => {
const zoneJsPatchedRemoveEventListener = jasmine.createSpy()
zoneJsStub.replaceProperty(EventTarget.prototype, 'removeEventListener', zoneJsPatchedRemoveEventListener)

const eventTarget = document.createElement('div')
const { stop } = addEventListener(eventTarget, DOM_EVENT.CLICK, noop)
stop()
expect(zoneJsPatchedRemoveEventListener).not.toHaveBeenCalled()
})
})
})
36 changes: 34 additions & 2 deletions packages/core/src/browser/addEventListener.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { monitor } from '../tools/monitor'
import { getZoneJsOriginalValue } from '../tools/getZoneJsOriginalValue'

export const enum DOM_EVENT {
BEFORE_UNLOAD = 'beforeunload',
Expand Down Expand Up @@ -86,10 +87,41 @@ export function addEventListeners<E extends Event>(
)

const options = passive ? { capture, passive } : capture
events.forEach((event) => eventTarget.addEventListener(event, wrappedListener, options))
const stop = () => events.forEach((event) => eventTarget.removeEventListener(event, wrappedListener, options))
const { addEventListener, removeEventListener } = getEventTargetOriginalMethods()
events.forEach((event) => addEventListener.call(eventTarget, event, wrappedListener, options))
const stop = () => events.forEach((event) => removeEventListener.call(eventTarget, event, wrappedListener, options))

return {
stop,
}
}

let getEventTargetOriginalMethodsCache: Pick<EventTarget, 'addEventListener' | 'removeEventListener'> | undefined

/**
* Get the original 'addEventListener' and 'removeEventListener' methods in case Zone.js did patch
* them.
*
* This is a workaround for a memory leak in Firefox: https://bugzilla.mozilla.org/show_bug.cgi?id=1804409
*/
export function getEventTargetOriginalMethods(): {
addEventListener: EventTarget['addEventListener']
removeEventListener: EventTarget['removeEventListener']
} {
if (!getEventTargetOriginalMethodsCache) {
getEventTargetOriginalMethodsCache = {
addEventListener:
// eslint-disable-next-line @typescript-eslint/unbound-method
getZoneJsOriginalValue(EventTarget.prototype, 'addEventListener') || EventTarget.prototype.addEventListener,
removeEventListener:
getZoneJsOriginalValue(EventTarget.prototype, 'removeEventListener') ||
// eslint-disable-next-line @typescript-eslint/unbound-method
EventTarget.prototype.removeEventListener,
}
}
return getEventTargetOriginalMethodsCache
}

export function clearGetEventTargetOriginMethodsCache() {
getEventTargetOriginalMethodsCache = undefined
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export * from './tools/timeUtils'
export * from './tools/utils'
export * from './tools/createEventRateLimiter'
export * from './tools/browserDetection'
export { getZoneJsOriginalValue } from './tools/getZoneJsOriginalValue'
export { instrumentMethod, instrumentMethodAndCallOriginal, instrumentSetter } from './tools/instrumentMethod'
export {
ErrorSource,
Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/tools/getZoneJsOriginalValue.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { stubZoneJs } from '../../test/specHelper'

import { getZoneJsOriginalValue } from './getZoneJsOriginalValue'
import { noop } from './utils'

describe('getZoneJsOriginalValue', () => {
let zoneJsStub: ReturnType<typeof stubZoneJs> | undefined

afterEach(() => {
zoneJsStub?.restore()
})

it('returns undefined if Zone is not not defined', () => {
expect(getZoneJsOriginalValue({ toto: noop }, 'toto')).toBeUndefined()
})

it("returns undefined if Zone is defined but didn't patch that method", () => {
zoneJsStub = stubZoneJs()
expect(getZoneJsOriginalValue({ toto: noop }, 'toto')).toBeUndefined()
})

it('returns the original value if Zone did patch the method', () => {
zoneJsStub = stubZoneJs()

const originalMethod = () => {
// noop
}
expect(getZoneJsOriginalValue({ toto: noop, [zoneJsStub.getSymbol('toto')]: originalMethod }, 'toto')).toBe(
originalMethod
)
})
})
28 changes: 28 additions & 0 deletions packages/core/src/tools/getZoneJsOriginalValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export interface BrowserWindowWithZoneJs extends Window {
Zone?: {
__symbol__: (name: string) => string
}
}

/**
* Gets the original value for a DOM API that was potentially patched by Zone.js.
*
* Zone.js[1] is a library that patches a bunch of JS and DOM APIs. It usually stores the original
* value of the patched functions/constructors/methods in a hidden property prefixed by
* __zone_symbol__.
*
* In multiple occasions, we observed that Zone.js is the culprit of important issues leading to
* browser resource exhaustion (memory leak, high CPU usage). This method is used as a workaround to
* use the original DOM API instead of the one patched by Zone.js.
*
* [1]: https://github.com/angular/angular/tree/main/packages/zone.js
*/
export function getZoneJsOriginalValue<Target, Name extends keyof Target & string>(
target: Target,
name: Name
): Target[Name] | undefined {
const browserWindow = window as BrowserWindowWithZoneJs
if (browserWindow.Zone) {
return (target as any)[browserWindow.Zone.__symbol__(name)] as Target[Name]
}
}
29 changes: 29 additions & 0 deletions packages/core/test/specHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { resetNavigationStart } from '../src/tools/timeUtils'
import { buildUrl } from '../src/tools/urlPolyfill'
import { noop, objectEntries, assign } from '../src/tools/utils'
import type { BrowserWindowWithEventBridge } from '../src/transport'
import type { BrowserWindowWithZoneJs } from '../src/tools/getZoneJsOriginalValue'

// to simulate different build env behavior
export interface BuildEnvWindow {
Expand Down Expand Up @@ -485,3 +486,31 @@ export function interceptRequests() {
},
}
}

export function stubZoneJs() {
const browserWindow = window as BrowserWindowWithZoneJs
const restorers: Array<() => void> = []

function getSymbol(name: string) {
return `__zone_symbol__${name}`
}

browserWindow.Zone = { __symbol__: getSymbol }

return {
restore: () => {
delete browserWindow.Zone
restorers.forEach((restorer) => restorer())
},
getSymbol,
replaceProperty<Target, Name extends keyof Target & string>(target: Target, name: Name, replacement: Target[Name]) {
const original = target[name]
target[name] = replacement
;(target as any)[getSymbol(name)] = original
restorers.push(() => {
delete (target as any)[getSymbol(name)]
target[name] = original
})
},
}
}
2 changes: 1 addition & 1 deletion packages/core/test/stubReportApis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function stubReportingObserver() {
}

export function stubCspEventListener() {
spyOn(document, 'addEventListener').and.callFake((_type: string, listener: EventListener) => {
spyOn(EventTarget.prototype, 'addEventListener').and.callFake((_type: string, listener: EventListener) => {
listeners.push(listener)
})

Expand Down
27 changes: 10 additions & 17 deletions packages/rum-core/src/browser/domMutationObservable.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { isIE } from '@datadog/browser-core'
import type { BrowserWindow } from './domMutationObservable'
import { stubZoneJs } from '../../../core/test/specHelper'
import { createDOMMutationObservable, getMutationObserverConstructor } from './domMutationObservable'

// The MutationObserver invokes its callback in an event loop microtask, making this asynchronous.
Expand Down Expand Up @@ -108,38 +108,31 @@ describe('domMutationObservable', () => {
)

describe('Zone.js support', () => {
const browserWindow = window as BrowserWindow
const OriginalMutationObserverConstructor = browserWindow.MutationObserver!
let zoneJsStub: ReturnType<typeof stubZoneJs>
const OriginalMutationObserverConstructor = window.MutationObserver

beforeEach(() => {
browserWindow.Zone = { __symbol__: zoneSymbol }
zoneJsStub = stubZoneJs()
})

afterEach(() => {
delete browserWindow.Zone
delete browserWindow[zoneSymbol('MutationObserver') as any]
browserWindow.MutationObserver = OriginalMutationObserverConstructor
zoneJsStub.restore()
window.MutationObserver = OriginalMutationObserverConstructor
})

it('gets the original MutationObserver constructor from the "window" object (Zone.js >= 0.8.6)', () => {
browserWindow.MutationObserver = function () {
zoneJsStub.replaceProperty(window, 'MutationObserver', function () {
// This won't be instantiated.
} as any
browserWindow[zoneSymbol('MutationObserver') as any] = OriginalMutationObserverConstructor as any

} as any)
expect(getMutationObserverConstructor()).toBe(OriginalMutationObserverConstructor)
})

it('gets the original MutationObserver constructor from a patched instance (Zone.js < 0.8.6)', () => {
browserWindow.MutationObserver = function (this: any, callback: () => void) {
this[zoneSymbol('originalInstance')] = new OriginalMutationObserverConstructor(callback)
window.MutationObserver = function (this: any, callback: () => void) {
this[zoneJsStub.getSymbol('originalInstance')] = new OriginalMutationObserverConstructor(callback)
} as any

expect(getMutationObserverConstructor()).toBe(OriginalMutationObserverConstructor)
})

function zoneSymbol(name: string) {
return `__zone_symbol__${name}`
}
})
})
21 changes: 8 additions & 13 deletions packages/rum-core/src/browser/domMutationObservable.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { monitor, noop, Observable } from '@datadog/browser-core'
import { monitor, noop, Observable, getZoneJsOriginalValue } from '@datadog/browser-core'

export function createDOMMutationObservable() {
const MutationObserver = getMutationObserverConstructor()
Expand All @@ -21,11 +21,10 @@ export function createDOMMutationObservable() {
}

type MutationObserverConstructor = new (callback: MutationCallback) => MutationObserver

export interface BrowserWindow extends Window {
MutationObserver?: MutationObserverConstructor
Zone?: {
__symbol__: (name: string) => string
}
Zone?: unknown
}

export function getMutationObserverConstructor(): MutationObserverConstructor | undefined {
Expand All @@ -44,14 +43,10 @@ export function getMutationObserverConstructor(): MutationObserverConstructor |
// [1] https://github.com/angular/angular/issues/26948
// [2] https://github.com/angular/angular/issues/31712
if (browserWindow.Zone) {
const zoneSymbol = browserWindow.Zone.__symbol__

// Zone.js 0.8.6+ is storing original class constructors into the browser 'window' object[3].
//
// [3] https://github.com/angular/angular/blob/6375fa79875c0fe7b815efc45940a6e6f5c9c9eb/packages/zone.js/lib/common/utils.ts#L288
constructor = browserWindow[zoneSymbol('MutationObserver') as any] as unknown as
| MutationObserverConstructor
| undefined
constructor = getZoneJsOriginalValue(browserWindow, 'MutationObserver')

if (!constructor && browserWindow.MutationObserver) {
// Anterior Zone.js versions (used in Angular 2) does not expose the original MutationObserver
Expand All @@ -61,11 +56,11 @@ export function getMutationObserverConstructor(): MutationObserverConstructor |
//
// [4] https://github.com/angular/zone.js/blob/v0.8.5/lib/common/utils.ts#L412

const patchedInstance = new browserWindow.MutationObserver(noop) as any
const originalInstance = patchedInstance[zoneSymbol('originalInstance')] as
| { constructor: MutationObserverConstructor }
| undefined
const patchedInstance = new browserWindow.MutationObserver(noop) as {
originalInstance?: { constructor: MutationObserverConstructor }
}

const originalInstance = getZoneJsOriginalValue(patchedInstance, 'originalInstance')
constructor = originalInstance && originalInstance.constructor
}
}
Expand Down

0 comments on commit 37efd06

Please sign in to comment.