Skip to content

Commit 6f7d7bb

Browse files
committed
fix(core): cleanup event listener memory
1 parent c07c96f commit 6f7d7bb

38 files changed

+525
-305
lines changed

client/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"@secret-agent/interfaces": "1.6.2",
1212
"@secret-agent/plugin-utils": "1.6.2",
1313
"@secret-agent/replay": "1.6.2",
14-
"awaited-dom": "1.3.0",
14+
"awaited-dom": "1.3.1",
1515
"uuid": "^8.3.2",
1616
"ws": "^7.4.6"
1717
},

commons/EventSubscriber.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import IRegisteredEventListener, {
2+
IEventSubscriber,
3+
} from '@secret-agent/interfaces/IRegisteredEventListener';
4+
5+
export default class EventSubscriber implements IEventSubscriber {
6+
private readonly registeredEventListeners: IRegisteredEventListener[] = [];
7+
8+
on<K extends string | symbol, Func extends (...args: any[]) => void>(
9+
emitter: {
10+
on(event: K, listener: Func, includeUnhandledEvents?: boolean);
11+
off(event: K, listener: Func);
12+
},
13+
eventName: K,
14+
handler: Func,
15+
includeUnhandledEvents?: boolean,
16+
): IRegisteredEventListener {
17+
emitter.on(eventName, handler, includeUnhandledEvents);
18+
const registeredEvent: IRegisteredEventListener = { emitter, eventName, handler };
19+
this.registeredEventListeners.push(registeredEvent);
20+
return registeredEvent;
21+
}
22+
23+
once<Event extends string | symbol, Func extends (...args: any[]) => void>(
24+
emitter: {
25+
once(event: Event, listener: Func, includeUnhandledEvents?: boolean);
26+
off(event: Event, listener: Func);
27+
},
28+
eventName: Event,
29+
handler: Func,
30+
includeUnhandledEvents?: boolean,
31+
): IRegisteredEventListener {
32+
emitter.once(eventName, handler, includeUnhandledEvents);
33+
const registeredEvent: IRegisteredEventListener = { emitter, eventName, handler };
34+
this.registeredEventListeners.push(registeredEvent);
35+
return registeredEvent;
36+
}
37+
38+
off(...listeners: IRegisteredEventListener[]): void {
39+
for (const listener of listeners) {
40+
listener.emitter.off(listener.eventName, listener.handler);
41+
}
42+
listeners.length = 0;
43+
}
44+
45+
close(...keepMockEvents: (string | symbol)[]): void {
46+
for (const listener of this.registeredEventListeners) {
47+
if (keepMockEvents.includes(listener.eventName)) {
48+
// add a mock event handler (like for capturing events)
49+
(listener.emitter as any).on(listener.eventName, () => null);
50+
}
51+
listener.emitter.off(listener.eventName, listener.handler);
52+
}
53+
this.registeredEventListeners.length = 0;
54+
}
55+
}

commons/Resolvable.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import IResolvablePromise from '@secret-agent/interfaces/IResolvablePromise';
2-
import { bindFunctions } from './utils';
32
import TimeoutError from './interfaces/TimeoutError';
43

54
export default class Resolvable<T = any> implements IResolvablePromise<T>, PromiseLike<T> {
65
public isResolved = false;
76
public resolved: T;
87
public promise: Promise<T>;
98
public readonly timeout: NodeJS.Timeout;
10-
public readonly stack: string;
9+
public stack: string;
1110

1211
private resolveFn: (value: T | PromiseLike<T>) => void;
1312
private rejectFn: (error?: Error) => void;
@@ -16,37 +15,42 @@ export default class Resolvable<T = any> implements IResolvablePromise<T>, Promi
1615
// get parent stack
1716
this.stack = new Error('').stack.slice(8);
1817

18+
this.promise = new Promise<T>((resolve, reject) => {
19+
this.resolveFn = resolve;
20+
this.rejectFn = reject;
21+
});
22+
1923
if (timeoutMillis !== undefined && timeoutMillis !== null) {
2024
this.timeout = setTimeout(
2125
this.rejectWithTimeout.bind(this, timeoutMessage),
2226
timeoutMillis,
2327
).unref();
2428
}
25-
this.promise = new Promise<T>((resolve, reject) => {
26-
this.resolveFn = resolve;
27-
this.rejectFn = reject;
28-
});
29-
bindFunctions(this);
29+
this.resolve = this.resolve.bind(this);
30+
this.reject = this.reject.bind(this);
3031
}
3132

