Skip to content

Commit 032aa2d

Browse files
committed
Update DLR; adapt to changed async API; add tests
1 parent 4049485 commit 032aa2d

6 files changed

Lines changed: 119 additions & 46 deletions

File tree

‎src/core/IronPython/Compiler/Ast/AstMethods.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ internal static class AstMethods {
8383
public static readonly MethodInfo MakeCoroutine = GetMethod((Func<PythonFunction, MutableTuple, object, PythonCoroutine>)PythonOps.MakeCoroutine);
8484
#if FEATURE_NET_ASYNC
8585
public static readonly MethodInfo AsTaskForAwait = GetMethod((Func<object, System.Threading.Tasks.Task<object>>)PythonOps.AsTaskForAwait);
86-
public static readonly MethodInfo MakeAsyncCoroutine = GetMethod((Func<PythonFunction, Func<System.Threading.Tasks.Task<object>>, System.Threading.CancellationTokenSource, System.Runtime.CompilerServices.StrongBox<System.Exception>, PythonCoroutine>)PythonOps.MakeAsyncCoroutine);
86+
public static readonly MethodInfo MakeAsyncCoroutine = GetMethod((Func<PythonFunction, Func<System.Threading.Tasks.Task<object>>, Microsoft.Scripting.Runtime.AsyncInterruptSource, PythonCoroutine>)PythonOps.MakeAsyncCoroutine);
8787
public static readonly MethodInfo MakeAsyncGenerator = GetMethod((Func<PythonFunction, System.Collections.Generic.IAsyncEnumerable<object>, System.Runtime.CompilerServices.StrongBox<object>, System.Runtime.CompilerServices.StrongBox<System.Exception>, System.Threading.CancellationTokenSource, PythonAsyncGenerator>)PythonOps.MakeAsyncGenerator);
8888
#endif
8989

‎src/core/IronPython/Compiler/Ast/FunctionDefinition.cs‎

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -727,33 +727,27 @@ private LightLambdaExpression CreateFunctionLambda() {
727727

728728
#if FEATURE_NET_ASYNC
729729
// Under .NET-async, an `async def` body returns a PythonCoroutine wrapping a Task<object?>.
730-
// We pre-allocate a CancellationTokenSource and a StrongBox<Exception?> here
731-
// so the same instances are shared with both AsyncExpression, which threads them into AsyncHelpers.DriveAsync
732-
// and PythonCoroutine, which uses them to implement coro.throw(exc) on a running coroutine:
733-
// write the exception to the box, cancel the CTS, and DriveAsync surfaces that exception in place of OperationCanceledException.
734730
if (IsAsync) {
735-
var cts = MSAst.Expression.Variable(typeof(CancellationTokenSource), "$cts");
736-
var excBox = MSAst.Expression.Variable(typeof(StrongBox<Exception>), "$cancelExc");
737-
var ctToken = MSAst.Expression.Property(cts, nameof(CancellationTokenSource.Token));
738731
if (IsGenerator) {
739732
// Async generator: the body has both `await` and `yield`. Lower it to an
740733
// IAsyncEnumerable<object?> via AsyncEnumerableExpression, sharing the generator label so
741734
// the body's yields and the rewritten awaits land in one generator, then wrap it in a
742735
// PythonAsyncGenerator. The send/throw slots are per-generator StrongBoxes captured by the
743736
// generator (the body's yields read them) AND handed to the wrapper, which writes them
744737
// before each resume — see AsyncSendSlot / AsyncThrowSlot.
738+
var cts = MSAst.Expression.Variable(typeof(CancellationTokenSource), "$cts");
739+
var ctToken = MSAst.Expression.Property(cts, nameof(CancellationTokenSource.Token));
745740
var sendSlot = AsyncSendSlot;
746741
var throwSlot = AsyncThrowSlot;
747742
body = MSAst.Expression.Block(
748-
[cts, excBox, sendSlot, throwSlot],
743+
[cts, sendSlot, throwSlot],
749744
MSAst.Expression.Assign(cts, MSAst.Expression.New(typeof(CancellationTokenSource))),
750-
MSAst.Expression.Assign(excBox, MSAst.Expression.New(typeof(StrongBox<Exception>))),
751745
MSAst.Expression.Assign(sendSlot, MSAst.Expression.New(typeof(StrongBox<object>))),
752746
MSAst.Expression.Assign(throwSlot, MSAst.Expression.New(typeof(StrongBox<Exception>))),
753747
Ast.Call(
754748
AstMethods.MakeAsyncGenerator,
755749
_functionParam,
756-
AstUtils.AsyncEnumerable(Name, body, GeneratorLabel, ctToken, excBox),
750+
AstUtils.AsyncEnumerable(Name, body, GeneratorLabel, interrupts: null, ctToken),
757751
sendSlot,
758752
throwSlot,
759753
cts));
@@ -763,17 +757,18 @@ private LightLambdaExpression CreateFunctionLambda() {
763757
// so the body doesn't execute until the coroutine is first driven (send/AsTask).
764758
// This makes calling an async def side-effect-free (PEP 492) and lets the body's first await capture the driver's SynchronizationContext
765759
// rather than whatever context happened to be current at construction.
760+
// The AsyncInterruptSource is shared by AsyncExpression, which threads it into AsyncHelpers.DriveAsync,
761+
// and PythonCoroutine, which uses it to implement coro.throw(exc).
762+
var interrupts = MSAst.Expression.Variable(typeof(Microsoft.Scripting.Runtime.AsyncInterruptSource), "$interrupts");
766763
body = MSAst.Expression.Block(
767-
[cts, excBox],
768-
MSAst.Expression.Assign(cts, MSAst.Expression.New(typeof(CancellationTokenSource))),
769-
MSAst.Expression.Assign(excBox, MSAst.Expression.New(typeof(StrongBox<Exception>))),
764+
[interrupts],
765+
MSAst.Expression.Assign(interrupts, MSAst.Expression.New(typeof(Microsoft.Scripting.Runtime.AsyncInterruptSource))),
770766
Ast.Call(
771767
AstMethods.MakeAsyncCoroutine,
772768
_functionParam,
773769
MSAst.Expression.Lambda<Func<Task<object>>>(
774-
AstUtils.Async(Name, body, ctToken, excBox)),
775-
cts,
776-
excBox));
770+
AstUtils.Async(Name, body, interrupts)),
771+
interrupts));
777772
}
778773
}
779774
#endif

