Repository: Textualize/textual

Issue:
Calling a thread worker from within a thread worker results in a crash
Coming from [this question](https://discord.com/channels/1026214085173461072/1033754296224841768/1159530884861538344), [this reply](https://discord.com/channels/1026214085173461072/1033754296224841768/1159532916901163030), and [this followup](https://discord.com/channels/1026214085173461072/1033754296224841768/1159665798651056252) on Discord...

It seems that if you call a thread worker from within a thread worker it results in a crash. This code:

```python
from time import sleep

from textual import on, work
from textual.app import App, ComposeResult
from textual.widgets import Button, Log
from textual.worker import get_current_worker

class WorkerInWorkerApp(App[None]):

    def compose(self) -> ComposeResult:
        yield Button("Start")
        yield Log()

    @work(thread=True)
    def outer(self) -> None:
        worker = get_current_worker()
        for n in range(50):
            if worker.is_cancelled:
                break
            if n == 10:
                self.inner()
            self.call_from_thread(
                self.query_one(Log).write_line,
                f"Outer {n}"
            )
            sleep(0.25)

    @work(thread=True)
    def inner(self) -> None:
        worker = get_current_worker()
        for n in range(50):
            if worker.is_cancelled:
                break
            self.call_from_thread(
                self.query_one(Log).write_line,
                f"Inner {n}"
            )
            sleep(0.25)

    @on(Button.Pressed)
    def start(self) -> None:
        self.outer()

if __name__ == "__main__":
    WorkerInWorkerApp().run()
```

when run results in this exception:

```
Task exception was never retrieved
future: <Task finished name='Task-29' coro=<Worker._run() done, defined at /Users/davep/develop/python/textual-sandbox/.venv/lib/python3.10/site-packages/textual/worker.py:351> exception=TypeError("'<' not supported between instances of 'WorkerState' and 'WorkerState'")>
Traceback (most recent call last):
  File "/Users/davep/develop/python/textual-sandbox/.venv/lib/python3.10/site-packages/textual/worker.py", line 363, in _run
    self._result = await self.run()
  File "/Users/davep/develop/python/textual-sandbox/.venv/lib/python3.10/site-packages/textual/worker.py", line 347, in run
    return await (
  File "/Users/davep/develop/python/textual-sandbox/.venv/lib/python3.10/site-packages/textual/worker.py", line 317, in _run_threaded
    return await asyncio.get_running_loop().run_in_executor(
  File "/Users/davep/.pyenv/versions/3.10.10/lib/python3.10/concurrent/futures/thread.py", line 58, in run
    result = self.fn(*self.args, **self.kwargs)
  File "/Users/davep/develop/python/textual-sandbox/.venv/lib/python3.10/site-packages/textual/worker.py", line 302, in run_callable
    return work()
  File "/Users/davep/develop/python/textual-sandbox/work_from_work.py", line 21, in outer
    self.inner()
  File "/Users/davep/develop/python/textual-sandbox/.venv/lib/python3.10/site-packages/textual/_work_decorator.py", line 144, in decorated
    self.run_worker(
  File "/Users/davep/develop/python/textual-sandbox/.venv/lib/python3.10/site-packages/textual/dom.py", line 270, in run_worker
    worker: Worker[ResultType] = self.workers._new_worker(
  File "/Users/davep/develop/python/textual-sandbox/.venv/lib/python3.10/site-packages/textual/_worker_manager.py", line 119, in _new_worker
    self.add_worker(worker, start=start, exclusive=exclusive)
  File "/Users/davep/develop/python/textual-sandbox/.venv/lib/python3.10/site-packages/textual/_worker_manager.py", line 80, in add_worker
    worker._start(self._app, self._remove_worker)
  File "/Users/davep/develop/python/textual-sandbox/.venv/lib/python3.10/site-packages/textual/worker.py", line 391, in _start
    self._task = asyncio.create_task(self._run(app))
  File "/Users/davep/.pyenv/versions/3.10.10/lib/python3.10/asyncio/tasks.py", line 336, in create_task
    loop = events.get_running_loop()
RuntimeError: no running event loop

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/Users/davep/develop/python/textual-sandbox/.venv/lib/python3.10/site-packages/textual/worker.py", line 374, in _run
    app._fatal_error()
  File "/Users/davep/develop/python/textual-sandbox/.venv/lib/python3.10/site-packages/textual/app.py", line 2000, in _fatal_error
    traceback = Traceback(
  File "/Users/davep/develop/python/textual-sandbox/.venv/lib/python3.10/site-packages/rich/traceback.py", line 264, in __init__
    trace = self.extract(
  File "/Users/davep/develop/python/textual-sandbox/.venv/lib/python3.10/site-packages/rich/traceback.py", line 449, in extract
    locals={
  File "/Users/davep/develop/python/textual-sandbox/.venv/lib/python3.10/site-packages/rich/traceback.py", line 450, in <dictcomp>
    key: pretty.traverse(
  File "/Users/davep/develop/python/textual-sandbox/.venv/lib/python3.10/site-packages/rich/pretty.py", line 853, in traverse
    node = _traverse(_object, root=True)
  File "/Users/davep/develop/python/textual-sandbox/.venv/lib/python3.10/site-packages/rich/pretty.py", line 648, in _traverse
    args = list(iter_rich_args(rich_repr_result))
  File "/Users/davep/develop/python/textual-sandbox/.venv/lib/python3.10/site-packages/rich/pretty.py", line 615, in iter_rich_args
    for arg in rich_args:
  File "/Users/davep/develop/python/textual-sandbox/.venv/lib/python3.10/site-packages/textual/_worker_manager.py", line 46, in __rich_repr__
    for state, count in sorted(counter.items()):
TypeError: '<' not supported between instances of 'WorkerState' and 'WorkerState'
sys:1: RuntimeWarning: coroutine 'Worker._run' was never awaited
RuntimeWarning: Enable tracemalloc to get the object allocation traceback
```

when the inner worker is called (actually you just get a terminal bell and the app stops doing anything; you see the exception if you break out of the application).

This is easily worked around by running the inner worker with:

```python
self.call_from_thread(self.inner)
```

which I think I would have done anyway had I been writing the code myself. However, in Discord Will says that it should work as written above; so I'm raising this issue as a thing to investigate further and perhaps make work.


Discussion:

Resolving patch:
diff --git a/src/textual/dom.py b/src/textual/dom.py
--- a/src/textual/dom.py
+++ b/src/textual/dom.py
@@ -1,5 +1,4 @@
 """
-
 A DOMNode is a base class for any object within the Textual Document Object Model,
 which includes all Widgets, Screens, and Apps.
 """
@@ -8,7 +7,8 @@
 from __future__ import annotations
 
 import re
-from functools import lru_cache
+import threading
+from functools import lru_cache, partial
 from inspect import getfile
 from typing import (
     TYPE_CHECKING,
@@ -267,7 +267,14 @@ def run_worker(
         Returns:
             New Worker instance.
         """
-        worker: Worker[ResultType] = self.workers._new_worker(
+
+        # If we're running a worker from inside a secondary thread,
+        # do so in a thread-safe way.
+        if self.app._thread_id != threading.get_ident():
+            creator = partial(self.app.call_from_thread, self.workers._new_worker)
+        else:
+            creator = self.workers._new_worker
+        worker: Worker[ResultType] = creator(
             work,
             self,
             name=name,


Test patch:
diff --git a/tests/workers/test_work_decorator.py b/tests/workers/test_work_decorator.py
--- a/tests/workers/test_work_decorator.py
+++ b/tests/workers/test_work_decorator.py
@@ -1,6 +1,6 @@
 import asyncio
 from time import sleep
-from typing import Callable
+from typing import Callable, List, Tuple
 
 import pytest
 
@@ -88,3 +88,99 @@ class _(App[None]):
             @work(thread=False)
             def foo(self) -> None:
                 pass
+
+
+class NestedWorkersApp(App[None]):
+    def __init__(self, call_stack: List[str]):
+        self.call_stack = call_stack
+        super().__init__()
+
+    def call_from_stack(self):
+        if self.call_stack:
+            call_now = self.call_stack.pop()
+            getattr(self, call_now)()
+
+    @work(thread=False)
+    async def async_no_thread(self):
+        self.call_from_stack()
+
+    @work(thread=True)
+    async def async_thread(self):
+        self.call_from_stack()
+
+    @work(thread=True)
+    def thread(self):
+        self.call_from_stack()
+
+
+@pytest.mark.parametrize(
+    "call_stack",
+    [  # from itertools import product; list(product("async_no_thread async_thread thread".split(), repeat=3))
+        ("async_no_thread", "async_no_thread", "async_no_thread"),
+        ("async_no_thread", "async_no_thread", "async_thread"),
+        ("async_no_thread", "async_no_thread", "thread"),
+        ("async_no_thread", "async_thread", "async_no_thread"),
+        ("async_no_thread", "async_thread", "async_thread"),
+        ("async_no_thread", "async_thread", "thread"),
+        ("async_no_thread", "thread", "async_no_thread"),
+        ("async_no_thread", "thread", "async_thread"),
+        ("async_no_thread", "thread", "thread"),
+        ("async_thread", "async_no_thread", "async_no_thread"),
+        ("async_thread", "async_no_thread", "async_thread"),
+        ("async_thread", "async_no_thread", "thread"),
+        ("async_thread", "async_thread", "async_no_thread"),
+        ("async_thread", "async_thread", "async_thread"),
+        ("async_thread", "async_thread", "thread"),
+        ("async_thread", "thread", "async_no_thread"),
+        ("async_thread", "thread", "async_thread"),
+        ("async_thread", "thread", "thread"),
+        ("thread", "async_no_thread", "async_no_thread"),
+        ("thread", "async_no_thread", "async_thread"),
+        ("thread", "async_no_thread", "thread"),
+        ("thread", "async_thread", "async_no_thread"),
+        ("thread", "async_thread", "async_thread"),
+        ("thread", "async_thread", "thread"),
+        ("thread", "thread", "async_no_thread"),
+        ("thread", "thread", "async_thread"),
+        ("thread", "thread", "thread"),
+        (  # Plus a longer chain to stress test this mechanism.
+            "async_no_thread",
+            "async_no_thread",
+            "thread",
+            "thread",
+            "async_thread",
+            "async_thread",
+            "async_no_thread",
+            "async_thread",
+            "async_no_thread",
+            "async_thread",
+            "thread",
+            "async_thread",
+            "async_thread",
+            "async_no_thread",
+            "async_no_thread",
+            "thread",
+            "thread",
+            "async_no_thread",
+            "async_no_thread",
+            "thread",
+            "async_no_thread",
+            "thread",
+            "thread",
+        ),
+    ],
+)
+async def test_calling_workers_from_within_workers(call_stack: Tuple[str]):
+    """Regression test for https://github.com/Textualize/textual/issues/3472.
+
+    This makes sure we can nest worker calls without a problem.
+    """
+    app = NestedWorkersApp(list(call_stack))
+    async with app.run_test():
+        app.call_from_stack()
+        # We need multiple awaits because we're creating a chain of workers that may
+        # have multiple async workers, each of which may need the await to have enough
+        # time to call the next one in the chain.
+        for _ in range(len(call_stack)):
+            await app.workers.wait_for_complete()
+        assert app.call_stack == []