fix(relay): keep a reconnected device online when its stale connection times out

After a network change (Wi-Fi to mobile data) the app reconnects right
away, while the relay only notices the old connection is dead once the
idle timeout expires. When that stale handler finally exited it removed
the device from the online map by address alone, which dropped the new,
healthy connection. From then on every message was stored as offline and
only a push wake-up could fetch it, even though the app was connected.

The online map is now its own type: a newly authenticated connection
takes over delivery for the address, and a closing connection removes
the entry only if it still points at itself. Tests cover the registry
and the full route after a stale connection ends.
This commit is contained in:
Julius Joska
2026-09-29 18:30:11 +02:00
parent bd51fef5cc
commit e5d0e54b80
2 changed files with 120 additions and 8 deletions
+117 -5
View File
@@ -70,12 +70,45 @@ impl OnlineDevice {
}
}
/// Cíl živého doručování: delivery_address → spojení, na kterém zařízení
/// právě poslouchá.
///
/// Jedno zařízení může mít chvíli víc spojení naráz: po změně sítě (Wi-Fi ↔
/// mobilní data) se appka připojí znovu hned, kdežto staré spojení relay
/// pozná jako mrtvé až po `ws::IDLE_TIMEOUT`. Doručuje se vždy na naposledy
/// ověřené spojení a staré spojení smí při svém konci odebrat jen samo sebe.
#[derive(Default)]
pub struct OnlineDevices(DashMap<Vec<u8>, Arc<OnlineDevice>>);
impl OnlineDevices {
pub fn get(&self, delivery_address: &[u8]) -> Option<Arc<OnlineDevice>> {
self.0.get(delivery_address).map(|d| d.clone())
}
/// Nově ověřené spojení přebírá doručování — i od staršího spojení
/// téhož zařízení, které ještě nevypršelo.
pub fn attach(&self, delivery_address: Vec<u8>, device: Arc<OnlineDevice>) {
self.0.insert(delivery_address, device);
}
/// Konec spojení: odebere záznam, jen pokud pořád patří tomuto spojení.
/// Mazat podle samotné adresy nejde — zombie spojení, které dožije až
/// po reconnectu, by tím vyřadilo nové živé spojení a relay by pak každou
/// zprávu jen uložil do fronty a čekal na budíček, přestože je zařízení
/// připojené. Vrací, zda se záznam odebral.
pub fn detach(&self, delivery_address: &[u8], device: &Arc<OnlineDevice>) -> bool {
self.0
.remove_if(delivery_address, |_, current| Arc::ptr_eq(current, device))
.is_some()
}
}
pub struct AppState {
pub pool: PgPool,
/// Kdy proces nastartoval — základ monotónních značek v [`OnlineDevice`].
pub started: std::time::Instant,
/// delivery_address → živé WS spojení zařízení.
pub online: DashMap<Vec<u8>, Arc<OnlineDevice>>,
pub online: OnlineDevices,
/// klientská IP → počet souběžných WS spojení (anti-flood cap).
/// Jen v paměti, nikam se neloguje ani neukládá.
pub ws_conns: DashMap<std::net::IpAddr, u32>,
@@ -103,7 +136,7 @@ impl AppState {
Self {
pool,
started: std::time::Instant::now(),
online: DashMap::new(),
online: OnlineDevices::default(),
ws_conns: DashMap::new(),
certs,
trusted_proxies,
@@ -146,7 +179,7 @@ impl AppState {
// enqueued_at → sama se nikdy nezahodí, padne jen starý přebytek.
self.trim_queue(&dst, MAX_QUEUE_BYTES_PER_RECIPIENT).await?;
if let Some(dev) = self.online.get(&dst).map(|d| d.clone()) {
if let Some(dev) = self.online.get(&dst) {
let deliver = v1::ServerFrame {
frame: Some(v1::server_frame::Frame::Deliver(env)),
};
@@ -405,6 +438,45 @@ impl AppState {
}
}
#[cfg(test)]
mod online_tests {
use super::*;
fn spojeni() -> Arc<OnlineDevice> {
let (tx, _rx) = mpsc::channel::<v1::ServerFrame>(1);
Arc::new(OnlineDevice::new(tx, 0))
}
#[test]
fn novejsi_spojeni_prebira_dorucovani() {
let online = OnlineDevices::default();
let adresa = vec![7u8; 32];
let (stare, nove) = (spojeni(), spojeni());
online.attach(adresa.clone(), stare.clone());
online.attach(adresa.clone(), nove.clone());
let cil = online.get(&adresa).expect("adresa je online");
assert!(Arc::ptr_eq(&cil, &nove));
}
#[test]
fn konec_stareho_spojeni_neodebere_nove() {
let online = OnlineDevices::default();
let adresa = vec![7u8; 32];
let (stare, nove) = (spojeni(), spojeni());
online.attach(adresa.clone(), stare.clone());
online.attach(adresa.clone(), nove.clone());
assert!(!online.detach(&adresa, &stare), "cizí záznam se nesmí odebrat");
let cil = online.get(&adresa).expect("nové spojení zůstává online");
assert!(Arc::ptr_eq(&cil, &nove));
assert!(online.detach(&adresa, &nove));
assert!(online.get(&adresa).is_none());
}
}
#[cfg(test)]
mod db_tests {
//! Integrace push registrace nad reálným Postgresem. #[ignore] — spouštět
@@ -679,7 +751,7 @@ mod db_tests {
let device = Arc::new(OnlineDevice::new(tx, 0));
state
.online
.insert(delivery_address.clone(), device.clone());
.attach(delivery_address.clone(), device.clone());
let env = v1::Envelope {
dst_address: delivery_address.clone(),
@@ -707,7 +779,7 @@ mod db_tests {
"živé spojení má být online"
);
state.online.remove(&delivery_address);
state.online.detach(&delivery_address, &device);
sqlx::query("DELETE FROM message_queue WHERE dst_address = $1")
.bind(&delivery_address)
.execute(&state.pool)
@@ -715,6 +787,46 @@ mod db_tests {
.unwrap();
}
/// Reconnect po změně sítě: staré spojení dožije až po novém a jeho
/// konec nesmí vyřadit nové spojení z živého doručování.
#[tokio::test]
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
async fn route_envelope_po_konci_zombie_spojeni_dorucuje_na_nove() {
let state = test_state().await;
let (account_id, _device_uuid, delivery_address) = seed_device(&state).await;
let (tx_stare, _rx_stare) = mpsc::channel::<v1::ServerFrame>(64);
let stare = Arc::new(OnlineDevice::new(tx_stare, state.now_ms()));
state.online.attach(delivery_address.clone(), stare.clone());
let (tx_nove, mut rx_nove) = mpsc::channel::<v1::ServerFrame>(64);
let nove = Arc::new(OnlineDevice::new(tx_nove, state.now_ms()));
state.online.attach(delivery_address.clone(), nove.clone());
// Handler starého spojení končí (idle timeout) až po reconnectu.
assert!(!state.online.detach(&delivery_address, &stare));
let msg_uuid = rand_bytes(16);
let env = v1::Envelope {
dst_address: delivery_address.clone(),
msg_uuid: msg_uuid.clone(),
content_hint: 0,
payload: vec![0xEF; 64],
};
assert_eq!(
state.route_envelope(env).await.unwrap(),
v1::receipt::Status::DeliveredOnline,
"konec zombie spojení nesmí vyřadit nové spojení"
);
match rx_nove.try_recv().map(|f| f.frame) {
Ok(Some(v1::server_frame::Frame::Deliver(env))) => assert_eq!(env.msg_uuid, msg_uuid),
jinak => panic!("nové spojení nedostalo DELIVER: {jinak:?}"),
}
assert!(state.online.detach(&delivery_address, &nove));
cleanup(&state, &account_id, &delivery_address).await;
}
/// Flush počítá pokusy — bez toho by strop nikdy nezabral.
#[tokio::test]
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
+3 -3
View File
@@ -276,7 +276,7 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
));
state
.online
.insert(pd.delivery_address.clone(), device.clone());
.attach(pd.delivery_address.clone(), device.clone());
online_device = Some(device);
authed_address = Some(pd.delivery_address.clone());
@@ -360,8 +360,8 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
}
}
if let Some(addr) = authed_address {
state.online.remove(&addr);
if let (Some(addr), Some(device)) = (&authed_address, &online_device) {
state.online.detach(addr, device);
}
writer.abort();
tracing::debug!("ws spojení uzavřeno");