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.
Files changed (35) hide show
  1. {reacton-1.10.5 → reacton-1.11.0}/PKG-INFO +1 -1
  2. reacton-1.11.0/reacton/_version.py +1 -0
  3. {reacton-1.10.5 → reacton-1.11.0}/reacton/core.py +147 -73
  4. {reacton-1.10.5 → reacton-1.11.0}/reacton/core_test.py +133 -0
  5. reacton-1.11.0/reacton/threads_test.py +562 -0
  6. reacton-1.10.5/reacton/_version.py +0 -1
  7. {reacton-1.10.5 → reacton-1.11.0}/.gitignore +0 -0
  8. {reacton-1.10.5 → reacton-1.11.0}/LICENSE +0 -0
  9. {reacton-1.10.5 → reacton-1.11.0}/pyproject.toml +0 -0
  10. {reacton-1.10.5 → reacton-1.11.0}/react_ipywidgets/__init__.py +0 -0
  11. {reacton-1.10.5 → reacton-1.11.0}/react_ipywidgets/core.py +0 -0
  12. {reacton-1.10.5 → reacton-1.11.0}/reacton/__init__.py +0 -0
  13. {reacton-1.10.5 → reacton-1.11.0}/reacton/bqplot.py +0 -0
  14. {reacton-1.10.5 → reacton-1.11.0}/reacton/deprecated/__init__.py +0 -0
  15. {reacton-1.10.5 → reacton-1.11.0}/reacton/deprecated/ipyvuetify.py +0 -0
  16. {reacton-1.10.5 → reacton-1.11.0}/reacton/find.py +0 -0
  17. {reacton-1.10.5 → reacton-1.11.0}/reacton/find_test.py +0 -0
  18. {reacton-1.10.5 → reacton-1.11.0}/reacton/fuzz_test.py +0 -0
  19. {reacton-1.10.5 → reacton-1.11.0}/reacton/generate.py +0 -0
  20. {reacton-1.10.5 → reacton-1.11.0}/reacton/generate_test.py +0 -0
  21. {reacton-1.10.5 → reacton-1.11.0}/reacton/ipycanvas.py +0 -0
  22. {reacton-1.10.5 → reacton-1.11.0}/reacton/ipyvue.py +0 -0
  23. {reacton-1.10.5 → reacton-1.11.0}/reacton/ipyvue_test.py +0 -0
  24. {reacton-1.10.5 → reacton-1.11.0}/reacton/ipyvuetify.py +0 -0
  25. {reacton-1.10.5 → reacton-1.11.0}/reacton/ipywidgets.py +0 -0
  26. {reacton-1.10.5 → reacton-1.11.0}/reacton/logging.py +0 -0
  27. {reacton-1.10.5 → reacton-1.11.0}/reacton/patch.py +0 -0
  28. {reacton-1.10.5 → reacton-1.11.0}/reacton/patch_display.py +0 -0
  29. {reacton-1.10.5 → reacton-1.11.0}/reacton/py.typed +0 -0
  30. {reacton-1.10.5 → reacton-1.11.0}/reacton/rx.py +0 -0
  31. {reacton-1.10.5 → reacton-1.11.0}/reacton/test.vue +0 -0
  32. {reacton-1.10.5 → reacton-1.11.0}/reacton/test_rx.py +0 -0
  33. {reacton-1.10.5 → reacton-1.11.0}/reacton/utils.py +0 -0
  34. {reacton-1.10.5 → reacton-1.11.0}/reacton/utils_test.py +0 -0
  35. {reacton-1.10.5 → reacton-1.11.0}/reacton/work.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: reacton
3
- Version: 1.10.5
3
+ Version: 1.11.0
4
4
  Dynamic: Summary
5
5
  Project-URL: Home, https://www.github.com/widgetti/reacton
6
6
  Project-URL: Documentation, https://reacton.solara.dev
@@ -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
- _construction_recording: Optional[List["widgets.Widget"]] = None
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
- if _construction_recording is not None:
97
- _construction_recording.append(widget)
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
- global _construction_recording, _chained_construction_callback
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
- _construction_recording = recording
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
- global _construction_recording
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
- # The recording is global state, so we need a lock.
503
- with self.create_lock:
504
- rc = get_render_context(required=True)
505
- recorded: List[widgets.Widget] = []
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
- try:
509
- widget = self.component.widget(**kwargs)
510
- hold_trait_notifications = widget.hold_trait_notifications
511
-
512
- @contextlib.contextmanager
513
- def hold_trait_notifications_extra(*args, **kwargs):
514
- with rc, hold_trait_notifications(*args, **kwargs):
515
- yield
516
-
517
- widget.hold_trait_notifications = hold_trait_notifications_extra
518
-
519
- if self._meta:
520
- widget._react_meta = dict(self._meta)
521
- except Exception as e:
522
- raise RuntimeError(f"Could not create widget {self.component.widget} with {kwargs}") from e
523
- for name, callback in listeners.items():
524
- if callback is not None:
525
- self._add_widget_event_listener(widget, name, callback)
526
- finally:
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
- all_contexts: List[ComponentContext] = []
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
- all_contexts.append(context)
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(self.element, self.container)
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
- self._walk_all = True
1617
- if self._is_rendering:
1618
- self.element = element
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(self.element, self.container)
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
- was_locked = False
1665
- if self.thread_lock.locked():
1666
- if self._lock_thread == threading.current_thread():
1667
- raise RuntimeError("Recursive render detected (avoided deadlock), current thread: %r" % threading.current_thread())
1668
- logger.info(
1669
- "Render phase still in progress, waiting for mutex to release (locked obtained by %r, we are in thread %r)",
1670
- self._lock_thread,
1671
- threading.current_thread(),
1672
- )
1673
- was_locked = True
1674
- with self.thread_lock:
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 render_counts > 50:
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 = f"Too many renders triggered, your render loop does not stop\nLast reason: {format(self._rerender_needed_reasons[-1])}\n"
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