1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

Mobile: Fix plugin API memory leak (#10115)

This commit is contained in:
Henry Heino 2024-03-14 11:42:13 -07:00 committed by GitHub
parent 6467bf0fc1
commit a53a8d67a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 125 additions and 6 deletions

View File

@ -147,4 +147,34 @@ describe('RemoteMessenger', () => {
expect(await remoteApi.subObject.multiplyRounded(1.1, 2)).toBe(2); expect(await remoteApi.subObject.multiplyRounded(1.1, 2)).toBe(2);
expect(await remoteApi.subObject.multiplyRounded.call(remoteApi.subObject, 3.1, 4.2)).toBe(12); expect(await remoteApi.subObject.multiplyRounded.call(remoteApi.subObject, 3.1, 4.2)).toBe(12);
}); });
it('should delete callbacks when dropped remotely', async () => {
const testApi = {
test: jest.fn(),
};
type ApiType = typeof testApi;
const messenger1 = new TestMessenger<ApiType, ApiType>('testid', testApi);
const messenger2 = new TestMessenger<ApiType, ApiType>('testid', testApi);
messenger1.connectTo(messenger2);
const callback = async () => {};
messenger1.remoteApi.test(callback);
// Callbacks should be stored with the source messenger
const callbackId = messenger1.getIdForCallback_(callback);
expect(callbackId).toBeTruthy();
expect(messenger2.getIdForCallback_(callback)).toBe(undefined);
// Dropping a callback at the remote messenger should clear the
// callback on the original messenger
messenger2.mockCallbackDropped(callbackId);
// To avoid random test failure, wait for a round-tip before checking
// whether the callback is still registered.
await messenger1.remoteApi.test(async ()=>{});
expect(messenger1.getIdForCallback_(callback)).toBe(undefined);
});
}); });

View File

