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:
2026-03-08 18:00:47 +01:00
parent 66eca065e0
commit e4c5868b31
8 changed files with 526 additions and 99 deletions

View File

@@ -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);

View File

@@ -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"));