1
0
mirror of https://github.com/trezor/trezor-firmware.git synced 2024-12-12 17:38:13 +00:00
trezor-firmware/core/src/trezor/loop.py
2024-11-15 16:29:52 +01:00

571 lines
18 KiB
Python

"""
Implements an event loop with cooperative multitasking and async I/O. Tasks in
the form of python coroutines (either plain generators or `async` functions) are
stepped through until completion, and can get asynchronously blocked by
`yield`ing or `await`ing a syscall.
See `schedule`, `run`, and syscalls `sleep`, `wait`, `signal` and `race`.
"""
import utime
import utimeq
from typing import TYPE_CHECKING
from trezor import io, log
if TYPE_CHECKING:
from typing import Any, Awaitable, Callable, Coroutine, Generator, Union
Task = Union[Coroutine, Generator, "wait"]
AwaitableTask = Task | Awaitable
Finalizer = Callable[[Task, Any], None]
# tasks scheduled for execution in the future
_queue = utimeq.utimeq(64)
# tasks paused on I/O
_paused: dict[int, set[Task]] = {}
# functions to execute after a task is finished
_finalizers: dict[int, Finalizer] = {}
# reference to the task that is currently executing
this_task: Task | None = None
class TaskClosed(Exception):
pass
TASK_CLOSED = TaskClosed()
def schedule(
task: Task,
value: Any = None,
deadline: int | None = None,
finalizer: Finalizer | None = None,
reschedule: bool = False,
) -> None:
"""
Schedule task to be executed with `value` on given `deadline` (in
milliseconds). Does not start the event loop itself, see `run`.
Usually done in very low-level cases, see `race` for more user-friendly
and correct concept.
If `reschedule` is set, updates an existing entry.
"""
if reschedule:
_queue.discard(task)
if deadline is None:
deadline = utime.ticks_ms()
if finalizer is not None:
_finalizers[id(task)] = finalizer
_queue.push(deadline, task, value)
def pause(task: Task, iface: int) -> None:
"""
Block task on given message interface. Task is resumed when the interface
is activated. It is most probably wrong to call `pause` from user code,
see the `wait` syscall for the correct concept.
"""
tasks = _paused.get(iface, None)
if tasks is None:
tasks = _paused[iface] = set()
tasks.add(task)
def finalize(task: Task, value: Any) -> None:
"""Call and remove any finalization callbacks registered for given task."""
fn = _finalizers.pop(id(task), None)
if fn is not None:
fn(task, value)
def close(task: Task) -> None:
"""
Unschedule and unblock a task, close it so it can release all resources, and
call its finalizer.
"""
for iface in _paused: # pylint: disable=consider-using-dict-items
_paused[iface].discard(task)
for iface in _paused: # pylint: disable=consider-using-dict-items
if not _paused[iface]:
del _paused[iface]
_queue.discard(task)
task.close()
finalize(task, GeneratorExit())
def run() -> None:
"""
Loop forever, stepping through scheduled tasks and awaiting I/O events
in between. Use `schedule` first to add a coroutine to the task queue.
Tasks yield back to the scheduler on any I/O, usually by calling `await` on
a `Syscall`.
"""
task_entry = [0, 0, 0] # deadline, task, value
msg_entry = [0, 0] # iface | flags, value
while _queue or _paused:
# compute the maximum amount of time we can wait for a message
if _queue:
delay = utime.ticks_diff(_queue.peektime(), utime.ticks_ms())
else:
delay = 1000 # wait for 1 sec maximum if queue is empty
if io.poll(_paused, msg_entry, delay):
# message received, run tasks paused on the interface
msg_tasks = _paused.pop(msg_entry[0], ())
for task in msg_tasks:
_step(task, msg_entry[1])
else:
# timeout occurred, run the first scheduled task
if _queue:
_queue.pop(task_entry)
_step(task_entry[1], task_entry[2]) # type: ignore [Argument of type "int" cannot be assigned to parameter "task" of type "Task" in function "_step"]
# error: Argument 1 to "_step" has incompatible type "int"; expected "Coroutine[Any, Any, Any]"
# rationale: We use untyped lists here, because that is what the C API supports.
def clear() -> None:
"""Clear all queue state. Any scheduled or paused tasks will be forgotten."""
_ = [0, 0, 0]
while _queue:
_queue.pop(_)
_paused.clear()
_finalizers.clear()
def _step(task: Task, value: Any) -> None:
"""
Step through the task by sending value to it. This can result in either:
1. The task raises an exception:
a) StopIteration
- The Task is completed and we call finalize() to finish it.
b) Exception
- An error occurred. We still need to call finalize().
2. Task does not raise exception and returns either:
a) Syscall
- Syscall.handle() is called.
b) None
- The Task is simply scheduled to continue.
c) Something else
- This should not happen - error.
"""
global this_task
this_task = task
try:
if isinstance(value, BaseException):
result = task.throw(value)
else:
result = task.send(value)
except StopIteration as e:
if __debug__:
log.debug(__name__, "finish: %s", task)
finalize(task, e.value)
except Exception as e:
if __debug__:
log.exception(__name__, e)
finalize(task, e)
else:
if isinstance(result, Syscall):
result.handle(task)
elif result is None:
schedule(task)
else:
if __debug__:
log.error(__name__, "unknown syscall: %s", result)
class Syscall:
"""
When tasks want to perform any I/O, or do any sort of communication with the
scheduler, they do so through instances of a class derived from `Syscall`.
"""
def __iter__(self) -> Generator:
# support `yield from` or `await` on syscalls
return (yield self)
if TYPE_CHECKING:
def __await__(self) -> Generator[Any, Any, Any]:
return self.__iter__()
def handle(self, task: Task) -> None:
pass
class Timeout(Exception):
pass
_TIMEOUT_ERROR = Timeout()
class sleep(Syscall):
"""Pause current task and resume it after given delay.
Result value is the calculated deadline.
Example:
>>> planned = await loop.sleep(1000) # sleep for 1s
>>> print(f"missed by {utime.ticks_diff(utime.ticks_ms(), planned)} ms")
"""
def __init__(self, delay_ms: int) -> None:
self.delay_ms = delay_ms
def handle(self, task: Task) -> None:
deadline = utime.ticks_add(utime.ticks_ms(), self.delay_ms)
schedule(task, deadline, deadline)
class wait(Syscall):
"""
Pause current task, and resume only after a message on `msg_iface` is
received. Messages are received either from an USB interface, or the
touch display. Result value is a tuple of message values.
Example:
>>> hid_report, = await loop.wait(0xABCD) # await USB HID report
>>> event, x, y = await loop.wait(io.TOUCH) # await touch event
"""
_DO_NOT_RESCHEDULE = Syscall()
def __init__(self, msg_iface: int, timeout_ms: int | None = None) -> None:
self.msg_iface = msg_iface
self.timeout_ms = timeout_ms
self.task: Task | None = None
def handle(self, task: Task) -> None:
self.task = task
pause(self, self.msg_iface)
if self.timeout_ms is not None:
deadline = utime.ticks_add(utime.ticks_ms(), self.timeout_ms)
schedule(self, _TIMEOUT_ERROR, deadline)
def send(self, __value: Any) -> Any:
assert self.task is not None
self.close()
_step(self.task, __value)
return self._DO_NOT_RESCHEDULE
throw = send
def close(self) -> None:
_queue.discard(self)
if self.msg_iface in _paused:
_paused[self.msg_iface].discard(self)
if not _paused[self.msg_iface]:
del _paused[self.msg_iface]
def __iter__(self) -> Generator:
try:
return (yield self)
finally:
# whichever way we got here, we must be removed from the paused list
self.close()
_type_gen: type[Generator] = type((lambda: (yield))())
class race(Syscall):
"""
Given a list of either children tasks or syscalls, `race` waits until one of
them completes (tasks are executed in parallel, syscalls are waited upon,
directly). Return value of `race` is the return value of the child that
triggered the completion. Other running children are killed (by cancelling
any pending schedules and raising a `GeneratorExit` by calling `close()`).
Example:
>>> # async def wait_for_touch(): ...
>>> # async def animate_logo(): ...
>>> touch_task = wait_for_touch()
>>> animation_task = animate_logo()
>>> racer = loop.race(touch_task, animation_task)
>>> result = await racer
Note: You should not directly `yield` a `race` instance, see logic in
`race.__iter__` for explanation. Always use `await`.
"""
def __init__(self, *children: AwaitableTask) -> None:
self.children = children
self.finished = False
self.scheduled: list[Task] = [] # scheduled wrapper tasks
def handle(self, task: Task) -> None:
"""
Schedule all children Tasks and set `task` as callback.
"""
finalizer = self._finish
scheduled = self.scheduled
self.finished = False
self.callback = task
scheduled.clear()
for child in self.children:
child_task: Task
if isinstance(child, _type_gen):
# child is a coroutine/generator
# i.e., async function, or function using yield (these are identical
# in micropython)
child_task = child
else:
# child is a layout -- type-wise, it is an Awaitable, but
# implementation-wise it is an Iterable and we know that its __iter__
# will return a Generator.
child_task = child.__iter__() # type: ignore [Cannot access attribute "__iter__" for class "Awaitable[Unknown]";;Cannot access attribute "__iter__" for class "Coroutine[Unknown, Unknown, Unknown]"]
schedule(child_task, None, None, finalizer)
scheduled.append(child_task)
def exit(self, except_for: Task | None = None) -> None:
for task in self.scheduled:
if task != except_for:
close(task)
def _finish(self, task: Task, result: Any) -> None:
if not self.finished:
self.finished = True
self.exit(task)
schedule(self.callback, result)
def __iter__(self) -> Task:
try:
return (yield self)
except: # noqa: E722
# exception was raised on the waiting task externally with
# close() or throw(), kill the children tasks and re-raise
# Make sure finalizers don't continue processing.
self.finished = True
self.exit()
raise
class mailbox(Syscall):
"""
Wait to receive a value.
In terms of synchronization primitives, this is a condition variable that also
contains a value. It is a simplification of Go channels, which is one-ended and
only has a buffer of size 1.
The receiving end pauses until a value is received, and then empties the mailbox
to wait again.
The sending end synchronously posts a value. It is impossible to wait until
the value is consumed. Trying to post a value when the mailbox is full raises
an error, unless `replace=True` is specified
Example:
>>> # in task #1:
>>> box = loop.mailbox()
>>> while True:
>>> result = await box
>>> print("awaited result:", result)
>>> # in task #2:
>>> box.put("Hello from the other task")
>>> print("put completed")
Example Output:
put completed
awaited result: Hello from the other task
"""
_NO_VALUE = object()
def __init__(self, initial_value: Any = _NO_VALUE) -> None:
self.value = initial_value
self.taker: Task | None = None
def is_empty(self) -> bool:
"""Is the mailbox empty?"""
return self.value is self._NO_VALUE
def clear(self) -> None:
"""Empty the mailbox."""
assert self.taker is None
self.value = self._NO_VALUE
def put(self, value: Any, replace: bool = False) -> None:
"""Put a value into the mailbox.
If there is another task waiting for the value, it will be scheduled to resume.
Otherwise, the mailbox will hold the value until someone consumes it.
It is an error to call `put()` when there is a value already held, unless
`replace` is set to `True`. In such case, the held value is replaced with
the new one.
"""
if not self.is_empty() and not replace:
raise ValueError("mailbox already has a value")
self.value = value
if self.taker is not None:
self._take(self.taker)
def _take(self, task: Task) -> None:
"""Take a value and schedule the taker."""
self.taker = None
schedule(task, self.value)
self.clear()
def handle(self, task: Task) -> None:
assert self.taker is None
if not self.is_empty():
self._take(task)
else:
self.taker = task
def __iter__(self) -> Generator:
assert self.taker is None
# short-circuit if there is a value already
if not self.is_empty():
value = self.value
self.clear()
return value
# otherwise, wait for a value
try:
return (yield self)
finally:
# Clear the taker even in case of exception. This way stale takers don't
# blow up someone calling `maybe_close()`
self.taker = None
def maybe_close(self) -> None:
"""Shut down the taker if possible."""
taker = self.taker
self.taker = None
if taker is not None and taker is not this_task:
taker.close()
class spawn(Syscall):
"""Spawn a task asynchronously and get an awaitable reference to it.
Abstraction over `loop.schedule` and `loop.close`. Useful when you need to start
a task in the background, but want to be able to kill it from the outside.
Examples:
1. Spawn a background task, get its result later.
>>> wire_read = loop.spawn(read_from_wire())
>>> long_result = await long_running_operation()
>>> wire_result = await wire_read
2. Allow the user to kill a long-running operation:
>>> try:
>>> operation = loop.spawn(long_running_operation())
>>> result = await operation
>>> print("finished with result", result)
>>> except loop.TaskClosed:
>>> print("task was closed before it could finish")
>>>
>>> # meanwhile, on the other side of town...
>>> controller.close()
Task is spawned only once. Multiple attempts to `await spawned_object` will return
the original return value (or raise the original exception).
"""
def __init__(self, task: Task) -> None:
self.task = task
self.callback: Task | None = None
self.finalizer_callback: Callable[["spawn"], None] | None = None
self.finished = False
self.return_value: Any = None
# schedule task immediately
if __debug__:
log.debug(__name__, "spawn new task: %s", task)
assert isinstance(task, _type_gen)
schedule(task, finalizer=self._finalize)
def _finalize(self, task: Task, value: Any) -> None:
# sanity check: make sure finalizer is for our task
assert task is self.task
# sanity check: make sure finalizer is not called more than once
assert self.finished is False
# now we are truly finished
self.finished = True
if isinstance(value, GeneratorExit):
# coerce GeneratorExit to a catchable TaskClosed
self.return_value = TASK_CLOSED
else:
self.return_value = value
if self.callback is not None:
schedule(self.callback, self.return_value)
self.callback = None
if self.finalizer_callback is not None:
self.finalizer_callback(self)
def __iter__(self) -> Task:
if self.finished:
# exit immediately if we already have a return value
if isinstance(self.return_value, BaseException):
raise self.return_value
else:
return self.return_value
try:
return (yield self)
except BaseException:
# Clear out the callback. Otherwise we would raise the exception into it,
# AND schedule it with the closing value of the child task.
self.callback = None
assert self.task is not this_task # closing parent from child :(
close(self.task)
raise
def handle(self, caller: Task) -> None:
# the same spawn should not be awaited multiple times
assert self.callback is None
self.callback = caller
def close(self) -> None:
"""Shut down the spawned task.
If another caller is awaiting its result it will get a TaskClosed exception.
If the task was already finished, the call has no effect.
"""
if not self.finished:
if __debug__:
log.debug(__name__, "close spawned task: %s", self.task)
close(self.task)
def set_finalizer(self, finalizer_callback: Callable[["spawn"], None]) -> None:
"""Register a finalizer callback.
The provided function is executed synchronously when the spawned task ends,
with the spawn object as an argument.
"""
if self.finished:
finalizer_callback(self)
self.finalizer_callback = finalizer_callback
def is_running(self) -> bool:
"""Check if the caller is executing from the spawned task.
Useful for checking if it is OK to call `task.close()`. If `task.is_running()`
is True, it would be calling close on self, which will result in a ValueError.
"""
return self.task is this_task