Skip to content

Commit 6a5e726

Browse files
guillaume-dequennesonartech
authored andcommitted
SONARPY-2960 Rule S7489: Async functions should not contain synchronous OS calls (#277)
GitOrigin-RevId: 34af3b4d68243a7494680466f2b2a98302596ef0
1 parent 8531d74 commit 6a5e726

7 files changed

Lines changed: 253 additions & 1 deletion

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,8 +363,9 @@ public Stream<Class<?>> getChecks() {
363363
SklearnPipelineParameterAreCorrectCheck.class,
364364
SuperfluousCurlyBraceCheck.class,
365365
SynchronousFileOperationsInAsyncCheck.class,
366-
TempFileCreationCheck.class,
367366
SynchronousHttpOperationsInAsyncCheck.class,
367+
SynchronousOsCallsInAsyncCheck.class,
368+
TempFileCreationCheck.class,
368369
ImplicitlySkippedTestCheck.class,
369370
TimeSleepInAsyncCheck.class,
370371
ToDoCommentCheck.class,
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.Set;
20+
import org.sonar.check.Rule;
21+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
22+
import org.sonar.plugins.python.api.SubscriptionContext;
23+
import org.sonar.plugins.python.api.tree.CallExpression;
24+
import org.sonar.plugins.python.api.tree.Tree;
25+
import org.sonar.python.tree.TreeUtils;
26+
import org.sonar.python.types.v2.TypeCheckMap;
27+
28+
@Rule(key = "S7489")
29+
public class SynchronousOsCallsInAsyncCheck extends PythonSubscriptionCheck {
30+
private static final String MESSAGE = "Use a thread executor to wrap blocking OS calls in this async function.";
31+
private static final String SECONDARY_MESSAGE = "This function is async.";
32+
33+
private static final Set<String> OS_BLOCKING_CALLS = Set.of(
34+
"os.wait",
35+
"os.waitpid",
36+
"os.waitid"
37+
);
38+
39+
private final TypeCheckMap<Object> syncOsCallsTypeChecks = new TypeCheckMap<>();
40+
41+
@Override
42+
public void initialize(Context context) {
43+
context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, this::setupTypeChecks);
44+
context.registerSyntaxNodeConsumer(Tree.Kind.CALL_EXPR, this::checkSyncOsCallsInAsync);
45+
}
46+
47+
private void setupTypeChecks(SubscriptionContext ctx) {
48+
var object = new Object();
49+
OS_BLOCKING_CALLS.forEach(path ->
50+
syncOsCallsTypeChecks.put(ctx.typeChecker().typeCheckBuilder().isTypeOrInstanceWithName(path), object));
51+
}
52+
53+
private void checkSyncOsCallsInAsync(SubscriptionContext ctx) {
54+
var callExpression = (CallExpression) ctx.syntaxNode();
55+
var asyncToken = TreeUtils.asyncTokenOfEnclosingFunction(callExpression).orElse(null);
56+
if (asyncToken == null) {
57+
return;
58+
}
59+
60+
syncOsCallsTypeChecks.getOptionalForType(callExpression.callee().typeV2())
61+
.ifPresent(object -> ctx.addIssue(callExpression.callee(), MESSAGE).secondary(asyncToken, SECONDARY_MESSAGE));
62+
}
63+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<p>This rule raises an issue when synchronous OS calls like <code>os.wait()</code> are used within asynchronous functions.</p>
2+
<h2>Why is this an issue?</h2>
3+
<p>Using synchronous operating system calls like <code>os.wait()</code>, <code>os.waitpid()</code>, or similar functions in asynchronous code blocks
4+
the entire event loop. This undermines the primary advantage of asynchronous programming - the ability to perform concurrent operations without
5+
blocking execution.</p>
6+
<p>When an async function makes a synchronous OS call:</p>
7+
<ul>
8+
<li> The event loop is completely blocked until the OS operation completes </li>
9+
<li> No other coroutines can run during this time, even if they’re ready to execute </li>
10+
<li> The responsiveness of the application is degraded </li>
11+
<li> In server applications, this can cause timeouts or failures for other concurrent requests </li>
12+
</ul>
13+
<p>Instead, async libraries provide mechanisms to run blocking operations in separate threads without blocking the event loop:</p>
14+
<ul>
15+
<li> <code>asyncio.loop.run_in_executor()</code> for asyncio </li>
16+
<li> <code>trio.to_thread.run_sync()</code> for Trio </li>
17+
<li> <code>anyio.to_thread.run_sync()</code> for AnyIO </li>
18+
</ul>
19+
<p>Using these constructs allows other tasks to continue executing while waiting for the blocking OS call to complete.</p>
20+
<h2>How to fix it in Asyncio</h2>
21+
<p>Use <code>asyncio.loop.run_in_executor()</code> to run blocking OS calls in a separate thread pool.</p>
22+
<h3>Code examples</h3>
23+
<h4>Noncompliant code example</h4>
24+
<pre data-diff-id="1" data-diff-type="noncompliant">
25+
import os
26+
27+
async def wait_for_child_process(pid):
28+
pid, status = os.waitpid(pid, 0) # Noncompliant
29+
return status
30+
</pre>
31+
<h4>Compliant solution</h4>
32+
<pre data-diff-id="1" data-diff-type="compliant">
33+
import asyncio
34+
import os
35+
36+
async def wait_for_child_process(pid):
37+
loop = asyncio.get_running_loop()
38+
pid, status = await loop.run_in_executor(
39+
None,
40+
os.waitpid, pid, 0
41+
)
42+
return status
43+
</pre>
44+
<h2>How to fix it in Trio</h2>
45+
<p>Use <code>trio.to_thread.run_sync()</code> to run blocking OS calls in a worker thread.</p>
46+
<h3>Code examples</h3>
47+
<h4>Noncompliant code example</h4>
48+
<pre data-diff-id="2" data-diff-type="noncompliant">
49+
import os
50+
51+
async def wait_for_child_process(pid):
52+
pid, status = os.waitpid(pid, 0) # Noncompliant
53+
return status
54+
</pre>
55+
<h4>Compliant solution</h4>
56+
<pre data-diff-id="2" data-diff-type="compliant">
57+
import trio
58+
import os
59+
60+
async def wait_for_child_process(pid):
61+
pid, status = await trio.to_thread.run_sync(
62+
os.waitpid, pid, 0
63+
)
64+
return status
65+
</pre>
66+
<h2>How to fix it in AnyIO</h2>
67+
<p>Use <code>anyio.to_thread.run_sync()</code> to run blocking OS calls in a worker thread.</p>
68+
<h3>Code examples</h3>
69+
<h4>Noncompliant code example</h4>
70+
<pre data-diff-id="3" data-diff-type="noncompliant">
71+
import os
72+
73+
async def wait_for_child_process(pid):
74+
pid, status = os.waitpid(pid, 0) # Noncompliant
75+
return status
76+
</pre>
77+
<h4>Compliant solution</h4>
78+
<pre data-diff-id="3" data-diff-type="compliant">
79+
import anyio
80+
import os
81+
82+
async def wait_for_child_process(pid):
83+
pid, status = await anyio.to_thread.run_sync(
84+
os.waitpid, pid, 0
85+
)
86+
return status
87+
</pre>
88+
<h2>Resources</h2>
89+
<h3>Documentation</h3>
90+
<ul>
91+
<li> Python asyncio - <a href="https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.run_in_executor">run_in_executor()
92+
documentation</a> </li>
93+
<li> Trio - <a href="https://trio.readthedocs.io/en/stable/reference-core.html#trio.to_thread.run_sync">to_thread.run_sync() documentation</a> </li>
94+
<li> AnyIO - <a href="https://anyio.readthedocs.io/en/stable/threads.html">Thread handling</a> </li>
95+
<li> Python OS - <a href="https://docs.python.org/3/library/os.html#os.waitpid">os.waitpid() documentation</a> </li>
96+
</ul>
97+
<h3>Articles &amp; blog posts</h3>
98+
<ul>
99+
<li> Python - <a href="https://realpython.com/python-concurrency/">Concurrency and Parallelism in Python</a> </li>
100+
</ul>
101+
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"title": "Async functions should not contain synchronous OS calls",
3+
"type": "BUG",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "5min"
8+
},
9+
"tags": [
10+
"async",
11+
"asyncio",
12+
"anyio",
13+
"trio"
14+
],
15+
"defaultSeverity": "Major",
16+
"ruleSpecification": "RSPEC-7489",
17+
"sqKey": "S7489",
18+
"scope": "All",
19+
"quickfix": "unknown",
20+
"code": {
21+
"impacts": {
22+
"RELIABILITY": "HIGH"
23+
},
24+
"attribute": "EFFICIENT"
25+
}
26+
}

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
@@ -257,6 +257,7 @@
257257
"S7483",
258258
"S7486",
259259
"S7488",
260+
"S7489",
260261
"S7491",
261262
"S7493",
262263
"S7494",
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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 SynchronousOsCallsInAsyncCheckTest {
23+
24+
@Test
25+
void test() {
26+
PythonCheckVerifier.verify("src/test/resources/checks/synchronousOsCallsInAsync.py", new SynchronousOsCallsInAsyncCheck());
27+
}
28+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import asyncio
2+
import os
3+
import trio
4+
import anyio
5+
from functools import partial
6+
7+
async def asyncio_with_os_waitpid():
8+
#^[sc=1;ec=5]> {{This function is async.}}
9+
pid, status = os.waitpid(123, 0) # Noncompliant {{Use a thread executor to wrap blocking OS calls in this async function.}}
10+
# ^^^^^^^^^^
11+
12+
async def asyncio_with_os_wait():
13+
pid, status = os.wait() # Noncompliant
14+
15+
16+
# Framework compliant solutions
17+
async def asyncio_compliant():
18+
loop = asyncio.get_running_loop()
19+
pid, status = await loop.run_in_executor(None, partial(os.waitpid, 123, 0))
20+
pid, status = await loop.run_in_executor(None, os.wait)
21+
res = await loop.run_in_executor(None, partial(os.waitid, os.P_PID, 123, os.WEXITED))
22+
23+
def nested_sync_function():
24+
return os.waitpid(123, 0)
25+
26+
async def trio_compliant():
27+
pid, status = await trio.to_thread.run_sync(os.waitpid, 123, 0)
28+
pid, status = await trio.to_thread.run_sync(os.wait)
29+
30+
async def anyio_compliant():
31+
pid, status = await anyio.to_thread.run_sync(os.waitpid, 123, 0)
32+
pid, status = await anyio.to_thread.run_sync(os.wait)

0 commit comments

Comments
 (0)