feat: add client auto-reconnect, heartbeat, and connection status UI
RPC layer (quicprochat-rpc): - RpcClient now uses tokio::sync::Mutex<Connection> for safe reconnection - Auto-reconnect with exponential backoff + jitter on retriable errors - QUIC-level keepalive via quinn TransportConfig - subscribe_push() returns Option<PushFrame> with None sentinel on break - RpcError::is_retriable() classifies transient vs permanent errors - ConnectionState enum (Connected/Reconnecting/Disconnected) with Display - Configurable max_retries, base_delay, max_backoff, keepalive_secs SDK layer (quicprochat-sdk): - QpqClient wraps RpcClient in Arc for safe heartbeat task sharing - start_heartbeat() spawns background task checking connection every 30s - connection_state() exposes RPC-layer state to UI - Reconnecting event added to ClientEvent enum - disconnect() aborts heartbeat before closing connection Client UI (quicprochat-client): - TUI status bar shows Connected/Reconnecting.../Offline with color - TUI handles Reconnecting event with attempt count display - REPL event listener prints connection state changes - REPL /status shows connection state instead of bool - Both TUI and REPL call start_heartbeat() on startup
This commit is contained in:
@@ -294,6 +294,15 @@ fn show_event(event: &ClientEvent) {
|
||||
};
|
||||
display::print_incoming(&sender, body);
|
||||
}
|
||||
ClientEvent::Connected => {
|
||||
display::print_status("connected to server");
|
||||
}
|
||||
ClientEvent::Disconnected { reason } => {
|
||||
display::print_error(&format!("disconnected: {reason}"));
|
||||
}
|
||||
ClientEvent::Reconnecting { attempt } => {
|
||||
display::print_status(&format!("reconnecting... (attempt {attempt})"));
|
||||
}
|
||||
ClientEvent::ConversationCreated { display_name, .. } => {
|
||||
display::print_status(&format!("new conversation: {display_name}"));
|
||||
}
|
||||
@@ -397,7 +406,7 @@ async fn dispatch(
|
||||
|
||||
fn do_status(client: &QpqClient, st: &ReplState) {
|
||||
println!("{BOLD}Status{RESET}");
|
||||
println!(" connected: {}", if client.is_connected() { "yes" } else { "no" });
|
||||
println!(" connection: {}", client.connection_state());
|
||||
println!(" authenticated: {}", if client.is_authenticated() { "yes" } else { "no" });
|
||||
println!(" username: {}", client.username().unwrap_or("(none)"));
|
||||
println!(" conversation: {}", st.current_display_name.as_deref().unwrap_or("(none)"));
|
||||
@@ -990,6 +999,9 @@ pub async fn run_v2_repl(
|
||||
// Connect to server.
|
||||
client.connect().await.context("connect to server")?;
|
||||
|
||||
// Start heartbeat for proactive dead-connection detection.
|
||||
client.start_heartbeat();
|
||||
|
||||
// Background event listener.
|
||||
let rx = client.subscribe();
|
||||
spawn_event_listener(rx);
|
||||
|
||||
@@ -41,7 +41,7 @@ use ratatui::{
|
||||
};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use quicprochat_sdk::client::QpqClient;
|
||||
use quicprochat_sdk::client::{ConnectionState, QpqClient};
|
||||
use quicprochat_sdk::conversation::ConversationStore;
|
||||
use quicprochat_sdk::events::ClientEvent;
|
||||
|
||||
@@ -87,8 +87,8 @@ pub struct TuiApp {
|
||||
focus: Focus,
|
||||
/// Notification line (shown briefly, e.g. "Message sent", "Error: ...").
|
||||
notification: Option<String>,
|
||||
/// Whether the client is currently connected.
|
||||
connected: bool,
|
||||
/// Current connection state.
|
||||
conn_state: quicprochat_sdk::client::ConnectionState,
|
||||
/// Current MLS epoch for the active conversation (if available).
|
||||
mls_epoch: Option<u64>,
|
||||
}
|
||||
@@ -108,7 +108,7 @@ impl TuiApp {
|
||||
server_addr: server_addr.to_string(),
|
||||
focus: Focus::Input,
|
||||
notification: None,
|
||||
connected: false,
|
||||
conn_state: ConnectionState::Disconnected,
|
||||
mls_epoch: None,
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,15 @@ impl TuiApp {
|
||||
}
|
||||
|
||||
fn update_status(&mut self) {
|
||||
let conn_indicator = if self.connected { "Online" } else { "Offline" };
|
||||
let conn_indicator = match self.conn_state {
|
||||
ConnectionState::Connected => "Connected",
|
||||
ConnectionState::Reconnecting { attempt } => {
|
||||
// We can't use format! in a match arm and return &str,
|
||||
// so we'll handle this below.
|
||||
return self.update_status_reconnecting(attempt);
|
||||
}
|
||||
ConnectionState::Disconnected => "Offline",
|
||||
};
|
||||
let user = self
|
||||
.username
|
||||
.as_deref()
|
||||
@@ -167,6 +175,25 @@ impl TuiApp {
|
||||
if conv_count == 1 { "" } else { "s" }
|
||||
);
|
||||
}
|
||||
|
||||
fn update_status_reconnecting(&mut self, attempt: u32) {
|
||||
let user = self
|
||||
.username
|
||||
.as_deref()
|
||||
.unwrap_or("not logged in");
|
||||
let conv_count = self.conversations.len();
|
||||
let epoch_str = match self.mls_epoch {
|
||||
Some(e) => format!("epoch {e}"),
|
||||
None => "epoch --".to_string(),
|
||||
};
|
||||
self.status_line = format!(
|
||||
"Reconnecting... (attempt {attempt}) | {} | {} | {} conversation{} | MLS {epoch_str}",
|
||||
self.server_addr,
|
||||
user,
|
||||
conv_count,
|
||||
if conv_count == 1 { "" } else { "s" }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Terminal Drop Guard ─────────────────────────────────────────────────────
|
||||
@@ -198,7 +225,7 @@ pub async fn run_v2_tui(client: &mut QpqClient) -> anyhow::Result<()> {
|
||||
"disconnected"
|
||||
};
|
||||
let mut app = TuiApp::new(server_addr);
|
||||
app.connected = client.is_connected();
|
||||
app.conn_state = client.connection_state();
|
||||
|
||||
// Populate initial state from client.
|
||||
if let Some(name) = client.username() {
|
||||
@@ -225,6 +252,9 @@ pub async fn run_v2_tui(client: &mut QpqClient) -> anyhow::Result<()> {
|
||||
|
||||
app.update_status();
|
||||
|
||||
// Start heartbeat for proactive dead-connection detection.
|
||||
client.start_heartbeat();
|
||||
|
||||
// Subscribe to SDK events.
|
||||
let mut event_rx = client.subscribe();
|
||||
|
||||
@@ -278,15 +308,20 @@ pub async fn run_v2_tui(client: &mut QpqClient) -> anyhow::Result<()> {
|
||||
fn handle_sdk_event(app: &mut TuiApp, event: ClientEvent) {
|
||||
match event {
|
||||
ClientEvent::Connected => {
|
||||
app.connected = true;
|
||||
app.conn_state = ConnectionState::Connected;
|
||||
app.notification = Some("Connected to server".to_string());
|
||||
app.update_status();
|
||||
}
|
||||
ClientEvent::Disconnected { reason } => {
|
||||
app.connected = false;
|
||||
app.conn_state = ConnectionState::Disconnected;
|
||||
app.notification = Some(format!("Disconnected: {reason}"));
|
||||
app.update_status();
|
||||
}
|
||||
ClientEvent::Reconnecting { attempt } => {
|
||||
app.conn_state = ConnectionState::Reconnecting { attempt };
|
||||
app.notification = Some(format!("Reconnecting... (attempt {attempt})"));
|
||||
app.update_status();
|
||||
}
|
||||
ClientEvent::Registered { username } => {
|
||||
app.notification = Some(format!("Registered as {username}"));
|
||||
}
|
||||
@@ -839,12 +874,11 @@ fn draw_input(frame: &mut Frame, app: &TuiApp, area: Rect) {
|
||||
}
|
||||
|
||||
fn draw_status(frame: &mut Frame, app: &TuiApp, area: Rect) {
|
||||
let conn_color = if app.connected {
|
||||
Color::Green
|
||||
} else {
|
||||
Color::Red
|
||||
let (conn_color, conn_indicator) = match app.conn_state {
|
||||
ConnectionState::Connected => (Color::Green, " ON "),
|
||||
ConnectionState::Reconnecting { .. } => (Color::Yellow, " ... "),
|
||||
ConnectionState::Disconnected => (Color::Red, " OFF "),
|
||||
};
|
||||
let conn_indicator = if app.connected { " ON " } else { " OFF " };
|
||||
|
||||
let spans = vec![
|
||||
Span::styled(
|
||||
@@ -1019,7 +1053,7 @@ mod tests {
|
||||
|
||||
fn make_app() -> TuiApp {
|
||||
let mut app = TuiApp::new("127.0.0.1:7000");
|
||||
app.connected = true;
|
||||
app.conn_state = ConnectionState::Connected;
|
||||
app.username = Some("alice".to_string());
|
||||
app.conversations.push(ConversationItem {
|
||||
id: [1u8; 16],
|
||||
@@ -1067,12 +1101,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_bar_shows_online() {
|
||||
fn status_bar_shows_connected() {
|
||||
let mut app = TuiApp::new("127.0.0.1:7000");
|
||||
app.connected = true;
|
||||
app.conn_state = ConnectionState::Connected;
|
||||
app.username = Some("alice".to_string());
|
||||
app.update_status();
|
||||
assert!(app.status_line.contains("Online"));
|
||||
assert!(app.status_line.contains("Connected"));
|
||||
assert!(app.status_line.contains("alice"));
|
||||
assert!(app.status_line.contains("MLS epoch --"));
|
||||
}
|
||||
@@ -1080,15 +1114,32 @@ mod tests {
|
||||
#[test]
|
||||
fn status_bar_shows_offline() {
|
||||
let mut app = TuiApp::new("127.0.0.1:7000");
|
||||
app.connected = false;
|
||||
app.conn_state = ConnectionState::Disconnected;
|
||||
app.update_status();
|
||||
assert!(app.status_line.contains("Offline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_bar_shows_reconnecting() {
|
||||
let mut app = TuiApp::new("127.0.0.1:7000");
|
||||
app.conn_state = ConnectionState::Reconnecting { attempt: 2 };
|
||||
app.update_status();
|
||||
assert!(
|
||||
app.status_line.contains("Reconnecting"),
|
||||
"expected Reconnecting in: {}",
|
||||
app.status_line
|
||||
);
|
||||
assert!(
|
||||
app.status_line.contains("attempt 2"),
|
||||
"expected attempt count in: {}",
|
||||
app.status_line
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_bar_shows_epoch() {
|
||||
let mut app = TuiApp::new("127.0.0.1:7000");
|
||||
app.connected = true;
|
||||
app.conn_state = ConnectionState::Connected;
|
||||
app.mls_epoch = Some(42);
|
||||
app.update_status();
|
||||
assert!(app.status_line.contains("MLS epoch 42"));
|
||||
|
||||
Reference in New Issue
Block a user