Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 12 additions & 18 deletions amaranth/sim/_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,15 +362,12 @@ async def until(self, condition: ValueLike):
raise TypeError(f"The shape of a condition may only be `signed` or `unsigned`, "
f"not {shape!r}")
tick = self.sample(condition).__aiter__()
try:
done = False
while not done:
clk, rst, *values, done = await tick.__anext__()
if rst:
raise DomainReset
return tuple(values)
finally:
await tick.aclose()
done = False
while not done:
clk, rst, *values, done = await tick.__anext__()
if rst:
raise DomainReset
return tuple(values)

async def repeat(self, count: int):
"""Repeat this trigger a specific number of times.
Expand Down Expand Up @@ -403,15 +400,12 @@ async def repeat(self, count: int):
if count <= 0:
raise ValueError(f"Repeat count must be a positive integer, not {count!r}")
tick = self.__aiter__()
try:
for _ in range(count):
clk, rst, *values = await tick.__anext__()
if rst:
raise DomainReset
assert clk
return tuple(values)
finally:
await tick.aclose()
for _ in range(count):
clk, rst, *values = await tick.__anext__()
if rst:
raise DomainReset
assert clk
return tuple(values)

def _collect_trigger(self):
clk_polarity = (1 if self._domain.clk_edge == "pos" else 0)
Expand Down
50 changes: 49 additions & 1 deletion amaranth/sim/core.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from contextlib import contextmanager
import inspect
import sys
import warnings

from .._utils import deprecated
Expand Down Expand Up @@ -189,6 +191,15 @@ async def testbench(ctx):
which they were added. If two testbenches share state, or must manipulate the design in
a coordinated way, they may rely on this execution order for correctness.

.. warning::

On Python 3.12 and earlier, async generators (:py:`async` functions that also
:py:`yield`) are not cleaned up reliably when the simulator exits. To make sure context
managers or :py:`finally` blocks inside async generators run properly, use Python 3.13
or later. See `PEP-525`_ for more information on async generator finalization.

.. _PEP-525: https://peps.python.org/pep-0525/#finalization

Raises
------
:exc:`RuntimeError`
Expand Down Expand Up @@ -246,6 +257,15 @@ async def process(ctx):
:py:`await ctx.tick().sample(...)`. Such state is visible in a waveform viewer,
simplifying debugging.

.. warning::

On Python 3.12 and earlier, async generators (:py:`async` functions that also
:py:`yield`) are not cleaned up reliably when the simulator exits. To make sure context
managers or :py:`finally` blocks inside async generators run properly, use Python 3.13
or later. See `PEP-525`_ for more information on async generator finalization.

.. _PEP-525: https://peps.python.org/pep-0525/#finalization

Raises
------
:exc:`RuntimeError`
Expand Down Expand Up @@ -341,6 +361,33 @@ def run_until(self, deadline, *, run_passive=None):
while self._engine.now < deadline.femtoseconds:
self.advance()

@contextmanager
def _replace_asyncgen_hooks(self):
# Async generators require hooks for lifetime management. Replace existing hooks with ours
# for the duration of this context manager.

def firstiter(agen):
# Prevent any outer event loop from seeing this generator.
pass

def finalizer(agen):
# Generators can't be closed if they are currently running.
if not agen.ag_running:
# Try to run aclose() once, but skip it if it awaits anything.
try:
coroutine = agen.aclose()
coroutine.send(None)
except StopIteration:
# Success
return

old_hooks = sys.get_asyncgen_hooks()
sys.set_asyncgen_hooks(firstiter=firstiter, finalizer=finalizer)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will completely replace the hooks for the entire process, right? I'm not comfortable merging it like this. Amaranth should never assumed to be the only thing running in the process.

Can we somehow check if this is "our" async generator and pass the generator along if it's not? Glasgow does mix Amaranth asyncgens and asyncio asyncgens, for example.

Barring that, would it be possible to at least detect if the hooks weren't installed before?

What does asyncio do? Does it just blindly replace the hooks?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

asyncio sets new hooks while it's running, and restores the old hooks when it stops. This is the behavior I replicated here.

I think this is fine -- the hooks are thread-local, so as long as each event loop saves/restores the old hooks as execution enters/leaves their control, I think it all works. It's effectively a stack of hooks that corresponds with the call stack of the thread. If Amaranth and asyncio are both used, then which hooks are active depends on which one is closest in the call stack.

That makes sense to me, but I'd be very interested to know if that meshes with how Glasgow mixes the two. I'm trying to be careful, because everything about these hooks feels messy.

Part of why I implemented firstiter is specifically to prevent asyncio from claiming Simulator asyncgens. The asyncio hooks store all generators it sees on the loop itself, and then tries to close them when the loop closes. If any of those are in use in the simulator at the time, that will raise a RuntimeError like the one I saw.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is fine -- the hooks are thread-local, so as long as each event loop saves/restores the old hooks as execution enters/leaves their control, I think it all works.

I agree this should work.

That makes sense to me, but I'd be very interested to know if that meshes with how Glasgow mixes the two. I'm trying to be careful, because everything about these hooks feels messy.

I will test this.

try:
yield
finally:
sys.set_asyncgen_hooks(*old_hooks)

def advance(self):
"""Advance the simulation.

Expand All @@ -356,7 +403,8 @@ def advance(self):
:py:`False` otherwise.
"""
self._running = True
return self._engine.advance()
with self._replace_asyncgen_hooks():
return self._engine.advance()

def write_vcd(self, vcd_file, gtkw_file=None, *, traces=(), fs_per_delta=0):
# `fs_per_delta`` is not currently documented; it is not clear if we want to expose
Expand Down
10 changes: 10 additions & 0 deletions tests/test_sim.py
Original file line number Diff line number Diff line change
Expand Up @@ -2077,6 +2077,16 @@ async def change(ctx):
sim.add_clock(Period(s=4))
sim.run()

def test_abandon_repeat(self):
m = Module()
toggle = Signal(1)
m.d.sync += toggle.eq(~toggle)
sim = Simulator(m)
async def process(ctx):
await ctx.tick().repeat(100)
sim.add_process(process)
sim.run()

def test_trigger_wrong(self):
a = Signal(4)
m = Module()
Expand Down
Loading