fix(lib): free a silent parent's slot and pace the moves we report

A candidate that accepted the dial and then said nothing kept its slot
for the session, and ten of those blocked every later batch; a link now
closes after a minute of silence. A parent that flaps its level made us
tell the server four messages per flap; moves are now told at most once
a second, the latest one winning. A parent that reports level zero is
its own root from then on, whatever it said before. The wait for a
searcher's control connection runs from the last message queued for it,
not the first. A successful login is pinned to reset the leaf.
This commit is contained in:
Michel de Graaf
2026-09-08 01:45:07 +02:00
parent 9d6a5aaa98
commit 85f26134ed
4 changed files with 111 additions and 14 deletions
@@ -36,15 +36,22 @@ fn the_server_socket_keeps_itself_alive() {
}
fn parked_actor(port: u16) -> ServerActor {
let (tx, _rx) = std::sync::mpsc::channel();
ServerActor::new(
parked_actor_with_client(port).0
}
fn parked_actor_with_client(
port: u16,
) -> (ServerActor, std::sync::mpsc::Receiver<ClientOperation>) {
let (tx, rx) = std::sync::mpsc::channel();
let actor = ServerActor::new(
PeerAddress::new("127.0.0.1".to_string(), port),
tx,
0,
false,
0,
0,
)
);
(actor, rx)
}
#[test]
@@ -191,6 +198,22 @@ fn a_successful_login_marks_the_session_live_again() {
assert_eq!(actor.session.loss(), None);
}
// A new session starts without a parent: the leaf drops whatever tree it
// hung from and announces its parentless stance from there.
#[test]
fn a_successful_login_resets_the_distributed_leaf() {
let (mut actor, client) = parked_actor_with_client(1);
actor.handle_login_status(true);
assert!(matches!(
client.try_recv(),
Ok(ClientOperation::ResetDistributed)
));
actor.handle_login_status(false);
assert!(client.try_recv().is_err(), "a failed login resets nothing");
}
#[test]
fn post_login_messages_carry_counts_and_conditional_wait_port() {
let messages = post_login_messages(true, 4321, 3, 7);
+69 -6
View File
@@ -20,6 +20,13 @@ use std::time::{Duration, Instant};
const LINK_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// How often an idle link looks at its cancel flag.
const CANCEL_POLL: Duration = Duration::from_secs(1);
/// A parent relays the network's whole search stream; one that says nothing
/// for this long is gone, and holding its slot would block every later
/// candidate.
const LINK_IDLE: Duration = Duration::from_mins(1);
/// Moves of our branch are told to the server at most this often; a parent
/// that flaps its level must not make us flood the server.
const MOVE_INTERVAL: Duration = Duration::from_secs(1);
/// A distributed frame is a search or a branch fact; anything bigger is not
/// one of ours.
const MAX_FRAME: usize = 64 * 1024;
@@ -82,6 +89,10 @@ pub struct Leaf {
candidates: HashMap<String, Candidate>,
next_link: u64,
searches: (Instant, u32),
/// When we last told the server we moved, and whether a move since then
/// is still waiting to be told.
moved: Option<Instant>,
move_pending: bool,
}
impl Leaf {
@@ -94,6 +105,8 @@ impl Leaf {
candidates: HashMap::new(),
next_link: 1,
searches: (Instant::now(), 0),
moved: None,
move_pending: false,
}
}
@@ -163,7 +176,7 @@ impl Leaf {
}
let candidate = self.candidates.get_mut(username)?;
candidate.level = u32::try_from(level).ok();
if level == 0 && candidate.root.is_none() {
if level == 0 {
candidate.root = Some(username.to_string());
}
self.moved_with_parent(username)
@@ -251,8 +264,24 @@ impl Leaf {
if branch == self.branch {
return None;
}
self.branch = branch.clone();
Some(branch)
self.branch = branch;
self.move_pending = true;
self.due_announcement(Instant::now())
}
/// The branch to tell the server about, if a move is waiting and the
/// last one was told at least [`MOVE_INTERVAL`] ago.
pub fn due_announcement(&mut self, now: Instant) -> Option<Branch> {
if !self.move_pending
|| self
.moved
.is_some_and(|at| now.duration_since(at) < MOVE_INTERVAL)
{
return None;
}
self.move_pending = false;
self.moved = Some(now);
Some(self.branch.clone())
}
fn cancel_candidates(&mut self) {
@@ -328,18 +357,25 @@ fn link(
.get_buffer(),
)?;
let mut reader = MessageReader::new();
let mut heard = Instant::now();
loop {
if dial.cancel.load(Ordering::Relaxed) {
return Ok(());
}
match reader.read_from_socket(&mut stream) {
Ok(()) => {}
Ok(()) => heard = Instant::now(),
Err(e)
if matches!(
e.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) =>
{
if heard.elapsed() > LINK_IDLE {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"silent parent",
));
}
continue;
}
Err(e) => return Err(e),
@@ -516,7 +552,7 @@ mod tests {
}
#[test]
fn the_parents_later_moves_move_us_too() {
fn the_parents_later_moves_move_us_too_but_are_told_once_a_second() {
let mut leaf = leaf();
let alice = dial(&mut leaf, &["alice"])["alice"].link;
leaf.branch_level("alice", alice, 0);
@@ -528,9 +564,36 @@ mod tests {
);
assert_eq!(
leaf.branch_level("alice", alice, 2),
None,
"a second move within the second waits"
);
assert_eq!(leaf.branch(), Branch::under("grand", 3), "but it took");
assert_eq!(leaf.due_announcement(Instant::now()), None);
assert_eq!(
leaf.due_announcement(Instant::now() + MOVE_INTERVAL),
Some(Branch::under("grand", 3))
);
assert_eq!(leaf.branch(), Branch::under("grand", 3));
assert_eq!(
leaf.due_announcement(Instant::now() + MOVE_INTERVAL),
None,
"told once"
);
}
#[test]
fn a_parent_that_becomes_a_root_is_the_root_from_then_on() {
let mut leaf = leaf();
let alice = dial(&mut leaf, &["alice"])["alice"].link;
leaf.branch_root("alice", alice, "grand");
leaf.branch_level("alice", alice, 5);
assert_eq!(
leaf.search_from("alice", alice),
Some(Branch::under("grand", 6))
);
assert_eq!(
leaf.branch_level("alice", alice, 0),
Some(Branch::under("alice", 1))
);
}
#[test]
+7 -5
View File
@@ -388,7 +388,8 @@ pub struct ClientContext {
/// Peer listen addresses learned from GetPeerAddress responses.
peer_addresses: HashMap<String, (String, u32)>,
/// Peer messages waiting for a control connection to that peer, and
/// since when: a peer that never answers must not keep them forever.
/// when the last was queued: a peer that never answers must not keep
/// them forever.
pending_peer_messages:
HashMap<String, (Instant, Vec<crate::message::Message>)>,
/// Uploads we have offered, keyed by our transfer token.
@@ -741,11 +742,12 @@ impl ClientContext {
username: &str,
message: crate::message::Message,
) {
self.pending_peer_messages
let entry = self
.pending_peer_messages
.entry(username.to_string())
.or_insert_with(|| (Instant::now(), Vec::new()))
.1
.push(message);
.or_insert_with(|| (Instant::now(), Vec::new()));
entry.0 = Instant::now();
entry.1.push(message);
}
/// Remove and return the messages queued for `username`.
+9
View File
@@ -26,6 +26,15 @@ impl Client {
Self::sweep_stale_offers(&client_context);
if let Ok(mut ctx) = client_context.write_safe() {
ctx.expire_pending_peer_messages(Instant::now());
if let Some(branch) =
ctx.leaf.due_announcement(Instant::now())
{
super::distributed::announce(
ctx.server_sender.as_ref(),
&branch,
true,
);
}
}
}
let operation = match next {