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 16 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 (that was not triggered by ApolloClient until now) in `Concast`.
Copy link
Contributor

Choose a reason for hiding this comment

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

Additional context: this leak never occurred in our runtime code, but could present a problem for different usages of Concast (that do not appear in our codebase). Since Concast is exported from our library, we should fix it as good citizens.

Copy link
Member

Choose a reason for hiding this comment

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

@alessbell that additional context is super helpful as I was a bit confused at what the "until now" meant.

@phryneas perhaps you could word this as such to make it more clear?

Fixes a potential memory leak in Concast when Concast was used outside of Apollo Client.

This gives a bit better context on when someone could expect to see the issue arise.

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
9 changes: 9 additions & 0 deletions src/testing/matchers/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import {
ProfiledHook,
} from "../internal/index.js";

declare class WeakRef<T extends WeakKey> {
Copy link
Contributor

Choose a reason for hiding this comment

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

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

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

interface ApolloCustomMatchers<R = void, T = {}> {
/**
* Used to determine if two GraphQL query documents are equal to each other by
Expand Down Expand Up @@ -43,6 +48,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,
});
57 changes: 57 additions & 0 deletions src/testing/matchers/toBeGarbageCollected.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { MatcherFunction } from "expect";

declare class WeakRef<T extends WeakKey> {
Copy link
Contributor

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
Contributor

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
Contributor

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
105 changes: 104 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,107 @@ 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>>();
const observers: Observer<any>[] = [{ next() {}, error() {} }];
const observerRefs = observers.map((observer) => new WeakRef(observer));

const concast = new Concast<number>([
Observable.of(1, 2),
promise,
Observable.of(3, 5),
Copy link
Contributor

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(observers[0]);
delete observers[0];

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

reject("error");
await expect(concast.promise).rejects.toBe("error");
await expect(observerRefs[0]).toBeGarbageCollected();
});

it("rejecting a source of a concast frees all observer references on `this.observers`", async () => {
const observers: Observer<any>[] = [{ next() {}, error() {} }];
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
const observers: Observer<any>[] = [{ next() {}, error() {} }];
const subscribingObservers: Observer<any>[] = [{ next() {}, error() {} }];

const observerRefs = observers.map((observer) => new WeakRef(observer));

let observer!: Observer<number>;
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
let observer!: Observer<number>;
let sourceObserver!: Observer<number>;

const observable = new Observable<number>((o) => {
observer = o;
});

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

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

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

await Promise.resolve();
observer.error!("error");
await expect(concast.promise).rejects.toBe("error");
await expect(observerRefs[0]).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();

const observers: Observer<any>[] = [{ next() {}, error() {} }];
const observerRefs = observers.map((observer) => new WeakRef(observer));

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

await expect(observerRefs[0]).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();

const observers: Observer<any>[] = [{ next() {}, error() {} }];
const observerRefs = observers.map((observer) => new WeakRef(observer));

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

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

function deferred<X>() {
let resolve!: (v: X) => void, reject!: (e: any) => void;
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor nit: declaring variables on separate lines for readability

Copy link
Member Author

Choose a reason for hiding this comment

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

Don't know why prettier let me down here ^^

const promise = new Promise<X>((res, rej) => {
resolve = res;
reject = rej;
});
return { resolve, reject, promise };
}
Loading