Skip to content

Commit 2cd59c6

Browse files
committed
tools: add lint rule for aborted AbortController
Refs: #63489 Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> Assisted-by: openai:gpt-5.5
1 parent c55b126 commit 2cd59c6

5 files changed

Lines changed: 243 additions & 6 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 & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,8 @@ test('AbortController inspection depth 1 or null works', () => {
161161

162162
test('AbortSignal reason is set correctly', () => {
163163
// Test AbortSignal.reason
164-
const ac = new AbortController();
165-
ac.abort('reason');
166-
assert.strictEqual(ac.signal.reason, 'reason');
164+
const signal = AbortSignal.abort('reason');
165+
assert.strictEqual(signal.reason, 'reason');
167166
});
168167

169168
test('AbortSignal reasonable is set correctly with AbortSignal.abort()', () => {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
if ((!common.hasCrypto) || (!common.hasIntl)) {
5+
common.skip('ESLint tests require crypto and Intl');
6+
}
7+
8+
common.skipIfEslintMissing();
9+
10+
const RuleTester = require('../../tools/eslint/node_modules/eslint').RuleTester;
11+
const rule = require('../../tools/eslint-rules/prefer-abort-signal-abort');
12+
13+
const message = 'Use AbortSignal.abort() instead of creating and aborting an AbortController.';
14+
15+
new RuleTester().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+
fn(controller.signal, controller.signal);
28+
`,
29+
`
30+
const controller = new AbortController();
31+
controller.abort();
32+
console.log(controller);
33+
fn(controller.signal);
34+
`,
35+
`
36+
const controller = new AbortController();
37+
// This comment should not be removed.
38+
controller.abort();
39+
fn(controller.signal);
40+
`,
41+
`
42+
const controller = new AbortController();
43+
setImmediate(() => controller.abort());
44+
fn(controller.signal);
45+
`,
46+
`
47+
const controller = new AbortController();
48+
controller.abort('reason', 'extra');
49+
fn(controller.signal);
50+
`,
51+
],
52+
invalid: [
53+
{
54+
code: `
55+
const controller = new AbortController();
56+
controller.abort();
57+
fn(controller.signal);
58+
`,
59+
errors: [{ message }],
60+
output: `
61+
fn(AbortSignal.abort());
62+
`,
63+
},
64+
{
65+
code: `
66+
const abortController = new AbortController();
67+
abortController.abort(new Error('aborted'));
68+
fn({ signal: abortController.signal });
69+
`,
70+
errors: [{ message }],
71+
output: `
72+
fn({ signal: AbortSignal.abort(new Error('aborted')) });
73+
`,
74+
},
75+
{
76+
code: `
77+
{
78+
const ac = new AbortController();
79+
ac.abort();
80+
await wait({ signal: ac.signal });
81+
}
82+
`,
83+
errors: [{ message }],
84+
output: `
85+
{
86+
await wait({ signal: AbortSignal.abort() });
87+
}
88+
`,
89+
},
90+
]
91+
});

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
const w = stream.writer;
3434

3535
// Create an already-aborted signal.
36-
const ac = new AbortController();
37-
ac.abort(new Error('already aborted'));
36+
const signal = AbortSignal.abort(new Error('already aborted'));
3837

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

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* @file Prefer AbortSignal.abort() for already-aborted signals.
3+
*/
4+
'use strict';
5+
6+
const message = 'Use AbortSignal.abort() instead of creating and aborting an AbortController.';
7+
8+
function isAbortControllerConstruction(node) {
9+
return node?.type === 'NewExpression' &&
10+
node.callee.type === 'Identifier' &&
11+
node.callee.name === 'AbortController' &&
12+
node.arguments.length === 0;
13+
}
14+
15+
function isIdentifier(node, name) {
16+
return node?.type === 'Identifier' && node.name === name;
17+
}
18+
19+
function isProperty(node, name) {
20+
return !node.computed && isIdentifier(node.property, name);
21+
}
22+
23+
function isAbortCallStatement(node, name) {
24+
const expression = node?.expression;
25+
const callee = expression?.callee;
26+
return node?.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+
function isSignalReference(reference, name) {
35+
const { identifier } = reference;
36+
const parent = identifier.parent;
37+
return isIdentifier(identifier, name) &&
38+
parent?.type === 'MemberExpression' &&
39+
parent.object === identifier &&
40+
isProperty(parent, 'signal');
41+
}
42+
43+
function isAbortReference(reference, abortStatement, name) {
44+
const { identifier } = reference;
45+
const parent = identifier.parent;
46+
return isIdentifier(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+
const sourceCode = context.sourceCode;
60+
const candidates = [];
61+
62+
function hasCommentsBetween(left, right) {
63+
return sourceCode.getCommentsBefore(right)
64+
.some((comment) => comment.range[0] > left.range[1]);
65+
}
66+
67+
function rangeIncludingTrailingLine(statement) {
68+
const tokenAfter = 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+
return statement.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+
const variableDeclaration = node.parent;
84+
const parent = variableDeclaration.parent;
85+
if (parent.type !== 'BlockStatement' && parent.type !== 'Program') {
86+
return;
87+
}
88+
89+
const index = parent.body.indexOf(variableDeclaration);
90+
const abortStatement = 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 } of candidates) {
105+
const [variable] = sourceCode.scopeManager.getDeclaredVariables(declarator);
106+
if (!variable) {
107+
continue;
108+
}
109+
110+
const name = declarator.id.name;
111+
const references = variable.references.filter((reference) => {
112+
return reference.identifier !== declarator.id;
113+
});
114+
const signalReferences = references.filter((reference) => {
115+
return isSignalReference(reference, name);
116+
});
117+
const abortReferences = references.filter((reference) => {
118+
return isAbortReference(reference, abortStatement, name);
119+
});
120+
121+
if (references.length !== 2 ||
122+
signalReferences.length !== 1 ||
123+
abortReferences.length !== 1) {
124+
continue;
125+
}
126+
127+
const signalNode = signalReferences[0].identifier.parent;
128+
const abortArguments = abortStatement.expression.arguments;
129+
const abortReason = abortArguments.length === 0 ?
130+
'' : sourceCode.getText(abortArguments[0]);
131+
132+
context.report({
133+
node: signalNode,
134+
message,
135+
fix(fixer) {
136+
return [
137+
fixer.removeRange(rangeIncludingTrailingLine(variableDeclaration)),
138+
fixer.removeRange(rangeIncludingTrailingLine(abortStatement)),
139+
fixer.replaceText(signalNode, `AbortSignal.abort(${abortReason})`),
140+
];
141+
},
142+
});
143+
}
144+
},
145+
};
146+
},
147+
};

0 commit comments

Comments
 (0)