Skip to content

Commit c9ac696

Browse files
authored
fix(execute): handle list promise rejections (#4643)
1 parent a3c3978 commit c9ac696

File tree

7 files changed

+375
-47
lines changed

7 files changed

+375
-47
lines changed

src/execution/Executor.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
collectFields,
5555
collectSubfields as _collectSubfields,
5656
} from './collectFields.js';
57+
import { collectIteratorPromises } from './collectIteratorPromises.js';
5758
import { buildResolveInfo } from './execute.js';
5859
import type { StreamUsage } from './getStreamUsage.js';
5960
import { getStreamUsage as _getStreamUsage } from './getStreamUsage.js';
@@ -833,6 +834,11 @@ export class Executor<
833834
} catch (error) {
834835
// eslint-disable-next-line @typescript-eslint/no-floating-promises
835836
returnIteratorCatchingErrors(asyncIterator);
837+
if (containsPromise) {
838+
return Promise.all(completedResults).finally(() => {
839+
throw error;
840+
});
841+
}
836842
throw error;
837843
}
838844

@@ -966,8 +972,13 @@ export class Executor<
966972
index++;
967973
}
968974
} catch (error) {
969-
// eslint-disable-next-line @typescript-eslint/no-floating-promises
970-
returnIteratorCatchingErrors(iterator);
975+
const maybePromises = containsPromise ? completedResults : [];
976+
maybePromises.push(...collectIteratorPromises(iterator));
977+
if (maybePromises.length) {
978+
return Promise.all(maybePromises).finally(() => {
979+
throw error;
980+
});
981+
}
971982
throw error;
972983
}
973984

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { expect } from 'chai';
2+
import { describe, it } from 'mocha';
3+
4+
import { collectIteratorPromises } from '../collectIteratorPromises.js';
5+
6+
describe('collectIteratorPromises', () => {
7+
it('collects promise values until completion', () => {
8+
const first = Promise.resolve(1);
9+
const second = Promise.resolve(2);
10+
const values: Array<unknown> = [first, 'x', second];
11+
12+
const iterator: Iterator<unknown> = {
13+
next() {
14+
const value = values.shift();
15+
if (value === undefined) {
16+
return { done: true, value: undefined };
17+
}
18+
return { done: false, value };
19+
},
20+
};
21+
22+
expect(collectIteratorPromises(iterator)).to.deep.equal([first, second]);
23+
});
24+
25+
it('returns collected promises when draining throws', () => {
26+
const first = Promise.resolve(1);
27+
let nextCalls = 0;
28+
29+
const iterator: Iterator<unknown> = {
30+
next() {
31+
nextCalls += 1;
32+
if (nextCalls === 1) {
33+
return { done: false, value: first };
34+
}
35+
throw new Error('bad');
36+
},
37+
};
38+
39+
expect(collectIteratorPromises(iterator)).to.deep.equal([first]);
40+
});
41+
});

src/execution/__tests__/lists-test.ts

Lines changed: 158 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { assert, expect } from 'chai';
22
import { describe, it } from 'mocha';
33

44
import { expectJSON } from '../../__testUtils__/expectJSON.js';
5+
import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js';
56

67
import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.js';
78

@@ -21,6 +22,14 @@ import { buildSchema } from '../../utilities/buildASTSchema.js';
2122
import { execute, executeSync } from '../execute.js';
2223
import type { ExecutionResult } from '../Executor.js';
2324