@ -9,6 +9,7 @@ enum MessageType {
ErrorResponse = 'ErrorResponse', ErrorResponse = 'ErrorResponse',
ReturnValueResponse = 'ReturnValueResponse', ReturnValueResponse = 'ReturnValueResponse',
CloseChannel = 'CloseChannel', CloseChannel = 'CloseChannel',
OnCallbackDropped = 'OnCallbackDropped',
} }
type RemoteReadyMessage = Readonly<{ type RemoteReadyMessage = Readonly<{
@ -55,11 +56,16 @@ type CloseChannelMessage = Readonly<{
kind: MessageType.CloseChannel; kind: MessageType.CloseChannel;
}>; }>;
type CallbackDroppedMessage = Readonly<{
kind: MessageType.OnCallbackDropped;
callbackIds: string[];
}>;
type BaseMessage = Readonly<{ type BaseMessage = Readonly<{
channelId: string; channelId: string;
}>; }>;
type InternalMessage = (RemoteReadyMessage|CloseChannelMessage|InvokeMethodMessage|ErrorResponse|ReturnValueResponse) & BaseMessage; type InternalMessage = (RemoteReadyMessage|CloseChannelMessage|InvokeMethodMessage|ErrorResponse|ReturnValueResponse|CallbackDroppedMessage) & BaseMessage;
// Listeners for a remote method to resolve or reject. // Listeners for a remote method to resolve or reject.
type OnMethodResolveListener = (returnValue: SerializableDataAndCallbacks)=> void; type OnMethodResolveListener = (returnValue: SerializableDataAndCallbacks)=> void;
@ -68,6 +74,13 @@ type OnRemoteReadyListener = ()=> void;
type OnAllMethodsRespondedToListener = ()=> void; type OnAllMethodsRespondedToListener = ()=> void;
// TODO: Remove after upgrading nodejs/browser types sufficiently
// (FinalizationRegistry is supported in modern browsers).
declare class FinalizationRegistry {
public constructor(onDrop: any);
public register(v: any, id: string): void;
}
// A thin wrapper around postMessage. A script within `targetWindow` should // A thin wrapper around postMessage. A script within `targetWindow` should
// also construct a RemoteMessenger (with IncomingMessageType and // also construct a RemoteMessenger (with IncomingMessageType and
// OutgoingMessageType reversed). // OutgoingMessageType reversed).
@ -75,6 +88,7 @@ export default abstract class RemoteMessenger<LocalInterface, RemoteInterface> {
private resolveMethodCallbacks: Record<string, OnMethodResolveListener> = Object.create(null); private resolveMethodCallbacks: Record<string, OnMethodResolveListener> = Object.create(null);
private rejectMethodCallbacks: Record<string, OnMethodRejectListener> = Object.create(null); private rejectMethodCallbacks: Record<string, OnMethodRejectListener> = Object.create(null);
private argumentCallbacks: Map<string, TransferableCallback> = new Map(); private argumentCallbacks: Map<string, TransferableCallback> = new Map();
private callbackTracker: FinalizationRegistry|undefined = undefined;
private numberUnrespondedToMethods = 0; private numberUnrespondedToMethods = 0;
private noWaitingMethodsListeners: OnAllMethodsRespondedToListener[] = []; private noWaitingMethodsListeners: OnAllMethodsRespondedToListener[] = [];
@ -121,6 +135,14 @@ export default abstract class RemoteMessenger<LocalInterface, RemoteInterface> {
}); });
}; };
this.remoteApi = makeApiFor([]) as RemoteInterface; this.remoteApi = makeApiFor([]) as RemoteInterface;
if (typeof FinalizationRegistry !== 'undefined') {
// Creating a FinalizationRegistry allows us to track **local** deletions of callbacks.
// We can then inform the remote so that it can free the corresponding remote callback.
this.callbackTracker = new FinalizationRegistry((callbackId: string) => {
this.dropRemoteCallback_(callbackId);
});
}
} }
private createResponseId(methodPath: string[]) { private createResponseId(methodPath: string[]) {
@ -133,6 +155,26 @@ export default abstract class RemoteMessenger<LocalInterface, RemoteInterface> {
} }
} }
private lastCallbackDropTime_ = 0;
private bufferedDroppedCallbackIds_: string[] = [];
// protected: For testing
protected dropRemoteCallback_(callbackId: string) {
this.bufferedDroppedCallbackIds_.push(callbackId);
if (!this.isRemoteReady) return;
// Don't send too many messages. On mobile platforms, each
// message has overhead and .dropRemoteCallback is called
// frequently.
if (Date.now() - this.lastCallbackDropTime_ < 10000) return;
this.postMessage({
kind: MessageType.OnCallbackDropped,
callbackIds: this.bufferedDroppedCallbackIds_,
channelId: this.channelId,
});
this.bufferedDroppedCallbackIds_ = [];
this.lastCallbackDropTime_ = Date.now();
}
private async invokeRemoteMethod(methodPath: string[], args: SerializableDataAndCallbacks[]) { private async invokeRemoteMethod(methodPath: string[], args: SerializableDataAndCallbacks[]) {
// Function arguments can't be transferred using standard .postMessage calls. // Function arguments can't be transferred using standard .postMessage calls.
// As such, we assign them IDs and transfer the IDs instead: // As such, we assign them IDs and transfer the IDs instead:
@ -191,6 +233,10 @@ export default abstract class RemoteMessenger<LocalInterface, RemoteInterface> {
return this.invokeRemoteMethod(['__callbacks', callbackId], callbackArgs); return this.invokeRemoteMethod(['__callbacks', callbackId], callbackArgs);
}; };
private trackCallbackFinalization = (callbackId: string, callback: any) => {
this.callbackTracker?.register(callback, callbackId);
};
// Calls a local method and sends the result to the remote connection. // Calls a local method and sends the result to the remote connection.
private async invokeLocalMethod(message: InvokeMethodMessage) { private async invokeLocalMethod(message: InvokeMethodMessage) {
try { try {
@ -239,6 +285,7 @@ export default abstract class RemoteMessenger<LocalInterface, RemoteInterface> {
message.arguments.serializable, message.arguments.serializable,
message.arguments.callbacks, message.arguments.callbacks,
this.onInvokeCallback, this.onInvokeCallback,
this.trackCallbackFinalization,
); );
let result; let result;
@ -336,6 +383,7 @@ export default abstract class RemoteMessenger<LocalInterface, RemoteInterface> {
message.returnValue.serializable, message.returnValue.serializable,
message.returnValue.callbacks, message.returnValue.callbacks,
this.onInvokeCallback, this.onInvokeCallback,
this.trackCallbackFinalization,
); );
this.resolveMethodCallbacks[message.responseId](returnValue); this.resolveMethodCallbacks[message.responseId](returnValue);
@ -347,6 +395,12 @@ export default abstract class RemoteMessenger<LocalInterface, RemoteInterface> {
this.onMethodRespondedTo(message.responseId); this.onMethodRespondedTo(message.responseId);
} }
private async onRemoteCallbackDropped(message: CallbackDroppedMessage) {
for (const id of message.callbackIds) {
this.argumentCallbacks.delete(id);
}
}
private async onRemoteReadyToReceive(message: RemoteReadyMessage) { private async onRemoteReadyToReceive(message: RemoteReadyMessage) {
if (this.isRemoteReady && !message.requiresResponse) { if (this.isRemoteReady && !message.requiresResponse) {
return; return;
@ -431,6 +485,8 @@ export default abstract class RemoteMessenger<LocalInterface, RemoteInterface> {
await this.onRemoteReject(asInternalMessage); await this.onRemoteReject(asInternalMessage);
} else if (asInternalMessage.kind === MessageType.RemoteReady) { } else if (asInternalMessage.kind === MessageType.RemoteReady) {
await this.onRemoteReadyToReceive(asInternalMessage); await this.onRemoteReadyToReceive(asInternalMessage);
} else if (asInternalMessage.kind === MessageType.OnCallbackDropped) {
await this.onRemoteCallbackDropped(asInternalMessage);
} else { } else {
// Have TypeScript verify that the above cases are exhaustive // Have TypeScript verify that the above cases are exhaustive
const exhaustivenessCheck: never = asInternalMessage; const exhaustivenessCheck: never = asInternalMessage;
@ -494,4 +550,15 @@ export default abstract class RemoteMessenger<LocalInterface, RemoteInterface> {
protected abstract postMessage(message: InternalMessage): void; protected abstract postMessage(message: InternalMessage): void;
protected abstract onClose(): void; protected abstract onClose(): void;
// For testing
public getIdForCallback_(callback: TransferableCallback) {
for (const [id, otherCallback] of this.argumentCallbacks) {
if (otherCallback === callback) {
return id;
}
}
return undefined;
}
} }

View File

@ -33,4 +33,11 @@ export default class TestMessenger<LocalInterface, RemoteInterface> extends Remo
protected override onClose(): void { protected override onClose(): void {
this.remoteMessenger = null; this.remoteMessenger = null;
} }
// Test utility methods
//
public mockCallbackDropped(callbackId: string) {
this.dropRemoteCallback_(callbackId);
}
} }

View File

@ -27,7 +27,7 @@ describe('mergeCallbacksAndSerializable', () => {
}; };
const callMethodWithId = jest.fn(); const callMethodWithId = jest.fn();
const merged: any = mergeCallbacksAndSerializable(data, callbacks, callMethodWithId); const merged: any = mergeCallbacksAndSerializable(data, callbacks, callMethodWithId, ()=>{});
// Should have created functions // Should have created functions
merged.foo.fn1(3, 4); merged.foo.fn1(3, 4);

View File

@ -2,6 +2,9 @@ import { CallbackIds, SerializableData, SerializableDataAndCallbacks } from '../
type CallMethodWithIdCallback = (id: string, args: SerializableDataAndCallbacks[])=> Promise<SerializableDataAndCallbacks>; type CallMethodWithIdCallback = (id: string, args: SerializableDataAndCallbacks[])=> Promise<SerializableDataAndCallbacks>;
// Intended to be used to track callbacks for garbage collection
type OnAfterCallbackCreated = (callbackId: string, callbackRef: ()=> any)=> void;
// Below, we use TypeScript syntax to specify the return type of mergeCallbacksAndSerializable // Below, we use TypeScript syntax to specify the return type of mergeCallbacksAndSerializable
// based on the type of its arguments. // based on the type of its arguments.
// //
@ -10,25 +13,37 @@ type CallMethodWithIdCallback = (id: string, args: SerializableDataAndCallbacks[
// //
// eslint-disable-next-line no-redeclare // eslint-disable-next-line no-redeclare
function mergeCallbacksAndSerializable( function mergeCallbacksAndSerializable(
serializable: SerializableData[], callbacks: CallbackIds[], callMethodWithId: CallMethodWithIdCallback, serializable: SerializableData[],
callbacks: CallbackIds[],
callMethodWithId: CallMethodWithIdCallback,
afterCallbackCreated: OnAfterCallbackCreated,
): SerializableDataAndCallbacks[]; ): SerializableDataAndCallbacks[];
// eslint-disable-next-line no-redeclare // eslint-disable-next-line no-redeclare
function mergeCallbacksAndSerializable( function mergeCallbacksAndSerializable(
serializable: SerializableData, callbacks: CallbackIds, callMethodWithId: CallMethodWithIdCallback, serializable: SerializableData,
callbacks: CallbackIds,
callMethodWithId: CallMethodWithIdCallback,
afterCallbackCreated: OnAfterCallbackCreated,
): SerializableDataAndCallbacks; ): SerializableDataAndCallbacks;
// eslint-disable-next-line no-redeclare // eslint-disable-next-line no-redeclare
function mergeCallbacksAndSerializable( function mergeCallbacksAndSerializable(
serializable: SerializableData|SerializableData[], callbacks: CallbackIds|CallbackIds[], callMethodWithId: CallMethodWithIdCallback, serializable: SerializableData|SerializableData[],
callbacks: CallbackIds|CallbackIds[],
callMethodWithId: CallMethodWithIdCallback,
afterCallbackCreated: OnAfterCallbackCreated,
): SerializableDataAndCallbacks|SerializableDataAndCallbacks[] { ): SerializableDataAndCallbacks|SerializableDataAndCallbacks[] {
const mergeCallbackAndSerializable = (serializableObj: SerializableData, callbackObj: CallbackIds): SerializableDataAndCallbacks => { const mergeCallbackAndSerializable = (serializableObj: SerializableData, callbackObj: CallbackIds): SerializableDataAndCallbacks => {
if (typeof callbackObj === 'string') { if (typeof callbackObj === 'string') {
const callbackId = callbackObj; const callbackId = callbackObj;
return (...args: SerializableDataAndCallbacks[]) => { const callback = (...args: SerializableDataAndCallbacks[]) => {
return callMethodWithId(callbackId, args); return callMethodWithId(callbackId, args);
}; };
afterCallbackCreated(callbackId, callback);
return callback;
} else if (typeof serializableObj === 'object' && serializableObj !== null) { // typeof(null) is object } else if (typeof serializableObj === 'object' && serializableObj !== null) { // typeof(null) is object
if (typeof callbackObj !== 'object') { if (typeof callbackObj !== 'object') {
throw new Error('Callback arguments should be an object (and thus match the type of serializableArgs)'); throw new Error('Callback arguments should be an object (and thus match the type of serializableArgs)');