Skip to content

Commit 3998f8a

Browse files
committed
Non-blocking await for .NET Task in Python async coroutines
Instead of blocking the thread with GetAwaiter().GetResult(), TaskAwaitable.__next__ now yields the Task back to the runner when it's not yet completed. The runner can then wait on the Task and resume the coroutine, enabling true concurrency between coroutines.
1 parent f47c54d commit 3998f8a

4 files changed

Lines changed: 206 additions & 15 deletions

File tree

src/core/IronPython/Runtime/AsyncEnumerableWrapper.cs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,13 @@ public object __anext__() {
4343

4444
/// <summary>
4545
/// The awaitable object returned by <see cref="AsyncEnumeratorWrapper{T}.__anext__"/>.
46-
/// Implements both __await__ and __iter__/__next__ (the yield-from protocol) to
47-
/// block on MoveNextAsync and return the current value via StopIteration.
46+
/// Implements both __await__ and __iter__/__next__ (the yield-from protocol).
47+
/// Non-blocking: yields the Task back to the runner if MoveNextAsync is not yet completed.
4848
/// </summary>
4949
[PythonType("async_enumerator_awaitable")]
5050
public sealed class AsyncEnumeratorAwaitable<T> {
5151
private readonly IAsyncEnumerator<T> _enumerator;
52+
private Task<bool>? _moveNextTask;
5253

5354
internal AsyncEnumeratorAwaitable(IAsyncEnumerator<T> enumerator) {
5455
_enumerator = enumerator;
@@ -60,7 +61,10 @@ internal AsyncEnumeratorAwaitable(IAsyncEnumerator<T> enumerator) {
6061

6162
[LightThrowing]
6263
public object __next__() {
63-
bool hasNext = _enumerator.MoveNextAsync().AsTask().GetAwaiter().GetResult();
64+
var task = _moveNextTask ??= _enumerator.MoveNextAsync().AsTask();
65+
if (!task.IsCompleted) return (Task)task; // yield Task to runner
66+
bool hasNext = task.GetAwaiter().GetResult();
67+
_moveNextTask = null; // reset for next call
6468
if (!hasNext) {
6569
return LightExceptions.Throw(
6670
new PythonExceptions._StopAsyncIteration().InitAndGetClrException());

src/core/IronPython/Runtime/TaskAwaitable.cs

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ namespace IronPython.Runtime {
1515
/// <summary>
1616
/// Provides an __await__ protocol wrapper for <see cref="Task"/>,
1717
/// enabling <c>await task</c> from Python async code.
18-
/// Blocks on the task and returns None via StopIteration.
18+
/// Non-blocking: yields the Task back to the runner if not yet completed.
1919
/// </summary>
2020
[PythonType("task_awaitable")]
2121
public sealed class TaskAwaitable {
@@ -31,15 +31,17 @@ internal TaskAwaitable(Task task) {
3131

3232
[LightThrowing]
3333
public object __next__() {
34-
_task.GetAwaiter().GetResult();
34+
var task = _task;
35+
if (!task.IsCompleted) return task; // yield Task to runner
36+
task.GetAwaiter().GetResult(); // propagate exceptions
3537
return LightExceptions.Throw(new PythonExceptions._StopIteration().InitAndGetClrException());
3638
}
3739
}
3840

3941
/// <summary>
4042
/// Provides an __await__ protocol wrapper for <see cref="Task{T}"/>,
4143
/// enabling <c>result = await task</c> from Python async code.
42-
/// Blocks on the task and returns the result via StopIteration.value.
44+
/// Non-blocking: yields the Task back to the runner if not yet completed.
4345
/// </summary>
4446
[PythonType("task_awaitable")]
4547
public sealed class TaskAwaitable<T> {
@@ -55,7 +57,9 @@ internal TaskAwaitable(Task<T> task) {
5557

5658
[LightThrowing]
5759
public object __next__() {
58-
T result = _task.GetAwaiter().GetResult();
60+
var task = _task;
61+
if (!task.IsCompleted) return task; // yield Task to runner
62+
T result = task.GetAwaiter().GetResult();
5963
return LightExceptions.Throw(new PythonExceptions._StopIteration().InitAndGetClrException(result!));
6064
}
6165
}
@@ -64,10 +68,12 @@ public object __next__() {
6468
/// <summary>
6569
/// Provides an __await__ protocol wrapper for <see cref="ValueTask"/>,
6670
/// enabling <c>await valuetask</c> from Python async code.
71+
/// Non-blocking: yields the Task back to the runner if not yet completed.
6772
/// </summary>
6873
[PythonType("task_awaitable")]
6974
public sealed class ValueTaskAwaitable {
7075
private readonly ValueTask _task;
76+
private Task? _asTask;
7177

7278
internal ValueTaskAwaitable(ValueTask task) {
7379
_task = task;
@@ -79,18 +85,22 @@ internal ValueTaskAwaitable(ValueTask task) {
7985

8086
[LightThrowing]
8187
public object __next__() {
82-
_task.AsTask().GetAwaiter().GetResult();
88+
var task = _asTask ??= _task.AsTask();
89+
if (!task.IsCompleted) return task; // yield Task to runner
90+
task.GetAwaiter().GetResult(); // propagate exceptions
8391
return LightExceptions.Throw(new PythonExceptions._StopIteration().InitAndGetClrException());
8492
}
8593
}
8694

8795
/// <summary>
8896
/// Provides an __await__ protocol wrapper for <see cref="ValueTask{T}"/>,
8997
/// enabling <c>result = await valuetask</c> from Python async code.
98+
/// Non-blocking: yields the Task back to the runner if not yet completed.
9099
/// </summary>
91100
[PythonType("task_awaitable")]
92101
public sealed class ValueTaskAwaitable<T> {
93102
private readonly ValueTask<T> _task;
103+
private Task<T>? _asTask;
94104

95105
internal ValueTaskAwaitable(ValueTask<T> task) {
96106
_task = task;
@@ -102,7 +112,9 @@ internal ValueTaskAwaitable(ValueTask<T> task) {
102112

103113
[LightThrowing]
104114
public object __next__() {
105-
T result = _task.AsTask().GetAwaiter().GetResult();
115+
var task = _asTask ??= _task.AsTask();
116+
if (!task.IsCompleted) return (Task)task; // yield Task to runner
117+
T result = task.GetAwaiter().GetResult();
106118
return LightExceptions.Throw(new PythonExceptions._StopIteration().InitAndGetClrException(result!));
107119
}
108120
}

tests/IronPython.Tests/AsyncInteropHelpers.cs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,64 @@ private static async IAsyncEnumerable<string> YieldStrings(
4747
}
4848
}
4949

50+
/// <summary>
51+
/// Returns a real async Task&lt;int&gt; with a delay.
52+
/// The runtime type will be AsyncStateMachineBox, not Task&lt;int&gt; directly.
53+
/// </summary>
54+
public static async Task<int> GetAsyncInt(int value, int delayMs = 50) {
55+
await Task.Delay(delayMs);
56+
return value;
57+
}
58+
59+
/// <summary>
60+
/// Returns a real async Task&lt;string&gt; with a delay.
61+
/// </summary>
62+
public static async Task<string> GetAsyncString(string value, int delayMs = 50) {
63+
await Task.Delay(delayMs);
64+
return value;
65+
}
66+
67+
/// <summary>
68+
/// Returns a real async Task (void result) with a delay.
69+
/// </summary>
70+
public static async Task DoAsync(int delayMs = 50) {
71+
await Task.Delay(delayMs);
72+
}
73+
74+
/// <summary>
75+
/// Async Task&lt;int&gt; that respects a CancellationToken.
76+
/// Throws OperationCanceledException if token is cancelled during the delay.
77+
/// </summary>
78+
public static async Task<int> GetAsyncIntWithCancellation(int value, CancellationToken token, int delayMs = 5000) {
79+
await Task.Delay(delayMs, token);
80+
return value;
81+
}
82+
83+
/// <summary>
84+
/// Async Task that respects a CancellationToken.
85+
/// </summary>
86+
public static async Task DoAsyncWithCancellation(CancellationToken token, int delayMs = 5000) {
87+
await Task.Delay(delayMs, token);
88+
}
89+
90+
/// <summary>
91+
/// IAsyncEnumerable&lt;int&gt; that yields values with delay and respects cancellation.
92+
/// </summary>
93+
public static IAsyncEnumerable<int> GetAsyncIntsWithCancellation(CancellationToken token, params int[] values) {
94+
return YieldIntsWithCancellation(values, token);
95+
}
96+
97+
private static async IAsyncEnumerable<int> YieldIntsWithCancellation(
98+
int[] values,
99+
CancellationToken token,
100+
[EnumeratorCancellation] CancellationToken ct = default) {
101+
using var linked = CancellationTokenSource.CreateLinkedTokenSource(token, ct);
102+
foreach (var v in values) {
103+
await Task.Delay(50, linked.Token);
104+
yield return v;
105+
}
106+
}
107+
50108
/// <summary>
51109
/// Returns an empty IAsyncEnumerable&lt;int&gt;.
52110
/// </summary>

tests/suite/test_async.py

Lines changed: 123 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,16 @@
1010

1111

1212
def run_coro(coro):
13-
"""Run a coroutine to completion and return its result."""
14-
try:
15-
coro.send(None)
16-
raise AssertionError("Coroutine did not raise StopIteration")
17-
except StopIteration as e:
18-
return e.value
13+
"""Run a coroutine to completion, blocking on yielded .NET Tasks."""
14+
value = None
15+
while True:
16+
try:
17+
task = coro.send(value)
18+
# .NET Task yielded — block on it (test runner is synchronous)
19+
task.Wait()
20+
value = None
21+
except StopIteration as e:
22+
return e.value
1923

2024

2125
class AsyncIter:
@@ -518,4 +522,117 @@ def test_async_for_has_aiter(self):
518522
self.assertTrue(hasattr(stream, '__aiter__'))
519523

520524

525+
@unittest.skipUnless(_has_async_helpers, "requires IronPythonTest with AsyncInteropHelpers")
526+
class DotNetRealAsyncTaskTest(unittest.TestCase):
527+
"""Tests for await on real async Task<T> methods.
528+
529+
These test real .NET async methods where the runtime type is
530+
AsyncStateMachineBox<TResult, TStateMachine>, not Task<T> directly.
531+
All methods include real delays (Task.Delay) to ensure truly async behavior.
532+
"""
533+
534+
def test_await_real_async_int(self):
535+
"""await a real async Task<int> with delay."""
536+
async def test():
537+
return await AsyncInteropHelpers.GetAsyncInt(42)
538+
self.assertEqual(run_coro(test()), 42)
539+
540+
def test_await_real_async_string(self):
541+
"""await a real async Task<string> with delay."""
542+
async def test():
543+
return await AsyncInteropHelpers.GetAsyncString("hello")
544+
self.assertEqual(run_coro(test()), "hello")
545+
546+
def test_await_real_async_void(self):
547+
"""await a real async Task (void) with delay."""
548+
async def test():
549+
await AsyncInteropHelpers.DoAsync()
550+
return 'done'
551+
self.assertEqual(run_coro(test()), 'done')
552+
553+
def test_await_real_async_multiple(self):
554+
"""Multiple awaits on real async Task<T> in sequence."""
555+
async def test():
556+
a = await AsyncInteropHelpers.GetAsyncInt(10)
557+
b = await AsyncInteropHelpers.GetAsyncInt(20)
558+
s = await AsyncInteropHelpers.GetAsyncString("!")
559+
return str(a + b) + s
560+
self.assertEqual(run_coro(test()), "30!")
561+
562+
def test_await_real_async_mixed_with_python(self):
563+
"""Mix real .NET async with Python coroutines."""
564+
async def py_double(x):
565+
return x * 2
566+
567+
async def test():
568+
val = await AsyncInteropHelpers.GetAsyncInt(5)
569+
doubled = await py_double(val)
570+
return doubled
571+
self.assertEqual(run_coro(test()), 10)
572+
573+
574+
@unittest.skipUnless(_has_async_helpers, "requires IronPythonTest with AsyncInteropHelpers")
575+
class DotNetCancellationTest(unittest.TestCase):
576+
"""Tests for CancellationToken and CancelledError with .NET async methods."""
577+
578+
def test_cancel_async_task_int(self):
579+
"""Cancelling a Task<int> should raise CancelledError."""
580+
from System.Threading import CancellationTokenSource
581+
cts = CancellationTokenSource()
582+
cts.Cancel()
583+
async def test():
584+
return await AsyncInteropHelpers.GetAsyncIntWithCancellation(42, cts.Token)
585+
with self.assertRaises(CancelledError):
586+
run_coro(test())
587+
588+
def test_cancel_async_task_void(self):
589+
"""Cancelling a Task (void) should raise CancelledError."""
590+
from System.Threading import CancellationTokenSource
591+
cts = CancellationTokenSource()
592+
cts.Cancel()
593+
async def test():
594+
await AsyncInteropHelpers.DoAsyncWithCancellation(cts.Token)
595+
with self.assertRaises(CancelledError):
596+
run_coro(test())
597+
598+
def test_cancel_async_enumerable(self):
599+
"""Cancelling during async for over IAsyncEnumerable should raise CancelledError."""
600+
from System.Threading import CancellationTokenSource
601+
cts = CancellationTokenSource()
602+
cts.Cancel()
603+
async def test():
604+
result = []
605+
async for x in AsyncInteropHelpers.GetAsyncIntsWithCancellation(cts.Token, 1, 2, 3):
606+
result.append(x)
607+
return result
608+
with self.assertRaises(CancelledError):
609+
run_coro(test())
610+
611+
def test_cancelled_error_is_exception_subclass(self):
612+
"""CancelledError should be a subclass of Exception."""
613+
self.assertTrue(issubclass(CancelledError, Exception))
614+
615+
def test_cancelled_error_catch_as_exception(self):
616+
"""CancelledError should be catchable as Exception."""
617+
from System.Threading import CancellationTokenSource
618+
cts = CancellationTokenSource()
619+
cts.Cancel()
620+
async def test():
621+
try:
622+
await AsyncInteropHelpers.GetAsyncIntWithCancellation(99, cts.Token)
623+
except Exception:
624+
return 'caught'
625+
self.assertEqual(run_coro(test()), 'caught')
626+
627+
def test_operation_cancelled_maps_to_cancelled_error(self):
628+
"""System.OperationCanceledException raised directly should be catchable as CancelledError."""
629+
from System import OperationCanceledException
630+
caught = False
631+
try:
632+
raise OperationCanceledException("test")
633+
except CancelledError:
634+
caught = True
635+
self.assertTrue(caught)
636+
637+
521638
run_test(__name__)

0 commit comments

Comments
 (0)