25+
function delayedReject(message: string): Promise<never> {
26+
return (async () => {
27+
await resolveOnNextTick();
28+
await resolveOnNextTick();
29+
throw new Error(message);
30+
})();
31+
}
32+
2433
describe('Execute: Accepts any iterable as list value', () => {
2534
function complete(rootValue: unknown) {
2635
return executeSync({
@@ -77,13 +86,18 @@ describe('Execute: Accepts any iterable as list value', () => {
7786
});
7887
});
7988

80-
it('Ignores iterator return errors when iteration throws', () => {
89+
it('Does not call iterator `return` when iteration throws', () => {
8190
let returnCalled = false;
91+
let nextCalls = 0;
8292
const listField = {
8393
[Symbol.iterator]() {
8494
return {
8595
next() {
86-
throw new Error('bad');
96+
nextCalls++;
97+
if (nextCalls === 1) {
98+
throw new Error('bad');
99+
}
100+
return { done: true, value: undefined };
87101
},
88102
return() {
89103
returnCalled = true;
@@ -103,7 +117,8 @@ describe('Execute: Accepts any iterable as list value', () => {
103117
},
104118
],
105119
});
106-
expect(returnCalled).to.equal(true);
120+
expect(nextCalls).to.equal(2);
121+
expect(returnCalled).to.equal(false);
107122
});
108123
});
109124

@@ -116,7 +131,7 @@ describe('Execute: Handles abrupt completion in synchronous iterables', () => {
116131
});
117132
}
118133

119-
it('closes the iterator when `next` throws', async () => {
134+
it('drains the iterator when `next` throws', async () => {
120135
let returned = false;
121136
let nextCalls = 0;
122137

@@ -129,7 +144,10 @@ describe('Execute: Handles abrupt completion in synchronous iterables', () => {
129144
if (nextCalls === 1) {
130145
return { done: false, value: 'ok' };
131146
}
132-
throw new Error('bad');
147+
if (nextCalls === 2) {
148+
throw new Error('bad');
149+
}
150+
return { done: true, value: undefined };
133151
},
134152
return(): IteratorResult<string> {
135153
returned = true;
@@ -147,11 +165,11 @@ describe('Execute: Handles abrupt completion in synchronous iterables', () => {
147165
},
148166
],
149167
});
150-
expect(nextCalls).to.equal(2);
151-
expect(returned).to.equal(true);
168+
expect(nextCalls).to.equal(3);
169+
expect(returned).to.equal(false);
152170
});
153171

154-
it('closes the iterator when a null bubbles up from a non-null item', async () => {
172+
it('drains the iterator when a null bubbles up from a non-null item', async () => {
155173
const values = [1, null, 2];
156174
let index = 0;
157175
let returned = false;
@@ -183,34 +201,98 @@ describe('Execute: Handles abrupt completion in synchronous iterables', () => {
183201
},
184202
],
185203
});
186-
expect(index).to.equal(2);
187-
expect(returned).to.equal(true);
204+
expect(index).to.equal(4);
205+
expect(returned).to.equal(false);
188206
});
189207

190-
it('ignores errors thrown by the iterator `return` method', async () => {
191-
const values = [1, null, 2];
192-
let index = 0;
208+
it('handles iterator errors with later pending promises without calling `return`', async () => {
209+
let unhandledRejection: unknown = null;
210+
const unhandledRejectionListener = (reason: unknown) => {
211+
unhandledRejection = reason;
212+
};
213+
// eslint-disable-next-line no-undef
214+
process.on('unhandledRejection', unhandledRejectionListener);
193215
let returned = false;
216+
let nextCalls = 0;
217+
const laterPromise = delayedReject('later bad');
194218

195-
const listField: IterableIterator<number | null> = {
196-
[Symbol.iterator](): IterableIterator<number | null> {
219+
const listField: IterableIterator<number | Promise<never>> = {
220+
[Symbol.iterator](): IterableIterator<number | Promise<never>> {
197221
return this;
198222
},
199-
next(): IteratorResult<number | null> {
223+
next(): IteratorResult<number | Promise<never>> {
224+
nextCalls++;
225+
if (nextCalls === 1) {
226+
return { done: false, value: 1 };
227+
}
228+
if (nextCalls === 2) {
229+
throw new Error('bad');
230+
}
231+
if (nextCalls === 3) {
232+
return { done: false, value: laterPromise };
233+
}
234+
return { done: true, value: undefined };
235+
},
236+
return(): IteratorResult<number | Promise<never>> {
237+
returned = true;
238+
throw new Error('ignored return error');
239+
},
240+
};
241+
242+
expectJSON(await complete({ listField })).toDeepEqual({
243+
data: { listField: null },
244+
errors: [
245+
{
246+
message: 'bad',
247+
locations: [{ line: 1, column: 3 }],
248+
path: ['listField'],
249+
},
250+
],
251+
});
252+
253+
await new Promise((resolve) => setTimeout(resolve, 20));
254+
255+
// eslint-disable-next-line no-undef
256+
process.removeListener('unhandledRejection', unhandledRejectionListener);
257+
258+
expect(nextCalls).to.equal(4);
259+
expect(returned).to.equal(false);
260+
expect(unhandledRejection).to.equal(null);
261+
});
262+
263+
it('handles sync errors with later pending promises without calling `return`', async () => {
264+
let unhandledRejection: unknown = null;
265+
const unhandledRejectionListener = (reason: unknown) => {
266+
unhandledRejection = reason;
267+
};
268+
// eslint-disable-next-line no-undef
269+
process.on('unhandledRejection', unhandledRejectionListener);
270+
let returned = false;
271+
let index = 0;
272+
const values = [
273+
delayedReject('first bad'),
274+
null,
275+
delayedReject('third bad'),
276+
];
277+
const listField: IterableIterator<Promise<string> | null> = {
278+
[Symbol.iterator](): IterableIterator<Promise<string> | null> {
279+
return this;
280+
},
281+
next(): IteratorResult<Promise<string> | null> {
200282
const value = values[index++];
201283
if (value === undefined) {
202284
return { done: true, value: undefined };
203285
}
204286
return { done: false, value };
205287
},
206-
return(): IteratorResult<number | null> {
288+
return(): IteratorResult<Promise<string> | null> {
207289
returned = true;
208290
throw new Error('ignored return error');
209291
},
210292
};
211293

212-
expectJSON(await complete({ listField }, '[Int!]')).toDeepEqual({
213-
data: { listField: null },
294+
expectJSON(await complete({ listField }, '[String!]!')).toDeepEqual({
295+
data: null,
214296
errors: [
215297
{
216298
message: 'Cannot return null for non-nullable field Query.listField.',
@@ -219,8 +301,15 @@ describe('Execute: Handles abrupt completion in synchronous iterables', () => {
219301
},
220302
],
221303
});
222-
expect(index).to.equal(2);
223-
expect(returned).to.equal(true);
304+
305+
await new Promise((resolve) => setTimeout(resolve, 20));
306+
307+
// eslint-disable-next-line no-undef
308+
process.removeListener('unhandledRejection', unhandledRejectionListener);
309+
310+
expect(returned).to.equal(false);
311+
expect(index).to.equal(4);
312+
expect(unhandledRejection).to.equal(null);
224313
});
225314
});
226315

@@ -235,7 +324,17 @@ describe('Execute: Accepts async iterables as list value', () => {
235324

236325
function completeObjectList(
237326
resolve: GraphQLFieldResolver<{ index: number }, unknown>,
327+
nonNullable = false,
238328
): PromiseOrValue<ExecutionResult> {
329+
const ObjectWrapperType = new GraphQLObjectType({
330+
name: 'ObjectWrapper',
331+
fields: {
332+
index: {
333+
type: new GraphQLNonNull(GraphQLString),
334+
resolve,
335+
},
336+
},
337+
});
239338
const schema = new GraphQLSchema({
240339
query: new GraphQLObjectType({
241340
name: 'Query',
@@ -247,15 +346,9 @@ describe('Execute: Accepts async iterables as list value', () => {
247346
yield await Promise.resolve({ index: 2 });
248347
},
249348
type: new GraphQLList(
250-
new GraphQLObjectType({
251-
name: 'ObjectWrapper',
252-
fields: {
253-
index: {
254-
type: new GraphQLNonNull(GraphQLString),
255-
resolve,
256-
},
257-
},
258-
}),
349+
nonNullable
350+
? new GraphQLNonNull(ObjectWrapperType)
351+
: ObjectWrapperType,
259352
),
260353
},
261354
},
@@ -362,6 +455,41 @@ describe('Execute: Accepts async iterables as list value', () => {
362455
],
363456
});
364457
});
458+
459+
it('handles mixture of sync and async errors in AsyncIterables', async () => {
460+
let unhandledRejection: unknown = null;
461+
const unhandledRejectionListener = (reason: unknown) => {
462+
unhandledRejection = reason;
463+
};
464+
// eslint-disable-next-line no-undef
465+
process.on('unhandledRejection', unhandledRejectionListener);
466+
467+
expectJSON(
468+
await completeObjectList(({ index }) => {
469+
if (index === 0) {
470+
return delayedReject('bad');
471+
}
472+
throw new Error('also bad');
473+
}, true),
474+
).toDeepEqual({
475+
data: { listField: null },
476+
errors: [
477+
{
478+
message: 'also bad',
479+
locations: [{ line: 1, column: 15 }],
480+
path: ['listField', 1, 'index'],
481+
},
482+
],
483+
});
484+
485+
await new Promise((resolve) => setTimeout(resolve, 20));
486+
487+
// eslint-disable-next-line no-undef
488+
process.removeListener('unhandledRejection', unhandledRejectionListener);
489+
490+
expect(unhandledRejection).to.equal(null);
491+
});
492+
365493
it('Handles nulls yielded by async generator', async () => {
366494
async function* listField() {
367495
yield await Promise.resolve(1);

0 commit comments

Comments
 (0)