3233
public resolve(value: T | PromiseLike<T>): void {
3334
if (this.isResolved) return;
34-
clearTimeout(this.timeout);
35+
3536
this.resolveFn(value);
37+
3638
Promise.resolve(value)
3739
// eslint-disable-next-line promise/always-return
3840
.then(x => {
3941
this.isResolved = true;
4042
this.resolved = x;
43+
this.clean();
44+
this.stack = null;
4145
})
4246
.catch(this.reject);
4347
}
4448

4549
public reject(error: Error): void {
4650
if (this.isResolved) return;
4751
this.isResolved = true;
48-
clearTimeout(this.timeout);
4952
this.rejectFn(error);
53+
this.clean();
5054
}
5155

5256
public toJSON(): object {
@@ -73,6 +77,12 @@ export default class Resolvable<T = any> implements IResolvablePromise<T>, Promi
7377
return this.promise.finally(onfinally);
7478
}
7579

80+
private clean(): void {
81+
clearTimeout(this.timeout);
82+
this.resolveFn = null;
83+
this.rejectFn = null;
84+
}
85+
7686
private rejectWithTimeout(message: string): void {
7787
const error = new TimeoutError(message);
7888
error.stack = `TimeoutError: ${message}\n${this.stack}`;

commons/eventUtils.ts

Lines changed: 5 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -5,58 +5,6 @@ import { IBoundLog } from '@secret-agent/interfaces/ILog';
55
import { createPromise } from './utils';
66
import IPendingWaitEvent, { CanceledPromiseError } from './interfaces/IPendingWaitEvent';
77

8-
export function addEventListener(
9-
emitter: EventEmitter,
10-
eventName: string | symbol,
11-
handler: (...args: any[]) => void,
12-
): IRegisteredEventListener {
13-
emitter.on(eventName, handler);
14-
return { emitter, eventName, handler };
15-
}
16-
17-
export function addEventListeners(
18-
emitter: EventEmitter,
19-
registrations: [string | symbol, (...args: any[]) => void, boolean?][],
20-
): IRegisteredEventListener[] {
21-
return registrations.map(([eventName, handler]) => {
22-
emitter.on(eventName, handler);
23-
return { emitter, eventName, handler };
24-
});
25-
}
26-
27-
export function removeEventListeners(
28-
listeners: Array<{
29-
emitter: EventEmitter | ITypedEventEmitter<any>;
30-
eventName: string | symbol;
31-
handler: (...args: any[]) => void;
32-
}>,
33-
): void {
34-
for (const listener of listeners) {
35-
listener.emitter.removeListener(listener.eventName, listener.handler);
36-
}
37-
listeners.length = 0;
38-
}
39-
40-
export function addTypedEventListener<T, K extends keyof T & (string | symbol)>(
41-
emitter: TypedEventEmitter<T>,
42-
eventName: K,
43-
handler: (this: TypedEventEmitter<T>, event?: T[K], initiator?: any) => any,
44-
includeUnhandledEvents?: boolean,
45-
): IRegisteredEventListener {
46-
emitter.on(eventName, handler, includeUnhandledEvents);
47-
return { emitter, eventName, handler };
48-
}
49-
50-
export function addTypedEventListeners<T, K extends keyof T & (string | symbol)>(
51-
emitter: TypedEventEmitter<T>,
52-
registrations: [K, (this: TypedEventEmitter<T>, event?: T[K]) => any, boolean?][],
53-
): IRegisteredEventListener[] {
54-
return registrations.map(([eventName, handler, includeUnhandled]) => {
55-
emitter.on(eventName, handler, includeUnhandled);
56-
return { emitter, eventName, handler };
57-
});
58-
}
59-
608
export class TypedEventEmitter<T> extends EventEmitter implements ITypedEventEmitter<T> {
619
public storeEventsWithoutListeners = false;
6210

@@ -110,18 +58,20 @@ export class TypedEventEmitter<T> extends EventEmitter implements ITypedEventEmi
11058
timeoutMillis,
11159
});
11260

