app: add auto-reconnect with backoff
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user