-
Notifications
You must be signed in to change notification settings - Fork 2.7k
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
Changes from 16 commits
0f799d8
b25f1ef
2abfbf5
f135a79
aff2af5
c6383c2
fb9632a
c492600
727bd44
d98a5e9
80d8b35
4a8d622
6659ec4
75731f6
606e5d6
5c35069
2992f11
59fe42c
1bbd815
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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`. | ||
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 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -10,6 +10,11 @@ import { | |
ProfiledHook, | ||
} from "../internal/index.js"; | ||
|
||
declare class WeakRef<T extends WeakKey> { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
@@ -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 { | ||
|
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> { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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." | ||
); | ||
}, | ||
}; | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,7 +5,7 @@ | |
{ | ||
"compilerOptions": { | ||
"noEmit": true, | ||
"lib": ["es2015", "esnext.asynciterable", "dom"], | ||
"lib": ["es2015", "esnext.asynciterable", "dom", "ES2021.WeakRef"], | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
"types": ["jest", "node", "./testing/matchers/index.d.ts"] | ||
}, | ||
"extends": "../tsconfig.json", | ||
|
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)", () => { | ||||||
|
@@ -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), | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() {} }]; | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
const observerRefs = observers.map((observer) => new WeakRef(observer)); | ||||||
|
||||||
let observer!: Observer<number>; | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
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; | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor nit: declaring variables on separate lines for readability There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }; | ||||||
} |
There was a problem hiding this comment.
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). SinceConcast
is exported from our library, we should fix it as good citizens.There was a problem hiding this comment.
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?
This gives a bit better context on when someone could expect to see the issue arise.