113-
const listener = addTypedEventListener(this, eventType, (result: T[K]) => {
61+
const callbackFn = (result: T[K]): void => {
11462
// give the listeners a second to register
11563
if (!listenerFn || listenerFn.call(this, result)) {
11664
this.logger?.stats(`waitOn.resolve:${eventType}`, {
11765
parentLogId: messageId,
11866
});
11967
promise.resolve(result);
12068
}
121-
});
69+
};
70+
71+
this.on(eventType, callbackFn);
12272

12373
return promise.promise.finally(() => {
124-
removeEventListeners([listener]);
74+
this.off(eventType, callbackFn);
12575
const idx = this.pendingWaitEvents.findIndex(x => x.id === id);
12676
if (idx >= 0) this.pendingWaitEvents.splice(idx, 1);
12777
});

core/lib/AwaitedEventListener.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { v1 as uuidv1 } from 'uuid';
22
import { IJsPath } from 'awaited-dom/base/AwaitedPath';
33
import ISessionMeta from '@secret-agent/interfaces/ISessionMeta';
44
import { assert } from '@secret-agent/commons/utils';
5+
import EventSubscriber from '@secret-agent/commons/EventSubscriber';
56
import IListenerObject from '../interfaces/IListenerObject';
67
import Session from './Session';
78

