Skip to content

Commit ee4c39f

Browse files
committed
Assign names to system threads.
1 parent 1f5a2c2 commit ee4c39f

File tree

7 files changed

+329
-0
lines changed

7 files changed

+329
-0
lines changed

CHANGELOG.md

+7
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) for its CLI (Command-Line Interface), i.e. for the `moulti` command.
77
Although Moulti's Python packages, modules and functions are obviously available, they do not constitute a public API yet.
88

9+
## Unreleased
10+
11+
### Changed
12+
13+
- Unless `MOULTI_NAME_THREADS=no` is set, system threads get assigned meaningful names:
14+
`moulti:main`, `network-loop`,`exec-command`, `export-to-dir`, `|step_name`, `>step_name`, `thread-pool`.
15+
916
## [1.33.0] - 2025-02-26
1017

1118
### Changed

doc/docs/environment-variables.md

+5
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,11 @@ Same as `MOULTI_DIFF_VERBOSE` for `moulti manpage`
147147

148148
`light` to start Moulti in light mode, `dark` to start Moulti in dark mode; defaults to `dark`.
149149

150+
### MOULTI_NAME_THREADS
151+
152+
By default, Moulti assigns names to its [system threads](threads.md).
153+
Setting `MOULTI_NAME_THREADS=no` disables this behavior.
154+
150155
### MOULTI_PASS_CONCURRENCY
151156

152157
Define how many concurrent "moulti pass" commands is acceptable; defaults to 20.

doc/docs/threads.md

