Skip to content

Commit 8dc1b24

Browse files
aburgmpatrick-kidger
authored andcommitted
Add nest to nest tinyio loops
This allows to isolate them from each other so that an exception in a nested group does not affect coroutines running outside. Make test with exceptions work Use isolate() instead of nest() as public API Explicit boolean check and add exception_group parameter Return exception instead of calling `cleanup` with it
1 parent b9baee0 commit 8dc1b24

3 files changed

Lines changed: 260 additions & 0 deletions

File tree

tests/test_isolate.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
from collections.abc import Callable
2+
3+
import pytest
4+
import tinyio
5+
6+
7+
class SingleElementQueue:
8+
def __init__(self):
9+
self._event = tinyio.Event()
10+
self._elem = None
11+
12+
def put(self, x):
13+
if self._elem is not None:
14+
raise ValueError("Queue is full")
15+
16+
self._elem = x
17+
self._event.set()
18+
19+
def get(self):
20+
while self._elem is None:
21+
yield self._event.wait()
22+
x = self._elem
23+
self._elem = None
24+
return x
25+
26+
27+
@pytest.mark.parametrize("isolate_g", (False, True))
28+
@pytest.mark.parametrize("isolate_h", (False, True))
29+
def test_isolate(isolate_g: bool, isolate_h: bool):
30+
"""Test that all coroutines make progress when some are isolated"""
31+
q1 = SingleElementQueue()
32+
q2 = SingleElementQueue()
33+
34+
# Intertwine two coroutines in such a way that they can only
35+
# finish if both of them make progress at the same time, but
36+
# not if one blocks until the other has completed.
37+
def g() -> tinyio.Coro[int]:
38+
q1.put(1)
39+
x = yield q2.get()
40+
q1.put(x + 1)
41+
return (yield q2.get())
42+
43+
def h() -> tinyio.Coro[int]:
44+
x = yield q1.get()
45+
q2.put(x + 1)
46+
x = yield q1.get()
47+
q2.put(x + 1)
48+
return x
49+
50+
def maybe_isolate(c: Callable[[], tinyio.Coro[int]], isolate: bool) -> tinyio.Coro[int]:
51+
if isolate:
52+
x, _ = yield tinyio.isolate(c)
53+
return x
54+
else:
55+
return (yield c())
56+
57+
def f() -> tinyio.Coro[list[int]]:
58+
return (yield [maybe_isolate(g, isolate_g), maybe_isolate(h, isolate_h)])
59+
60+
out = tinyio.Loop().run(f())
61+
assert out == [4, 3]
62+
63+
64+
def test_isolate_with_error_in_inner_loop():
65+
"""Test exceptions happening in the isolated loop.
66+
67+
If an isolated coroutine raises an exception, all other coroutines within
68+
the isolation are cancelled, but outer coroutines keep running."""
69+
q1 = SingleElementQueue()
70+
q2 = SingleElementQueue()
71+
q3 = SingleElementQueue()
72+
73+
g_was_cancelled = True
74+
i_was_cancelled = True
75+
76+
def g() -> tinyio.Coro[int]:
77+
nonlocal g_was_cancelled
78+
q2.put(5)
79+
yield q3.get()
80+
g_was_cancelled = False
81+
return 1
82+
83+
def h() -> tinyio.Coro[int]:
84+
x = yield q1.get()
85+
y = yield q2.get()
86+
if x == 5 and y == 5:
87+
raise RuntimeError("Kaboom")
88+
return x + y
89+
90+
def i() -> tinyio.Coro[int]:
91+
nonlocal i_was_cancelled
92+
q1.put(5)
93+
yield tinyio.sleep(1)
94+
i_was_cancelled = False
95+
return 2
96+
97+
def isolated() -> tinyio.Coro[list[int]]:
98+
return (yield [h(), i()])
99+
100+
def try_isolated() -> tinyio.Coro[list[int]]:
101+
x, success = yield tinyio.isolate(isolated)
102+
if not success:
103+
x = [-1, -1]
104+
105+
q3.put(0) # wake up the "outer" loop g()
106+
return x
107+
108+
def f() -> tinyio.Coro[list[int]]:
109+
return (yield [g(), try_isolated()])
110+
111+
assert tinyio.Loop().run(f()) == [1, [-1, -1]]
112+
113+
assert not g_was_cancelled
114+
assert i_was_cancelled
115+
116+
117+
def test_isolate_with_args():
118+
"""Test that isolate can be called with additional coroutines as arguments"""
119+
120+
def slow_add_one(x: int) -> tinyio.Coro[int]:
121+
yield
122+
return x + 1
123+
124+
def unreliable_add_two(get_x: tinyio.Coro[int]) -> tinyio.Coro[int]:
125+
x = yield get_x
126+
if x == 3:
127+
raise RuntimeError("That is too hard.")
128+
else:
129+
y = yield slow_add_one(x)
130+
z = yield slow_add_one(y)
131+
return z
132+
133+
def try_add_three(x: int) -> tinyio.Coro[tuple[int, bool]]:
134+
return (yield tinyio.isolate(unreliable_add_two, slow_add_one(x)))
135+
136+
assert tinyio.Loop().run(try_add_three(0)) == (3, True)
137+
assert tinyio.Loop().run(try_add_three(1)) == (4, True)
138+
assert tinyio.Loop().run(try_add_three(3)) == (6, True)
139+
assert tinyio.Loop().run(try_add_three(4)) == (7, True)
140+
141+
result, success = tinyio.Loop().run(try_add_three(2))
142+
assert not success
143+
assert type(result) is RuntimeError
144+
assert str(result) == "That is too hard."

