fix(messages): acknowledge stored private messages, and disconnect cleanly

Two bugs an e2e test found the moment one existed for this path.

A private message the server held while we were offline arrives with
new_message false, and the handler only acknowledged the ones flagged
new — so exactly the messages that need an ack never got one and the
server re-delivered them at every login, forever. Every delivery is now
acknowledged.

A dropped Client also left its server connection open, so the session
lived on: mail addressed to us went to a socket nobody was reading and a
later login collided with our own ghost. Client::disconnect closes the
server and peer connections, and Drop calls it.

Also covers two features that were never driven end to end: an offline
message delivered once and not again, and a wishlist search answered like
any other.
This commit is contained in:
Michel de Graaf
2026-09-09 16:39:10 +00:00
parent 19791d1699
commit f5a97744f8
6 changed files with 215 additions and 10 deletions
@@ -202,6 +202,24 @@ impl PeerRegistry {
None
}
/// Stop every peer actor and empty the registry, closing the connections
/// they hold. Used when the client shuts down.
pub fn stop_all(&self) {
let handles = match self.peers.lock_safe() {
Ok(mut peers) => peers
.drain()
.map(|(_, (_, handle))| handle)
.collect::<Vec<_>>(),
Err(e) => {
error!("[peer_registry] stop_all: {}", e);
return;
}
};
for handle in handles {
let _ = handle.stop();
}
}
#[must_use]
pub fn contains(&self, username: &str) -> bool {
match self.peers.lock_safe() {
+24
View File
@@ -133,6 +133,30 @@ impl Client {
Ok(())
}
/// Close the server connection and every peer connection.
///
/// The server sees the socket close and marks us offline, which is what
/// releases the username: a client that simply goes out of scope without
/// this leaves a session the server still believes in, so mail addressed
/// to us is delivered to a connection nobody is reading and later logins
/// collide with our own ghost.
///
/// Called automatically when the client is dropped. The peer listener's
/// bound port, if one was opened, is released with the process.
pub fn disconnect(&mut self) {
if let Some(handle) = self.server_handle.take() {
let _ = handle.stop();
}
let registry = self
.context
.read_safe()
.ok()
.and_then(|ctx| ctx.peer_registry.clone());
if let Some(registry) = registry {
registry.stop_all();
}
}
/// Log in and wait for the server's verdict.
///
/// The wait is bounded: the actor only answers once it has a working
+6
View File
@@ -1163,6 +1163,12 @@ pub struct Client {
session: SessionWatch,
}
impl Drop for Client {
fn drop(&mut self) {
self.disconnect();
}
}
impl Client {
pub fn new(
username: impl Into<String>,
+3 -3
View File
@@ -551,7 +551,7 @@ fn an_expired_broker_connect_fails_the_queued_downloads() {
let (_ops_tx, ops_rx) = mpsc::channel();
Client::listen_to_client_operations(
ops_rx,
client.context,
client.context.clone(),
"u".to_string(),
);
@@ -582,7 +582,7 @@ fn a_replayed_transfer_response_does_not_start_a_second_transfer() {
let (ops_tx, ops_rx) = mpsc::channel();
Client::listen_to_client_operations(
ops_rx,
client.context,
client.context.clone(),
"u".to_string(),
);
@@ -648,7 +648,7 @@ fn a_cancelled_download_is_not_started_when_the_peer_allows_it() {
let (ops_tx, ops_rx) = mpsc::channel();
Client::listen_to_client_operations(
ops_rx,
client.context,
client.context.clone(),
"u".to_string(),
);
let peer = Peer::new(
@@ -28,16 +28,73 @@ impl MessageHandler<ServerMessage> for MessageUser {
info!("[MessageUser] User message received:{:?}", user_message);
// Acknowledge freshly delivered messages so the server does not keep
// re-sending them on every reconnect.
if new_message {
let _ = sender.send(ServerMessage::SendMessage(
MessageFactory::build_message_acked(id),
));
}
// Acknowledge every message, not only the ones flagged new. The flag
// marks a message delivered as it was sent; one the server stored
// while we were offline arrives with it false, and those are exactly
// the messages the server keeps re-sending at every login until they
// are acknowledged.
let _ = sender.send(ServerMessage::SendMessage(
MessageFactory::build_message_acked(id),
));
// Surface the message to the client so it can be read via the API.
let _ =
sender.send(ServerMessage::PrivateMessageReceived(user_message));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::framed;
fn deliver(new_message: bool) -> Vec<ServerMessage> {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_int32(7);
m.write_int32(1_700_000_000);
m.write_string("alice");
m.write_string("hello");
m.write_int8(u8::from(new_message));
});
MessageUser.handle(&mut message, tx);
rx.try_iter().collect()
}
fn acked_id(messages: &[ServerMessage]) -> Option<u32> {
messages.iter().find_map(|message| match message {
ServerMessage::SendMessage(m) => {
let mut decoded = Message::new_with_data(m.get_buffer());
(decoded.get_message_code() == 23).then(|| {
decoded.set_pointer(8);
decoded.read_int32()
})
}
_ => None,
})
}
#[test]
fn a_freshly_delivered_message_is_acknowledged() {
let sent = deliver(true);
assert_eq!(acked_id(&sent), Some(7));
}
#[test]
fn a_message_stored_while_we_were_offline_is_acknowledged_too() {
// Stored messages arrive with new_message false; without an ack the
// server re-delivers them at every login, forever.
let sent = deliver(false);
assert_eq!(acked_id(&sent), Some(7));
}
#[test]
fn the_message_itself_is_surfaced_to_the_client() {
let sent = deliver(false);
assert!(sent.iter().any(|message| matches!(
message,
ServerMessage::PrivateMessageReceived(m)
if m.message() == "hello" && m.username() == "alice"
)));
}
}