Skip to content

Commit cd29258

Browse files
committed
fix(graphql): prevent potential memory leak for mostly falsy filter fn
1 parent e9326a2 commit cd29258

File tree

2 files changed

+91
-12
lines changed

2 files changed

+91
-12
lines changed

packages/apollo/lib/utils/async-iterator.util.ts

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { $$asyncIterator } from 'iterall';
22

3-
type AsyncIterator<T> = {
3+
export type AsyncIterator<T> = {
44
next(value?: any): Promise<IteratorResult<T>>;
55
return(): any;
66
throw(error: any): any;
@@ -12,18 +12,38 @@ export const createAsyncIterator = async <T = any>(
1212
filterFn: Function,
1313
): Promise<AsyncIterator<T>> => {
1414
const asyncIterator = await lazyFactory;
15-
const getNextValue = async () => {
16-
if (!asyncIterator || typeof asyncIterator.next !== 'function') {
17-
return Promise.reject(asyncIterator);
18-
}
15+
const getNextValue = () => {
16+
return new Promise<IteratorResult<any>>((resolve, reject) => {
17+
const inner = () => {
18+
if (!asyncIterator || typeof asyncIterator.next !== 'function') {
19+
reject(asyncIterator);
20+
return;
21+
}
1922

20-
const payload = await asyncIterator.next();
21-
if (payload.done === true) {
22-
return payload;
23-
}
24-
return Promise.resolve(filterFn(payload.value))
25-
.catch(() => false)
26-
.then((result) => (result ? payload : getNextValue()));
23+
asyncIterator
24+
.next()
25+
.then((payload) => {
26+
if (payload.done === true) {
27+
resolve(payload);
28+
return;
29+
}
30+
Promise.resolve(filterFn(payload.value))
31+
.catch(() => false)
32+
.then((result) => {
33+
if (result === true) {
34+
resolve(payload);
35+
return;
36+
}
37+
38+
inner();
39+
return;
40+
});
41+
})
42+
.catch(reject);
43+
};
44+
45+
inner();
46+
});
2747
};
2848

2949
return {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Old implementation of with-filter was leaking memory with was visible
2+
// in case with long lived subscriptions where filter is skipping most of messages
3+
4+
import { $$asyncIterator } from 'iterall';
5+
import { setFlagsFromString } from 'v8';
6+
import { runInNewContext } from 'vm';
7+
import {
8+
AsyncIterator,
9+
createAsyncIterator,
10+
} from '../../lib/utils/async-iterator.util';
11+
12+
setFlagsFromString('--expose_gc');
13+
const gc = runInNewContext('gc');
14+
15+
// https://github.com/apollographql/graphql-subscriptions/issues/212
16+
it('does not leak memory with promise chain #memory', async function () {
17+
jest.setTimeout(5000);
18+
19+
let stopped = false;
20+
let index = 0;
21+
const asyncIterator: AsyncIterator<any> = {
22+
async next() {
23+
if (stopped) {
24+
return Promise.resolve({ done: true, value: undefined });
25+
}
26+
index += 1;
27+
return new Promise((resolve) => setImmediate(resolve)).then(() => ({
28+
done: false,
29+
value: index,
30+
}));
31+
},
32+
return() {
33+
return Promise.resolve({ value: undefined, done: true });
34+
},
35+
throw(error) {
36+
return Promise.reject(error);
37+
},
38+
[$$asyncIterator]() {
39+
return this;
40+
},
41+
};
42+
43+
const filteredAsyncIterator = await createAsyncIterator(
44+
Promise.resolve(asyncIterator),
45+
() => stopped,
46+
);
47+
48+
gc();
49+
const heapUsed = process.memoryUsage().heapUsed;
50+
const nextPromise = filteredAsyncIterator.next();
51+
await new Promise((resolve) => setTimeout(resolve, 3000));
52+
gc();
53+
const heapUsed2 = process.memoryUsage().heapUsed;
54+
stopped = true;
55+
await nextPromise;
56+
57+
// Heap memory goes up for less than 1%
58+
expect(Math.max(0, heapUsed2 - heapUsed) / heapUsed).toBeLessThan(0.01);
59+
});

0 commit comments

Comments
 (0)