Skip to content

Commit 5f89230

Browse files
guillaume-dequennesonartech
authored andcommitted
SONARPY-2961 Rule S7502: Asyncio tasks should be saved to prevent premature garbage collection (#274)
GitOrigin-RevId: 42086ea4fa8930a7a1cf9a54d7772acfa2f8233e
1 parent eb180b9 commit 5f89230

8 files changed

Lines changed: 241 additions & 12 deletions

File tree

python-checks/src/main/java/org/sonar/python/checks/AsyncLongSleepCheck.java

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@
1616
*/
1717
package org.sonar.python.checks;
1818

19-
import java.util.HashMap;
20-
import java.util.Map;
2119
import java.util.Optional;
2220
import org.sonar.check.Rule;
2321
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
@@ -40,7 +38,6 @@ public class AsyncLongSleepCheck extends PythonSubscriptionCheck {
4038

4139
private TypeCheckBuilder isTrioSleepCall;
4240
private TypeCheckBuilder isAnyioSleepCall;
43-
private final Map<String, String> asyncLibraryAliases = new HashMap<>();
4441

4542
@Override
4643
public void initialize(Context context) {
@@ -51,7 +48,6 @@ public void initialize(Context context) {
5148
private void setupCheck(SubscriptionContext ctx) {
5249
isTrioSleepCall = ctx.typeChecker().typeCheckBuilder().isTypeWithFqn("trio.sleep");
5350
isAnyioSleepCall = ctx.typeChecker().typeCheckBuilder().isTypeWithName("anyio.sleep");
54-
asyncLibraryAliases.clear();
5551
}
5652

5753
private void checkAsyncLongSleep(SubscriptionContext context) {
@@ -77,14 +73,12 @@ private void checkAsyncLongSleep(SubscriptionContext context) {
7773
return;
7874
}
7975
if (CommonValidationUtils.isMoreThan(durationExpr, SECONDS_IN_DAY)) {
80-
String libraryAlias = asyncLibraryAliases.getOrDefault(libraryName, libraryName);
81-
String message = String.format(MESSAGE, libraryAlias);
76+
String message = String.format(MESSAGE, libraryName);
8277
context.addIssue(callExpression, message).secondary(asyncToken, "This function is async.");
8378
}
8479
}
8580

8681
private static Optional<Expression> extractDurationExpression(CallExpression callExpression, String paramName) {
87-
RegularArgument durationArgument = TreeUtils.nthArgumentOrKeyword(0, paramName, callExpression.arguments());
88-
return durationArgument != null ? Optional.of(durationArgument.expression()) : Optional.empty();
82+
return Optional.ofNullable(TreeUtils.nthArgumentOrKeyword(0, paramName, callExpression.arguments())).map(RegularArgument::expression);
8983
}
9084
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource SA
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonar.python.checks;
18+
19+
import java.util.ArrayList;
20+
import java.util.List;
21+
import java.util.Set;
22+
import org.sonar.check.Rule;
23+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
24+
import org.sonar.plugins.python.api.SubscriptionContext;
25+
import org.sonar.plugins.python.api.tree.CallExpression;
26+
import org.sonar.plugins.python.api.tree.Expression;
27+
import org.sonar.plugins.python.api.tree.Tree;
28+
import org.sonar.plugins.python.api.types.v2.PythonType;
29+
import org.sonar.plugins.python.api.types.v2.TriBool;
30+
import org.sonar.python.tree.TreeUtils;
31+
import org.sonar.python.types.v2.TypeCheckBuilder;
32+
33+
@Rule(key = "S7502")
34+
public class AsyncioTaskNotStoredCheck extends PythonSubscriptionCheck {
35+
36+
private static final String MESSAGE = "Save this task in a variable to prevent premature garbage collection.";
37+
private static final Set<String> TASK_CREATION_FUNCTIONS = Set.of("asyncio.create_task", "asyncio.ensure_future");
38+
39+
private final List<TypeCheckBuilder> asyncioTaskTypeChecks = new ArrayList<>();
40+
41+
@Override
42+
public void initialize(Context context) {
43+
context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, this::setupTypeChecker);
44+
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, this::checkCallExpression);
45+
}
46+
47+
private void setupTypeChecker(SubscriptionContext ctx) {
48+
asyncioTaskTypeChecks.clear();
49+
TASK_CREATION_FUNCTIONS.forEach(fqn -> asyncioTaskTypeChecks.add(ctx.typeChecker().typeCheckBuilder().isTypeWithName(fqn)));
50+
}
51+
52+
private void checkCallExpression(SubscriptionContext ctx) {
53+
CallExpression callExpression = (CallExpression) ctx.syntaxNode();
54+
Expression callee = callExpression.callee();
55+
PythonType calleeType = callee.typeV2();
56+
if (asyncioTaskTypeChecks.stream().noneMatch(t -> t.check(calleeType) == TriBool.TRUE)) {
57+
return;
58+
}
59+
if (TreeUtils.firstAncestorOfKind(callExpression, Tree.Kind.ASSIGNMENT_STMT, Tree.Kind.ASSIGNMENT_EXPRESSION, Tree.Kind.CALL_EXPR) == null) {
60+
ctx.addIssue(callee, MESSAGE);
61+
}
62+
}
63+
}

python-checks/src/main/java/org/sonar/python/checks/OpenSourceCheckList.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ public Stream<Class<?>> getChecks() {
123123
AssertOnTupleLiteralCheck.class,
124124
AsyncFunctionNotAsyncCheck.class,
125125
AsyncFunctionWithTimeoutCheck.class,
126+
AsyncioTaskNotStoredCheck.class,
126127
AsyncLongSleepCheck.class,
127128
BackslashInStringCheck.class,
128129
BackticksUsageCheck.class,
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<p>This rule raises an issue when <code>asyncio.create_task()</code> or <code>asyncio.ensure_future()</code> is called without saving the returned
2+
task.</p>
3+
<h2>Why is this an issue?</h2>
4+
<p>When creating asyncio tasks with <code>asyncio.create_task()</code> or <code>asyncio.ensure_future()</code>, you create independent units of work
5+
that execute concurrently. However, if you don’t save the returned task object in a variable or collection, the task may be garbage collected at any
6+
time, even before it’s done.</p>
7+
<p>This happens because the event loop only maintains a weak reference to tasks. Without a strong reference:</p>
8+
<ul>
9+
<li> Tasks may be terminated unpredictably before completion </li>
10+
<li> Application behavior becomes inconsistent and difficult to debug </li>
11+
<li> Exceptions raised within the task are silently ignored </li>
12+
<li> Results of the task execution are lost </li>
13+
<li> Resources may not be properly released </li>
14+
</ul>
15+
<p>For a task to run to completion and handle exceptions properly, you must save the task reference and eventually await it.</p>
16+
<h2>How to fix it</h2>
17+
<p>To properly handle asyncio tasks:</p>
18+
<ul>
19+
<li> Store the task in a variable or collection </li>
20+
<li> Eventually await the task, either directly or using <code>asyncio.gather()</code>, <code>asyncio.wait()</code>, or similar functions </li>
21+
<li> Consider using <code>asyncio.TaskGroup</code> (available in Python 3.11+) for structured concurrency with better cancellation semantics and
22+
error handling </li>
23+
</ul>
24+
<h3>Code examples</h3>
25+
<h4>Noncompliant code example</h4>
26+
<pre data-diff-id="1" data-diff-type="noncompliant">
27+
import asyncio
28+
29+
async def fetch_data():
30+
asyncio.create_task(process_data()) # Noncompliant: task may be garbage collected before completion
31+
32+
async def process_data():
33+
await asyncio.sleep(1)
34+
return {"result": "processed"}
35+
</pre>
36+
<h4>Compliant solution</h4>
37+
<pre data-diff-id="1" data-diff-type="compliant">
38+
import asyncio
39+
40+
async def fetch_data():
41+
task = asyncio.create_task(process_data()) # Compliant
42+
await task
43+
44+
async def process_data():
45+
await asyncio.sleep(1)
46+
return {"result": "processed"}
47+
</pre>
48+
<p>Or, using TaskGroup (Python 3.11+):</p>
49+
<pre>
50+
import asyncio
51+
52+
async def fetch_data():
53+
async with asyncio.TaskGroup() as tg:
54+
# Tasks are managed by the TaskGroup and won't be garbage collected
55+
tg.create_task(process_data())
56+
57+
async def process_data():
58+
await asyncio.sleep(1)
59+
return {"result": "processed"}
60+
</pre>
61+
<h2>Resources</h2>
62+
<h3>Documentation</h3>
63+
<ul>
64+
<li> Python asyncio - <a href="https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task">create_task() documentation</a> </li>
65+
<li> Python asyncio - <a href="https://docs.python.org/3/library/asyncio-future.html#asyncio.ensure_future">ensure_future() documentation</a> </li>
66+
<li> Python asyncio - <a href="https://docs.python.org/3/library/asyncio-dev.html#asyncio-dev">Developing with asyncio</a> </li>
67+
<li> Python asyncio - <a href="https://docs.python.org/3/library/asyncio-task.html#task-object">Task Object</a> </li>
68+
<li> Python asyncio - <a href="https://docs.python.org/3/library/asyncio-task.html#asyncio.TaskGroup">TaskGroup documentation</a> </li>
69+
</ul>
70+
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"title": "Asyncio tasks should be saved to prevent premature garbage collection",
3+
"type": "BUG",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "5min"
8+
},
9+
"tags": [
10+
"async",
11+
"asyncio",
12+
"pitfall"
13+
],
14+
"defaultSeverity": "Major",
15+
"ruleSpecification": "RSPEC-7502",
16+
"sqKey": "S7502",
17+
"scope": "All",
18+
"quickfix": "unknown",
19+
"code": {
20+
"impacts": {
21+
"RELIABILITY": "HIGH"
22+
},
23+
"attribute": "LOGICAL"
24+
}
25+
}

python-checks/src/main/resources/org/sonar/l10n/py/rules/python/Sonar_way_profile.json

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -263,11 +263,10 @@
263263
"S7498",
264264
"S7499",
265265
"S7501",
266+
"S7502",
267+
"S7503",
266268
"S7506",
267269
"S7510",
268-
"S7511",
269-
"S7510",
270-
"S7501",
271-
"S7503"
270+
"S7511"
272271
]
273272
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource SA
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonar.python.checks;
18+
19+
import org.junit.jupiter.api.Test;
20+
import org.sonar.python.checks.utils.PythonCheckVerifier;
21+
22+
class AsyncioTaskNotStoredCheckTest {
23+
@Test
24+
void test() {
25+
PythonCheckVerifier.verify("src/test/resources/checks/asyncioTaskNotStored.py", new AsyncioTaskNotStoredCheck());
26+
}
27+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import asyncio
2+
3+
async def noncompliant_example():
4+
asyncio.create_task(some_coroutine()) # Noncompliant {{Save this task in a variable to prevent premature garbage collection.}}
5+
# ^^^^^^^^^^^^^^^^^^^
6+
asyncio.ensure_future(some_coroutine()) # Noncompliant
7+
asyncio.create_task(some_function_call(param1, param2)) # Noncompliant
8+
9+
async def compliant_examples():
10+
# Compliant - task is stored in a variable
11+
task1 = asyncio.create_task(some_coroutine())
12+
13+
# Compliant - task is stored in a variable
14+
task2 = asyncio.ensure_future(some_coroutine())
15+
16+
# Compliant - tasks are stored in a collection
17+
tasks = [
18+
asyncio.create_task(some_coroutine()),
19+
asyncio.create_task(another_coroutine())
20+
]
21+
22+
# Compliant - task is passed to another function
23+
await_task(asyncio.create_task(some_coroutine()))
24+
25+
# Compliant - used with gather
26+
await asyncio.gather(
27+
asyncio.create_task(some_coroutine()),
28+
asyncio.create_task(another_coroutine())
29+
)
30+
31+
async def some_coroutine():
32+
await asyncio.sleep(1)
33+
return {"result": "value"}
34+
35+
async def another_coroutine():
36+
await asyncio.sleep(0.5)
37+
return {"result": "other value"}
38+
39+
def some_function_call(p1, p2):
40+
return some_coroutine()
41+
42+
async def await_task(task):
43+
return await task
44+
45+
# Python 3.11+ TaskGroup example
46+
async def task_group_example():
47+
async with asyncio.TaskGroup() as tg:
48+
# Compliant - TaskGroup manages the tasks
49+
tg.create_task(some_coroutine())
50+
tg.create_task(another_coroutine())

0 commit comments

Comments
 (0)