Coverage for src/lilbee/cli/tui/thread_safe.py: 100%

21 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-28 17:20 +0000

1"""Thread-safe helpers for posting from @work(thread=True) workers to the main thread. 

2 

3A thread worker's body can outlive the app during shutdown, and Textual then 

4refuses the call. These wrappers drop the call instead of crashing the worker. 

5""" 

6 

7from __future__ import annotations 

8 

9import logging 

10from typing import Any 

11 

12from textual.dom import DOMNode 

13from textual.message import Message 

14 

15log = logging.getLogger(__name__) 

16 

17 

18def call_from_thread(node: DOMNode, fn: Any, *args: Any, **kwargs: Any) -> None: 

19 """Post *fn* to the main thread via the app. 

20 

21 Drops the call (does not crash the worker) when the target node's app 

22 is no longer reachable, e.g. during shutdown or after a screen was 

23 replaced. Logs at debug so the drop is discoverable without leaking 

24 warning text into the TUI render (textual's log handler routes 

25 stderr into the rendered frame). Long-running workers that must 

26 survive a screen switch should own their state on the app 

27 (TaskBarController pattern in widgets/task_bar.py) rather than 

28 relying on this wrapper. 

29 """ 

30 # Resolving the app is guarded separately from running *fn*. Textual's 

31 # ``node.app`` reads a contextvar that is unset in a plain thread and then 

32 # walks ``node._parent``, which raises AttributeError on a node whose 

33 # MessagePump state is gone: seen in CI as "'ChatScreen' object has no 

34 # attribute '_MessagePump__parent'" from a worker outliving its screen. 

35 # That is the same "app is gone" case this wrapper exists for, but it cannot 

36 # be folded into the except below: an AttributeError raised *inside* fn must 

37 # still propagate, which is what the docstring promises. 

38 try: 

39 app = node.app 

40 except (AttributeError, RuntimeError) as exc: 

41 log.debug("call_from_thread found no app for %s: %s", getattr(fn, "__name__", fn), exc) 

42 return 

43 try: 

44 app.call_from_thread(fn, *args, **kwargs) 

45 except (OSError, RuntimeError) as exc: 

46 # Only the shutdown signals: OSError when the message queue is closed, 

47 # RuntimeError (incl. NoActiveAppError) when the app is no longer running. 

48 # A genuine exception raised inside *fn* propagates so the bug surfaces 

49 # instead of being silently swallowed. 

50 log.debug( 

51 "call_from_thread dropped %s: %s", 

52 getattr(fn, "__name__", fn), 

53 exc, 

54 ) 

55 

56 

57def post_from_thread(node: DOMNode, message: Message) -> None: 

58 """Post *message* to *node* without waiting; drop it when the app is gone.""" 

59 try: 

60 node.post_message(message) 

61 except (AttributeError, RuntimeError) as exc: 

62 # The same "app is gone" signals call_from_thread drops: a torn-down 

63 # node has no parent to reach the app through. 

64 log.debug("post_from_thread dropped %s: %s", type(message).__name__, exc)