# 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`). ```python # 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: ```python 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`: ```python 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: ```python 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: ```python @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: ```python self.on_connected = None self.on_disconnected = None ``` This works but lacks type safety and discoverability. Consider a typed protocol or simple typed attributes: ```python 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`: ```python 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: ```python 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: ```python 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: ```python 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`: ```python 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: ```python 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: 1. `__init__` (line 395) 2. `_reconnect_loop` (line 507) 3. `action_reload_config` disconnected path (line 864) 4. `_apply_restart_changes` (line 824) Extract a factory method: ```python 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: ```python 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: ```python 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:86` `set_dispatcher(self, fn: Callable)` -- should be `Callable | None` - `client.py:93` `_dispatch(self, callback, *args)` -- no return type, no `callback` type - `client.py:154` `connect(self)` -- no return type annotation - `audio.py:58` `start(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): ```python 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): ```python 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`: ```python 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: ```python 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: ```python 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: ```python 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: ```python @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: - `StatusBar` rendering (connection states, volume bars, responsive breakpoints) - `ChannelTree` rendering (tree structure, truncation, focus navigation) - `TuimbleApp` message handling (connect/disconnect/state change flows) - Config reload state machine - Input history navigation Textual provides `App.run_test()` for async widget testing: ```python 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:** 1. `StatusBar.render()` at each width breakpoint (< 40, < 60, >= 60) 2. `ChannelTree` keyboard navigation (up/down/enter) 3. `_strip_html` with edge cases (nested tags, malformed HTML, entities) 4. `_next_volume` wraparound behavior 5. Config reload with unknown TOML keys (crash test) 6. `_detect_config_changes` safe 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: ```python # 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: ``) - Nested markup: `` - CDATA/comments: `` 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: ```python 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`: ```python 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: ```python 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`: ```python 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: ```python 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 |