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
« 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.
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"""
7from __future__ import annotations
9import logging
10from typing import Any
12from textual.dom import DOMNode
13from textual.message import Message
15log = logging.getLogger(__name__)
18def call_from_thread(node: DOMNode, fn: Any, *args: Any, **kwargs: Any) -> None:
19 """Post *fn* to the main thread via the app.
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 )
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)