-
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 all 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 in `Concast` that might have been triggered when `Concast` was used outside of Apollo Client. |
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 |
---|---|---|
@@ -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> { | ||
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,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), | ||
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(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 }; | ||
} |
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.
We can also remove this declaration since we're including it in our tsconfig.