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:
@@ -1,19 +1,28 @@
|
||||
//! `QpqClient` — the main entry point for the quicprochat SDK.
|
||||
//!
|
||||
//! Provides connection lifecycle management with auto-reconnect, heartbeat
|
||||
//! monitoring, push subscription recovery, and a connection state machine.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::info;
|
||||
|
||||
pub use quicprochat_rpc::client::ConnectionState;
|
||||
|
||||
use crate::config::ClientConfig;
|
||||
use crate::conversation::ConversationStore;
|
||||
use crate::error::SdkError;
|
||||
use crate::events::ClientEvent;
|
||||
|
||||
/// Default heartbeat interval for proactive dead-connection detection.
|
||||
const HEARTBEAT_INTERVAL_SECS: u64 = 30;
|
||||
|
||||
/// The main SDK client. All state is contained within this struct — no globals.
|
||||
pub struct QpqClient {
|
||||
config: ClientConfig,
|
||||
rpc: Option<quicprochat_rpc::client::RpcClient>,
|
||||
rpc: Option<Arc<quicprochat_rpc::client::RpcClient>>,
|
||||
event_tx: broadcast::Sender<ClientEvent>,
|
||||
/// The authenticated username, if logged in.
|
||||
username: Option<String>,
|
||||
@@ -24,9 +33,9 @@ pub struct QpqClient {
|
||||
/// Local conversation store (SQLCipher).
|
||||
conv_store: Option<ConversationStore>,
|
||||
/// Device ID for multi-device support.
|
||||
/// When set, fetch/peek/ack requests include this device_id so the server
|
||||
/// scopes them to the correct per-device queue.
|
||||
device_id: Option<Vec<u8>>,
|
||||
/// Handle to the heartbeat background task (if running).
|
||||
heartbeat_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl QpqClient {
|
||||
@@ -42,6 +51,7 @@ impl QpqClient {
|
||||
session_token: None,
|
||||
conv_store: None,
|
||||
device_id: None,
|
||||
heartbeat_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,10 +65,14 @@ impl QpqClient {
|
||||
tls_config: Arc::new(tls_config),
|
||||
alpn: self.config.alpn.clone(),
|
||||
session_token: self.session_token.clone(),
|
||||
max_retries: 0, // use defaults
|
||||
base_delay_ms: 0,
|
||||
max_backoff_ms: 0,
|
||||
keepalive_secs: 0,
|
||||
};
|
||||
|
||||
let client = quicprochat_rpc::client::RpcClient::connect(rpc_config).await?;
|
||||
self.rpc = Some(client);
|
||||
self.rpc = Some(Arc::new(client));
|
||||
|
||||
// Open local conversation store.
|
||||
let store = ConversationStore::open(
|
||||
@@ -109,7 +123,7 @@ impl QpqClient {
|
||||
|
||||
/// Get a reference to the RPC client (for direct calls).
|
||||
pub fn rpc(&self) -> Result<&quicprochat_rpc::client::RpcClient, SdkError> {
|
||||
self.rpc.as_ref().ok_or(SdkError::NotConnected)
|
||||
self.rpc.as_deref().ok_or(SdkError::NotConnected)
|
||||
}
|
||||
|
||||
/// Get a reference to the conversation store.
|
||||
@@ -119,12 +133,70 @@ impl QpqClient {
|
||||
.ok_or(SdkError::NotConnected)
|
||||
}
|
||||
|
||||
/// Register a new user account via OPAQUE.
|
||||
/// Get the current connection state from the RPC layer.
|
||||
pub fn connection_state(&self) -> ConnectionState {
|
||||
match &self.rpc {
|
||||
Some(rpc) => rpc.connection_state(),
|
||||
None => ConnectionState::Disconnected,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a background heartbeat task that monitors the connection and
|
||||
/// emits events on state changes. Checks QUIC connection liveness every
|
||||
/// 30 seconds. If a dead connection is detected, emits a `Disconnected`
|
||||
/// event.
|
||||
///
|
||||
/// Generates a fresh identity keypair, registers it with the server, and
|
||||
/// stores the identity key locally.
|
||||
/// Call this after `connect()` to enable proactive dead-connection detection.
|
||||
pub fn start_heartbeat(&mut self) {
|
||||
// Cancel any existing heartbeat.
|
||||
if let Some(h) = self.heartbeat_handle.take() {
|
||||
h.abort();
|
||||
}
|
||||
|
||||
let rpc = match self.rpc.clone() {
|
||||
Some(rpc) => rpc,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let event_tx = self.event_tx.clone();
|
||||
|
||||
self.heartbeat_handle = Some(tokio::spawn(async move {
|
||||
let mut last_state = ConnectionState::Connected;
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(HEARTBEAT_INTERVAL_SECS)).await;
|
||||
|
||||
let alive = rpc.is_alive();
|
||||
let current_state = rpc.connection_state();
|
||||
|
||||
if current_state != last_state {
|
||||
match current_state {
|
||||
ConnectionState::Connected => {
|
||||
let _ = event_tx.send(ClientEvent::Connected);
|
||||
}
|
||||
ConnectionState::Reconnecting { attempt } => {
|
||||
let _ = event_tx.send(ClientEvent::Reconnecting { attempt });
|
||||
}
|
||||
ConnectionState::Disconnected => {
|
||||
let _ = event_tx.send(ClientEvent::Disconnected {
|
||||
reason: "connection lost".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
last_state = current_state;
|
||||
} else if !alive && last_state == ConnectionState::Connected {
|
||||
// Connection died but RPC layer hasn't noticed yet.
|
||||
let _ = event_tx.send(ClientEvent::Disconnected {
|
||||
reason: "heartbeat: connection dead".into(),
|
||||
});
|
||||
last_state = ConnectionState::Disconnected;
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Register a new user account via OPAQUE.
|
||||
pub async fn register(&mut self, username: &str, password: &str) -> Result<(), SdkError> {
|
||||
let rpc = self.rpc.as_ref().ok_or(SdkError::NotConnected)?;
|
||||
let rpc = self.rpc.as_deref().ok_or(SdkError::NotConnected)?;
|
||||
let keypair = crate::auth::opaque_register(rpc, username, password, None).await?;
|
||||
self.identity_key = Some(keypair.public_key_bytes().to_vec());
|
||||
self.emit(ClientEvent::Registered {
|
||||
@@ -135,10 +207,6 @@ impl QpqClient {
|
||||
}
|
||||
|
||||
/// Log in via OPAQUE and store the session token.
|
||||
///
|
||||
/// Requires an identity key to be set (either from a previous `register()`
|
||||
/// call or loaded from state). After login, the client is authenticated
|
||||
/// and subsequent RPC calls include the session token.
|
||||
pub async fn login(&mut self, username: &str, password: &str) -> Result<(), SdkError> {
|
||||
let identity_key = self
|
||||
.identity_key
|
||||
@@ -146,7 +214,7 @@ impl QpqClient {
|
||||
.ok_or_else(|| SdkError::AuthFailed("no identity key — register or load state first".into()))?
|
||||
.clone();
|
||||
|
||||
let rpc = self.rpc.as_ref().ok_or(SdkError::NotConnected)?;
|
||||
let rpc = self.rpc.as_deref().ok_or(SdkError::NotConnected)?;
|
||||
let session_token = crate::auth::opaque_login(rpc, username, password, &identity_key).await?;
|
||||
|
||||
self.session_token = Some(session_token);
|
||||
@@ -181,8 +249,7 @@ impl QpqClient {
|
||||
|
||||
// ── Multi-device ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Set the device ID for this client. Subsequent fetch/peek/ack calls
|
||||
/// will include this ID so the server scopes them to the correct queue.
|
||||
/// Set the device ID for this client.
|
||||
pub fn set_device_id(&mut self, device_id: Vec<u8>) {
|
||||
self.device_id = Some(device_id);
|
||||
}
|
||||
@@ -193,13 +260,12 @@ impl QpqClient {
|
||||
}
|
||||
|
||||
/// Register this device with the server.
|
||||
/// Sets the local device_id on success.
|
||||
pub async fn register_device(
|
||||
&mut self,
|
||||
device_id: &[u8],
|
||||
device_name: &str,
|
||||
) -> Result<bool, SdkError> {
|
||||
let rpc = self.rpc.as_ref().ok_or(SdkError::NotConnected)?;
|
||||
let rpc = self.rpc.as_deref().ok_or(SdkError::NotConnected)?;
|
||||
let newly_registered =
|
||||
crate::devices::register_device(rpc, device_id, device_name).await?;
|
||||
self.device_id = Some(device_id.to_vec());
|
||||
@@ -208,13 +274,13 @@ impl QpqClient {
|
||||
|
||||
/// List all registered devices for this identity.
|
||||
pub async fn list_devices(&self) -> Result<Vec<crate::devices::DeviceInfo>, SdkError> {
|
||||
let rpc = self.rpc.as_ref().ok_or(SdkError::NotConnected)?;
|
||||
let rpc = self.rpc.as_deref().ok_or(SdkError::NotConnected)?;
|
||||
crate::devices::list_devices(rpc).await
|
||||
}
|
||||
|
||||
/// Revoke (remove) a registered device.
|
||||
pub async fn revoke_device(&self, device_id: &[u8]) -> Result<bool, SdkError> {
|
||||
let rpc = self.rpc.as_ref().ok_or(SdkError::NotConnected)?;
|
||||
let rpc = self.rpc.as_deref().ok_or(SdkError::NotConnected)?;
|
||||
crate::devices::revoke_device(rpc, device_id).await
|
||||
}
|
||||
|
||||
@@ -258,8 +324,15 @@ impl QpqClient {
|
||||
.map_err(|e| SdkError::Storage(e.to_string()))
|
||||
}
|
||||
|
||||
/// Disconnect from the server.
|
||||
/// Disconnect from the server gracefully.
|
||||
///
|
||||
/// Stops the heartbeat task and closes the QUIC connection. Emits a
|
||||
/// `Disconnected` event.
|
||||
pub fn disconnect(&mut self) {
|
||||
// Stop heartbeat first.
|
||||
if let Some(h) = self.heartbeat_handle.take() {
|
||||
h.abort();
|
||||
}
|
||||
if let Some(rpc) = self.rpc.take() {
|
||||
rpc.close();
|
||||
self.emit(ClientEvent::Disconnected {
|
||||
|
||||
Reference in New Issue
Block a user