Files
Aleksandr Bogomiakov ed28e86748 Derive default network ranges and switch overlapping networks
Every first network previously proposed 10.13.37.0/24. Independently created
networks therefore collided when a device joined more than one of them.
Use the existing stable NetworkId-derived /24 in 10/8 for every new network,
independent of activation order or peer availability. Legacy and explicit
signed ranges outrank the derived default; remaining range disagreements
still use author-id ordering. The derivation, identity labels, handshake and
signed record encoding stay unchanged. There is no creator, timestamp or vote.

Reserve ranges before starting network actors and serialise start, stop and
switching through the agent lifecycle lock. Refuse equal or nested overlaps
without changing a network's subnet locally. A late authenticated range that
collides suspends only its network before updating plugins or the interface.
Coalesced watch notifications let the agent clean up outside the network actor
and reconsider suspended networks when ranges change.

An explicit switch stops every blocker before activating the chosen network.
Suspended networks retain their startup request and resume whenever their
ranges become free, including switching from a wide range to a narrow one.
A manual stop cancels that request even for an already suspended network.
Persist preference order and enabling together in an SQLite transaction so
the user's selection survives restart; no database schema migration is needed.

CLI status and network listing show blocker names, ids, ranges and the start
command. Network start switches explicitly and reports suspended networks;
joining an overlapping network reports it as configured but inactive. The
tray adds conflict details, an amber icon, Switch to this network and Keep
stopped actions. Local IPC wire version is 19 for the extended reports, so
restart the agent and tray together. Release versions and peer wire formats
are unchanged. Update the user documentation and compatibility tests.

Validation on Linux through nix develop:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets with TSUNAGI_CONTROL_SOCKET
  removed from the test environment: 486 passed, 2 pre-existing ignored tests
- final focused range_conflicts integration suite after making discovery
  fixtures deterministic: 6 passed

Earlier validation attempts caught outdated diagnostic assertions, a new
test helper scope error, and the inherited system control socket override.
Those were corrected before the successful runs. Plain cargo was unavailable
in the host shell, so validation used the Nix development toolchain. macOS,
Windows, privileged host TUN operations and manual tray interaction were not
exercised.
2026-10-11 11:58:27 +01:00

172 lines
6.4 KiB
Rust

//! Scenario 11: leaving a network, and what the others are left holding.
//!
//! Leaving is not deactivating and not forgetting. It is a signed statement
//! that this member gives up what it claimed, published while there is still
//! somebody to hear it, because signed state has no expiry and nothing else
//! will ever free the address.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::testing::{TestAgent, network, wait_for_peers, wait_until};
#[tokio::test]
async fn leaving_frees_the_address_for_everyone_still_there() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("leaving-frees");
let leaver = TestAgent::spawn(&discovery).await.unwrap();
let stayer = TestAgent::spawn(&discovery).await.unwrap();
let network_id = leaver.agent.join_network(&name, &secret).await.unwrap();
stayer.agent.join_network(&name, &secret).await.unwrap();
wait_for_peers(&stayer.agent, network_id, 1).await;
let leaver_id = leaver.agent.endpoint_id();
let address = wait_until("the one leaving holds an address", || async {
let status = stayer.agent.network_status(network_id).await.ok()?;
status
.members
.iter()
.find(|member| member.endpoint_id == leaver_id)?
.overlay_address_v4
})
.await;
let outcome = leaver.agent.leave_network(network_id).await.unwrap();
assert!(outcome.announced, "there was a session to announce it on");
assert_eq!(outcome.peers_told, 1);
// The one still there drops it from the roster. The tombstone stays in
// the record set — it has to, or a replica that never heard of it would
// reinstate the old claim — but a member that gave everything up is not
// a member, and listing it as one makes leaving look like a fault.
wait_until("the member is gone from the roster", || async {
let status = stayer.agent.network_status(network_id).await.ok()?;
status
.members
.iter()
.all(|member| member.endpoint_id != leaver_id)
.then_some(())
})
.await;
let taken = stayer.agent.network_status(network_id).await.unwrap();
assert!(
!taken
.members
.iter()
.any(|member| member.overlay_address_v4 == Some(address)),
"the address is free for somebody else"
);
// And locally there is no membership left to be surprised by.
assert!(
leaver.agent.list_networks().await.unwrap().is_empty(),
"the network is gone from the store"
);
assert!(!leaver.agent.is_active(network_id).await);
leaver.agent.shutdown().await;
stayer.agent.shutdown().await;
}
#[tokio::test]
async fn leaving_with_nobody_connected_says_so_rather_than_pretending() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("leaving-alone");
let agent = TestAgent::spawn(&discovery).await.unwrap();
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
// Nobody is here, so the tombstone reaches nobody. The network is still
// left — the caller asked — but the outcome does not claim an audience
// there was not one for.
let outcome = agent.agent.leave_network(network_id).await.unwrap();
assert!(outcome.announced, "it was published locally");
assert_eq!(outcome.peers_told, 0);
assert!(agent.agent.list_networks().await.unwrap().is_empty());
agent.agent.shutdown().await;
}
#[tokio::test]
async fn leaving_a_network_that_is_not_running_tells_nobody() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("leaving-inactive");
let agent = TestAgent::spawn(&discovery).await.unwrap();
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
agent.agent.deactivate_network(network_id).await.unwrap();
// There is no runtime to sign and send from, so nothing was announced
// and the outcome says exactly that instead of a quiet success.
let outcome = agent.agent.leave_network(network_id).await.unwrap();
assert!(!outcome.announced);
assert_eq!(outcome.peers_told, 0);
assert!(agent.agent.list_networks().await.unwrap().is_empty());
agent.agent.shutdown().await;
}
#[tokio::test]
async fn rejoining_after_leaving_is_not_taken_for_a_stale_record() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("leaving-rejoin");
let returner = TestAgent::spawn(&discovery).await.unwrap();
let stayer = TestAgent::spawn(&discovery).await.unwrap();
let network_id = returner.agent.join_network(&name, &secret).await.unwrap();
stayer.agent.join_network(&name, &secret).await.unwrap();
wait_for_peers(&stayer.agent, network_id, 1).await;
let returner_id = returner.agent.endpoint_id();
wait_until("the one leaving holds an address", || async {
stayer
.agent
.network_status(network_id)
.await
.ok()?
.members
.iter()
.find(|member| member.endpoint_id == returner_id)?
.overlay_address_v4
})
.await;
returner.agent.leave_network(network_id).await.unwrap();
wait_until("the release reached the other one", || async {
let status = stayer.agent.network_status(network_id).await.ok()?;
status
.members
.iter()
.all(|member| member.endpoint_id != returner_id)
.then_some(())
})
.await;
// The same device, the same key, the same network. The version counter
// survived leaving on purpose: a claim numbered below the release would
// be ignored by every replica that already has the release, and this
// member would be invisible for good.
returner.agent.join_network(&name, &secret).await.unwrap();
wait_for_peers(&stayer.agent, network_id, 1).await;
let again = wait_until("the returning member is seen again", || async {
stayer
.agent
.network_status(network_id)
.await
.ok()?
.members
.iter()
.find(|member| member.endpoint_id == returner_id)?
.overlay_address_v4
})
.await;
assert!(
tsunagi::state::derived_ipv4_range(network_id).contains(again),
"an address in the network's range: {again}"
);
returner.agent.shutdown().await;
stayer.agent.shutdown().await;
}