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 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()
|
||||||
|
|||||||
Reference in New Issue
Block a user