Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix potential memory leak in Concast, add tests #11358

Merged
merged 19 commits into from
Nov 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/forty-cups-shop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@apollo/client": patch
---

Fixes a potential memory leak in `Concast` that might have been triggered when `Concast` was used outside of Apollo Client.
4 changes: 2 additions & 2 deletions .size-limits.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"dist/apollo-client.min.cjs": 38630,
"import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32213
"dist/apollo-client.min.cjs": 38632,
"import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32215
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"ci:precheck": "node config/precheck.js",
"format": "prettier --write .",
"lint": "eslint 'src/**/*.{[jt]s,[jt]sx}'",
"test": "jest --config ./config/jest.config.js",
"test": "node --expose-gc ./node_modules/jest/bin/jest.js --config ./config/jest.config.js",
"test:debug": "node --inspect-brk node_modules/.bin/jest --config ./config/jest.config.js --runInBand --testTimeout 99999 --logHeapUsage",
"test:ci": "TEST_ENV=ci npm run test:coverage -- --logHeapUsage && npm run test:memory",
"test:watch": "jest --config ./config/jest.config.js --watch",
Expand Down
4 changes: 4 additions & 0 deletions src/testing/matchers/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ interface ApolloCustomMatchers<R = void, T = {}> {
| ProfiledHook<any, any>
? (count: number, options?: NextRenderOptions) => Promise<R>
: { error: "matcher needs to be called on a ProfiledComponent instance" };

toBeGarbageCollected: T extends WeakRef<any>
? () => Promise<R>
: { error: "matcher needs to be called on a WeakRef instance" };
}

declare global {
Expand Down
2 changes: 2 additions & 0 deletions src/testing/matchers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { expect } from "@jest/globals";
import { toMatchDocument } from "./toMatchDocument.js";
import { toHaveSuspenseCacheEntryUsing } from "./toHaveSuspenseCacheEntryUsing.js";
import { toRerender, toRenderExactlyTimes } from "./ProfiledComponent.js";
import { toBeGarbageCollected } from "./toBeGarbageCollected.js";

expect.extend({
toHaveSuspenseCacheEntryUsing,
toMatchDocument,
toRerender,
toRenderExactlyTimes,
toBeGarbageCollected,
});
59 changes: 59 additions & 0 deletions src/testing/matchers/toBeGarbageCollected.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { MatcherFunction } from "expect";

// this is necessary because this file is picked up by `tsc` (it's not a test),
// but our main `tsconfig.json` doesn't include `"ES2021.WeakRef"` on purpose
declare class WeakRef<T extends WeakKey> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can also remove this declaration since we're including it in our tsconfig.

constructor(target: T);
deref(): T | undefined;
}