+181
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# Threads
2+
3+
Under the hood, Moulti uses multiple [threads](https://en.wikipedia.org/wiki/Thread_(computing)).
4+
This document intends to describe them.
5+
6+
## Main thread
7+
8+
Name: `moulti:main`
9+
10+
Moulti's main thread runs [Textual](https://textual.textualize.io/)'s
11+
[asyncio](https://docs.python.org/3/library/asyncio.html)
12+
[event loop](https://en.wikipedia.org/wiki/Event_loop).
13+
Otherly put, it manages most of the TUI part.
14+
15+
## Textual threads
16+
17+
All Textual applications spawn two unnamed threads and Moulti is no exception:
18+
19+
- a first thread renders the user interface to stderr;
20+
- a second thread reads events (e.g. keystrokes and mouse events) from stdin.
21+
22+
## Network loop
23+
24+
Name: `network-loop`
25+
26+
Once the TUI part is ready, Moulti spawns a third thread that runs the network
27+
loop, i.e. the infinite loop that handles network events: new connections,
28+
incoming messages, outgoing responses, etc.
29+
This loop relies on non-blocking Unix sockets. Unlike the main thread, it does
30+
not leverage asyncio.
31+
Incoming messages are analyzed within this thread but actual TUI changes (e.g.
32+
adding or removing steps) are delegated to the main thread.
33+
34+
## Execute command
35+
36+
Name: `exec-command`
37+
38+
When Moulti is launched through [moulti run](shell-scripting.md#moulti-run), and
39+
once the network loop is ready, Moulti spawns a fourth thread in charge of
40+
executing and monitoring the given command.
41+
42+
## moulti pass
43+
44+
Each `moulti pass` command results in Moulti spawning two extra threads.
45+
46+
### Data ingestion
47+
48+
Name: `|step_name`
49+
50+
This thread reads data from the file descriptor provided by `moulti pass`.
51+
This file descriptor is typically a pipe, hence the `|` prefix.
52+
53+
### Data processing
54+
55+
Name: `>step_name`
56+
57+
This thread converts data read by the ingestion thread so they can be
58+
added to the TUI.
59+
The `>` prefix represents the idea that lines get written to the target step.
60+
Unlike the `>` redirection in shell languages, this symbol does not imply creating or writing to files.
61+
62+
### Why do step names look weird?
63+
64+
Thread names cannot exceed:
65+
66+
- 15 characters on Linux;
67+
- 63 characters on macOS;
68+
- 19 characters on FreeBSD;
69+
- 23 characters on OpenBSD;
70+
- 15 characters on NetBSD.
71+
72+
Here, the first character is either `|` or `>`, leaving only:
73+
74+
- 14 characters on Linux;
75+
- 62 characters on macOS;
76+
- 18 characters on FreeBSD;
77+
- 22 characters on OpenBSD;
78+
- 14 characters on NetBSD.
79+
80+
This is why longer step names must be abridged in thread names.
81+
This is particularly true on Linux and NetBSD where abridged names keep:
82+
83+
- the first 4 characters;
84+
- the middle 5 characters;
85+
- the last 5 characters.
86+
87+
!!! example "Examples"
88+
- `moulti pass abcdefghijklmnopqrstuvwxyz` results in threads
89+
`|abcdklmnovwxyz` and `>abcdklmnovwxyz`:
90+
91+
```
92+
abcdefghijklmnopqrstuvwxyz
93+
^^^^ ^^^^^ ^^^^^
94+
first 4 middle 5 last 5
95+
```
96+
97+
- The `moulti_run_output` special step results in threads `|mouli_runutput`
98+
and `>mouli_runutput`.
99+
100+
```
101+
moulti_run_output
102+
^^^^ ^^^^^ ^^^^^
103+
| | `---- last 5
104+
| `----------- middle 5
105+
`---------------- first 4
106+
```
107+
108+
## Save / export contents
109+
110+
Name: `export-to-dir`
111+
112+
Through its "Save" action, Moulti is able to [export all shown contents to text
113+
files](saving-and-loading.md/#saving-a-complete-moulti-instance) stored inside
114+
a timestamped directory, hence the name of the thread dedicated to this operation.
115+
116+
## Inactive threads
117+
118+
Name: `thread-pool`
119+
120+
Threads that have finished executing are not deleted.
121+
Instead, they remain inactive until the [thread pool](https://docs.python.org/3/library/concurrent.futures.html#threadpoolexecutor) decides to reuse them.
122+
123+
## How to inspect thread names?
124+
125+
The previous sections mention thread names.
126+
But how to inpsect threads and their names in the first place?
127+
128+
### How to inspect thread names on Linux?
129+
130+
Combining `pgrep` and `pstree` (typically found in the `psmisc` package) does wonders:
131+
132+
```
133+
pgrep moulti:main | xargs -n 1 pstree -napt
134+
```
135+
136+
Alternatively, use `htop`: hit `H` to toggle threads.
137+
138+
### How to inspect thread names on FreeBSD?
139+
140+
```
141+
ps Ho pid,tid,tdname,command
142+
```
143+
144+
Thread names are shown in the `TDNAME` column.
145+
146+
### How to inspect thread names on OpenBSD?
147+
148+
```
149+
ps Ho pid,tid,command
150+
```
151+
152+
Thread names are shown at the end of the `COMMAND` column, between parens, after
153+
the `/` character.
154+
155+
### How to inspect thread names on NetBSD?
156+
157+
```
158+
top -t -c -U $(whoami)
159+
```
160+
161+
Thread names are shown in the `NAME` column, which is unfortunately truncated
162+
to 9 characters.
163+
164+
### How to inspect thread names on macOS?
165+
166+
On macOS, listing threads requires root privileges:
167+
168+
```
169+
sudo htop -t -u $(whoami)
170+
```
171+
172+
## How to turn off thread names?
173+
174+
If you suspect naming threads triggers issues (e.g. crash on some niche
175+
operating system), you can disable it by setting the
176+
[`MOULTI_NAME_THREADS`](environment-variables.md/#moulti_name_threads)
177+
environment variable to `no`:
178+
179+
```bash
180+
export MOULTI_NAME_THREADS=no
181+
```

doc/mkdocs.yml

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ nav:
3131
- Classes: classes.md
3232
- Design: design.md
3333
- Technical requirements: technical-requirements.md
34+
- Threads: threads.md
3435
- Why this name?: why-this-name.md
3536
- Reference:
3637
- Commands: commands.md

src/moulti/app.py

+5
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from .security import MoultiSecurityPolicy
3333
from .server import Message, FDs, MoultiServer, current_instance
3434
from .themes import MOULTI_THEMES
35+
from .thread_name import thread_name, KEEP_THREAD_NAME
3536
from .widgets import MoultiWidgetException
3637
from .widgets.tui import MoultiWidgets
3738
from .widgets.footer import Footer
@@ -260,6 +261,7 @@ def on_mount(self) -> None:
260261
self.register_theme(theme)
261262
self.theme = 'moulti-dark' if self.dark else 'moulti-light'
262263

264+
@thread_name('moulti:main', end=KEEP_THREAD_NAME)
263265
def on_ready(self) -> None:
264266
self.logconsole(f'Moulti v{MOULTI_VERSION}, Textual v{TEXTUAL_VERSION}, Python v{sys.version}')
265267
self.logconsole(f'instance "{self.instance_name}", PID {os.getpid()}')
@@ -374,6 +376,7 @@ def debug(line: str) -> None:
374376
step.append_from_file_descriptor_to_queue(queue, {}, helpers)
375377

376378
@work(thread=True, group='app-exec', name='moulti-run')
379+
@thread_name('exec-command')
377380
def exec(self, command: list[str]) -> None:
378381
"""
379382
Launch the given command with the assumption it is meant to drive the current Moulti instance.
@@ -562,6 +565,7 @@ def on_quit_dialog_exit_request(self, exit_request: QuitDialog.ExitRequest) -> N
562565
self.exit()
563566

564567
@work(thread=True, group='app-save', name='save')
568+
@thread_name('export-to-dir')
565569
def action_save(self) -> None:
566570
"""
567571
Export everything currently shown by the instance as a bunch of files in a directory.
@@ -756,6 +760,7 @@ def debug(line: str) -> None:
756760
self.reply(connection, raddr, message, done=error is None, error=error)
757761

758762
@work(thread=True, group='app-network', name='network-loop')
763+
@thread_name('network-loop', end=KEEP_THREAD_NAME)
759764
def network_loop(self) -> None:
760765
current_worker = get_current_worker()
761766
self.server = MoultiServer(

src/moulti/thread_name.py

+125
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""
2+
Provide helpers to assign human-friendly names to system threads.
3+
"""
4+
5+
import sys
6+
from ctypes import CDLL, c_char_p, c_void_p
7+
from ctypes.util import find_library
8+
from functools import cache, wraps
9+
from threading import current_thread
10+
from typing import Any, Callable
11+
from .environ import env
12+
from .helpers import abridge_string
13+
14+
def thread_name_max_len() -> int:
15+
"""
16+
Return the maximum name length (excluding trailing null byte) for system threads.
17+
"""
18+
platform = sys.platform
19+
# Most Unix operating systems truncate thread names to MAXCOMLEN characters + a null byte:
20+
max_len = 15
21+
if platform.startswith("darwin"):
22+
max_len = 63
23+
elif platform.startswith("freebsd"):
24+
max_len = 19
25+
elif platform.startswith("openbsd"):
26+
max_len = 23
27+
elif platform.startswith("netbsd"):
28+
max_len = 15
29+
return max_len
30+
31+
32+
THREAD_NAME_MAX_LEN = thread_name_max_len()
33+
34+
35+
@cache
36+
def pthread_set_name_function() -> Callable:
37+
"""
38+
Return a simple wrapper around pthread_setname_np().
39+
This wrapper expects a name as single string (str) argument and assigns it to the current thread.
40+
"""
41+
# Access the pthread library:
42+
pthread_lib = CDLL(find_library("pthread"))
43+
44+
# Access the pthread_self function:
45+
pthread_self = pthread_lib.pthread_self
46+
pthread_self.restype = c_void_p
47+
48+
# Access the pthread_set_name_np function:
49+
try:
50+
pthread_setname_np = pthread_lib.pthread_setname_np
51+
except AttributeError:
52+
pthread_setname_np = pthread_lib.pthread_set_name_np
53+
54+
# Deal with its non-portable (hence "_np") arguments:
55+
if sys.platform.startswith("netbsd"):
56+
pthread_setname_np.argtypes = [c_void_p, c_char_p, c_char_p]
57+
def make_args(name: bytes) -> tuple:
58+
return (pthread_self(), b"%s\0", name)
59+
elif sys.platform.startswith("darwin"):
60+
pthread_setname_np.argtypes = [c_char_p]
61+
def make_args(name: bytes) -> tuple:
62+
return (name,)
63+
else:
64+
pthread_setname_np.argtypes = [c_void_p, c_char_p]
65+
def make_args(name: bytes) -> tuple:
66+
return (pthread_self(), name,)
67+
68+
def function(name: str) -> None:
69+
name_bytes = name.encode("ascii", "replace")[:THREAD_NAME_MAX_LEN] + b"\0"
70+
pthread_setname_np(*make_args(name_bytes))
71+
return function
72+
73+
74+
def set_thread_name(name: str) -> None:
75+
"""
76+
Assign the given name to the current thread.
77+
If necessary, the name gets abridged so as to fit system limitations (typically 15 or 63 characters max).
78+
"""
79+
if env("MOULTI_NAME_THREADS") == "no":
80+
return
81+
abridged_name = abridge_string(name, threshold=THREAD_NAME_MAX_LEN, sep="")
82+
83+
# Python >= 3.14 assigns names to system threads out of the box:
84+
if sys.version_info >= (3, 14):
85+
current_thread().name = abridged_name
86+
else: # Previous versions of Python:
87+
try:
88+
pthread_set_name_np = pthread_set_name_function()
89+
pthread_set_name_np(abridged_name)
90+
except Exception:
91+
# Best-effort: we are not even interested in what went wrong.
92+
pass
93+
94+
95+
96+
IDLE_THREAD_NAME = "thread-pool"
97+
"""
98+
Thread name set by the thread_name() decorator after a decorated function
99+
returns. This value reflects the underlying thread has become idle and has
100+
reintegrated the thread pool.
101+
"""
102+
103+
KEEP_THREAD_NAME = None
104+
"""
105+
Legible constant, to be used with the thread_name() decorator.
106+
"""
107+
108+
def thread_name(start: str|None = KEEP_THREAD_NAME, end: str|None = IDLE_THREAD_NAME) -> Callable:
109+
"""
110+
Decorator that calls:
111+
- set_thread_name(start) before the decorated function, unless start is KEEP_THREAD_NAME;
112+
- set_thread_name(end) after the decorated function, unless end is KEEP_THREAD_NAME.
113+
"""
114+
def thread_name_decorator(func: Callable) -> Callable:
115+
@wraps(func)
116+
def wrapper(*args: Any, **kwargs: Any) -> Any:
117+
try:
118+
if start is not KEEP_THREAD_NAME:
119+
set_thread_name(start)
120+
return func(*args, **kwargs)
121+
finally:
122+
if end is not KEEP_THREAD_NAME:
123+
set_thread_name(end)
124+
return wrapper
125+
return thread_name_decorator

0 commit comments

Comments
 (0)