fix: review findings on the protocol work

Correctness:
- A child that stopped reading could freeze the whole client: the relay
  writes under the context lock and the socket was blocking, so one stalled
  peer took every download, upload and API call with it. Child writes now
  have a deadline, and a child that hits it loses its slot.
- Relaying searches to children was gated by the leaf's answer budget, so a
  parent forwarded a fraction of the stream while advertising itself as
  healthy. The budget bounds our own share scan, which is what it was for;
  the tree's stream goes down whole.
- A dropped client kept its listener port for the life of the process, so a
  retried login advertised an ephemeral port nobody was told about. The
  listener is now stopped and the operations thread let go.
- Excluded phrases were compared against a lowercased path without being
  lowercased themselves, so a capitalised phrase never matched. They are
  normalised where they arrive.
- Losing our own operatorship wiped the room's whole operator roster,
  though we are still a member who can see it.
- A brokered ConnectToPeer of type D was logged and dropped; the peer is
  now told, since we never dial out to adopt a child.
- A peer request made with no server connection returned Ok and parked
  forever; it says NotConnected, as its docs always claimed.
- An empty interest was refused locally but still sent, leaving the
  server's list and ours permanently apart.

Simplification:
- Deleted the excluded-phrase helper nothing called, and the field doc that
  promised behaviour it never had.
- The four roster handlers and four standing handlers were eight
  constructors carrying constants their own message code already implies:
  one newtype each, 100 lines less.
- One place registers a search; the phrase list is borrowed, not cloned per
  relayed search.
This commit is contained in:
Michel de Graaf
2026-09-09 18:38:34 +00:00
parent 435ff382d8
commit 6f32a4e090
11 changed files with 294 additions and 194 deletions
+44
View File
@@ -5452,3 +5452,47 @@ fn privileges_can_be_handed_to_another_user() {
"the gifted privileges should show up as time of our own"
);
}
#[test]
fn a_disconnected_client_gives_its_listener_port_back() {
// A client that goes out of scope must release the port it bound: the
// next one has to be able to advertise the port it was configured with,
// not fall back to an ephemeral one nobody was told about. No server is
// needed — binding the listener happens in connect(), before the dial.
let port = free_port().expect("a port to hold");
let settings = |port: u16| ClientSettings {
username: "e2e_port_release".to_string(),
password: "pw".to_string(),
server_address: PeerAddress::new("127.0.0.1".to_string(), 1),
enable_listen: true,
listen_port: port,
shared_directories: Vec::new(),
accept_children: false,
version: ClientVersion::default(),
};
let mut first = Client::with_settings(settings(port));
first.connect().expect("bind the listener");
assert_eq!(first.listen_port(), Some(port));
drop(first);
// The listener thread notices the stop the moment it is woken, which the
// disconnect does by dialling it; give it a beat to unwind.
let deadline = Instant::now() + Duration::from_secs(5);
let mut second_port = None;
while Instant::now() < deadline && second_port != Some(port) {
let mut second = Client::with_settings(settings(port));
second.connect().expect("bind again");
second_port = second.listen_port();
if second_port == Some(port) {
break;
}
drop(second);
std::thread::sleep(Duration::from_millis(200));
}
assert_eq!(
second_port,
Some(port),
"the port should be free again once the first client is dropped"
);
}