From e443facd3b2ef8d47902c4b4c3633013b36d0e7b Mon Sep 17 00:00:00 2001 From: Username Date: Tue, 24 Feb 2026 14:43:29 +0100 Subject: [PATCH] app: add auto-reconnect with backoff --- src/tuimble/app.py | 149 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 143 insertions(+), 6 deletions(-) diff --git a/src/tuimble/app.py b/src/tuimble/app.py index d16d772..3a90830 100644 --- a/src/tuimble/app.py +++ b/src/tuimble/app.py @@ -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()