diff --git a/reacton/core.py b/reacton/core.py index 3a37526..0394043 100644 --- a/reacton/core.py +++ b/reacton/core.py @@ -1416,7 +1416,15 @@ def find(self, cls: Type[W] = ipywidgets.Widget, **matches): _find = find # for backward compatibility def close(self): + # close() waits for the render lock, so it cannot run in its own render: it would wait for itself + if self._lock_thread == threading.current_thread(): + raise RuntimeError("close() called during a render of this render context, current thread: %r" % threading.current_thread()) + if self._closing: + # closed or closing: a second close(), or an effect cleanup that calls close() during close() + return with self.thread_lock: + if self._closing: + return # another thread closed us while we waited for the lock self._closing = True # snapshot the component contexts before _remove_element detaches them from # their parents: detached contexts would escape the teardown below while the diff --git a/reacton/threads_test.py b/reacton/threads_test.py index ebfeaf3..c0c378e 100644 --- a/reacton/threads_test.py +++ b/reacton/threads_test.py @@ -137,3 +137,61 @@ def filter(self, record): assert not deadlocked, "the other thread waited for the render lock while it held the user lock" assert box.children[0].description == expected[request_render] rc.close() + + +def _run_in_thread(target): + # a thread that is still alive after TIMEOUT hangs; errors[0] is what target raised, if anything + errors: list = [] + + def run(): + try: + target() + except BaseException as e: + errors.append(e) + + thread = threading.Thread(target=run, daemon=True) + thread.start() + thread.join(TIMEOUT) + return thread, errors + + +def test_close_from_its_own_render_raises_instead_of_hanging(): + # close() waits for the render lock, which its own render holds: it would wait for itself + close_errors: list = [] + setters = {} + + @reacton.component + def Test(): + a, setters["a"] = reacton.use_state(0) + + def effect(): + if a == 1: + try: + rc.close() + except RuntimeError as e: + close_errors.append(e) + + reacton.use_effect(effect, [a]) + return w.Button(description=f"{a}") + + box, rc = reacton.render(Test(), handle_error=False) + thread, errors = _run_in_thread(lambda: setters["a"](1)) + assert not thread.is_alive(), "close() from its own render hangs" + assert not errors, errors + assert len(close_errors) == 1 + rc.close() + + +def test_close_during_or_after_close_returns(): + # an effect cleanup, which runs during close(), calls close() again: that must not wait for the + # render lock that the first close() holds. A close() after close() is a no-op as well. + @reacton.component + def Test(): + reacton.use_effect(lambda: lambda: rc.close(), []) # the cleanup calls close() + return w.Button() + + box, rc = reacton.render(Test(), handle_error=False) + thread, errors = _run_in_thread(rc.close) + assert not thread.is_alive(), "close() from a cleanup during close() hangs" + assert not errors, errors + rc.close()