reacton 1.10.5__tar.gz → 1.11.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {reacton-1.10.5 → reacton-1.11.0}/PKG-INFO +1 -1
- reacton-1.11.0/reacton/_version.py +1 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/core.py +147 -73
- {reacton-1.10.5 → reacton-1.11.0}/reacton/core_test.py +133 -0
- reacton-1.11.0/reacton/threads_test.py +562 -0
- reacton-1.10.5/reacton/_version.py +0 -1
- {reacton-1.10.5 → reacton-1.11.0}/.gitignore +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/LICENSE +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/pyproject.toml +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/react_ipywidgets/__init__.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/react_ipywidgets/core.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/__init__.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/bqplot.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/deprecated/__init__.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/deprecated/ipyvuetify.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/find.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/find_test.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/fuzz_test.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/generate.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/generate_test.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/ipycanvas.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/ipyvue.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/ipyvue_test.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/ipyvuetify.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/ipywidgets.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/logging.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/patch.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/patch_display.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/py.typed +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/rx.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/test.vue +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/test_rx.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/utils.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/utils_test.py +0 -0
- {reacton-1.10.5 → reacton-1.11.0}/reacton/work.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.11.0"
|
|
@@ -88,30 +88,43 @@ _get_widgets_dict = _widgets_dict_getter()
|
|
|
88
88
|
# via the widget constructed hook: diffing the global widgets dict per widget
|
|
89
89
|
# creation is O(total widgets), and solara replaces that dict with a context
|
|
90
90
|
# aware mapping we should not depend on.
|
|
91
|
-
|
|
91
|
+
# The recording is per thread, so no lock is held while a widget is constructed:
|
|
92
|
+
# construction opens a comm, and in a server that send can wait for a slow client,
|
|
93
|
+
# which with a process wide lock stalled widget creation on every other thread.
|
|
94
|
+
class _ConstructionRecording(threading.local):
|
|
95
|
+
# where the widgets constructed on this thread go, between _start_recording_constructed and
|
|
96
|
+
# _stop_recording_constructed
|
|
97
|
+
constructed: Optional[List["widgets.Widget"]] = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_construction_recording = _ConstructionRecording()
|
|
92
101
|
_chained_construction_callback: Optional[Callable] = None
|
|
93
102
|
|
|
94
103
|
|
|
95
104
|
def _record_constructed_widget(widget: "widgets.Widget"):
|
|
96
|
-
|
|
97
|
-
|
|
105
|
+
constructed = _construction_recording.constructed
|
|
106
|
+
if constructed is not None:
|
|
107
|
+
constructed.append(widget)
|
|
98
108
|
if _chained_construction_callback is not None:
|
|
99
109
|
_chained_construction_callback(widget)
|
|
100
110
|
|
|
101
111
|
|
|
102
|
-
def _start_recording_constructed(recording: List["widgets.Widget"]):
|
|
103
|
-
|
|
112
|
+
def _start_recording_constructed(recording: List["widgets.Widget"]) -> Optional[List["widgets.Widget"]]:
|
|
113
|
+
"""Record the widgets this thread constructs; returns what to pass to _stop_recording_constructed."""
|
|
114
|
+
global _chained_construction_callback
|
|
104
115
|
current = getattr(widgets.Widget, "_widget_construction_callback", None)
|
|
105
116
|
if current is not _record_constructed_widget:
|
|
106
117
|
# first time, or someone else registered a callback after us: chain it
|
|
107
118
|
_chained_construction_callback = current
|
|
108
119
|
widgets.Widget.on_widget_constructed(_record_constructed_widget)
|
|
109
|
-
|
|
120
|
+
# a widget whose constructor renders records in its own list; the outer recording continues afterwards
|
|
121
|
+
previous = _construction_recording.constructed
|
|
122
|
+
_construction_recording.constructed = recording
|
|
123
|
+
return previous
|
|
110
124
|
|
|
111
125
|
|
|
112
|
-
def _stop_recording_constructed():
|
|
113
|
-
|
|
114
|
-
_construction_recording = None
|
|
126
|
+
def _stop_recording_constructed(previous: Optional[List["widgets.Widget"]]):
|
|
127
|
+
_construction_recording.constructed = previous
|
|
115
128
|
|
|
116
129
|
|
|
117
130
|
_last_rc = None # used for testing
|
|
@@ -334,7 +347,6 @@ class Element(Generic[W]):
|
|
|
334
347
|
# to make every unique on_value callback to a unique wrapper
|
|
335
348
|
# so that we can remove the listeners
|
|
336
349
|
_callback_wrappers: Dict[Tuple[str, str, Callable], Callable] = {}
|
|
337
|
-
create_lock: ContextManager = threading.Lock()
|
|
338
350
|
_shared = False
|
|
339
351
|
|
|
340
352
|
def __init__(self, component, args=None, kwargs=None):
|
|
@@ -499,32 +511,30 @@ class Element(Generic[W]):
|
|
|
499
511
|
# we can't use our own kwarg, since that contains elements, not widgets
|
|
500
512
|
kwargs, listeners = self._split_kwargs(kwargs)
|
|
501
513
|
assert isinstance(self.component, ComponentWidget)
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
_start_recording_constructed(recorded)
|
|
514
|
+
rc = get_render_context(required=True)
|
|
515
|
+
recorded: List[widgets.Widget] = []
|
|
516
|
+
previous_recording = _start_recording_constructed(recorded)
|
|
517
|
+
try:
|
|
507
518
|
try:
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
_stop_recording_constructed()
|
|
519
|
+
widget = self.component.widget(**kwargs)
|
|
520
|
+
hold_trait_notifications = widget.hold_trait_notifications
|
|
521
|
+
|
|
522
|
+
@contextlib.contextmanager
|
|
523
|
+
def hold_trait_notifications_extra(*args, **kwargs):
|
|
524
|
+
with rc, hold_trait_notifications(*args, **kwargs):
|
|
525
|
+
yield
|
|
526
|
+
|
|
527
|
+
widget.hold_trait_notifications = hold_trait_notifications_extra
|
|
528
|
+
|
|
529
|
+
if self._meta:
|
|
530
|
+
widget._react_meta = dict(self._meta)
|
|
531
|
+
except Exception as e:
|
|
532
|
+
raise RuntimeError(f"Could not create widget {self.component.widget} with {kwargs}") from e
|
|
533
|
+
for name, callback in listeners.items():
|
|
534
|
+
if callback is not None:
|
|
535
|
+
self._add_widget_event_listener(widget, name, callback)
|
|
536
|
+
finally:
|
|
537
|
+
_stop_recording_constructed(previous_recording)
|
|
528
538
|
widgets_dict = _get_widgets_dict()
|
|
529
539
|
orphans = {w.model_id for w in recorded if w is not widget and w.comm is not None and w.model_id in widgets_dict}
|
|
530
540
|
return widget, orphans
|
|
@@ -1334,6 +1344,9 @@ class _RenderContext:
|
|
|
1334
1344
|
|
|
1335
1345
|
def __init__(self, element: Element, container: widgets.Widget = None, children_trait="children", handle_error: bool = True, initial_state=None):
|
|
1336
1346
|
self.element = element
|
|
1347
|
+
# the last requested root element: the thread that holds the render lock copies it to
|
|
1348
|
+
# self.element at the start of each pass (see render)
|
|
1349
|
+
self._element_next = element
|
|
1337
1350
|
self.container = container
|
|
1338
1351
|
self.children_next_trait = children_trait
|
|
1339
1352
|
self.first_render = True
|
|
@@ -1345,6 +1358,9 @@ class _RenderContext:
|
|
|
1345
1358
|
self.last_root_widget: widgets.Widget = None
|
|
1346
1359
|
self._is_rendering = False
|
|
1347
1360
|
self._rerender_needed = False
|
|
1361
|
+
self._state_set_by_other_thread = False
|
|
1362
|
+
# used for tests only: called when a render loop is done, right after its last look at _rerender_needed
|
|
1363
|
+
self._on_render_loop_done: Optional[Callable[[], None]] = None
|
|
1348
1364
|
# the reasons are only read for the "too many renders" error message, and a reason
|
|
1349
1365
|
# holds the previous and next state value: keeping all of them kept every old state
|
|
1350
1366
|
# value alive until close(). REACTON_RERENDER_REASONS keeps more, for debugging.
|
|
@@ -1400,15 +1416,27 @@ class _RenderContext:
|
|
|
1400
1416
|
_find = find # for backward compatibility
|
|
1401
1417
|
|
|
1402
1418
|
def close(self):
|
|
1419
|
+
# close() waits for the render lock, so it cannot run in its own render: it would wait for itself
|
|
1420
|
+
if self._lock_thread == threading.current_thread():
|
|
1421
|
+
raise RuntimeError("close() called during a render of this render context, current thread: %r" % threading.current_thread())
|
|
1422
|
+
if self._closing:
|
|
1423
|
+
# closed or closing: a second close(), or an effect cleanup that calls close() during close()
|
|
1424
|
+
return
|
|
1403
1425
|
with self.thread_lock:
|
|
1426
|
+
if self._closing:
|
|
1427
|
+
return # another thread closed us while we waited for the lock
|
|
1404
1428
|
self._closing = True
|
|
1405
1429
|
# snapshot the component contexts before _remove_element detaches them from
|
|
1406
1430
|
# their parents: detached contexts would escape the teardown below while the
|
|
1407
1431
|
# setter/handler closures in their state still reference them and us
|
|
1408
|
-
|
|
1432
|
+
# by id: after a failed render, a context can be in both children and children_next, and
|
|
1433
|
+
# a visit per path to it is 2**depth visits
|
|
1434
|
+
all_contexts: Dict[int, ComponentContext] = {}
|
|
1409
1435
|
|
|
1410
1436
|
def collect(context: ComponentContext):
|
|
1411
|
-
|
|
1437
|
+
if id(context) in all_contexts:
|
|
1438
|
+
return
|
|
1439
|
+
all_contexts[id(context)] = context
|
|
1412
1440
|
for child in list(context.children.values()) + list(context.children_next.values()):
|
|
1413
1441
|
collect(child)
|
|
1414
1442
|
|
|
@@ -1433,7 +1461,7 @@ class _RenderContext:
|
|
|
1433
1461
|
exceptions = [*self.context.exceptions_children, *self.context_root.exceptions_self]
|
|
1434
1462
|
# break the reference cycles through the tree (see _teardown_component_context);
|
|
1435
1463
|
# _closing stays True, making stray setters and event handlers no-ops
|
|
1436
|
-
for context in all_contexts:
|
|
1464
|
+
for context in all_contexts.values():
|
|
1437
1465
|
_teardown_component_context(context)
|
|
1438
1466
|
self.context = None
|
|
1439
1467
|
self.context_root = None # type: ignore
|
|
@@ -1441,6 +1469,7 @@ class _RenderContext:
|
|
|
1441
1469
|
# widgets reference their elements, whose kwargs hold user callbacks - which
|
|
1442
1470
|
# capture use_state setters and therefore this render context
|
|
1443
1471
|
self.element = None # type: ignore
|
|
1472
|
+
self._element_next = None # type: ignore
|
|
1444
1473
|
self.container = None
|
|
1445
1474
|
self.last_root_widget = None
|
|
1446
1475
|
self._old_element_ids.clear()
|
|
@@ -1564,6 +1593,9 @@ class _RenderContext:
|
|
|
1564
1593
|
# TODO: enable
|
|
1565
1594
|
context.needs_render = True
|
|
1566
1595
|
_mark_needs_render_ancestors(context)
|
|
1596
|
+
if self._lock_thread is not threading.current_thread():
|
|
1597
|
+
# not part of the render loop of the thread that renders (see render)
|
|
1598
|
+
self._state_set_by_other_thread = True
|
|
1567
1599
|
if self._rerender_needed is False:
|
|
1568
1600
|
if DEBUG:
|
|
1569
1601
|
trigger_stack = traceback.format_stack()
|
|
@@ -1590,8 +1622,13 @@ class _RenderContext:
|
|
|
1590
1622
|
return
|
|
1591
1623
|
# a forced update re-walks the whole tree, no subtree skipping
|
|
1592
1624
|
self._walk_all = True
|
|
1625
|
+
# mark before we look at _is_rendering, like a setter: a render that is ending then sees the
|
|
1626
|
+
# mark after it released the lock (see render), or we see that no render runs
|
|
1627
|
+
if self._rerender_needed is False:
|
|
1628
|
+
self._rerender_needed_reasons.append(RerenderReason(reason="forced update"))
|
|
1629
|
+
self._rerender_needed = True
|
|
1593
1630
|
if not self._is_rendering:
|
|
1594
|
-
self.render(
|
|
1631
|
+
self.render()
|
|
1595
1632
|
|
|
1596
1633
|
def use_effect(self, effect: EffectCallable, dependencies=None):
|
|
1597
1634
|
assert self.context is not None
|
|
@@ -1613,17 +1650,13 @@ class _RenderContext:
|
|
|
1613
1650
|
self.context.effect_index += 1
|
|
1614
1651
|
|
|
1615
1652
|
def update(self, element: Element):
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
self._rerender_needed_reasons.append(RerenderReason(reason="root element changed"))
|
|
1620
|
-
self._rerender_needed = True
|
|
1621
|
-
else:
|
|
1622
|
-
self.render(element, self.container)
|
|
1653
|
+
# the element first: force_update marks, and the render takes _element_next after it saw the mark
|
|
1654
|
+
self._element_next = element
|
|
1655
|
+
self.force_update()
|
|
1623
1656
|
|
|
1624
1657
|
def _possible_rerender(self):
|
|
1625
1658
|
if not self._is_rendering and self._batch_counter.current() == 0:
|
|
1626
|
-
self.render(
|
|
1659
|
+
self.render()
|
|
1627
1660
|
else:
|
|
1628
1661
|
logger.info("No render phase triggered, already rendering")
|
|
1629
1662
|
|
|
@@ -1640,8 +1673,12 @@ class _RenderContext:
|
|
|
1640
1673
|
# state), and a context created by the aborted pass owns no widgets yet, it is simply
|
|
1641
1674
|
# reused or pruned by the next render
|
|
1642
1675
|
contexts: List[ComponentContext] = [self.context_root]
|
|
1676
|
+
seen: Set[int] = set() # a context can be in both children and children_next (see close)
|
|
1643
1677
|
while contexts:
|
|
1644
1678
|
context = contexts.pop()
|
|
1679
|
+
if id(context) in seen:
|
|
1680
|
+
continue
|
|
1681
|
+
seen.add(id(context))
|
|
1645
1682
|
for effect in context.effects:
|
|
1646
1683
|
effect.next = None
|
|
1647
1684
|
context.root_element_next = None
|
|
@@ -1656,41 +1693,60 @@ class _RenderContext:
|
|
|
1656
1693
|
self._shared_elements_next = set()
|
|
1657
1694
|
self.context = self.context_root
|
|
1658
1695
|
|
|
1659
|
-
def render(self, element: Element, container: widgets.Widget = None):
|
|
1696
|
+
def render(self, element: Optional[Element] = None, container: widgets.Widget = None):
|
|
1660
1697
|
# render + consolidate
|
|
1698
|
+
# We never wait for the render lock: the caller may hold a lock that the render needs (a
|
|
1699
|
+
# deadlock). So we first mark the request, then only try the lock. When another thread holds
|
|
1700
|
+
# it, that thread renders the request: it takes _element_next at the start of each pass, and
|
|
1701
|
+
# looks at _rerender_needed again after it released the lock (below).
|
|
1702
|
+
widget, rendered = self._render_once(element, container)
|
|
1703
|
+
# A request from another thread (a setter, update(), render()) sets _rerender_needed, and leaves
|
|
1704
|
+
# the render to us while we render or hold the render lock. We released the lock, and look again
|
|
1705
|
+
# now: either we see the request here, or that thread got the lock and renders it itself. A loop,
|
|
1706
|
+
# not a recursion: this can repeat many times in a row while another thread changes state.
|
|
1707
|
+
while rendered and self._rerender_needed and not self._is_rendering and self._batch_counter.current() == 0:
|
|
1708
|
+
widget, rendered = self._render_once(None, container)
|
|
1709
|
+
return widget
|
|
1710
|
+
|
|
1711
|
+
def _render_once(self, element: Optional[Element], container: widgets.Widget) -> Tuple[Any, bool]:
|
|
1712
|
+
# returns the root widget, and whether we rendered (False: another thread holds the render
|
|
1713
|
+
# lock and renders our request, or we are closed, or we rendered an error message)
|
|
1714
|
+
if self._lock_thread == threading.current_thread():
|
|
1715
|
+
raise RuntimeError("Recursive render detected (avoided deadlock), current thread: %r" % threading.current_thread())
|
|
1661
1716
|
widget = None
|
|
1662
1717
|
if container is None:
|
|
1663
1718
|
container = self.container
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
)
|
|
1673
|
-
|
|
1674
|
-
|
|
1719
|
+
if element is not None:
|
|
1720
|
+
self._element_next = element
|
|
1721
|
+
if self._rerender_needed is False:
|
|
1722
|
+
self._rerender_needed_reasons.append(RerenderReason(reason="render requested"))
|
|
1723
|
+
self._rerender_needed = True
|
|
1724
|
+
locked = False
|
|
1725
|
+
try:
|
|
1726
|
+
# acquire inside the try, in one statement with the assignment: an exception raised
|
|
1727
|
+
# on the next line (an interrupt, a cancel from a trace function) still releases the lock
|
|
1728
|
+
locked = self.thread_lock.acquire(blocking=False)
|
|
1729
|
+
if not locked:
|
|
1730
|
+
logger.info("Render phase in progress in thread %r, leaving the render to it", self._lock_thread)
|
|
1731
|
+
return container, False
|
|
1675
1732
|
self._lock_thread = threading.current_thread()
|
|
1676
|
-
if was_locked:
|
|
1677
|
-
logger.info("Mutex released, continuing render phase")
|
|
1678
1733
|
if self._closing or self.context is None:
|
|
1679
1734
|
# close() won the race for the lock (a disconnect can close the
|
|
1680
1735
|
# kernel while an update was waiting to render): the tree is
|
|
1681
1736
|
# torn down, there is nothing to render into anymore
|
|
1682
1737
|
logger.info("Render requested on a closing/closed render context, ignoring")
|
|
1683
|
-
return container
|
|
1738
|
+
return container, False
|
|
1684
1739
|
prev_rc = getattr(local, "rc", None)
|
|
1685
1740
|
# an exception that escapes while this is True aborted a render pass (see the except below)
|
|
1686
1741
|
in_render_phase = True
|
|
1687
1742
|
try:
|
|
1688
1743
|
local.rc = self
|
|
1689
|
-
self.element = element
|
|
1690
|
-
del element
|
|
1691
1744
|
main_render_phase = not self._is_rendering
|
|
1692
1745
|
render_count = self.render_count # make a copy
|
|
1746
|
+
# clear before taking the element: a request that comes in between is seen by the loop
|
|
1693
1747
|
self._rerender_needed = False
|
|
1748
|
+
self._state_set_by_other_thread = False
|
|
1749
|
+
self.element = self._element_next
|
|
1694
1750
|
logger.info("Render phase: %r %r of %r", self.render_count, "main" if main_render_phase else "(nested)", self.element)
|
|
1695
1751
|
self.render_count += 1
|
|
1696
1752
|
self._is_rendering = True
|
|
@@ -1715,11 +1771,18 @@ class _RenderContext:
|
|
|
1715
1771
|
if main_render_phase:
|
|
1716
1772
|
stable = False
|
|
1717
1773
|
render_counts = 0
|
|
1774
|
+
render_limit = 50
|
|
1718
1775
|
while not stable and not self.context_root.exceptions_children:
|
|
1719
1776
|
# we started the rendering loop (main_render_phase is True), so we keep going
|
|
1720
1777
|
# but if an exception bubbled up, we should stop
|
|
1721
1778
|
while self._rerender_needed and not self.context_root.exceptions_children:
|
|
1722
|
-
if
|
|
1779
|
+
if self._state_set_by_other_thread:
|
|
1780
|
+
# another thread changed state during a pass (a progress update for
|
|
1781
|
+
# instance): that is not a render loop of our own, so allow more passes.
|
|
1782
|
+
# But not without end: our caller would not return, and close() waits.
|
|
1783
|
+
self._state_set_by_other_thread = False
|
|
1784
|
+
render_limit = 100
|
|
1785
|
+
if render_counts > render_limit:
|
|
1723
1786
|
|
|
1724
1787
|
def format(reason: RerenderReason):
|
|
1725
1788
|
f = f"Reason: {reason.reason}\nValue changed from {reason.prev_value} to {reason.next_value}\n"
|
|
@@ -1729,13 +1792,17 @@ class _RenderContext:
|
|
|
1729
1792
|
f += f"Triggered at: {''.join(reason.trigger_stack)}\n"
|
|
1730
1793
|
return f
|
|
1731
1794
|
|
|
1732
|
-
msg =
|
|
1795
|
+
msg = "Too many renders triggered, your render loop does not stop\n"
|
|
1796
|
+
if render_limit > 50:
|
|
1797
|
+
msg += "Another thread changes state during every render pass: add a pause between its changes\n"
|
|
1798
|
+
msg += f"Last reason: {format(self._rerender_needed_reasons[-1])}\n"
|
|
1733
1799
|
if len(self._rerender_needed_reasons) >= 2:
|
|
1734
1800
|
previous = reversed(list(self._rerender_needed_reasons)[:-1])
|
|
1735
1801
|
msg += f"Previous reasons: {''.join(format(reason) for reason in previous)}\n"
|
|
1736
1802
|
raise RuntimeError(msg)
|
|
1737
1803
|
logger.info("Entering nested render phase: %r", self._rerender_needed_reasons[-1])
|
|
1738
1804
|
self._rerender_needed = False
|
|
1805
|
+
self.element = self._element_next
|
|
1739
1806
|
self._shared_elements_next = set()
|
|
1740
1807
|
self.context.exception_handler = False
|
|
1741
1808
|
self.context.exceptions_children = []
|
|
@@ -1809,6 +1876,8 @@ class _RenderContext:
|
|
|
1809
1876
|
stable = False
|
|
1810
1877
|
else:
|
|
1811
1878
|
stable = True
|
|
1879
|
+
if self._on_render_loop_done is not None:
|
|
1880
|
+
self._on_render_loop_done()
|
|
1812
1881
|
|
|
1813
1882
|
self._is_rendering = False
|
|
1814
1883
|
self.context = context_prev
|
|
@@ -1835,13 +1904,18 @@ class _RenderContext:
|
|
|
1835
1904
|
finally:
|
|
1836
1905
|
local.rc = prev_rc # type: ignore
|
|
1837
1906
|
self._is_rendering = False
|
|
1838
|
-
# clear before the lock is released: a stale _lock_thread makes the
|
|
1839
|
-
# recursion guard above fire for a thread that merely rendered last,
|
|
1840
|
-
# while a *different* thread holds the lock (false "Recursive render")
|
|
1841
|
-
self._lock_thread = None
|
|
1842
1907
|
assert self.context is self.context_root
|
|
1908
|
+
# read the errors of this render while we hold the lock: once it is released, close()
|
|
1909
|
+
# on another thread can tear the tree down (self.context is None then)
|
|
1910
|
+
exceptions = [*self.context.exceptions_children, *self.context_root.exceptions_self]
|
|
1911
|
+
finally:
|
|
1912
|
+
if locked:
|
|
1913
|
+
# clear before the lock is released, on every way out (also the return for a closed
|
|
1914
|
+
# render context): a stale _lock_thread makes the checks for a render or close() in
|
|
1915
|
+
# its own render fire for a thread that merely rendered last
|
|
1916
|
+
self._lock_thread = None
|
|
1917
|
+
self.thread_lock.release()
|
|
1843
1918
|
|
|
1844
|
-
exceptions = [*self.context.exceptions_children, *self.context_root.exceptions_self]
|
|
1845
1919
|
if exceptions:
|
|
1846
1920
|
exc = exceptions[0]
|
|
1847
1921
|
if DEBUG:
|
|
@@ -1858,10 +1932,10 @@ class _RenderContext:
|
|
|
1858
1932
|
value = html.escape(error)
|
|
1859
1933
|
from . import ipywidgets as w
|
|
1860
1934
|
|
|
1861
|
-
return self.render(w.HTML(value="<pre>" + value + "</pre>", layout=w.Layout(overflow="auto")), self.container)
|
|
1935
|
+
return self.render(w.HTML(value="<pre>" + value + "</pre>", layout=w.Layout(overflow="auto")), self.container), False
|
|
1862
1936
|
else:
|
|
1863
1937
|
raise exc
|
|
1864
|
-
return widget
|
|
1938
|
+
return widget, True
|
|
1865
1939
|
|
|
1866
1940
|
def _render(self, element: Element, default_key: str, parent_key: str):
|
|
1867
1941
|
if not isinstance(element, Element):
|
|
@@ -4304,3 +4304,136 @@ def test_keyed_child_out_of_removed_wrapper_keeps_its_effects():
|
|
|
4304
4304
|
assert log == ["run"]
|
|
4305
4305
|
rc.close()
|
|
4306
4306
|
assert log == ["run", "cleanup"]
|
|
4307
|
+
|
|
4308
|
+
|
|
4309
|
+
def test_widget_construction_does_not_block_other_threads():
|
|
4310
|
+
# A widget construction that blocks (in a server: the comm_open send to a client that stopped
|
|
4311
|
+
# reading) used to hold a process wide lock, so no other thread could create widgets.
|
|
4312
|
+
entered, release = threading.Event(), threading.Event()
|
|
4313
|
+
|
|
4314
|
+
class BlockingButton(ipywidgets.Button):
|
|
4315
|
+
def __init__(self, **kwargs):
|
|
4316
|
+
super().__init__(**kwargs)
|
|
4317
|
+
if threading.current_thread().name == "slow":
|
|
4318
|
+
entered.set()
|
|
4319
|
+
release.wait(10)
|
|
4320
|
+
|
|
4321
|
+
BlockingButtonElement = reacton.core.ComponentWidget(BlockingButton)
|
|
4322
|
+
|
|
4323
|
+
@reacton.component
|
|
4324
|
+
def Slow():
|
|
4325
|
+
return BlockingButtonElement(description="slow")
|
|
4326
|
+
|
|
4327
|
+
@reacton.component
|
|
4328
|
+
def Fast():
|
|
4329
|
+
return w.Button(description="fast")
|
|
4330
|
+
|
|
4331
|
+
results: dict = {}
|
|
4332
|
+
|
|
4333
|
+
def render(name, component):
|
|
4334
|
+
results[name] = reacton.render(component(), handle_error=False)
|
|
4335
|
+
|
|
4336
|
+
slow = threading.Thread(target=render, args=("slow", Slow), name="slow", daemon=True)
|
|
4337
|
+
slow.start()
|
|
4338
|
+
try:
|
|
4339
|
+
assert entered.wait(10)
|
|
4340
|
+
fast = threading.Thread(target=render, args=("fast", Fast), name="fast", daemon=True)
|
|
4341
|
+
fast.start()
|
|
4342
|
+
fast.join(5)
|
|
4343
|
+
assert "fast" in results, "widget creation waited for a widget construction on another thread"
|
|
4344
|
+
finally:
|
|
4345
|
+
release.set()
|
|
4346
|
+
slow.join(10)
|
|
4347
|
+
for name in ["slow", "fast"]:
|
|
4348
|
+
box, rc = results[name]
|
|
4349
|
+
button = box.children[0]
|
|
4350
|
+
# side-effect widgets are still attributed to their own render context
|
|
4351
|
+
assert rc._orphans[button.model_id] == {button.layout.model_id, button.style.model_id}
|
|
4352
|
+
rc.close()
|
|
4353
|
+
|
|
4354
|
+
|
|
4355
|
+
def test_widget_created_by_another_thread_is_not_an_orphan():
|
|
4356
|
+
# the recording is per thread: a widget that user code creates on another thread while a
|
|
4357
|
+
# render constructs a widget must not be recorded as that widget's side effect (and closed
|
|
4358
|
+
# with it)
|
|
4359
|
+
entered, release = threading.Event(), threading.Event()
|
|
4360
|
+
|
|
4361
|
+
class BlockingButton(ipywidgets.Button):
|
|
4362
|
+
def __init__(self, **kwargs):
|
|
4363
|
+
super().__init__(**kwargs)
|
|
4364
|
+
entered.set()
|
|
4365
|
+
release.wait(10)
|
|
4366
|
+
|
|
4367
|
+
BlockingButtonElement = reacton.core.ComponentWidget(BlockingButton)
|
|
4368
|
+
|
|
4369
|
+
@reacton.component
|
|
4370
|
+
def Slow():
|
|
4371
|
+
return BlockingButtonElement(description="slow")
|
|
4372
|
+
|
|
4373
|
+
results: dict = {}
|
|
4374
|
+
render = threading.Thread(target=lambda: results.update(result=reacton.render(Slow(), handle_error=False)), daemon=True)
|
|
4375
|
+
render.start()
|
|
4376
|
+
try:
|
|
4377
|
+
assert entered.wait(10)
|
|
4378
|
+
unrelated = ipywidgets.IntSlider()
|
|
4379
|
+
finally:
|
|
4380
|
+
release.set()
|
|
4381
|
+
render.join(10)
|
|
4382
|
+
box, rc = results["result"]
|
|
4383
|
+
button = box.children[0]
|
|
4384
|
+
assert unrelated.model_id not in rc._orphans[button.model_id]
|
|
4385
|
+
rc.close()
|
|
4386
|
+
assert unrelated.comm is not None, "a widget of another thread was closed as an orphan"
|
|
4387
|
+
for widget in [unrelated, unrelated.layout, unrelated.style]:
|
|
4388
|
+
widget.close()
|
|
4389
|
+
|
|
4390
|
+
|
|
4391
|
+
def test_record_constructed_nested():
|
|
4392
|
+
# a widget whose constructor renders reacton records in its own list, and the outer recording
|
|
4393
|
+
# continues afterwards
|
|
4394
|
+
outer: list = []
|
|
4395
|
+
inner: list = []
|
|
4396
|
+
previous_outer = core._start_recording_constructed(outer)
|
|
4397
|
+
try:
|
|
4398
|
+
before = ipywidgets.Button()
|
|
4399
|
+
previous_inner = core._start_recording_constructed(inner)
|
|
4400
|
+
try:
|
|
4401
|
+
nested = ipywidgets.Button()
|
|
4402
|
+
finally:
|
|
4403
|
+
core._stop_recording_constructed(previous_inner)
|
|
4404
|
+
after = ipywidgets.Button()
|
|
4405
|
+
finally:
|
|
4406
|
+
core._stop_recording_constructed(previous_outer)
|
|
4407
|
+
assert core._construction_recording.constructed is None
|
|
4408
|
+
assert before in outer and after in outer and nested not in outer
|
|
4409
|
+
assert nested in inner and before not in inner
|
|
4410
|
+
for widget in outer + inner:
|
|
4411
|
+
widget.close()
|
|
4412
|
+
|
|
4413
|
+
|
|
4414
|
+
def test_close_after_a_failed_render_of_a_deep_tree_is_fast():
|
|
4415
|
+
# After a render that failed, a component context can be in both children and children_next of
|
|
4416
|
+
# its parent. close() visited such a context once for every path to it: twice per level, 2**depth
|
|
4417
|
+
# visits, which hangs for a deep tree.
|
|
4418
|
+
depth = 22
|
|
4419
|
+
setters = {}
|
|
4420
|
+
|
|
4421
|
+
def make(n):
|
|
4422
|
+
@react.component
|
|
4423
|
+
def Level():
|
|
4424
|
+
if n == 0:
|
|
4425
|
+
value, setters["value"] = react.use_state(0)
|
|
4426
|
+
if value == 1:
|
|
4427
|
+
raise ValueError("fail")
|
|
4428
|
+
return w.Button(description="leaf")
|
|
4429
|
+
return w.VBox(children=[make(n - 1)()])
|
|
4430
|
+
|
|
4431
|
+
return Level
|
|
4432
|
+
|
|
4433
|
+
box, rc = react.render(make(depth)(), handle_error=False)
|
|
4434
|
+
with pytest.raises(ValueError):
|
|
4435
|
+
setters["value"](1)
|
|
4436
|
+
start = time.monotonic()
|
|
4437
|
+
with pytest.raises(ValueError):
|
|
4438
|
+
rc.close() # close() raises the error of the failed render again
|
|
4439
|
+
assert time.monotonic() - start < 2
|
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
import threading
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import reacton
|
|
8
|
+
import reacton.ipywidgets as w
|
|
9
|
+
|
|
10
|
+
TIMEOUT = 10
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_state_change_from_another_thread_after_the_last_check_is_rendered():
|
|
14
|
+
# A setter on another thread leaves the render to a running render loop. When it runs just
|
|
15
|
+
# after the loop took its last look at _rerender_needed, its change must still be rendered.
|
|
16
|
+
setters = {}
|
|
17
|
+
|
|
18
|
+
@reacton.component
|
|
19
|
+
def Test():
|
|
20
|
+
a, setters["a"] = reacton.use_state(0)
|
|
21
|
+
b, setters["b"] = reacton.use_state(0)
|
|
22
|
+
return w.Button(description=f"{a} {b}")
|
|
23
|
+
|
|
24
|
+
box, rc = reacton.render(Test(), handle_error=False)
|
|
25
|
+
other = threading.Thread(target=lambda: setters["b"](1))
|
|
26
|
+
|
|
27
|
+
def set_b_on_other_thread():
|
|
28
|
+
rc._on_render_loop_done = None
|
|
29
|
+
other.start()
|
|
30
|
+
# the setter returns at once when it leaves the render to us,
|
|
31
|
+
# or it waits for our render lock when it renders the change itself
|
|
32
|
+
other.join(0.5)
|
|
33
|
+
|
|
34
|
+
rc._on_render_loop_done = set_b_on_other_thread
|
|
35
|
+
setters["a"](1)
|
|
36
|
+
other.join(TIMEOUT)
|
|
37
|
+
assert not other.is_alive()
|
|
38
|
+
assert box.children[0].description == "1 1"
|
|
39
|
+
rc.close()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_state_changes_from_another_thread_are_not_a_render_loop():
|
|
43
|
+
# Another thread changes state once during each render pass (a progress bar, for instance),
|
|
44
|
+
# more often than the "Too many renders" limit allows for a render loop.
|
|
45
|
+
n = 60
|
|
46
|
+
setters = {}
|
|
47
|
+
remaining = [0]
|
|
48
|
+
go, done = threading.Semaphore(0), threading.Semaphore(0)
|
|
49
|
+
|
|
50
|
+
@reacton.component
|
|
51
|
+
def Test():
|
|
52
|
+
trigger, setters["trigger"] = reacton.use_state(0)
|
|
53
|
+
progress, setters["progress"] = reacton.use_state(0)
|
|
54
|
+
if remaining[0] > 0:
|
|
55
|
+
go.release() # the other thread changes state during this pass
|
|
56
|
+
assert done.acquire(timeout=TIMEOUT)
|
|
57
|
+
return w.Button(description=f"{trigger} {progress}")
|
|
58
|
+
|
|
59
|
+
box, rc = reacton.render(Test(), handle_error=False)
|
|
60
|
+
|
|
61
|
+
def report_progress():
|
|
62
|
+
for i in range(n):
|
|
63
|
+
assert go.acquire(timeout=TIMEOUT)
|
|
64
|
+
setters["progress"](i + 1)
|
|
65
|
+
remaining[0] -= 1
|
|
66
|
+
done.release()
|
|
67
|
+
|
|
68
|
+
other = threading.Thread(target=report_progress)
|
|
69
|
+
other.start()
|
|
70
|
+
remaining[0] = n
|
|
71
|
+
setters["trigger"](1)
|
|
72
|
+
other.join(TIMEOUT)
|
|
73
|
+
assert not other.is_alive()
|
|
74
|
+
assert box.children[0].description == f"1 {n}"
|
|
75
|
+
rc.close()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.mark.parametrize("request_render", ["set_state", "update", "render", "force_update"])
|
|
79
|
+
def test_render_request_while_holding_a_user_lock_does_not_deadlock(request_render):
|
|
80
|
+
# Another thread holds its own lock while it asks for a render (a state change, update(), render()
|
|
81
|
+
# or force_update()), and the render on this thread takes that lock in an effect. If the other
|
|
82
|
+
# thread waits for the render lock (held by this thread), this thread waits for the user lock
|
|
83
|
+
# (held by the other thread): a deadlock. The effect uses a timeout to break it.
|
|
84
|
+
user_lock = threading.RLock()
|
|
85
|
+
deadlocked = []
|
|
86
|
+
setters = {}
|
|
87
|
+
|
|
88
|
+
@reacton.component
|
|
89
|
+
def Test(label=""):
|
|
90
|
+
a, setters["a"] = reacton.use_state(0)
|
|
91
|
+
b, setters["b"] = reacton.use_state(0)
|
|
92
|
+
|
|
93
|
+
def effect():
|
|
94
|
+
if user_lock.acquire(timeout=2):
|
|
95
|
+
user_lock.release()
|
|
96
|
+
else:
|
|
97
|
+
deadlocked.append(True)
|
|
98
|
+
|
|
99
|
+
reacton.use_effect(effect, [a])
|
|
100
|
+
return w.Button(description=f"{label}{a} {b}")
|
|
101
|
+
|
|
102
|
+
box, rc = reacton.render(Test(), handle_error=False)
|
|
103
|
+
requests = {
|
|
104
|
+
"set_state": lambda: setters["b"](1),
|
|
105
|
+
"update": lambda: rc.update(Test(label="new ")),
|
|
106
|
+
"render": lambda: rc.render(Test(label="new ")),
|
|
107
|
+
"force_update": lambda: rc.force_update(),
|
|
108
|
+
}
|
|
109
|
+
expected = {"set_state": "1 1", "update": "new 1 0", "render": "new 1 0", "force_update": "1 0"}
|
|
110
|
+
|
|
111
|
+
def request_holding_user_lock():
|
|
112
|
+
with user_lock:
|
|
113
|
+
requests[request_render]()
|
|
114
|
+
|
|
115
|
+
other = threading.Thread(target=request_holding_user_lock)
|
|
116
|
+
|
|
117
|
+
class RequestAtRenderStart(logging.Filter):
|
|
118
|
+
# "Render phase: " is logged after the render took the render lock, but before it renders
|
|
119
|
+
def filter(self, record):
|
|
120
|
+
if str(record.msg).startswith("Render phase: ") and other.ident is None:
|
|
121
|
+
other.start()
|
|
122
|
+
# returns at once when the other thread does not wait for the render lock
|
|
123
|
+
other.join(0.5)
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
logger = logging.getLogger("reacton")
|
|
127
|
+
level = logger.level
|
|
128
|
+
request = RequestAtRenderStart()
|
|
129
|
+
logger.setLevel(logging.INFO)
|
|
130
|
+
logger.addFilter(request)
|
|
131
|
+
try:
|
|
132
|
+
setters["a"](1)
|
|
133
|
+
finally:
|
|
134
|
+
logger.removeFilter(request)
|
|
135
|
+
logger.setLevel(level)
|
|
136
|
+
other.join(TIMEOUT)
|
|
137
|
+
assert not other.is_alive()
|
|
138
|
+
assert not deadlocked, "the other thread waited for the render lock while it held the user lock"
|
|
139
|
+
assert box.children[0].description == expected[request_render]
|
|
140
|
+
rc.close()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _run_in_thread(target):
|
|
144
|
+
# a thread that is still alive after TIMEOUT hangs; errors[0] is what target raised, if anything
|
|
145
|
+
errors: list = []
|
|
146
|
+
|
|
147
|
+
def run():
|
|
148
|
+
try:
|
|
149
|
+
target()
|
|
150
|
+
except BaseException as e:
|
|
151
|
+
errors.append(e)
|
|
152
|
+
|
|
153
|
+
thread = threading.Thread(target=run, daemon=True)
|
|
154
|
+
thread.start()
|
|
155
|
+
thread.join(TIMEOUT)
|
|
156
|
+
return thread, errors
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_close_from_its_own_render_raises_instead_of_hanging():
|
|
160
|
+
# close() waits for the render lock, which its own render holds: it would wait for itself
|
|
161
|
+
close_errors: list = []
|
|
162
|
+
setters = {}
|
|
163
|
+
|
|
164
|
+
@reacton.component
|
|
165
|
+
def Test():
|
|
166
|
+
a, setters["a"] = reacton.use_state(0)
|
|
167
|
+
|
|
168
|
+
def effect():
|
|
169
|
+
if a == 1:
|
|
170
|
+
try:
|
|
171
|
+
rc.close()
|
|
172
|
+
except RuntimeError as e:
|
|
173
|
+
close_errors.append(e)
|
|
174
|
+
|
|
175
|
+
reacton.use_effect(effect, [a])
|
|
176
|
+
return w.Button(description=f"{a}")
|
|
177
|
+
|
|
178
|
+
box, rc = reacton.render(Test(), handle_error=False)
|
|
179
|
+
thread, errors = _run_in_thread(lambda: setters["a"](1))
|
|
180
|
+
assert not thread.is_alive(), "close() from its own render hangs"
|
|
181
|
+
assert not errors, errors
|
|
182
|
+
assert len(close_errors) == 1
|
|
183
|
+
rc.close()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_close_during_or_after_close_returns():
|
|
187
|
+
# an effect cleanup, which runs during close(), calls close() again: that must not wait for the
|
|
188
|
+
# render lock that the first close() holds. A close() after close() is a no-op as well.
|
|
189
|
+
@reacton.component
|
|
190
|
+
def Test():
|
|
191
|
+
reacton.use_effect(lambda: lambda: rc.close(), []) # the cleanup calls close()
|
|
192
|
+
return w.Button()
|
|
193
|
+
|
|
194
|
+
box, rc = reacton.render(Test(), handle_error=False)
|
|
195
|
+
thread, errors = _run_in_thread(rc.close)
|
|
196
|
+
assert not thread.is_alive(), "close() from a cleanup during close() hangs"
|
|
197
|
+
assert not errors, errors
|
|
198
|
+
rc.close()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_render_loop_for_another_thread_is_bounded():
|
|
202
|
+
# Another thread changes state during every render pass, and does not stop (a loop without a
|
|
203
|
+
# pause). The thread that renders must not keep rendering those changes forever: it would not
|
|
204
|
+
# return, and close() waits for it. It stops with "Too many renders", which names the cause.
|
|
205
|
+
setters = {}
|
|
206
|
+
stop = threading.Event()
|
|
207
|
+
go, done = threading.Semaphore(0), threading.Semaphore(0)
|
|
208
|
+
|
|
209
|
+
@reacton.component
|
|
210
|
+
def Test():
|
|
211
|
+
trigger, setters["trigger"] = reacton.use_state(0)
|
|
212
|
+
progress, setters["progress"] = reacton.use_state(0)
|
|
213
|
+
if trigger and not stop.is_set():
|
|
214
|
+
go.release() # the other thread changes state during this pass
|
|
215
|
+
done.acquire(timeout=TIMEOUT)
|
|
216
|
+
return w.Button(description=f"{trigger} {progress}")
|
|
217
|
+
|
|
218
|
+
box, rc = reacton.render(Test(), handle_error=False)
|
|
219
|
+
|
|
220
|
+
def report_progress():
|
|
221
|
+
i = 0
|
|
222
|
+
while not stop.is_set():
|
|
223
|
+
if go.acquire(timeout=0.1):
|
|
224
|
+
i += 1
|
|
225
|
+
setters["progress"](i)
|
|
226
|
+
done.release()
|
|
227
|
+
|
|
228
|
+
other = threading.Thread(target=report_progress, daemon=True)
|
|
229
|
+
other.start()
|
|
230
|
+
thread, errors = _run_in_thread(lambda: setters["trigger"](1))
|
|
231
|
+
returned_by_itself = not thread.is_alive()
|
|
232
|
+
stop.set()
|
|
233
|
+
thread.join(TIMEOUT)
|
|
234
|
+
other.join(TIMEOUT)
|
|
235
|
+
assert returned_by_itself, "the render kept rendering the changes of another thread"
|
|
236
|
+
assert len(errors) == 1 and "another thread" in str(errors[0]).lower(), errors
|
|
237
|
+
rc.close()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_many_changes_after_the_last_look_in_a_row_do_not_recurse():
|
|
241
|
+
# A change after the last look of the render loop is rendered after the render lock is released
|
|
242
|
+
# (see the lost-update test above). When that happens many times in a row, the renders must not
|
|
243
|
+
# nest (a RecursionError).
|
|
244
|
+
setters = {}
|
|
245
|
+
|
|
246
|
+
@reacton.component
|
|
247
|
+
def Test():
|
|
248
|
+
a, setters["a"] = reacton.use_state(0)
|
|
249
|
+
return w.Button(description=f"{a}")
|
|
250
|
+
|
|
251
|
+
box, rc = reacton.render(Test(), handle_error=False)
|
|
252
|
+
n = 2000
|
|
253
|
+
|
|
254
|
+
def change_after_the_last_look():
|
|
255
|
+
a = int(box.children[0].description)
|
|
256
|
+
if 0 < a < n:
|
|
257
|
+
setters["a"](a + 1) # a render is running: this only marks _rerender_needed
|
|
258
|
+
|
|
259
|
+
rc._on_render_loop_done = change_after_the_last_look
|
|
260
|
+
setters["a"](1)
|
|
261
|
+
assert box.children[0].description == f"{n}"
|
|
262
|
+
rc.close()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_own_render_loop_stops_after_about_50_passes():
|
|
266
|
+
# A component that changes its own state on every render: "Too many renders" after about 50
|
|
267
|
+
# passes. The higher limit is only for changes from other threads, not for every render that a
|
|
268
|
+
# state change (from outside a render) started.
|
|
269
|
+
renders = []
|
|
270
|
+
setters = {}
|
|
271
|
+
|
|
272
|
+
@reacton.component
|
|
273
|
+
def Test():
|
|
274
|
+
a, setters["a"] = reacton.use_state(0)
|
|
275
|
+
renders.append(a)
|
|
276
|
+
if a > 0:
|
|
277
|
+
setters["a"](a + 1) # never stops
|
|
278
|
+
return w.Button(description=f"{a}")
|
|
279
|
+
|
|
280
|
+
box, rc = reacton.render(Test(), handle_error=False)
|
|
281
|
+
renders.clear()
|
|
282
|
+
with pytest.raises(RuntimeError, match="Too many renders"):
|
|
283
|
+
setters["a"](1)
|
|
284
|
+
assert len(renders) < 60, len(renders)
|
|
285
|
+
rc.close()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_force_update_after_the_last_look_is_rendered():
|
|
289
|
+
# force_update() from another thread, just after the render loop took its last look (an event
|
|
290
|
+
# handler that routes an exception to the render does this). It must mark before it looks at
|
|
291
|
+
# _is_rendering, like a setter, or the render that is ending does not see it, and no render
|
|
292
|
+
# follows. update() does the same (it calls force_update()).
|
|
293
|
+
setters = {}
|
|
294
|
+
|
|
295
|
+
@reacton.component
|
|
296
|
+
def Test():
|
|
297
|
+
a, setters["a"] = reacton.use_state(0)
|
|
298
|
+
return w.Button(description=f"{a}")
|
|
299
|
+
|
|
300
|
+
box, rc = reacton.render(Test(), handle_error=False)
|
|
301
|
+
other = threading.Thread(target=rc.force_update)
|
|
302
|
+
|
|
303
|
+
def force_update_on_other_thread():
|
|
304
|
+
rc._on_render_loop_done = None
|
|
305
|
+
other.start()
|
|
306
|
+
other.join(TIMEOUT)
|
|
307
|
+
|
|
308
|
+
rc._on_render_loop_done = force_update_on_other_thread
|
|
309
|
+
render_count = rc.render_count
|
|
310
|
+
setters["a"](1)
|
|
311
|
+
other.join(TIMEOUT)
|
|
312
|
+
assert not other.is_alive()
|
|
313
|
+
assert rc.render_count == render_count + 2, "no render for the forced update" # a=1, then the forced update
|
|
314
|
+
rc.close()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_render_on_a_closed_render_context_leaves_nothing_behind():
|
|
318
|
+
# A render that gets the render lock after close() returns without a pass. It left its thread as
|
|
319
|
+
# the lock owner: a later close() or render() on that thread raised, as if called in its own render.
|
|
320
|
+
@reacton.component
|
|
321
|
+
def Test():
|
|
322
|
+
return w.Button(description="hi")
|
|
323
|
+
|
|
324
|
+
box, rc = reacton.render(Test(), handle_error=False)
|
|
325
|
+
rc.close()
|
|
326
|
+
rc.render()
|
|
327
|
+
rc.close()
|
|
328
|
+
rc.render()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def test_close_right_after_a_render_released_the_render_lock():
|
|
332
|
+
# close() on another thread, right after a render released the render lock. The render read the
|
|
333
|
+
# errors of its pass after the release, from a tree that close() had torn down (AttributeError).
|
|
334
|
+
setters = {}
|
|
335
|
+
|
|
336
|
+
@reacton.component
|
|
337
|
+
def Test():
|
|
338
|
+
a, setters["a"] = reacton.use_state(0)
|
|
339
|
+
return w.Button(description=f"{a}")
|
|
340
|
+
|
|
341
|
+
box, rc = reacton.render(Test(), handle_error=False)
|
|
342
|
+
close_errors: list = []
|
|
343
|
+
|
|
344
|
+
class LockWithHook:
|
|
345
|
+
# the render lock, which runs close() on another thread right after the first release
|
|
346
|
+
def __init__(self, lock):
|
|
347
|
+
self._lock = lock
|
|
348
|
+
self.armed = True
|
|
349
|
+
|
|
350
|
+
def acquire(self, blocking=True):
|
|
351
|
+
return self._lock.acquire(blocking)
|
|
352
|
+
|
|
353
|
+
def release(self):
|
|
354
|
+
self._lock.release()
|
|
355
|
+
if self.armed:
|
|
356
|
+
self.armed = False
|
|
357
|
+
thread, errors = _run_in_thread(rc.close)
|
|
358
|
+
close_errors.extend(errors)
|
|
359
|
+
|
|
360
|
+
def locked(self):
|
|
361
|
+
return self._lock.locked()
|
|
362
|
+
|
|
363
|
+
def __enter__(self):
|
|
364
|
+
self._lock.acquire()
|
|
365
|
+
|
|
366
|
+
def __exit__(self, *args):
|
|
367
|
+
self.release()
|
|
368
|
+
|
|
369
|
+
rc.thread_lock = LockWithHook(rc.thread_lock) # type: ignore
|
|
370
|
+
setters["a"](1)
|
|
371
|
+
assert not close_errors, close_errors
|
|
372
|
+
assert rc._closing
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def test_exception_right_after_the_render_lock_is_taken_does_not_leak_it():
|
|
376
|
+
# An exception on the first line after the render lock is taken (an interrupt, or a cancel from a
|
|
377
|
+
# trace function, like solara's cancel_guard) must still release the lock: a leaked render lock
|
|
378
|
+
# stops every later render.
|
|
379
|
+
setters = {}
|
|
380
|
+
|
|
381
|
+
@reacton.component
|
|
382
|
+
def Test():
|
|
383
|
+
a, setters["a"] = reacton.use_state(0)
|
|
384
|
+
return w.Button(description=f"{a}")
|
|
385
|
+
|
|
386
|
+
box, rc = reacton.render(Test(), handle_error=False)
|
|
387
|
+
core_file = reacton.core.__file__
|
|
388
|
+
fired: list = []
|
|
389
|
+
|
|
390
|
+
class Cancelled(Exception):
|
|
391
|
+
pass
|
|
392
|
+
|
|
393
|
+
def trace(frame, event, arg):
|
|
394
|
+
if not fired and frame.f_code.co_filename == core_file and rc.thread_lock.locked() and not rc._is_rendering:
|
|
395
|
+
fired.append((frame.f_code.co_name, frame.f_lineno))
|
|
396
|
+
raise Cancelled()
|
|
397
|
+
return trace
|
|
398
|
+
|
|
399
|
+
def set_a_with_trace():
|
|
400
|
+
sys.settrace(trace)
|
|
401
|
+
try:
|
|
402
|
+
setters["a"](1)
|
|
403
|
+
finally:
|
|
404
|
+
sys.settrace(None)
|
|
405
|
+
|
|
406
|
+
thread, errors = _run_in_thread(set_a_with_trace)
|
|
407
|
+
assert fired, "the trace function never ran while the render lock was taken"
|
|
408
|
+
assert len(errors) == 1 and isinstance(errors[0], Cancelled), errors
|
|
409
|
+
assert not rc.thread_lock.locked(), f"the render lock leaked after an exception at {fired}"
|
|
410
|
+
setters["a"](2)
|
|
411
|
+
assert box.children[0].description == "2"
|
|
412
|
+
rc.close()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def test_render_from_its_own_render_raises():
|
|
416
|
+
# render() from its own render would leave the render to itself (it holds the render lock)
|
|
417
|
+
render_errors: list = []
|
|
418
|
+
setters = {}
|
|
419
|
+
|
|
420
|
+
@reacton.component
|
|
421
|
+
def Test():
|
|
422
|
+
a, setters["a"] = reacton.use_state(0)
|
|
423
|
+
|
|
424
|
+
def effect():
|
|
425
|
+
if a == 1:
|
|
426
|
+
try:
|
|
427
|
+
rc.render(Test())
|
|
428
|
+
except RuntimeError as e:
|
|
429
|
+
render_errors.append(e)
|
|
430
|
+
|
|
431
|
+
reacton.use_effect(effect, [a])
|
|
432
|
+
return w.Button(description=f"{a}")
|
|
433
|
+
|
|
434
|
+
box, rc = reacton.render(Test(), handle_error=False)
|
|
435
|
+
thread, errors = _run_in_thread(lambda: setters["a"](1))
|
|
436
|
+
assert not thread.is_alive(), "render() from its own render hangs"
|
|
437
|
+
assert not errors, errors
|
|
438
|
+
assert len(render_errors) == 1 and "Recursive render" in str(render_errors[0]), render_errors
|
|
439
|
+
rc.close()
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def test_state_change_during_a_render_only_marks():
|
|
443
|
+
# D3: a state change from another thread during a running render only marks. It does not try the
|
|
444
|
+
# render lock: the running render loop sees the mark.
|
|
445
|
+
setters = {}
|
|
446
|
+
go, done = threading.Semaphore(0), threading.Semaphore(0)
|
|
447
|
+
armed = [False]
|
|
448
|
+
|
|
449
|
+
@reacton.component
|
|
450
|
+
def Test():
|
|
451
|
+
a, setters["a"] = reacton.use_state(0)
|
|
452
|
+
b, setters["b"] = reacton.use_state(0)
|
|
453
|
+
if armed[0]:
|
|
454
|
+
armed[0] = False
|
|
455
|
+
go.release() # the other thread changes state during this pass
|
|
456
|
+
assert done.acquire(timeout=TIMEOUT)
|
|
457
|
+
return w.Button(description=f"{a} {b}")
|
|
458
|
+
|
|
459
|
+
box, rc = reacton.render(Test(), handle_error=False)
|
|
460
|
+
render_calls = []
|
|
461
|
+
render_once = rc._render_once
|
|
462
|
+
|
|
463
|
+
def counting_render_once(*args, **kwargs):
|
|
464
|
+
render_calls.append(threading.current_thread())
|
|
465
|
+
return render_once(*args, **kwargs)
|
|
466
|
+
|
|
467
|
+
rc._render_once = counting_render_once # type: ignore
|
|
468
|
+
|
|
469
|
+
def set_b():
|
|
470
|
+
assert go.acquire(timeout=TIMEOUT)
|
|
471
|
+
setters["b"](1)
|
|
472
|
+
done.release()
|
|
473
|
+
|
|
474
|
+
other = threading.Thread(target=set_b)
|
|
475
|
+
other.start()
|
|
476
|
+
armed[0] = True
|
|
477
|
+
setters["a"](1)
|
|
478
|
+
other.join(TIMEOUT)
|
|
479
|
+
assert other not in render_calls, "the state change during a render tried the render lock"
|
|
480
|
+
assert box.children[0].description == "1 1"
|
|
481
|
+
rc.close()
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def test_element_request_right_after_a_pass_took_the_element_is_rendered():
|
|
485
|
+
# D4: a pass first clears _rerender_needed, then takes _element_next. An element request that lands
|
|
486
|
+
# right after the take leaves its mark set, so one more pass renders its element.
|
|
487
|
+
setters = {}
|
|
488
|
+
|
|
489
|
+
@reacton.component
|
|
490
|
+
def Test(label="old"):
|
|
491
|
+
a, setters["a"] = reacton.use_state(0)
|
|
492
|
+
return w.Button(description=f"{label} {a}")
|
|
493
|
+
|
|
494
|
+
box, rc = reacton.render(Test(), handle_error=False)
|
|
495
|
+
renderer = threading.current_thread()
|
|
496
|
+
other = threading.Thread(target=lambda: rc.update(Test(label="new")))
|
|
497
|
+
|
|
498
|
+
class RequestRightAfterTheTake(type(rc)): # type: ignore
|
|
499
|
+
@property
|
|
500
|
+
def _element_next(self):
|
|
501
|
+
element = self.__dict__["_element_next"]
|
|
502
|
+
if other.ident is None and self._lock_thread is renderer:
|
|
503
|
+
# the renderer takes the element for its pass: another thread requests a new one now
|
|
504
|
+
other.start()
|
|
505
|
+
other.join(TIMEOUT)
|
|
506
|
+
return element
|
|
507
|
+
|
|
508
|
+
@_element_next.setter
|
|
509
|
+
def _element_next(self, element):
|
|
510
|
+
self.__dict__["_element_next"] = element
|
|
511
|
+
|
|
512
|
+
rc.__class__ = RequestRightAfterTheTake
|
|
513
|
+
setters["a"](1)
|
|
514
|
+
other.join(TIMEOUT)
|
|
515
|
+
assert not other.is_alive()
|
|
516
|
+
assert box.children[0].description == "new 1"
|
|
517
|
+
rc.close()
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def test_two_close_calls_at_the_same_time_tear_down_once():
|
|
521
|
+
# D8: two close() calls both pass the first _closing check. The second waits for the render lock,
|
|
522
|
+
# and must check again once it has it: a second teardown of a torn-down tree fails.
|
|
523
|
+
@reacton.component
|
|
524
|
+
def Test():
|
|
525
|
+
return w.Button(description="hi")
|
|
526
|
+
|
|
527
|
+
box, rc = reacton.render(Test(), handle_error=False)
|
|
528
|
+
second: list = []
|
|
529
|
+
|
|
530
|
+
class LockWithHook:
|
|
531
|
+
# the render lock; the first close() starts the second one after it took the lock
|
|
532
|
+
def __init__(self, lock):
|
|
533
|
+
self._lock = lock
|
|
534
|
+
self.second_waits = threading.Event()
|
|
535
|
+
|
|
536
|
+
def acquire(self, blocking=True):
|
|
537
|
+
return self._lock.acquire(blocking)
|
|
538
|
+
|
|
539
|
+
def release(self):
|
|
540
|
+
self._lock.release()
|
|
541
|
+
|
|
542
|
+
def locked(self):
|
|
543
|
+
return self._lock.locked()
|
|
544
|
+
|
|
545
|
+
def __enter__(self):
|
|
546
|
+
if not second:
|
|
547
|
+
self._lock.acquire()
|
|
548
|
+
second.append(threading.Thread(target=lambda: second.append(_run_in_thread(rc.close)[1])))
|
|
549
|
+
second[0].start()
|
|
550
|
+
# the second close() passed its first check, and waits for the lock
|
|
551
|
+
assert self.second_waits.wait(TIMEOUT)
|
|
552
|
+
else:
|
|
553
|
+
self.second_waits.set()
|
|
554
|
+
self._lock.acquire()
|
|
555
|
+
|
|
556
|
+
def __exit__(self, *args):
|
|
557
|
+
self._lock.release()
|
|
558
|
+
|
|
559
|
+
rc.thread_lock = LockWithHook(rc.thread_lock) # type: ignore
|
|
560
|
+
rc.close()
|
|
561
|
+
second[0].join(TIMEOUT)
|
|
562
|
+
assert second[1:] == [[]], second[1:] # the second close() raised nothing
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.10.5"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|