‎src/core/IronPython/Runtime/Coroutine.cs‎

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
using System;
88
using System.Runtime.CompilerServices;
9-
using System.Threading;
109
using System.Threading.Tasks;
1110

1211
using Microsoft.Scripting.Runtime;
@@ -24,12 +23,8 @@ public sealed class PythonCoroutine : ICodeFormattable, IWeakReferenceable {
2423
#if FEATURE_NET_ASYNC
2524

2625
// Under .NET async, `async def` is compiled directly to a Task<object?> via the DLR's AsyncExpression + AsyncHelpers.
27-
// PythonCoroutine is a wrapper over the Task with two cancellation channels pre-bound at the codegen level:
28-
//
29-
// _cts — CancellationTokenSource whose Token was passed into AsyncExpression. throw(exc) cancels this.
30-
// _cancellationException — StrongBox<Exception?> shared with DriveAsync's `cancellationException` parameter;
31-
// when non-null at cancellation time, that exception is surfaced to the body
32-
// instead of OperationCanceledException.
26+
// PythonCoroutine is a wrapper over the Task. _interrupts is the AsyncInterruptSource passed into AsyncExpression;
27+
// throw(exc) interrupts the body with it.
3328
//
3429
// Lazy start: the body is not executed at construction. _factory is the thunk that creates (and thereby starts driving) the Task on
3530
// first access; _task caches the materialized Task. Calling an async def is therefore side-effect free until the coroutine is first
@@ -38,18 +33,14 @@ public sealed class PythonCoroutine : ICodeFormattable, IWeakReferenceable {
3833
private Task<object?>? _task;
3934
private readonly string _name;
4035
private readonly FunctionCode? _code;
41-
private readonly CancellationTokenSource _cts;
42-
private readonly StrongBox<Exception?> _cancellationException;
36+
private readonly AsyncInterruptSource _interrupts;
4337
private WeakRefTracker? _tracker;
4438

45-
internal PythonCoroutine(Func<Task<object?>> factory, string name, FunctionCode? code,
46-
CancellationTokenSource cts,
47-
StrongBox<Exception?> cancellationException) {
39+
internal PythonCoroutine(Func<Task<object?>> factory, string name, FunctionCode? code, AsyncInterruptSource interrupts) {
4840
_factory = factory;
4941
_name = name;
5042
_code = code;
51-
_cts = cts;
52-
_cancellationException = cancellationException;
43+
_interrupts = interrupts;
5344
}
5445

5546
private Task<object?> EnsureTask() => _task ??= _factory();
@@ -106,22 +97,18 @@ public object @throw(object? type, object? value, object? traceback) {
10697
Exception ex = PythonOps.MakeExceptionForGenerator(
10798
DefaultContext.Default, type, value, traceback, cause: null);
10899

109-
var task = EnsureTask();
110-
if (task.IsCompleted) {
111-
// Case 1: coroutine has finished. throw() raises the exception immediately —
100+
// The body sees `ex` raised at the await it is suspended at, or at its next await if it is running.
101+
// If the body hasn't started, it doesn't run at all: its task fails with `ex`, as in CPython.
102+
if (!_interrupts.Interrupt(ex)) {
103+
// The coroutine has finished. throw() raises the exception immediately —
112104
// the body has nothing left to observe it. Matches CPython.
113105
return LightExceptions.Throw(ex);
114106
}
115107

116-
// Case 2: body is still running (or suspended at an await). Stash the exception in the
117-
// shared StrongBox so DriveAsync surfaces it (instead of OCE) the moment it observes the
118-
// cancellation, then cancel. The body sees `ex` rethrown at its next resume point.
119-
_cancellationException.Value = ex;
120-
_cts.Cancel();
121-
122108
// Wait for the body to settle. After this the task is in a terminal state — dispatch via
123109
// the same logic as send() so the body's reaction (catch-and-return, propagate, etc.) is
124110
// reflected back to the caller of throw().
111+
var task = EnsureTask();
125112
((IAsyncResult)task).AsyncWaitHandle.WaitOne();
126113
return SettleCompletedTask(task);
127114
}

‎src/core/IronPython/Runtime/Operations/PythonOps.cs‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3345,12 +3345,11 @@ public static object MakeCoroutineWrapper(PythonGenerator generator) {
33453345
/// <remarks>
33463346
/// The thunk is invoked lazily on first drive (send/AsTask), so calling an <c>async def</c> is side-effect-free until the coroutine
33473347
/// is awaited (PEP 492).
3348-
/// The supplied <see cref="CancellationTokenSource"/> and exception-override box are pre-bound
3349-
/// to <see cref="Microsoft.Scripting.Runtime.AsyncHelpers.DriveAsync"/>'s cancellation channel — the coroutine uses them to implement
3350-
/// <c>coro.throw(exc)</c> on a running body (set the box, cancel the CTS).
3348+
/// The supplied <see cref="AsyncInterruptSource"/> is the one the body's driver,
3349+
/// <see cref="Microsoft.Scripting.Runtime.AsyncHelpers.DriveAsync"/>, uses — the coroutine uses it to implement <c>coro.throw(exc)</c>.
33513350
/// </remarks>
3352-
public static PythonCoroutine MakeAsyncCoroutine(PythonFunction function, Func<Task<object?>> taskFactory, CancellationTokenSource cts, StrongBox<System.Exception?> cancellationException) {
3353-
return new PythonCoroutine(taskFactory, function.__name__, function.__code__, cts, cancellationException);
3351+
public static PythonCoroutine MakeAsyncCoroutine(PythonFunction function, Func<Task<object?>> taskFactory, AsyncInterruptSource interrupts) {
3352+
return new PythonCoroutine(taskFactory, function.__name__, function.__code__, interrupts);
33543353
}
33553354

33563355

‎tests/suite/test_async.py‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,98 @@ def test_cancellation_token_cancel(self):
519519
self.assertTrue(token.IsCancellationRequested)
520520

521521

522+
@skipUnlessIronPython()
523+
@unittest.skipUnless(is_netcoreapp, "tests the .NET async implementation (FEATURE_NET_ASYNC)")
524+
class CoroutineThrowTest(unittest.TestCase):
525+
"""coro.throw(exc) raises exc once, at the await the coroutine is suspended at, and can be repeated."""
526+
527+
@staticmethod
528+
def pending():
529+
from System.Threading.Tasks import TaskCompletionSource
530+
return TaskCompletionSource[object]().Task # never completes
531+
532+
@staticmethod
533+
def throw_at_pending_await(coro, exc):
534+
import clr
535+
coro.AsTask() # runs the body up to its first await of a task that hasn't completed
536+
return coro.throw(exc)
537+
538+
def test_caught(self):
539+
async def coro():
540+
try:
541+
await self.pending()
542+
except KeyError as e:
543+
return 'caught %s' % e.args[0]
544+
with self.assertRaises(StopIteration) as cm:
545+
self.throw_at_pending_await(coro(), KeyError('k'))
546+
self.assertEqual(cm.exception.value, 'caught k')
547+
548+
def test_not_caught(self):
549+
async def coro():
550+
await self.pending()
551+
with self.assertRaises(KeyError):
552+
self.throw_at_pending_await(coro(), KeyError('k'))
553+
554+
def test_awaits_after_caught(self):
555+
# The exception interrupts the body once: its later awaits complete normally
556+
from System.Threading.Tasks import Task
557+
async def coro():
558+
try:
559+
await self.pending()
560+
except KeyError:
561+
pass
562+
await Task.Delay(10)
563+
return await Task.FromResult(42)
564+
with self.assertRaises(StopIteration) as cm:
565+
self.throw_at_pending_await(coro(), KeyError('k'))
566+
self.assertEqual(cm.exception.value, 42)
567+
568+
def test_repeated(self):
569+
# The body handles a throw() and continues; a later throw() interrupts it again
570+
import threading
571+
at_second = threading.Event()
572+
async def coro():
573+
caught = []
574+
try:
575+
await self.pending()
576+
except KeyError:
577+
caught.append('KeyError')
578+
at_second.set()
579+
try:
580+
await self.pending()
581+
except ValueError:
582+
caught.append('ValueError')
583+
return caught
584+
585+
c = coro()
586+
c.AsTask()
587+
first = []
588+
def throw_first():
589+
# throw() returns only when the coroutine finishes
590+
try:
591+
c.throw(KeyError('k'))
592+
except StopIteration as e:
593+
first.append(e.value)
594+
t = threading.Thread(target=throw_first)
595+
t.start()
596+
self.assertTrue(at_second.wait(10))
597+
with self.assertRaises(StopIteration) as cm:
598+
c.throw(ValueError('v'))
599+
t.join(10)
600+
self.assertEqual(cm.exception.value, ['KeyError', 'ValueError'])
601+
self.assertEqual(first, [['KeyError', 'ValueError']])
602+
603+
def test_not_started(self):
604+
# As in CPython, the body doesn't run
605+
ran = []
606+
async def coro():
607+
ran.append(True)
608+
await self.pending()
609+
with self.assertRaises(KeyError):
610+
coro().throw(KeyError('k'))
611+
self.assertEqual(ran, [])
612+
613+
522614
@unittest.skipUnless(is_netcoreapp, "ValueTask is not available on .NET Framework")
523615
class ValueTaskInteropTest(unittest.TestCase):
524616
def test_await_valuetask(self):

0 commit comments

Comments
 (0)