Skip to content

Commit 59c01b9

Browse files
trivikraduh95
andcommitted
tools: add lint rule for aborted AbortController
Refs: #63489 Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com> Assisted-by: openai:gpt-5.5 PR-URL: #63541 Reviewed-By: Ethan Arrowood <ethan@arrowood.dev> Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com> Reviewed-By: Stephen Belanger <admin@stephenbelanger.com>
1 parent 7198895 commit 59c01b9

5 files changed

Lines changed: 277 additions & 3 deletions

File tree

‎test/eslint.config_partial.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ export default [
162162
'node-core/require-common-first': 'error',
163163
'node-core/no-duplicate-requires': 'off',
164164
'node-core/must-call-assert': 'error',
165+
'node-core/prefer-abort-signal-abort': 'error',
165166
},
166167
},
167168
{

‎test/parallel/test-abortcontroller.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ test('AbortController inspection depth 1 or null works', () => {
161161

162162
test('AbortSignal reason is set correctly',()=>{
163163
// Test AbortSignal.reason
164+
// eslint-disable-next-line node-core/prefer-abort-signal-abort
164165
constac=newAbortController();
165166
ac.abort('reason');
166167
assert.strictEqual(ac.signal.reason,'reason');
@@ -235,6 +236,7 @@ test('AbortSignal.reason should default', () => {
235236
assert.ok(signal.reasoninstanceofDOMException);
236237
assert.strictEqual(signal.reason.code,20);
237238

239+
// eslint-disable-next-line node-core/prefer-abort-signal-abort
238240
constac=newAbortController();
239241
ac.abort();
240242
assert.ok(ac.signal.reasoninstanceofDOMException);
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
'use strict';
2+
3+
constcommon=require('../common');
4+
if((!common.hasCrypto)||(!common.hasIntl)){
5+
common.skip('ESLint tests require crypto and Intl');
6+
}
7+
8+
common.skipIfEslintMissing();
9+
10+
constRuleTester=require('../../tools/eslint/node_modules/eslint').RuleTester;
11+
construle=require('../../tools/eslint-rules/prefer-abort-signal-abort');
12+
13+
constmessage='Use AbortSignal.abort() instead of creating and aborting an AbortController.';
14+
15+
newRuleTester().run('prefer-abort-signal-abort',rule,{
16+
valid: [
17+
'const signal = AbortSignal.abort();',
18+
`
19+
const controller = new AbortController();
20+
controller.abort();
21+
controller.abort();
22+
fn(controller.signal);
23+
`,
24+
`
25+
const controller = new AbortController();
26+
controller.abort();
27+
console.log(controller);
28+
fn(controller.signal);
29+
`,
30+
`
31+
const controller = new AbortController();
32+
// This comment should not be removed.
33+
controller.abort();
34+
fn(controller.signal);
35+
`,
36+
`
37+
const controller = new AbortController();
38+
setImmediate(() => controller.abort());
39+
fn(controller.signal);
40+
`,
41+
`
42+
const controller = new AbortController();
43+
controller.abort('reason', 'extra');
44+
fn(controller.signal);
45+
`,
46+
],
47+
invalid: [
48+
{
49+
code: `
50+
const controller = new AbortController();
51+
controller.abort();
52+
fn(controller.signal);
53+
`,
54+
errors: [{ message }],
55+
output: `
56+
fn(AbortSignal.abort());
57+
`,
58+
},
59+
{
60+
code: `
61+
const abortController = new AbortController();
62+
abortController.abort(new Error('aborted'));
63+
fn({ signal: abortController.signal });
64+
`,
65+
errors: [{ message }],
66+
output: `
67+
fn({ signal: AbortSignal.abort(new Error('aborted')) });
68+
`,
69+
},
70+
{
71+
code: `
72+
{
73+
const ac = new AbortController();
74+
ac.abort();
75+
await wait({ signal: ac.signal });
76+
}
77+
`,
78+
errors: [{ message }],
79+
output: `
80+
{
81+
await wait({ signal: AbortSignal.abort() });
82+
}
83+
`,
84+
},
85+
{
86+
code: `
87+
{
88+
const controller = new AbortController();
89+
controller.abort();
90+
fn(controller.signal, controller.signal);
91+
}
92+
`,
93+
errors: [{ message }],
94+
output: `
95+
{
96+
const controller = AbortSignal.abort();
97+
fn(controller, controller);
98+
}
99+
`,
100+
},
101+
{
102+
code: `
103+
{
104+
const controller = new AbortController();
105+
controller.abort("reason");
106+
fn(controller.signal, controller.signal);
107+
}
108+
`,
109+
errors: [{ message }],
110+
output: `
111+
{
112+
const controller = AbortSignal.abort("reason");
113+
fn(controller, controller);
114+
}
115+
`,
116+
},
117+
]
118+
});

‎test/parallel/test-quic-writer-abort-signal.mjs‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,11 @@ const stream = await clientSession.createBidirectionalStream();
3333
constw=stream.writer;
3434

3535
// Create an already-aborted signal.
36-
constac=newAbortController();
37-
ac.abort(newError('already aborted'));
36+
constsignal=AbortSignal.abort(newError('already aborted'));
3837

3938
// write() with an already-aborted signal should reject immediately.
4039
awaitrejects(
41-
w.write(encoder.encode('data'),{signal: ac.signal}),
40+
w.write(encoder.encode('data'),{ signal }),
4241
{message: 'already aborted'},
4342
);
4443

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/**
2+
* @file Prefer AbortSignal.abort() for already-aborted signals.
3+
*/
4+
'use strict';
5+
6+
constmessage='Use AbortSignal.abort() instead of creating and aborting an AbortController.';
7+
8+
functionisAbortControllerConstruction(node){
9+
returnnode?.type==='NewExpression'&&
10+
node.callee.type==='Identifier'&&
11+
node.callee.name==='AbortController'&&
12+
node.arguments.length===0;
13+
}
14+
15+
functionisIdentifier(node,name){
16+
returnnode?.type==='Identifier'&&node.name===name;
17+
}
18+
19+
functionisProperty(node,name){
20+
return!node.computed&&isIdentifier(node.property,name);
21+
}
22+
23+
functionisAbortCallStatement(node,name){
24+
constexpression=node?.expression;
25+
constcallee=expression?.callee;
26+
returnnode?.type==='ExpressionStatement'&&
27+
expression.type==='CallExpression'&&
28+
callee.type==='MemberExpression'&&
29+
isIdentifier(callee.object,name)&&
30+
isProperty(callee,'abort')&&
31+
expression.arguments.length<=1;
32+
}
33+
34+
functionisSignalReference(reference,name){
35+
const{ identifier }=reference;
36+
constparent=identifier.parent;
37+
returnisIdentifier(identifier,name)&&
38+
parent?.type==='MemberExpression'&&
39+
parent.object===identifier&&
40+
isProperty(parent,'signal');
41+
}
42+
43+
functionisAbortReference(reference,abortStatement,name){
44+
const{ identifier }=reference;
45+
constparent=identifier.parent;
46+
returnisIdentifier(identifier,name)&&
47+
parent?.type==='MemberExpression'&&
48+
parent.object===identifier&&
49+
isProperty(parent,'abort')&&
50+
parent.parent===abortStatement.expression;
51+
}
52+
53+
module.exports={
54+
meta: {
55+
fixable: 'code',
56+
},
57+
58+
create(context){
59+
constsourceCode=context.sourceCode;
60+
constcandidates=[];
61+
62+
functionhasCommentsBetween(left,right){
63+
returnsourceCode.getCommentsBefore(right)
64+
.some((comment)=>comment.range[0]>left.range[1]);
65+
}
66+
67+
functionrangeIncludingTrailingLine(statement){
68+
consttokenAfter=sourceCode.getTokenAfter(statement,{includeComments: true});
69+
if(tokenAfter&&tokenAfter.loc.start.line>statement.loc.end.line){
70+
return[statement.range[0],tokenAfter.range[0]];
71+
}
72+
returnstatement.range;
73+
}
74+
75+
return{
76+
VariableDeclarator(node){
77+
if(node.id.type!=='Identifier'||
78+
!isAbortControllerConstruction(node.init)||
79+
node.parent.declarations.length!==1){
80+
return;
81+
}
82+
83+
constvariableDeclaration=node.parent;
84+
constparent=variableDeclaration.parent;
85+
if(parent.type!=='BlockStatement'&&parent.type!=='Program'){
86+
return;
87+
}
88+
89+
constindex=parent.body.indexOf(variableDeclaration);
90+
constabortStatement=parent.body[index+1];
91+
if(!isAbortCallStatement(abortStatement,node.id.name)||
92+
hasCommentsBetween(variableDeclaration,abortStatement)){
93+
return;
94+
}
95+
96+
candidates.push({
97+
abortStatement,
98+
declarator: node,
99+
variableDeclaration,
100+
});
101+
},
102+
103+
'Program:exit'(){
104+
for(const{ abortStatement, declarator, variableDeclaration }ofcandidates){
105+
const[variable]=sourceCode.scopeManager.getDeclaredVariables(declarator);
106+
if(!variable){
107+
continue;
108+
}
109+
110+
constname=declarator.id.name;
111+
constreferences=variable.references.filter((reference)=>{
112+
returnreference.identifier!==declarator.id;
113+
});
114+
constsignalReferences=references.filter((reference)=>{
115+
returnisSignalReference(reference,name);
116+
});
117+
constabortReferences=references.filter((reference)=>{
118+
returnisAbortReference(reference,abortStatement,name);
119+
});
120+
121+
if(references.length!==(1+signalReferences.length)||
122+
abortReferences.length!==1){
123+
continue;
124+
}
125+
126+
constsignalNode=signalReferences[0].identifier.parent;
127+
constabortArguments=abortStatement.expression.arguments;
128+
constabortReason=abortArguments.length===0 ?
129+
'' : sourceCode.getText(abortArguments[0]);
130+
131+
context.report({
132+
node: declarator,
133+
message,
134+
fix(fixer){
135+
constabortSignalCreationCall=`AbortSignal.abort(${abortReason})`;
136+
if(signalReferences.length>1){
137+
return[
138+
fixer.replaceText(declarator.init,abortSignalCreationCall),
139+
fixer.removeRange(rangeIncludingTrailingLine(abortStatement)),
140+
...signalReferences.map(({identifier: { parent }})=>fixer.replaceText(parent,name)),
141+
];
142+
}
143+
return[
144+
fixer.removeRange(rangeIncludingTrailingLine(variableDeclaration)),
145+
fixer.removeRange(rangeIncludingTrailingLine(abortStatement)),
146+
fixer.replaceText(signalNode,abortSignalCreationCall),
147+
];
148+
},
149+
});
150+
}
151+
},
152+
};
153+
},
154+
};

0 commit comments

Comments
 (0)