Skip to content

Commit 4a6e5bc

Browse files
committed
Allow cancellation via abortSignal
and terminate "zombie" execution when result returns. "Zombie" execution can occur secondary to null bubbling.
1 parent 66968ce commit 4a6e5bc

11 files changed

Lines changed: 1209 additions & 27 deletions

src/execution/ResolveInfo.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ export class ResolveInfo implements GraphQLResolveInfo {
2626
private _fieldDetailsList: FieldDetailsList;
2727
private _parentType: GraphQLObjectType;
2828
private _path: Path;
29+
private _abortSignal: AbortSignal | undefined;
30+
private _registerAbortSignal: () => {
31+
abortSignal: AbortSignal | undefined;
32+
unregister?: () => void;
33+
};
34+
private _unregisterAbortSignal: (() => void) | undefined;
2935

3036
private _fieldName: string | undefined;
3137
private _fieldNodes: ReadonlyArray<FieldNode> | undefined;
@@ -37,18 +43,24 @@ export class ResolveInfo implements GraphQLResolveInfo {
3743
private _operation: OperationDefinitionNode | undefined;
3844
private _variableValues: VariableValues | undefined;
3945

46+
// eslint-disable-next-line max-params
4047
constructor(
4148
validatedExecutionArgs: ValidatedExecutionArgs,
4249
fieldDef: GraphQLField<unknown, unknown>,
4350
fieldDetailsList: FieldDetailsList,
4451
parentType: GraphQLObjectType,
4552
path: Path,
53+
registerAbortSignal: () => {
54+
abortSignal: AbortSignal | undefined;
55+
unregister?: () => void;
56+
},
4657
) {
4758
this._validatedExecutionArgs = validatedExecutionArgs;
4859
this._fieldDef = fieldDef;
4960
this._fieldDetailsList = fieldDetailsList;
5061
this._parentType = parentType;
5162
this._path = path;
63+
this._registerAbortSignal = registerAbortSignal;
5264
}
5365

5466
get fieldName(): string {
@@ -103,4 +115,17 @@ export class ResolveInfo implements GraphQLResolveInfo {
103115
this._variableValues ??= this._validatedExecutionArgs.variableValues;
104116
return this._variableValues;
105117
}
118+
119+
get abortSignal(): AbortSignal | undefined {
120+
if (this._abortSignal !== undefined) {
121+
return this._abortSignal;
122+
}
123+
const { abortSignal, unregister } = this._registerAbortSignal();
124+
this._abortSignal = abortSignal;
125+
this._unregisterAbortSignal = unregister;
126+
return this._abortSignal;
127+
}
128+
unregisterAbortSignal(): void {
129+
this._unregisterAbortSignal?.();
130+
}
106131
}

src/execution/__tests__/ResolveInfo-test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,22 @@ describe('ResolveInfo', () => {
4444
assert(fieldDetailsList != null);
4545

4646
const path = { key: 'test', prev: undefined, typename: 'Query' };
47+
48+
const abortController = new AbortController();
49+
const abortSignal = abortController.signal;
50+
let unregisterCalled = false;
4751
const resolveInfo = new ResolveInfo(
4852
validatedExecutionArgs,
4953
query.getFields().test,
5054
fieldDetailsList,
5155
query,
5256
path,
57+
() => ({
58+
abortSignal,
59+
unregister: () => {
60+
unregisterCalled = true;
61+
},
62+
}),
5363
);
5464

5565
it('exposes fieldName', () => {
@@ -99,4 +109,15 @@ describe('ResolveInfo', () => {
99109
validatedExecutionArgs.variableValues,
100110
);
101111
});
112+
113+
it('exposes abortSignal', () => {
114+
const retrievedAbortSignal = resolveInfo.abortSignal;
115+
expect(retrievedAbortSignal).to.equal(abortSignal);
116+
expect(retrievedAbortSignal).to.equal(resolveInfo.abortSignal); // ensure same reference
117+
});
118+
119+
it('calls unregisterAbortSignal', () => {
120+
resolveInfo.unregisterAbortSignal();
121+
expect(unregisterCalled).to.equal(true);
122+
});
102123
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { expect } from 'chai';
2+
import { describe, it } from 'mocha';
3+
4+
import { expectPromise } from '../../__testUtils__/expectPromise.js';
5+
6+
import { cancellablePromise } from '../cancellablePromise.js';
7+
8+
describe('cancellablePromise', () => {
9+
it('works to wrap a resolved promise', async () => {
10+
const abortController = new AbortController();
11+
12+
const promise = Promise.resolve(1);
13+
14+
const withCancellation = cancellablePromise(
15+
promise,
16+
abortController.signal,
17+
);
18+
19+
expect(await withCancellation).to.equal(1);
20+
});
21+
22+
it('works to wrap a rejected promise', async () => {
23+
const abortController = new AbortController();
24+
25+
const promise = Promise.reject(new Error('Rejected!'));
26+
27+
const withCancellation = cancellablePromise(
28+
promise,
29+
abortController.signal,
30+
);
31+
32+
await expectPromise(withCancellation).toRejectWith('Rejected!');
33+
});
34+
35+
it('works to cancel an already resolved promise', async () => {
36+
const abortController = new AbortController();
37+
38+
const promise = Promise.resolve(1);
39+
40+
const withCancellation = cancellablePromise(
41+
promise,
42+
abortController.signal,
43+
);
44+
45+
abortController.abort(new Error('Cancelled!'));
46+
47+
await expectPromise(withCancellation).toRejectWith('Cancelled!');
48+
});
49+
50+
it('works to cancel an already resolved promise after abort signal triggered', async () => {
51+
const abortController = new AbortController();
52+
53+
abortController.abort(new Error('Cancelled!'));
54+
55+
const promise = Promise.resolve(1);
56+
57+
const withCancellation = cancellablePromise(
58+
promise,
59+
abortController.signal,
60+
);
61+
62+
await expectPromise(withCancellation).toRejectWith('Cancelled!');
63+
});
64+
65+
it('works to cancel an already rejected promise after abort signal triggered', async () => {
66+
const abortController = new AbortController();
67+
68+
abortController.abort(new Error('Cancelled!'));
69+
70+
const promise = Promise.reject(new Error('Rejected!'));
71+
72+
const withCancellation = cancellablePromise(
73+
promise,
74+
abortController.signal,
75+
);
76+
77+
await expectPromise(withCancellation).toRejectWith('Cancelled!');
78+
});
79+
80+
it('works to cancel a hanging promise', async () => {
81+
const abortController = new AbortController();
82+
83+
const promise = new Promise(() => {
84+
/* never resolves */
85+
});
86+
87+
const withCancellation = cancellablePromise(
88+
promise,
89+
abortController.signal,
90+
);
91+
92+
abortController.abort(new Error('Cancelled!'));
93+
94+
await expectPromise(withCancellation).toRejectWith('Cancelled!');
95+
});
96+
97+
it('works to cancel a hanging promise created after abort signal triggered', async () => {
98+
const abortController = new AbortController();
99+
100+
abortController.abort(new Error('Cancelled!'));
101+
102+
const promise = new Promise(() => {
103+
/* never resolves */
104+
});
105+
106+
const withCancellation = cancellablePromise(
107+
promise,
108+
abortController.signal,
109+
);
110+
111+
await expectPromise(withCancellation).toRejectWith('Cancelled!');
112+
});
113+
});

0 commit comments

Comments
 (0)