Skip to content

Commit 17ce540

Browse files
committed
Fix crash in node when mixing sync/async resolvers
Fixes #3528
1 parent 5f247e0 commit 17ce540

File tree

2 files changed

+86
-13
lines changed

2 files changed

+86
-13
lines changed

src/execution/__tests__/executor-test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,60 @@ describe('Execute: Handles basic execution tasks', () => {
625625
});
626626
});
627627

628+
it('handles sync errors combined with rejections', async () => {
629+
const catchUnhandledRejections = (reason: any) => {
630+
throw new Error('Unhandled rejection: ' + reason.message);
631+
};
632+
633+
process.on('unhandledRejection', catchUnhandledRejections);
634+
635+
try {
636+
const schema = new GraphQLSchema({
637+
query: new GraphQLObjectType({
638+
name: 'Type',
639+
fields: {
640+
syncNullError: {
641+
type: new GraphQLNonNull(GraphQLString),
642+
resolve: () => null,
643+
},
644+
asyncNullError: {
645+
type: new GraphQLNonNull(GraphQLString),
646+
resolve: () => Promise.resolve(null),
647+
},
648+
},
649+
}),
650+
});
651+
652+
// Order is important here, as the promise has to be created before the synchronous error is thrown
653+
const document = parse(`
654+
{
655+
asyncNullError
656+
syncNullError
657+
}
658+
`);
659+
660+
const rootValue = {};
661+
662+
const result = await execute({ schema, document, rootValue });
663+
expectJSON(result).toDeepEqual({
664+
data: null,
665+
errors: [
666+
{
667+
message:
668+
'Cannot return null for non-nullable field Type.asyncNullError.',
669+
locations: [{ line: 3, column: 11 }],
670+
path: ['asyncNullError'],
671+
},
672+
],
673+
});
674+
675+
// Allow time for the asyncReject field to be rejected
676+
await new Promise((resolve) => setTimeout(resolve, 50));
677+
} finally {
678+
process.off('unhandledRejection', catchUnhandledRejections);
679+
}
680+
});
681+
628682
it('Full response path is included for non-nullable fields', () => {
629683
const A: GraphQLObjectType = new GraphQLObjectType({
630684
name: 'A',

src/execution/execute.ts

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -444,34 +444,53 @@ function executeFields(
444444
): PromiseOrValue<ObjMap<unknown>> {
445445
const results = Object.create(null);
446446
let containsPromise = false;
447+
let error: Error | undefined;
447448

448449
for (const [responseName, fieldNodes] of fields.entries()) {
449450
const fieldPath = addPath(path, responseName, parentType.name);
450-
const result = executeField(
451-
exeContext,
452-
parentType,
453-
sourceValue,
454-
fieldNodes,
455-
fieldPath,
456-
);
451+
try {
452+
const result = executeField(
453+
exeContext,
454+
parentType,
455+
sourceValue,
456+
fieldNodes,
457+
fieldPath,
458+
);
457459

458-
if (result !== undefined) {
459-
results[responseName] = result;
460-
if (isPromise(result)) {
461-
containsPromise = true;
460+
if (result !== undefined) {
461+
results[responseName] = result;
462+
if (isPromise(result)) {
463+
containsPromise = true;
464+
}
462465
}
466+
} catch (err) {
467+
error = err;
468+
}
469+
470+
if (error) {
471+
break;
463472
}
464473
}
465474

466475
// If there are no promises, we can just return the object
467476
if (!containsPromise) {
468-
return results;
477+
if (error) {
478+
throw error;
479+
} else {
480+
return results;
481+
}
469482
}
470483

471484
// Otherwise, results is a map from field name to the result of resolving that
472485
// field, which is possibly a promise. Return a promise that will return this
473486
// same map, but with any promises replaced with the values they resolved to.
474-
return promiseForObject(results);
487+
const promise = promiseForObject(results);
488+
if (error) {
489+
// Ensure that any promises returned by other fields are handled, as they may also reject.
490+
return promise.then(() => Promise.reject(error));
491+
}
492+
493+
return promise;
475494
}
476495

477496
/**

0 commit comments

Comments
 (0)