@@ -14,9 +15,10 @@ export interface ITrigger {
1415
// TODO: can we merge TypedEventEmitter ideas and eventListenerIdsByType?. Revisit when we get to dom events
1516
export default class AwaitedEventListener {
1617
private readonly listenersById = new Map<string, IListenerObject>();
18+
private eventSubscriber = new EventSubscriber();
1719

18-
constructor(readonly session: Session) {
19-
session.on('closing', () => this.triggerListenersWithType('close'));
20+
constructor(private session: Session) {
21+
this.eventSubscriber.once(session, 'closing', () => this.triggerListenersWithType('close'));
2022
}
2123

2224
public close() {
@@ -25,6 +27,8 @@ export default class AwaitedEventListener {
2527
this.remove(entry.id);
2628
}
2729
}
30+
this.eventSubscriber.close();
31+
this.session = null;
2832
}
2933

3034
public listen(

core/lib/CommandRecorder.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ export default class CommandRecorder {
1212
public readonly fnNames = new Set<string>();
1313
private logger: IBoundLog;
1414
constructor(
15-
readonly owner: any,
16-
readonly session: Session,
15+
private owner: any,
16+
private session: Session,
1717
readonly tabId: number,
1818
readonly frameId: number,
1919
fns: AsyncFunc[],
@@ -29,8 +29,14 @@ export default class CommandRecorder {
2929
});
3030
}
3131

32+
public clear(): void {
33+
this.session = null;
34+
this.owner = null;
35+
}
36+
3237
private async runCommandFn<T>(commandFn: AsyncFunc, ...args: any[]): Promise<T> {
33-
if (!this.fnNames.has(commandFn.name)) throw new Error(`Unsupported function requested ${commandFn.name}`);
38+
if (!this.fnNames.has(commandFn.name))
39+
throw new Error(`Unsupported function requested ${commandFn.name}`);
3440

3541
const { session } = this;
3642
const sessionState = session.sessionState;

core/lib/FrameEnvironment.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ export default class FrameEnvironment {
171171
for (const path of this.cleanPaths) {
172172
Fs.promises.unlink(path).catch(() => null);
173173
}
174+
this.commandRecorder.clear();
174175
} catch (error) {
175176
if (!error.message.includes('Target closed') && !(error instanceof CanceledPromiseError)) {
176177
this.logger.error('FrameEnvironment.ClosingError', { error, parentLogId });

core/lib/Session.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import IViewport from '@secret-agent/interfaces/IViewport';
2121
import IJsPathResult from '@secret-agent/interfaces/IJsPathResult';
2222
import ISessionCreateOptions from '@secret-agent/interfaces/ISessionCreateOptions';
2323
import IGeolocation from '@secret-agent/interfaces/IGeolocation';
24+
import EventSubscriber from '@secret-agent/commons/EventSubscriber';
2425
import SessionState from './SessionState';
2526
import AwaitedEventListener from './AwaitedEventListener';
2627
import GlobalPool from './GlobalPool';
@@ -80,6 +81,7 @@ export default class Session extends TypedEventEmitter<{
8081
private isolatedMitmProxy?: MitmProxy;
8182
private _isClosing = false;
8283
private detachedTabsById = new Map<number, Tab>();
84+
private eventSubscriber = new EventSubscriber();
8385

8486
private tabIdCounter = 0;
8587
private frameIdCounter = 0;
@@ -212,7 +214,7 @@ export default class Session extends TypedEventEmitter<{
212214
detachedState.detachedAtCommandId,
213215
);
214216
this.detachedTabsById.set(newTab.id, newTab);
215-
newTab.on('close', () => {
217+
newTab.once('close', () => {
216218
if (newTab.mainFrameEnvironment.jsPath.hasNewExecJsPathHistory) {
217219
this.sessionState.recordDetachedJsPathCalls(
218220
newTab.mainFrameEnvironment.jsPath.execHistory,
@@ -253,20 +255,22 @@ export default class Session extends TypedEventEmitter<{
253255

254256
public async initialize(context: IPuppetContext) {
255257
this.browserContext = context;
256-
context.on('devtools-message', this.onDevtoolsMessage.bind(this));
258+
259+
this.eventSubscriber.on(context, 'devtools-message', this.onDevtoolsMessage.bind(this));
260+
257261
if (this.userProfile) {
258262
await UserProfile.install(this);
259263
}
260264

261265
context.defaultPageInitializationFn = InjectedScripts.install;
262266

263267
const requestSession = this.mitmRequestSession;
264-
requestSession.on('request', this.onMitmRequest.bind(this));
265-
requestSession.on('response', this.onMitmResponse.bind(this));
266-
requestSession.on('http-error', this.onMitmError.bind(this));
267-
requestSession.on('resource-state', this.onResourceStates.bind(this));
268-
requestSession.on('socket-close', this.onSocketClose.bind(this));
269-
requestSession.on('socket-connect', this.onSocketConnect.bind(this));
268+
this.eventSubscriber.on(requestSession, 'request', this.onMitmRequest.bind(this));
269+
this.eventSubscriber.on(requestSession, 'response', this.onMitmResponse.bind(this));
270+
this.eventSubscriber.on(requestSession, 'http-error', this.onMitmError.bind(this));
271+
this.eventSubscriber.on(requestSession, 'resource-state', this.onResourceStates.bind(this));
272+
this.eventSubscriber.on(requestSession, 'socket-close', this.onSocketClose.bind(this));
273+
this.eventSubscriber.on(requestSession, 'socket-connect', this.onSocketConnect.bind(this));
270274
await this.plugins.onHttpAgentInitialized(requestSession.requestAgent);
271275
}
272276

@@ -332,6 +336,9 @@ export default class Session extends TypedEventEmitter<{
332336
sessionId: this.id,
333337
parentLogId: start,
334338
});
339+
this.commandRecorder.clear();
340+
this.eventSubscriber.close();
341+
335342
this.emit('closed');
336343
// should go last so we can capture logs
337344
this.sessionState.close();

core/lib/SessionState.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export default class SessionState {
5656

5757
private readonly logger: IBoundLog;
5858

59-
private readonly browserRequestIdToResources: {
59+
private browserRequestIdToResources: {
6060
[browserRequestId: string]: { resourceId: number; url: string }[];
6161
} = {};
6262

@@ -465,6 +465,10 @@ export default class SessionState {
465465
loggerSessionIdNames.delete(this.sessionId);
466466
this.db.flush();
467467
this.db.close();
468+
this.resourcesById.clear();
469+
this.browserRequestIdToResources = {};
470+
this.websocketListeners = {};
471+
this.websocketMessages.length = 0;
468472
SessionState.registry.delete(this.sessionId);
469473
}
470474

core/lib/Tab.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ export default class Tab extends TypedEventEmitter<ITabEventParams> {
252252
errors.push(error);
253253
}
254254
}
255+
this.commandRecorder.clear();
255256
this.emit('close');
256257
this.logger.stats('Tab.Closed', { parentLogId, errors });
257258
}

0 commit comments

Comments
 (0)