export const toBeGarbageCollected: MatcherFunction<[weakRef: WeakRef<any>]> =
async function (actual) {
const hint = this.utils.matcherHint("toBeGarbageCollected");

if (!(actual instanceof WeakRef)) {
throw new Error(
hint +
"\n\n" +
`Expected value to be a WeakRef, but it was a ${typeof actual}.`
);
}

let pass = false;
let interval: NodeJS.Timeout | undefined;
let timeout: NodeJS.Timeout | undefined;
await Promise.race([
new Promise<void>((resolve) => {
timeout = setTimeout(resolve, 1000);
}),
new Promise<void>((resolve) => {
interval = setInterval(() => {
global.gc!();
pass = actual.deref() === undefined;
if (pass) {
resolve();
}
}, 1);
}),
]);

clearInterval(interval);
clearTimeout(timeout);

return {
pass,
message: () => {
if (pass) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note on how matchers work: if the user is expecting a pass and pass is true, no message is shown. So the two return cases here cover if the user gets a result that is unexpected, both in the affirmative and negative.

return (
hint +
"\n\n" +
"Expected value to not be cache-collected, but it was."
);
}

return (
hint + "\n\n Expected value to be cache-collected, but it was not."
);
},
};
};
2 changes: 1 addition & 1 deletion src/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
{
"compilerOptions": {
"noEmit": true,
"lib": ["es2015", "esnext.asynciterable", "dom"],
"lib": ["es2015", "esnext.asynciterable", "dom", "ES2021.WeakRef"],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With our two tsconfigs now, the one extending the outer config which is used for builds, this change will not display an error in our editors if we use WeakRefs but tsc would fail at build time.

"types": ["jest", "node", "./testing/matchers/index.d.ts"]
},
"extends": "../tsconfig.json",
Expand Down
5 changes: 4 additions & 1 deletion src/utilities/observables/Concast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,10 @@ export class Concast<T> extends Observable<T> {
// followed by a 'complete' message (see addObserver).
iterateObserversSafely(this.observers, "complete");
} else if (isPromiseLike(value)) {
value.then((obs) => (this.sub = obs.subscribe(this.handlers)));
value.then(
(obs) => (this.sub = obs.subscribe(this.handlers)),
this.handlers.error
);
} else {
this.sub = value.subscribe(this.handlers);
}
Expand Down
113 changes: 112 additions & 1 deletion src/utilities/observables/__tests__/Concast.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { itAsync } from "../../../testing/core";
import { Observable } from "../Observable";
import { Observable, Observer } from "../Observable";
import { Concast, ConcastSourcesIterable } from "../Concast";

describe("Concast Observable (similar to Behavior Subject in RxJS)", () => {
Expand Down Expand Up @@ -187,4 +187,115 @@ describe("Concast Observable (similar to Behavior Subject in RxJS)", () => {
sub.unsubscribe();
});
});

it("resolving all sources of a concast frees all observer references on `this.observers`", async () => {
const { promise, resolve } = deferred<Observable<number>>();
const observers: Observer<any>[] = [{ next() {} }];
const observerRefs = observers.map((observer) => new WeakRef(observer));

const concast = new Concast<number>([Observable.of(1, 2), promise]);

concast.subscribe(observers[0]);
delete observers[0];

expect(concast["observers"].size).toBe(1);

resolve(Observable.of(3, 4));

await expect(concast.promise).resolves.toBe(4);

await expect(observerRefs[0]).toBeGarbageCollected();
});

it("rejecting a source-wrapping promise of a concast frees all observer references on `this.observers`", async () => {
const { promise, reject } = deferred<Observable<number>>();
let subscribingObserver: Observer<any> | undefined = {
next() {},
error() {},
};
const subscribingObserverRef = new WeakRef(subscribingObserver);

const concast = new Concast<number>([
Observable.of(1, 2),
promise,
// just to ensure this also works if the cancelling source is not the last source
Observable.of(3, 5),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may not be load-bearing in this test, but initializing our Concast with a third item after our promise-wrapped Observable which will be rejected gives us additional signal that the promise rejects with an error even though there are additional observables coming after it.

]);

concast.subscribe(subscribingObserver);

expect(concast["observers"].size).toBe(1);

reject("error");
await expect(concast.promise).rejects.toBe("error");
subscribingObserver = undefined;
await expect(subscribingObserverRef).toBeGarbageCollected();
});

it("rejecting a source of a concast frees all observer references on `this.observers`", async () => {
let subscribingObserver: Observer<any> | undefined = {
next() {},
error() {},
};
const subscribingObserverRef = new WeakRef(subscribingObserver);

let sourceObserver!: Observer<number>;
const sourceObservable = new Observable<number>((o) => {
sourceObserver = o;
});

const concast = new Concast<number>([
Observable.of(1, 2),
sourceObservable,
Observable.of(3, 5),
]);

concast.subscribe(subscribingObserver);

expect(concast["observers"].size).toBe(1);

await Promise.resolve();
sourceObserver.error!("error");
await expect(concast.promise).rejects.toBe("error");
subscribingObserver = undefined;
await expect(subscribingObserverRef).toBeGarbageCollected();
});

it("after subscribing to an already-resolved concast, the reference is freed up again", async () => {
const concast = new Concast<number>([Observable.of(1, 2)]);
await expect(concast.promise).resolves.toBe(2);
await Promise.resolve();

let sourceObserver: Observer<any> | undefined = { next() {}, error() {} };
const sourceObserverRef = new WeakRef(sourceObserver);

concast.subscribe(sourceObserver);

sourceObserver = undefined;
await expect(sourceObserverRef).toBeGarbageCollected();
});

it("after subscribing to an already-rejected concast, the reference is freed up again", async () => {
const concast = new Concast<number>([Promise.reject("error")]);
await expect(concast.promise).rejects.toBe("error");
await Promise.resolve();

let sourceObserver: Observer<any> | undefined = { next() {}, error() {} };
const sourceObserverRef = new WeakRef(sourceObserver);

concast.subscribe(sourceObserver);

sourceObserver = undefined;
await expect(sourceObserverRef).toBeGarbageCollected();
});
});

function deferred<X>() {
let resolve!: (v: X) => void;
let reject!: (e: any) => void;
const promise = new Promise<X>((res, rej) => {
resolve = res;
reject = rej;
});
return { resolve, reject, promise };
}
Loading