22 KiB
tuimble Code Review
Date: 2026-02-24 Scope: Full codebase review (1,750 LOC across 7 modules) Constraints: No theme/layout redesign. Targeted, incremental improvements only.
1. Architecture & Design
[CRITICAL] app.py is a 999-line monolith
TuimbleApp directly owns connection lifecycle, audio management, reconnection
logic, config reload state machine, input history, PTT wiring, and all UI
updates. This makes it hard to test, hard to reason about, and hard to extend.
Specific extractions worth making:
a) Reconnection manager
The reconnect loop, backoff logic, attempt counting, and cancellation form a
self-contained state machine currently scattered across _reconnect_loop,
_log_reconnect, _on_reconnect_success, _on_reconnect_exhausted,
_cancel_reconnect, plus three boolean flags (_reconnecting,
_reconnect_attempt, _intentional_disconnect).
# src/tuimble/reconnect.py
from __future__ import annotations
import time
import logging
from dataclasses import dataclass
from typing import Callable
log = logging.getLogger(__name__)
INITIAL_DELAY = 2
MAX_DELAY = 30
MAX_RETRIES = 10
@dataclass
class ReconnectState:
active: bool = False
attempt: int = 0
intentional: bool = False
class ReconnectManager:
"""Exponential backoff reconnection with cancellation."""
def __init__(
self,
connect_fn: Callable[[], None],
on_attempt: Callable[[int, float], None],
on_success: Callable[[], None],
on_failure: Callable[[int, str], None],
on_exhausted: Callable[[], None],
):
self._connect = connect_fn
self._on_attempt = on_attempt
self._on_success = on_success
self._on_failure = on_failure
self._on_exhausted = on_exhausted
self.state = ReconnectState()
def cancel(self) -> None:
self.state.active = False
self.state.attempt = 0
@property
def delay(self) -> float:
return min(
INITIAL_DELAY * (2 ** (self.state.attempt - 1)),
MAX_DELAY,
)
def run(self) -> None:
"""Blocking reconnect loop -- run in a worker thread."""
self.state.active = True
self.state.attempt = 0
while self.state.active:
self.state.attempt += 1
d = self.delay
self._on_attempt(self.state.attempt, d)
elapsed = 0.0
while elapsed < d and self.state.active:
time.sleep(0.5)
elapsed += 0.5
if not self.state.active:
break
try:
self._connect()
self.state.active = False
self.state.attempt = 0
self._on_success()
return
except Exception as exc:
self._on_failure(self.state.attempt, str(exc))
if self.state.attempt >= MAX_RETRIES:
self.state.active = False
self._on_exhausted()
return
Removes ~80 lines from app.py and makes the backoff logic unit-testable
without a TUI.
b) Input history
The history navigation in on_key (lines 928-953) is a classic "small feature
that grew legs." Extract to a reusable class:
class InputHistory:
def __init__(self):
self._entries: list[str] = []
self._idx: int = -1
self._draft: str = ""
def push(self, text: str) -> None:
self._entries.append(text)
self._idx = -1
def up(self, current: str) -> str | None:
if not self._entries:
return None
if self._idx == -1:
self._draft = current
self._idx = len(self._entries) - 1
elif self._idx > 0:
self._idx -= 1
return self._entries[self._idx]
def down(self) -> str | None:
if self._idx == -1:
return None
if self._idx < len(self._entries) - 1:
self._idx += 1
return self._entries[self._idx]
self._idx = -1
return self._draft
[IMPORTANT] Thread safety gaps in _reconnect_loop
self._reconnecting and self._reconnect_attempt are plain booleans/ints
accessed from both the main thread (via _cancel_reconnect,
action_reload_config) and the worker thread (_reconnect_loop). Python's GIL
makes this "mostly safe" for simple booleans, but it's fragile and not
guaranteed for compound operations.
app.py:486-488:
while self._reconnecting: # read on worker thread
self._reconnect_attempt += 1 # read-modify-write on worker thread
Meanwhile _cancel_reconnect at line 572 writes both from the main thread.
Use threading.Event for the cancellation flag:
self._reconnect_cancel = threading.Event()
# in _reconnect_loop:
while not self._reconnect_cancel.is_set():
...
# interruptible sleep:
if self._reconnect_cancel.wait(timeout=delay):
break
# in _cancel_reconnect:
self._reconnect_cancel.set()
This also eliminates the 0.5s sleep polling loop (lines 496-499), making cancellation instant instead of up to 500ms delayed.
[IMPORTANT] MumbleClient creates User/Channel objects on every property access
client.py:112-141 -- The users and channels properties rebuild full
dictionaries from pymumble's internal state on every call.
_refresh_channel_tree in app.py:688-699 calls both, then iterates all
users. During a debounced state update, this means three full traversals.
Consider caching with a dirty flag set by callbacks:
@property
def users(self) -> dict[int, User]:
if self._users_dirty or self._users_cache is None:
self._users_cache = self._build_users()
self._users_dirty = False
return self._users_cache
[NICE] Callback wiring pattern
client.py:79-84 uses bare None-able attributes for callbacks:
self.on_connected = None
self.on_disconnected = None
This works but lacks type safety and discoverability. Consider a typed protocol or simple typed attributes:
from typing import Protocol
class ClientCallbacks(Protocol):
def on_connected(self) -> None: ...
def on_disconnected(self) -> None: ...
def on_text_message(self, sender: str, text: str) -> None: ...
Not urgent -- current approach is pragmatic for the project size.
2. TUI/UX Best Practices
[IMPORTANT] _strip_html recompiles regex on every message
app.py:994-999:
def _strip_html(text: str) -> str:
import re
clean = re.sub(r"<[^>]+>", "", text)
return html.unescape(clean)
The re import inside the function is fine (cached by Python), but the pattern
is recompiled on every call. Compile once at module level:
import re
_HTML_TAG_RE = re.compile(r"<[^>]+>")
def _strip_html(text: str) -> str:
return html.unescape(_HTML_TAG_RE.sub("", text))
[IMPORTANT] PTT state logging is noisy
app.py:969-973 -- Every PTT toggle writes to the chatlog. For toggle mode
this is acceptable, but if someone later implements hold-mode (evdev),
pressing/releasing space would flood the log with "transmitting"/"stopped
transmitting" on every keypress.
Add a config option or suppress log for hold-mode, or use the status bar alone:
def _on_ptt_change(self, transmitting: bool) -> None:
self._audio.capturing = transmitting
status = self.query_one("#status", StatusBar)
status.ptt_active = transmitting
# Only log for toggle mode; hold-mode updates too frequently
if self._config.ptt.mode == "toggle":
chatlog = self.query_one("#chatlog", ChatLog)
if transmitting:
chatlog.write("[#e0af68] transmitting[/]")
else:
chatlog.write("[dim] stopped transmitting[/dim]")
[NICE] on_resize sets sidebar width but doesn't refresh the channel tree
app.py:977-981 -- After resize, the sidebar width changes but
ChannelTree.render() uses self.content_size.width which may not update
until the next render pass. The tree truncation logic depends on width. Call
tree.refresh() explicitly:
def on_resize(self, event: events.Resize) -> None:
sidebar = self.query_one("#sidebar", ChannelTree)
sidebar.styles.width = max(16, min(32, event.size.width // 4))
sidebar.refresh()
self.query_one("#status", StatusBar).refresh()
3. Code Quality
[CRITICAL] load_config passes raw TOML dicts to dataclass constructors without validation
config.py:62-67:
if "server" in data:
cfg.server = ServerConfig(**data["server"])
If the TOML file contains an unexpected key (ServerConfig(host="x", typo_field="y")), this raises an opaque TypeError. If a value has the wrong
type (port = "abc"), it silently accepts it and fails later.
Add defensive loading:
def _load_section(cls, data: dict, section: str):
"""Load a dataclass section, ignoring unknown keys."""
raw = data.get(section, {})
import dataclasses
valid_keys = {f.name for f in dataclasses.fields(cls)}
filtered = {k: v for k, v in raw.items() if k in valid_keys}
return cls(**filtered)
# Usage:
cfg.server = _load_section(ServerConfig, data, "server")
This prevents crashes from typos in config files -- critical for a user-facing tool.
[IMPORTANT] Duplicate logic for creating MumbleClient
MumbleClient is instantiated in four places with the same pattern:
__init__(line 395)_reconnect_loop(line 507)action_reload_configdisconnected path (line 864)_apply_restart_changes(line 824)
Extract a factory method:
def _make_client(self, srv: ServerConfig | None = None) -> MumbleClient:
srv = srv or self._config.server
return MumbleClient(
host=srv.host,
port=srv.port,
username=srv.username,
password=srv.password,
certfile=srv.certfile,
keyfile=srv.keyfile,
)
[IMPORTANT] ChannelTree._find_root is called twice per render
render() at line 223 calls _find_root(), and _build_channel_order() at
line 173 also calls _find_root(). Both execute during set_state() then
render(). Cache the root ID:
def set_state(self, channels, users_by_channel, my_channel_id=None):
self._channels = channels
self._users_by_channel = users_by_channel
self._my_channel_id = my_channel_id
self._root_id = self._find_root() if channels else 0
self._channel_ids = self._build_channel_order()
# ...
[IMPORTANT] _detect_config_changes duplicates field-level comparison
app.py:740-780 manually compares each field. If you add a new server field,
you must remember to add it to both _detect_config_changes AND
_apply_restart_changes. Use dataclasses.asdict() for comparison:
from dataclasses import asdict
def _detect_config_changes(self, old: Config, new: Config):
safe, restart = [], []
for attr in ("key", "mode", "backend"):
if getattr(old.ptt, attr) != getattr(new.ptt, attr):
safe.append(
f"ptt.{attr}: {getattr(old.ptt, attr)} -> "
f"{getattr(new.ptt, attr)}"
)
for attr in ("input_gain", "output_gain"):
if getattr(old.audio, attr) != getattr(new.audio, attr):
safe.append(
f"audio.{attr}: {getattr(old.audio, attr)} -> "
f"{getattr(new.audio, attr)}"
)
if asdict(old.server) != asdict(new.server):
for k in asdict(old.server):
if getattr(old.server, k) != getattr(new.server, k):
label = "password" if k == "password" else k
restart.append(f"server.{label} changed")
for attr in ("input_device", "output_device", "sample_rate"):
if getattr(old.audio, attr) != getattr(new.audio, attr):
restart.append(f"audio.{attr} changed")
return safe, restart
[NICE] Type annotations missing on several methods
client.py:86set_dispatcher(self, fn: Callable)-- should beCallable | Noneclient.py:93_dispatch(self, callback, *args)-- no return type, nocallbacktypeclient.py:154connect(self)-- no return type annotationaudio.py:58start(self)-- no return type
Add -> None where appropriate. Minor but compounds into unclear interfaces.
4. Performance
[IMPORTANT] _apply_gain uses Python-level sample iteration
audio.py:22-30 -- Unpacking, scaling, and repacking every sample via a list
comprehension in Python is slow for real-time audio (960 samples = 1920 bytes
per 20ms frame):
samples = struct.unpack(fmt, pcm[: n * 2])
scaled = [max(-32768, min(32767, int(s * gain))) for s in samples]
return struct.pack(fmt, *scaled)
Use numpy (already a transitive dependency via sounddevice):
import numpy as np
def _apply_gain(pcm: bytes, gain: float) -> bytes:
if not pcm:
return pcm
samples = np.frombuffer(pcm, dtype=np.int16)
scaled = np.clip(samples * gain, -32768, 32767).astype(np.int16)
return scaled.tobytes()
This is ~50-100x faster for a 960-sample frame. Since sounddevice already pulls in numpy, there's no new dependency.
[IMPORTANT] _audio_send_loop polls with time.sleep(0.005)
app.py:676-684:
while self._client.connected:
frame = self._audio.get_capture_frame()
if frame is not None:
self._client.send_audio(frame)
else:
time.sleep(0.005)
5ms polling means up to 5ms of added latency on every frame, plus unnecessary
CPU wake-ups when idle. Use queue.Queue.get(timeout=...) instead:
def get_capture_frame(self, timeout: float = 0.02) -> bytes | None:
try:
return self._capture_queue.get(timeout=timeout)
except queue.Empty:
return None
Then the send loop becomes:
while self._client.connected:
frame = self._audio.get_capture_frame(timeout=0.02)
if frame is not None:
self._client.send_audio(frame)
No polling, no wasted cycles, deterministic wake on data arrival.
[NICE] ChannelTree.render() rebuilds the entire tree string every refresh
For small channel lists (typical Mumble servers have 5-30 channels), this is
fine. But _render_tree is recursive and allocates many string fragments. If
performance becomes an issue, cache the rendered string and invalidate on
set_state().
5. Scalability & Extensibility
[IMPORTANT] No structured event bus
All communication between components flows through Textual's message system,
which is good. But the _cb_* callback wrappers in app.py:462-475 are thin
boilerplate. Consider a mapping-driven approach:
def _wire_client_callbacks(self) -> None:
self._client.set_dispatcher(self.call_from_thread)
self._client.on_connected = lambda: self.post_message(ServerConnected())
self._client.on_disconnected = lambda: self.post_message(ServerDisconnected())
self._client.on_text_message = (
lambda s, t: self.post_message(TextMessageReceived(s, t))
)
self._client.on_user_update = lambda: self.post_message(ServerStateChanged())
self._client.on_channel_update = (
lambda: self.post_message(ServerStateChanged())
)
self._client.on_sound_received = (
lambda _u, pcm: self._audio.queue_playback(pcm)
)
This eliminates six one-line methods. The audio callback bypasses the message system correctly (it's hot-path, no UI update needed).
[NICE] Config is mutable
Config dataclasses are mutable, and _apply_safe_changes directly mutates
self._config.ptt. Consider frozen dataclasses with a replace() pattern to
prevent accidental state corruption:
@dataclass(frozen=True)
class PttConfig:
key: str = "f4"
mode: str = "toggle"
backend: str = "auto"
Then: self._config = dataclasses.replace(self._config, ptt=new.ptt)
6. Testing
[CRITICAL] No tests for TuimbleApp or any widget
The test suite covers audio.py, client.py, ptt.py, and config.py --
all non-UI modules. There are zero tests for:
StatusBarrendering (connection states, volume bars, responsive breakpoints)ChannelTreerendering (tree structure, truncation, focus navigation)TuimbleAppmessage handling (connect/disconnect/state change flows)- Config reload state machine
- Input history navigation
Textual provides App.run_test() for async widget testing:
import pytest
from textual.testing import AppTest
@pytest.mark.asyncio
async def test_status_bar_connected():
app = TuimbleApp()
async with app.run_test(size=(80, 24)) as pilot:
status = app.query_one("#status", StatusBar)
status.connected = True
await pilot.pause()
rendered = status.render()
assert "connected" in rendered
Priority tests to add:
StatusBar.render()at each width breakpoint (< 40, < 60, >= 60)ChannelTreekeyboard navigation (up/down/enter)_strip_htmlwith edge cases (nested tags, malformed HTML, entities)_next_volumewraparound behavior- Config reload with unknown TOML keys (crash test)
_detect_config_changessafe vs restart classification
[IMPORTANT] Tests access private attributes
test_audio.py directly accesses ap._capture_queue, ap._playback_queue,
ap._sample_rate. This couples tests to implementation. Where possible, test
through public interfaces:
# Instead of: ap._capture_queue.put(b"\x01\x02\x03")
# Use the actual capture callback:
ap.capturing = True
ap._capture_callback(b"\x01\x02\x03", 1, None, None)
assert ap.get_capture_frame() == b"\x01\x02\x03"
7. Security & Robustness
[CRITICAL] _strip_html regex is insufficient for Mumble HTML
app.py:996-999 uses re.sub(r"<[^>]+>", "", text) which fails on:
- Malformed tags:
<img src="x" onerror="alert(1)"(no closing>) - Nested markup:
<a href="<script>"> - CDATA/comments:
<!-- <script> -->
While Textual's Rich markup won't execute scripts, malformed input could break
Rich's parser or produce garbled output. Consider using html.parser from the
stdlib:
from html.parser import HTMLParser
class _HTMLStripper(HTMLParser):
def __init__(self):
super().__init__()
self._parts: list[str] = []
def handle_data(self, data: str):
self._parts.append(data)
def get_text(self) -> str:
return "".join(self._parts)
def _strip_html(text: str) -> str:
stripper = _HTMLStripper()
stripper.feed(text)
return html.unescape(stripper.get_text())
[IMPORTANT] join_channel doesn't handle missing channel IDs
client.py:240-243:
def join_channel(self, channel_id: int):
if self._mumble and self._connected:
self._mumble.channels[channel_id].move_in()
If channel_id is stale (channel was removed between tree render and user
pressing Enter), this raises KeyError. Add a guard:
def join_channel(self, channel_id: int):
if self._mumble and self._connected:
ch = self._mumble.channels.get(channel_id)
if ch is None:
raise ValueError(f"channel {channel_id} not found")
ch.move_in()
[IMPORTANT] send_text silently fails on missing channel
client.py:229-233:
def send_text(self, message: str):
if self._mumble and self._connected:
ch = self._mumble.channels[self._mumble.users.myself["channel_id"]]
ch.send_text_message(message)
If myself has no channel_id key (edge case during connection setup), this
crashes with KeyError. Guard with try/except or a my_channel_id check
first.
[NICE] audio.py stop() doesn't drain queues
After stop(), the capture and playback queues still hold stale frames. If
start() is called again (audio device hot-swap), old frames leak into the new
session:
def stop(self):
for stream in (self._input_stream, self._output_stream):
if stream is not None:
stream.stop()
stream.close()
self._input_stream = None
self._output_stream = None
# Drain stale frames
while not self._capture_queue.empty():
try:
self._capture_queue.get_nowait()
except queue.Empty:
break
while not self._playback_queue.empty():
try:
self._playback_queue.get_nowait()
except queue.Empty:
break
Priority Matrix
| # | Area | Severity | Item |
|---|---|---|---|
| 1 | Architecture | Critical | Extract reconnect manager from app.py |
| 2 | Security | Critical | Replace regex HTML stripping with proper parser |
| 3 | Robustness | Critical | Validate TOML config keys before dataclass init |
| 4 | Testing | Critical | Add widget/app tests using Textual's test harness |
| 5 | Performance | Important | Use numpy for _apply_gain (transitive dep exists) |
| 6 | Performance | Important | Replace polling sleep with queue.get(timeout=) |
| 7 | Thread safety | Important | Use threading.Event for reconnect cancellation |
| 8 | Code quality | Important | Extract _make_client() factory (4 dup sites) |
| 9 | Code quality | Important | Compile _strip_html regex at module level |
| 10 | Robustness | Important | Guard join_channel/send_text against KeyError |
| 11 | UX | Important | Suppress PTT chatlog spam for hold-mode |
| 12 | Code quality | Important | Deduplicate config change detection logic |
| 13 | Architecture | Nice | Extract InputHistory class |
| 14 | Architecture | Nice | Inline callback wrappers with lambdas |
| 15 | Robustness | Nice | Drain audio queues on stop() |
| 16 | Code quality | Nice | Add return type annotations throughout |
| 17 | Scalability | Nice | Consider frozen dataclasses for Config |