tinyio/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
to_asyncio as to_asyncio,
1212
to_trio as to_trio,
1313
)
14+
from ._isolate import isolate as isolate
1415
from ._sync import Barrier as Barrier, Lock as Lock, Semaphore as Semaphore
1516
from ._thread import ThreadPool as ThreadPool, run_in_thread as run_in_thread
1617
from ._time import TimeoutError as TimeoutError, sleep as sleep, timeout as timeout

tinyio/_isolate.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
from collections.abc import Callable
2+
from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar
3+
4+
import tinyio
5+
6+
7+
_P = ParamSpec("_P")
8+
_T = TypeVar("_T")
9+
_R = TypeVar("_R")
10+
11+
12+
def _dupe(coro: tinyio.Coro[_T]) -> tuple[tinyio.Coro[None], tinyio.Coro[_T]]:
13+
"""Takes a coro assumed to be scheduled on an event loop, and returns:
14+
15+
- a new coroutine that should be scheduled in the background of the same loop;
16+
- a new coroutine that can be scheduled anywhere at all (typically a new loop), and
17+
will return the same value as the original coroutine.
18+
19+
Thus, this is a pipe through which two event loops can talk to one another.
20+
"""
21+
pipe = []
22+
done = tinyio.Event()
23+
failed = tinyio.Event()
24+
25+
def put_on_old_loop():
26+
try:
27+
out = yield coro
28+
except BaseException:
29+
failed.set()
30+
done.set()
31+
raise
32+
else:
33+
pipe.append(out)
34+
done.set()
35+
36+
def put_on_new_loop():
37+
yield done.wait()
38+
if failed.is_set():
39+
raise RuntimeError("Could not get input as underlying coroutine failed.")
40+
else:
41+
return pipe[0]
42+
43+
return put_on_old_loop(), put_on_new_loop()
44+
45+
46+
def _nest(coro: tinyio.Coro[_R], exception_group: None | bool = None) -> tinyio.Coro[_R]:
47+
"""Runs one tinyio event loop within another.
48+
49+
The outer loop will be in control of the stepping. The inner loop will have a
50+
separate collection of coroutines, which will be grouped and mutually shut down if
51+
one of them produces an error. Thus, this provides a way to isolate a group of
52+
coroutines within a broader collection.
53+
"""
54+
with tinyio.Loop().runtime(coro, exception_group) as gen:
55+
while True:
56+
try:
57+
wait = next(gen)
58+
except StopIteration as e:
59+
return e.value
60+
if wait is None:
61+
yield
62+
else:
63+
yield tinyio.run_in_thread(wait)
64+
65+
66+
def isolate(
67+
fn: Callable[..., tinyio.Coro[_R]],
68+
/,
69+
*args: tinyio.Coro,
70+
exception_group: None | bool = None,
71+
) -> tinyio.Coro[tuple[_R | BaseException, bool]]:
72+
"""Runs a coroutine in an isolated event loop, and if it fails, returns the exception that occurred.
73+
74+
**Arguments:**
75+
76+
- `fn`: a function that returns a tinyio coroutine. Will be called as `fn(*args)` in order to get the coroutine to
77+
run. All coroutines that it depends on must be passed as `*args` (so that communication can be established
78+
between the two loops).
79+
- `*args`: all coroutines that `fn` depends upon.
80+
81+
**Returns:**
82+
83+
A 2-tuple:
84+
85+
- the first element is either the result of `fn(*args)` or an exception.
86+
- whether `fn(*args)` succeeded or raised an exception.
87+
"""
88+
if len(args) > 0:
89+
olds, news = zip(*map(_dupe, args), strict=True)
90+
else:
91+
olds, news = [], []
92+
yield set(olds)
93+
try:
94+
# This `yield from` is load bearing! We must not allow the tinyio event loop to
95+
# interpose itself between the exception arising out of `fn(*news)`, and the
96+
# current stack frame. Otherwise we would get a `CancelledError` here instead.
97+
return (yield from _nest(fn(*news), exception_group=exception_group)), True
98+
except BaseException as e:
99+
return e, False
100+
101+
102+
# Stand back, some typing hackery required.
103+
if TYPE_CHECKING:
104+
105+
def _fn_signature(*args: tinyio.Coro[_T], exception_group: None | bool = None): ...
106+
107+
def _make_isolate(
108+
fn: Callable[_P, Any],
109+
) -> Callable[
110+
Concatenate[Callable[_P, tinyio.Coro[_R]], _P],
111+
tinyio.Coro[tuple[_R | BaseException, bool]],
112+
]: ...
113+
114+
isolate = _make_isolate(_fn_signature)
115+
del _fn_signature, _make_isolate

0 commit comments

Comments
 (0)