Skip to content

Commit dc0db87

Browse files
ghislainpiotsonartech
authored andcommitted
SONARPY-2914 Rule S7484: Events should be used instead of sleep in asynchronous loops (#273)
GitOrigin-RevId: b5c372d48e236888efdee408fdbb8d9bd6cf4943
1 parent 5990a05 commit dc0db87

7 files changed

Lines changed: 618 additions & 0 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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+
18+
package org.sonar.python.checks;
19+
20+
import java.util.Optional;
21+
22+
import javax.annotation.Nullable;
23+
24+
import org.sonar.check.Rule;
25+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
26+
import org.sonar.plugins.python.api.SubscriptionContext;
27+
import org.sonar.plugins.python.api.symbols.Symbol;
28+
import org.sonar.plugins.python.api.symbols.Usage;
29+
import org.sonar.plugins.python.api.tree.AwaitExpression;
30+
import org.sonar.plugins.python.api.tree.BaseTreeVisitor;
31+
import org.sonar.plugins.python.api.tree.CallExpression;
32+
import org.sonar.plugins.python.api.tree.Expression;
33+
import org.sonar.plugins.python.api.tree.FileInput;
34+
import org.sonar.plugins.python.api.tree.FunctionDef;
35+
import org.sonar.plugins.python.api.tree.Name;
36+
import org.sonar.plugins.python.api.tree.Tree;
37+
import org.sonar.plugins.python.api.tree.Tree.Kind;
38+
import org.sonar.plugins.python.api.tree.WhileStatement;
39+
import org.sonar.python.tree.TreeUtils;
40+
import org.sonar.python.types.v2.TypeCheckMap;
41+
42+
@Rule(key = "S7484")
43+
public class BusyWaitingInAsyncCheck extends PythonSubscriptionCheck {
44+
private static final String MESSAGE = "Refactor this loop to use an `Event` instead of polling with `sleep`.";
45+
private static final String SECONDARY_MESSAGE = "This function is async.";
46+
private static final String POLLING_MESSAGE = "Polling happens here.";
47+
48+
private TypeCheckMap<Object> asyncSleepTypeChecks;
49+
50+
@Override
51+
public void initialize(Context context) {
52+
context.registerSyntaxNodeConsumer(Kind.FILE_INPUT, this::initializeTypeCheckMap);
53+
context.registerSyntaxNodeConsumer(Kind.WHILE_STMT, this::checkWhileStatement);
54+
}
55+
56+
private void initializeTypeCheckMap(SubscriptionContext ctx) {
57+
var object = new Object();
58+
asyncSleepTypeChecks = new TypeCheckMap<>();
59+
asyncSleepTypeChecks.put(ctx.typeChecker().typeCheckBuilder().isTypeOrInstanceWithName("asyncio.sleep"), object);
60+
asyncSleepTypeChecks.put(ctx.typeChecker().typeCheckBuilder().isTypeWithFqn("trio.sleep"), object);
61+
asyncSleepTypeChecks.put(ctx.typeChecker().typeCheckBuilder().isTypeOrInstanceWithName("anyio.sleep"), object);
62+
}
63+
64+
private void checkWhileStatement(SubscriptionContext ctx) {
65+
var whileStmt = (WhileStatement) ctx.syntaxNode();
66+
var enclosingFuncDef = (FunctionDef) TreeUtils.firstAncestorOfKind(whileStmt, Kind.FUNCDEF);
67+
var asyncToken = Optional.ofNullable(enclosingFuncDef).map(FunctionDef::asyncKeyword);
68+
if (asyncToken.isEmpty()) {
69+
return;
70+
}
71+
72+
var sleepFinder = new SleepCallFinder(asyncSleepTypeChecks);
73+
whileStmt.body().accept(sleepFinder);
74+
75+
if (sleepFinder.sleepAwait == null) {
76+
return;
77+
}
78+
79+
var conditionChecker = new GlobalOrNonLocalNameFinder(enclosingFuncDef);
80+
whileStmt.condition().accept(conditionChecker);
81+
82+
if (conditionChecker.foundGlobalOrNonLocal) {
83+
ctx.addIssue(whileStmt.condition(), MESSAGE)
84+
.secondary(sleepFinder.sleepAwait, POLLING_MESSAGE)
85+
.secondary(asyncToken.get(), SECONDARY_MESSAGE);
86+
}
87+
}
88+
89+
private static class GlobalOrNonLocalNameFinder extends BaseTreeVisitor {
90+
boolean foundGlobalOrNonLocal = false;
91+
private final FunctionDef whileLoopFunction;
92+
93+
GlobalOrNonLocalNameFinder(FunctionDef whileLoopFunction) {
94+
this.whileLoopFunction = whileLoopFunction;
95+
}
96+
97+
@Override
98+
public void visitName(Name name) {
99+
if (foundGlobalOrNonLocal) {
100+
return;
101+
}
102+
var symbol = name.symbol();
103+
if (symbol != null && isSymbolDeclaredInOuterScope(symbol, whileLoopFunction)) {
104+
foundGlobalOrNonLocal = true;
105+
}
106+
super.visitName(name);
107+
}
108+
109+
// Use of Symbol V1 instead of SymbolV2 because of SONARPY-2974
110+
private static boolean isSymbolDeclaredInOuterScope(Symbol symbol, FunctionDef currentFunctionContext) {
111+
var fileInput = (FileInput) TreeUtils.firstAncestorOfKind(currentFunctionContext, Kind.FILE_INPUT);
112+
for (var usage : symbol.usages()) {
113+
if (usage.kind() == Usage.Kind.ASSIGNMENT_LHS) {
114+
if (fileInput.globalVariables().contains(symbol)) {
115+
return true;
116+
}
117+
var currentFunction = (FunctionDef) TreeUtils.firstAncestorOfKind(currentFunctionContext, Kind.FUNCDEF);
118+
while (currentFunction != null) {
119+
if (currentFunction.localVariables().contains(symbol)) {
120+
return true;
121+
}
122+
currentFunction = (FunctionDef) TreeUtils.firstAncestorOfKind(currentFunction, Kind.FUNCDEF);
123+
}
124+
125+
}
126+
}
127+
return false;
128+
}
129+
}
130+
131+
private static class SleepCallFinder extends BaseTreeVisitor {
132+
Tree sleepAwait = null;
133+
private final TypeCheckMap<Object> asyncSleepTypeChecks;
134+
135+
SleepCallFinder(TypeCheckMap<Object> asyncSleepTypeChecks) {
136+
this.asyncSleepTypeChecks = asyncSleepTypeChecks;
137+
}
138+
139+
@Override
140+
public void visitAwaitExpression(AwaitExpression awaitExpr) {
141+
var expr = awaitExpr.expression();
142+
if (expr instanceof CallExpression callExpr && isAsyncSleepCall(callExpr.callee())) {
143+
sleepAwait = awaitExpr.awaitToken();
144+
}
145+
super.visitAwaitExpression(awaitExpr);
146+
}
147+
148+
@Override
149+
protected void scan(@Nullable Tree tree) {
150+
if (sleepAwait != null) {
151+
return;
152+
}
153+
super.scan(tree);
154+
}
155+
156+
private boolean isAsyncSleepCall(Expression call) {
157+
return asyncSleepTypeChecks.getOptionalForType(call.typeV2()).isPresent();
158+
}
159+
}
160+
}

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
@@ -133,6 +133,7 @@ public Stream<Class<?>> getChecks() {
133133
BreakContinueOutsideLoopCheck.class,
134134
BuiltinShadowingAssignmentCheck.class,
135135
BuiltinGenericsOverTypingModuleCheck.class,
136+
BusyWaitingInAsyncCheck.class,
136137
CancellationScopeNoCheckpointCheck.class,
137138
CaughtExceptionsCheck.class,
138139
ChangeMethodContractCheck.class,
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<p>This rule raises an issue when the <code>sleep</code> function is used in an asynchronous loop instead of an Event object.</p>
2+
<h2>Why is this an issue?</h2>
3+
<p>Asynchronous tasks often need to wait for a condition to change or an event to occur. A simple-looking but inefficient way to achieve this is by
4+
polling the condition within a loop, using <code>sleep</code> to pause between checks:</p>
5+
<pre>
6+
while not condition_is_met:
7+
await asyncio.sleep(0.1) # Noncompliant
8+
# Condition is met, we can proceed
9+
</pre>
10+
<p>This busy-waiting approach is problematic in asynchronous code because it introduces increased latency. The task only notices the condition change
11+
after the <code>sleep</code> interval expires. If the condition becomes true just after the task starts sleeping, the reaction is delayed.</p>
12+
<p>Instead of polling with <code>sleep</code>, use dedicated synchronization primitives like <code>asyncio.Event</code>, <code>trio.Event</code> or
13+
<code>anyio.Event</code>. Using an <code>Event</code> allows a task to efficiently pause (<code>await event.wait()</code>) until another part of the
14+
program signals the event (<code>event.set()</code>). The waiting task consumes almost no resources while paused and reacts immediately when the event
15+
is set.</p>
16+
<h2>How to fix it in Asyncio</h2>
17+
<h3>Code examples</h3>
18+
<h4>Noncompliant code example</h4>
19+
<pre data-diff-id="2" data-diff-type="noncompliant">
20+
import asyncio
21+
22+
SHARED_CONDITION = False
23+
24+
async def worker():
25+
while not SHARED_CONDITION: # Noncompliant
26+
await asyncio.sleep(0.01)
27+
print("Condition is now true")
28+
29+
asyncio.run(worker)
30+
</pre>
31+
<h4>Compliant solution</h4>
32+
<pre data-diff-id="2" data-diff-type="compliant">
33+
import asyncio
34+
35+
SHARED_CONDITION = asyncio.Event()
36+
37+
async def worker():
38+
await SHARED_CONDITION.wait() # Compliant
39+
print("Condition is now true")
40+
41+
asyncio.run(worker)
42+
</pre>
43+
<h2>How to fix it in Trio</h2>
44+
<h3>Code examples</h3>
45+
<h4>Noncompliant code example</h4>
46+
<pre data-diff-id="1" data-diff-type="noncompliant">
47+
import trio
48+
49+
SHARED_CONDITION = False
50+
51+
async def worker():
52+
while not SHARED_CONDITION: # Noncompliant
53+
await trio.sleep(0.01)
54+
print("Condition is now true")
55+
56+
trio.run(worker)
57+
</pre>
58+
<h4>Compliant solution</h4>
59+
<pre data-diff-id="1" data-diff-type="compliant">
60+
import trio
61+
62+
SHARED_CONDITION = trio.Event()
63+
64+
async def worker():
65+
await SHARED_CONDITION.wait() # Compliant
66+
print("Condition is now true")
67+
68+
trio.run(worker)
69+
</pre>
70+
<h2>How to fix it in AnyIO</h2>
71+
<h3>Code examples</h3>
72+
<h4>Noncompliant code example</h4>
73+
<pre data-diff-id="3" data-diff-type="noncompliant">
74+
import anyio
75+
76+
SHARED_CONDITION = False
77+
78+
async def worker():
79+
while not SHARED_CONDITION: # Noncompliant
80+
await anyio.sleep(0.01)
81+
print("Condition is now true")
82+
83+
anyio.run(worker)
84+
</pre>
85+
<h4>Compliant solution</h4>
86+
<pre data-diff-id="3" data-diff-type="compliant">
87+
import anyio
88+
89+
SHARED_CONDITION = anyio.Event()
90+
91+
async def worker():
92+
await SHARED_CONDITION.wait() # Compliant
93+
print("Condition is now true")
94+
95+
anyio.run(worker)
96+
</pre>
97+
<h2>Resources</h2>
98+
<h3>Documentation</h3>
99+
<ul>
100+
<li> Asyncio documentation - <a href="https://docs.python.org/3/library/asyncio-sync.html#asyncio.Event">Event</a> </li>
101+
<li> Trio documentation - <a href="https://trio.readthedocs.io/en/stable/reference-core.html#broadcasting-an-event-with-event">Broadcasting an event
102+
with Event</a> </li>
103+
<li> AnyIO documentation - <a href="https://anyio.readthedocs.io/en/stable/synchronization.html#events">Events</a> </li>
104+
</ul>
105+
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"title": "Events should be used instead of `sleep` in asynchronous loops",
3+
"type": "CODE_SMELL",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "10min"
8+
},
9+
"tags": [
10+
"async",
11+
"asyncio",
12+
"anyio",
13+
"trio"
14+
],
15+
"defaultSeverity": "Major",
16+
"ruleSpecification": "RSPEC-7484",
17+
"sqKey": "S7484",
18+
"scope": "All",
19+
"quickfix": "infeasible",
20+
"code": {
21+
"impacts": {
22+
"MAINTAINABILITY": "MEDIUM",
23+
"RELIABILITY": "MEDIUM"
24+
},
25+
"attribute": "EFFICIENT"
26+
}
27+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@
255255
"S6984",
256256
"S6985",
257257
"S7483",
258+
"S7484",
258259
"S7486",
259260
"S7487",
260261
"S7488",
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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+
18+
package org.sonar.python.checks;
19+
20+
import org.junit.jupiter.api.Test;
21+
import org.sonar.python.checks.utils.PythonCheckVerifier;
22+
23+
class BusyWaitingInAsyncCheckTest {
24+
25+
@Test
26+
void test() {
27+
PythonCheckVerifier.verify("src/test/resources/checks/busyWaitingInAsync.py", new BusyWaitingInAsyncCheck());
28+
}
29+
30+
}

0 commit comments

Comments
 (0)