From 64d386e9f57351e8140d5b35b15ea02a67296559 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Wed, 30 Sep 2026 11:31:30 +0200 Subject: [PATCH] Do not let close() wait for itself close() waits for the render lock, the only wait left in reacton. Called from its own render (a component or an effect), it waited for the lock its own thread held, and hung forever. Called again from an effect cleanup during close(), the same. Now the first raises, and a close() on a closing or closed render context returns: that also makes a second close() a no-op, which crashed with an AttributeError before. Co-Authored-By: Claude Opus 5.5 (1M context) --- reacton/core.py | 8 ++++++ reacton/threads_test.py | 58 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) 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()