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 textual.widgets import Footer, Header, Input, RichLog, Static
from tuimble.audio import AudioPipeline 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.config import Config, load_config
from tuimble.ptt import KittyPtt, TogglePtt, detect_backend 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) 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: def _next_volume(current: float) -> float:
"""Cycle through VOLUME_STEPS, wrapping to 0.0 after max.""" """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.input_gain = acfg.input_gain
self._audio.output_gain = acfg.output_gain self._audio.output_gain = acfg.output_gain
self._pending_reload: Config | None = None self._pending_reload: Config | None = None
self._reconnecting: bool = False
self._reconnect_attempt: int = 0
self._intentional_disconnect: bool = False
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header() yield Header()
@@ -428,8 +435,8 @@ class TuimbleApp(App):
# -- server connection (worker thread) ----------------------------------- # -- server connection (worker thread) -----------------------------------
@work(thread=True) def _wire_client_callbacks(self) -> None:
def _connect_to_server(self) -> None: """Wire pymumble callbacks to message dispatchers."""
self._client.set_dispatcher(self.call_from_thread) self._client.set_dispatcher(self.call_from_thread)
self._client.on_connected = self._cb_connected self._client.on_connected = self._cb_connected
self._client.on_disconnected = self._cb_disconnected 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_channel_update = self._cb_state_changed
self._client.on_sound_received = self._cb_sound_received self._client.on_sound_received = self._cb_sound_received
@work(thread=True)
def _connect_to_server(self) -> None:
self._wire_client_callbacks()
try: try:
self._client.connect() self._client.connect()
except Exception as exc: except Exception as exc:
@@ -462,9 +472,101 @@ class TuimbleApp(App):
chatlog = self.query_one("#chatlog", ChatLog) chatlog = self.query_one("#chatlog", ChatLog)
chatlog.write(f"[#f7768e]\u2717 {text}[/]") 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 ---------------------------------------------------- # -- message handlers ----------------------------------------------------
def on_server_connected(self, _msg: ServerConnected) -> None: def on_server_connected(self, _msg: ServerConnected) -> None:
self._intentional_disconnect = False
status = self.query_one("#status", StatusBar) status = self.query_one("#status", StatusBar)
status.connected = True status.connected = True
srv = self._config.server srv = self._config.server
@@ -484,11 +586,18 @@ class TuimbleApp(App):
status.connected = False status.connected = False
status.server_info = "" 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 = self.query_one("#chatlog", ChatLog)
chatlog.write("[#f7768e]\u2717 disconnected from server[/]") chatlog.write("[#f7768e]\u2717 disconnected from server[/]")
tree = self.query_one("#sidebar", ChannelTree) self._reconnecting = True
tree.clear_state() self._reconnect_attempt = 0
self._reconnect_loop()
def on_text_message_received(self, msg: TextMessageReceived) -> None: def on_text_message_received(self, msg: TextMessageReceived) -> None:
chatlog = self.query_one("#chatlog", ChatLog) chatlog = self.query_one("#chatlog", ChatLog)
@@ -683,6 +792,8 @@ class TuimbleApp(App):
) )
if server_changed: if server_changed:
self._intentional_disconnect = True
self._cancel_reconnect()
self._audio.stop() self._audio.stop()
self._client.set_dispatcher(None) self._client.set_dispatcher(None)
self._client.disconnect() self._client.disconnect()
@@ -720,7 +831,31 @@ class TuimbleApp(App):
chatlog.write("[dim]audio pipeline restarted[/dim]") chatlog.write("[dim]audio pipeline restarted[/dim]")
def action_reload_config(self) -> None: 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) chatlog = self.query_one("#chatlog", ChatLog)
if self._pending_reload is not None: if self._pending_reload is not None:
@@ -828,6 +963,8 @@ class TuimbleApp(App):
# -- lifecycle ----------------------------------------------------------- # -- lifecycle -----------------------------------------------------------
def action_quit(self) -> None: def action_quit(self) -> None:
self._intentional_disconnect = True
self._cancel_reconnect()
self._audio.stop() self._audio.stop()
self._client.set_dispatcher(None) self._client.set_dispatcher(None)
self._client.disconnect() self._client.disconnect()