3131class BaseLoop :
3232 """The base class for an event-loop object.
3333
34+ The rendercanvas ``loop`` object is a proxy to a real event-loop. It abstracts away methods like ``run()``,
35+ ``call_later``, ``call_soon_threadsafe()``, and more.
36+
3437 Canvas backends can implement their own loop subclass (like qt and wx do), but a
3538 canvas backend can also rely on one of multiple loop implementations (like glfw
3639 running on asyncio or trio).
3740
38- The lifecycle states of a loop are:
39-
40- * off: the initial state, the subclass should probably not even import dependencies yet.
41- * ready: the first canvas is created, ``_rc_init()`` is called to get the loop ready for running.
42- * active: the loop is active (we detect it because our task is running), but we don't know how.
43- * interactive: the loop is inter-active in e.g. an IDE, reported by the backend.
44- * running: the loop is running via ``_rc_run()`` or ``_rc_run_async()``.
45-
46- Notes:
41+ In the majority of use-cases, users don't need to know much about the loop. It will typically
42+ run once. In more complex scenario's the section below explains the working of the loop in more detail.
43+
44+ **Details about loop lifetime**
45+
46+ The rendercanvas loop object is a proxy, which has to support a variety of backends.
47+ To realize this, it has the following lifetime model:
48+
49+ * off:
50+ * Entered when the loop is instantiated, and when the loop has stopped.
51+ * This is the 'idle' state.
52+ * The backend probably has not even imported dependencies yet.
53+ * ready:
54+ * Entered when the first canvas is created that is associated with this loop, or when a task is added.
55+ * It is assumed that the loop will become active soon.
56+ * This is when ``_rc_init()`` is called to get the backend ready for running.
57+ * A special 'loop-task' is created (a coroutine, which is not yet running).
58+ * running:
59+ * Entered when ``loop.run()`` is called.
60+ * The loop is now running.
61+ * Signal handlers and asyncgen hooks are installed if applicable.
62+ * interactive:
63+ * Entered in ``_rc_init()`` when the backend detects that the loop is interactive.
64+ * Example use-cases are a notebook or interactive IDE, usually via asyncio.
65+ * This means there is a persistent native loop already running, which rendercanvas makes use of.
66+ * active:
67+ * Entered when the backend-loop starts running, but not via the loop's ``run()`` method.
68+ * This is detected via the loop-task.
69+ * Signal handlers and asyncgen hooks are installed if applicable.
70+ * Detecting loop stopping occurs by the loop-task being cancelled.
71+
72+ Notes related to starting and stopping:
4773
4874 * The loop goes back to the "off" state once all canvases are closed.
4975 * Stopping the loop (via ``.stop()``) closes the canvases, which will then stop the loop.
5076 * From there it can go back to the ready state (which would call ``_rc_init()`` again).
5177 * In backends like Qt, the native loop can be started without us knowing: state "active".
5278 * In interactive settings like an IDE that runs an asyncio or Qt loop, the
53- loop can become "active" as soon as the first canvas is created.
54-
55- The lifecycle of this loop does not necessarily co-inside with the native loop's cycle:
56-
57- * The rendercanvas loop can be in the 'off' state while the native loop is running.
58- * When we stop the loop, the native loop likely runs slightly longer.
59- * When the loop is interactive (asyncio or Qt) the native loop keeps running when rendercanvas' loop stops.
60- * For async loops (asyncio or trio), the native loop may run before and after this loop.
61- * On Qt, we detect the app's aboutToQuit to stop this loop.
62- * On wx, we detect all windows closed to stop this loop.
79+ loop becomes "interactive" as soon as the first canvas is created.
80+ * The rendercanvas loop can be in the 'off' state while the native loop is running (especially for the 'interactive' case).
81+ * On Qt, the app's 'aboutToQuit' signal is used to stop this loop.
82+ * On wx, the loop is stopped when all windows are closed.
6383
6484 """
6585
6686 def __init__ (self ):
67- self .__tasks = set ()
87+ self .__tasks = set () # only used by the async adapter
6888 self .__canvas_groups = set ()
6989 self .__should_stop = 0
7090 self .__state = LoopState .off
7191 self .__is_initialized = False
92+ self .__hook_data = None
93+ self .__using_adapter = False # set to True if using our asyncadapter
7294 self ._asyncgens = weakref .WeakSet ()
7395 # self._setup_debug_thread()
7496
@@ -143,6 +165,7 @@ async def wrapper():
143165 self .__is_initialized = True
144166 self ._rc_init ()
145167 self ._rc_add_task (wrapper , "loop-task" )
168+ self .__using_adapter = len (self .__tasks ) > 0
146169
147170 async def _loop_task (self ):
148171 # This task has multiple purposes:
@@ -162,6 +185,10 @@ async def _loop_task(self):
162185 # because its minimized (applies to backends that implement
163186 # _rc_gui_poll).
164187
188+ # In some cases the task may run after the loop was closed
189+ if self .__state == LoopState .off :
190+ return
191+
165192 # The loop has started!
166193 self .__start ()
167194
@@ -295,8 +322,7 @@ def run(self) -> None:
295322
296323 self ._ensure_initialized ()
297324
298- # Register interrupt handler
299- prev_sig_handlers = self .__setup_interrupt ()
325+ need_unregister = self .__setup_hooks ()
300326
301327 # Run. We could be in this loop for a long time. Or we can exit immediately if
302328 # the backend already has an (interactive) event loop and did not call _mark_as_interactive().
@@ -307,14 +333,15 @@ def run(self) -> None:
307333 # Mark state as not 'running', but also not to 'off', that happens elsewhere.
308334 if self .__state == LoopState .running :
309335 self .__state = LoopState .active
310- for sig , cb in prev_sig_handlers . items () :
311- signal . signal ( sig , cb )
336+ if need_unregister :
337+ self . __restore_hooks ( )
312338
313339 async def run_async (self ) -> None :
314340 """ "Alternative to ``run()``, to enter the mainloop from a running async framework.
315341
316342 Only supported by the asyncio and trio loops.
317343 """
344+
318345 # Can we enter the loop?
319346 if self .__state in (LoopState .off , LoopState .ready ):
320347 pass
@@ -337,7 +364,7 @@ async def run_async(self) -> None:
337364 def stop (self , * , force = False ) -> None :
338365 """Close all windows and stop the currently running event-loop.
339366
340- If the loop is active but not running via our ``run()`` method, the loop
367+ If the loop is active but not running via the ``run()`` method, the loop
341368 moves back to its off-state, but the underlying loop is not stopped.
342369
343370 Normally, the windows are closed and the underlying event loop is given
@@ -368,17 +395,47 @@ def stop(self, *, force=False) -> None:
368395 if len (canvases ) == 0 or self .__should_stop >= 2 :
369396 self .__stop ()
370397
398+ def __setup_hooks (self ):
399+ """Setup asycgen hooks and interrupt hooks."""
400+ if self .__hook_data is not None :
401+ return False
402+
403+ # Setup asyncgen hooks
404+ prev_asyncgen_hooks = self .__setup_asyncgen_hooks ()
405+
406+ # Set interrupts
407+ prev_interrupt_hooks = self .__setup_interrupt_hooks ()
408+
409+ self .__hook_data = prev_asyncgen_hooks , prev_interrupt_hooks
410+ return True
411+
412+ def __restore_hooks (self ):
413+ """Unregister hooks."""
414+
415+ # This is separated from stop(), so that a loop can be 'active' by repeated calls to ``run()``, but will only
416+ # actually have registered hooks while inside ``run()``. The StubLoop has this behavior, and it may be a bit silly
417+ # to organize for this special use-case, but it does make it more clean/elegant, and maybe someday we will want another
418+ # loop class that runs for short periods. This now works, even when another loop is running.
419+
420+ if self .__hook_data is None :
421+ return
422+
423+ prev_asyncgen_hooks , prev_interrupt_hooks = self .__hook_data
424+ self .__hook_data = None
425+
426+ if prev_asyncgen_hooks is not None :
427+ sys .set_asyncgen_hooks (* prev_asyncgen_hooks )
428+
429+ for sig , cb in prev_interrupt_hooks .items ():
430+ signal .signal (sig , cb )
431+
371432 def __start (self ):
372433 """Move to running state."""
373-
374434 # Update state, but leave 'interactive' and 'running'
375435 if self .__state in (LoopState .off , LoopState .ready ):
376436 self .__state = LoopState .active
377437
378- # Setup asyncgen hooks. This is done when we detect the loop starting,
379- # not in run(), because most event-loops will handle interrupts, while
380- # e.g. qt won't care about async generators.
381- self .__setup_asyncgen_hooks ()
438+ self .__setup_hooks ()
382439
383440 def __stop (self ):
384441 """Move to the off-state."""
@@ -390,8 +447,6 @@ def __stop(self):
390447 self .__state = LoopState .off
391448 self .__should_stop = 0
392449
393- self .__finish_asyncgen_hooks ()
394-
395450 # If we used the async adapter, cancel any tasks. If we could assume
396451 # that the backend processes pending events before actually shutting
397452 # down, we could only call .cancel(), and leave the event-loop to do the
@@ -407,11 +462,20 @@ def __stop(self):
407462 # Note that backends that do not use the asyncadapter are responsible
408463 # for cancelling pending tasks.
409464
465+ self .__restore_hooks ()
466+
467+ # Cancel async gens
468+ if len (self ._asyncgens ):
469+ closing_agens = list (self ._asyncgens )
470+ self ._asyncgens .clear ()
471+ for agen in closing_agens :
472+ close_agen (agen )
473+
410474 # Tell the backend to stop the loop. This usually means it will stop
411475 # soon, but not *now*; remember that we're currently in a task as well.
412476 self ._rc_stop ()
413477
414- def __setup_interrupt (self ):
478+ def __setup_interrupt_hooks (self ):
415479 """Setup the interrupt handlers."""
416480
417481 def on_interrupt (sig , _frame ):
@@ -445,29 +509,20 @@ def __setup_asyncgen_hooks(self):
445509 # the generator in the user's code. Note that when a proper async
446510 # framework (asyncio or trio) is used, all of this does not apply; only
447511 # for the qt/wx/raw loop do we do this, an in these cases we don't
448- # expect fancy async stuff.
449-
450- current_asyncgen_hooks = sys .get_asyncgen_hooks ()
451- if (
452- current_asyncgen_hooks .firstiter is None
453- and current_asyncgen_hooks .finalizer is None
454- ):
455- sys .set_asyncgen_hooks (
456- firstiter = self ._asyncgen_firstiter_hook ,
457- finalizer = self ._asyncgen_finalizer_hook ,
458- )
459- else :
460- # Assume that the hooks are from asyncio/trio on which this loop is running.
461- pass
512+ # expect fancy async stuff. Oh, and the sleep and Event actually become no-ops when the
513+ # asyncgen hooks are restored, so that error message should in theory never happen anyway.
462514
463- def __finish_asyncgen_hooks (self ):
464- sys .set_asyncgen_hooks (None , None )
515+ # Only register hooks if we use the asyncadapter; async frameworks install their own hooks.
516+ if not self .__using_adapter :
517+ return None
465518
466- if len (self ._asyncgens ):
467- closing_agens = list (self ._asyncgens )
468- self ._asyncgens .clear ()
469- for agen in closing_agens :
470- close_agen (agen )
519+ prev_asyncgen_hooks = sys .get_asyncgen_hooks ()
520+ sys .set_asyncgen_hooks (
521+ firstiter = self ._asyncgen_firstiter_hook ,
522+ finalizer = self ._asyncgen_finalizer_hook ,
523+ )
524+
525+ return prev_asyncgen_hooks
471526
472527 def _asyncgen_firstiter_hook (self , agen ):
473528 self ._asyncgens .add (agen )
@@ -518,7 +573,7 @@ def _rc_stop(self):
518573 raise NotImplementedError ()
519574
520575 def _rc_add_task (self , async_func , name ):
521- """Add an async task to the running loop.
576+ """Add an async task to this loop.
522577
523578 True async loop-backends (like asyncio and trio) should implement this.
524579 When they do, ``_rc_call_later`` is not used.
0 commit comments