app: add auto-reconnect with backoff

This commit is contained in:
Username
2026-02-24 14:43:29 +01:00
parent d02bb5239a
commit e443facd3b

View File

@@ -14,7 +14,7 @@ from textual.reactive import reactive
from textual.widgets import Footer, Header, Input, RichLog, Static
from tuimble.audio import AudioPipeline
from tuimble.client import Channel, MumbleClient, User
from tuimble.client import Channel, ConnectionFailed, MumbleClient, User
from tuimble.config import Config, load_config
from tuimble.ptt import KittyPtt, TogglePtt, detect_backend
@@ -22,6 +22,10 @@ log = logging.getLogger(__name__)
VOLUME_STEPS = (0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0)
RECONNECT_INITIAL = 2 # seconds before first retry
RECONNECT_MAX = 30 # maximum backoff delay
RECONNECT_RETRIES = 10 # attempts before giving up
def _next_volume(current: float) -> float:
"""Cycle through VOLUME_STEPS, wrapping to 0.0 after max."""
@@ -404,6 +408,9 @@ class TuimbleApp(App):
self._audio.input_gain = acfg.input_gain
self._audio.output_gain = acfg.output_gain
self._pending_reload: Config | None = None
self._reconnecting: bool = False
self._reconnect_attempt: int = 0
self._intentional_disconnect: bool = False
def compose(self) -> ComposeResult:
yield Header()
@@ -428,8 +435,8 @@ class TuimbleApp(App):
# -- server connection (worker thread) -----------------------------------
@work(thread=True)
def _connect_to_server(self) -> None:
def _wire_client_callbacks(self) -> None:
"""Wire pymumble callbacks to message dispatchers."""
self._client.set_dispatcher(self.call_from_thread)
self._client.on_connected = self._cb_connected
self._client.on_disconnected = self._cb_disconnected
@@ -438,6 +445,9 @@ class TuimbleApp(App):
self._client.on_channel_update = self._cb_state_changed
self._client.on_sound_received = self._cb_sound_received
@work(thread=True)
def _connect_to_server(self) -> None:
self._wire_client_callbacks()
try:
self._client.connect()
except Exception as exc:
@@ -462,9 +472,101 @@ class TuimbleApp(App):
chatlog = self.query_one("#chatlog", ChatLog)
chatlog.write(f"[#f7768e]\u2717 {text}[/]")
# -- auto-reconnect ------------------------------------------------------
@work(thread=True)
def _reconnect_loop(self) -> None:
"""Retry connection with exponential backoff."""
while self._reconnecting:
self._reconnect_attempt += 1
delay = min(
RECONNECT_INITIAL * (2 ** (self._reconnect_attempt - 1)),
RECONNECT_MAX,
)
self.call_from_thread(
self._log_reconnect, self._reconnect_attempt, delay,
)
elapsed = 0.0
while elapsed < delay and self._reconnecting:
time.sleep(0.5)
elapsed += 0.5
if not self._reconnecting:
break
try:
self._client.disconnect()
srv = self._config.server
self._client = MumbleClient(
host=srv.host,
port=srv.port,
username=srv.username,
password=srv.password,
certfile=srv.certfile,
keyfile=srv.keyfile,
)
self._wire_client_callbacks()
self._client.connect()
self._reconnecting = False
self._reconnect_attempt = 0
self.call_from_thread(self._on_reconnect_success)
return
except ConnectionFailed as exc:
if not exc.retryable:
self.call_from_thread(
self._show_error, f"rejected: {exc}",
)
self._reconnecting = False
self.call_from_thread(self._on_reconnect_exhausted)
return
self.call_from_thread(
self._show_error,
f"attempt {self._reconnect_attempt}: {exc}",
)
except Exception as exc:
self.call_from_thread(
self._show_error,
f"attempt {self._reconnect_attempt}: {exc}",
)
if self._reconnect_attempt >= RECONNECT_RETRIES:
self._reconnecting = False
self.call_from_thread(self._on_reconnect_exhausted)
return
def _log_reconnect(self, attempt: int, delay: int) -> None:
"""Log reconnection attempt to chatlog."""
chatlog = self.query_one("#chatlog", ChatLog)
chatlog.write(
f"[dim]reconnecting in {delay}s "
f"(attempt {attempt}/{RECONNECT_RETRIES})...[/dim]"
)
def _on_reconnect_success(self) -> None:
"""Handle successful reconnection."""
self.post_message(ServerConnected())
def _on_reconnect_exhausted(self) -> None:
"""Handle all reconnection attempts exhausted."""
chatlog = self.query_one("#chatlog", ChatLog)
chatlog.write(
f"[#f7768e]reconnection failed after "
f"{RECONNECT_RETRIES} attempts[/]"
)
chatlog.write("[dim]press F5 to retry manually[/dim]")
def _cancel_reconnect(self) -> None:
"""Cancel an in-progress reconnect loop."""
self._reconnecting = False
self._reconnect_attempt = 0
# -- message handlers ----------------------------------------------------
def on_server_connected(self, _msg: ServerConnected) -> None:
self._intentional_disconnect = False
status = self.query_one("#status", StatusBar)
status.connected = True
srv = self._config.server
@@ -484,11 +586,18 @@ class TuimbleApp(App):
status.connected = False
status.server_info = ""
tree = self.query_one("#sidebar", ChannelTree)
tree.clear_state()
if self._intentional_disconnect or self._reconnecting:
return
chatlog = self.query_one("#chatlog", ChatLog)
chatlog.write("[#f7768e]\u2717 disconnected from server[/]")
tree = self.query_one("#sidebar", ChannelTree)
tree.clear_state()
self._reconnecting = True
self._reconnect_attempt = 0
self._reconnect_loop()
def on_text_message_received(self, msg: TextMessageReceived) -> None:
chatlog = self.query_one("#chatlog", ChatLog)
@@ -683,6 +792,8 @@ class TuimbleApp(App):
)
if server_changed:
self._intentional_disconnect = True
self._cancel_reconnect()
self._audio.stop()
self._client.set_dispatcher(None)
self._client.disconnect()
@@ -720,7 +831,31 @@ class TuimbleApp(App):
chatlog.write("[dim]audio pipeline restarted[/dim]")
def action_reload_config(self) -> None:
"""Reload config from disk (F5)."""
"""Reload config from disk (F5).
Also serves as manual reconnect when disconnected.
"""
if self._reconnecting:
self._cancel_reconnect()
if not self._client.connected:
chatlog = self.query_one("#chatlog", ChatLog)
srv = self._config.server
self._client = MumbleClient(
host=srv.host,
port=srv.port,
username=srv.username,
password=srv.password,
certfile=srv.certfile,
keyfile=srv.keyfile,
)
chatlog.write(
f"[dim]connecting to {srv.host}:{srv.port}...[/dim]"
)
self._intentional_disconnect = False
self._connect_to_server()
return
chatlog = self.query_one("#chatlog", ChatLog)
if self._pending_reload is not None:
@@ -828,6 +963,8 @@ class TuimbleApp(App):
# -- lifecycle -----------------------------------------------------------
def action_quit(self) -> None:
self._intentional_disconnect = True
self._cancel_reconnect()
self._audio.stop()
self._client.set_dispatcher(None)
self._client.disconnect()