//! Switching a network session off and on. //! //! A session runs in its own supervisor, which reconciling never reached. Disabling one changed //! the configuration and nothing else: it went on listening, advertising and carrying MIDI while //! the configuration said it was off. Enabling one that had started disabled did nothing until //! the daemon restarted. #![allow( clippy::expect_used, clippy::indexing_slicing, clippy::panic, clippy::unwrap_used )] mod common; use midi_harbor_core::endpoint::InvitationPolicy; use midi_harbor_core::paths::Paths; use midi_harbor_daemon::Daemon; use midi_harbor_platform::fake::FakeMidiPlatform; use midi_harbor_platform::midi::MidiPlatform; use std::net::{Ipv6Addr, SocketAddr, UdpSocket}; use std::sync::Arc; use std::time::Duration; /// Starts a daemon over a scratch directory of its own. async fn daemon(label: &str) -> Arc { let root = common::scratch("midi-harbor-sessions").join(format!("{label}-{}", uuid::Uuid::new_v4())); Daemon::start( common::quiet(root), Arc::new(FakeMidiPlatform::new()) as Arc, ) .await .expect("the daemon starts over a scratch directory") } /// Reports whether anything still holds the port, which is how a stopped session is told from /// one that only says it stopped. fn port_is_held(port: u16) -> bool { UdpSocket::bind(SocketAddr::from((Ipv6Addr::UNSPECIFIED, port))).is_err() && UdpSocket::bind(SocketAddr::from(([0, 0, 0, 0], port))).is_err() } /// Waits up to two seconds for a condition, reporting whether it held. async fn eventually(mut condition: impl FnMut() -> bool) -> bool { for _ in 0..40 { if condition() { return true; } tokio::time::sleep(Duration::from_millis(50)).await; } false } /// Proves that disabling a session stops its supervisor and releases its UDP port, rather than /// only marking it off in the configuration while it goes on listening. #[tokio::test] async fn a_disabled_session_stops_listening() { let daemon = daemon("disable").await; let session = daemon .create_network_session("Stage", 0, InvitationPolicy::Prompt) .await .expect("the session is created"); let port = daemon .session_status(session.id) .await .expect("the session is running") .control_port; assert!( port_is_held(port), "the session is not listening to begin with" ); daemon .set_enabled(session.id, false) .await .expect("the session is disabled"); assert!( daemon.session_status(session.id).await.is_none(), "the supervisor still runs" ); assert!( eventually(|| !port_is_held(port)).await, "a disabled session still holds its port" ); } /// Proves that a session switched off and on again starts listening again, on the UDP port the /// system chose when it was made, and keeps that port in the configuration so a restarted daemon /// comes back on it as well. /// /// A peer that connected by address retries that address. A session that came back on a /// different port left it retrying a port with nothing behind it, for good. #[tokio::test] async fn a_session_keeps_the_port_the_system_chose() { let daemon = daemon("pinned").await; let session = daemon .create_network_session("Stage", 0, InvitationPolicy::Prompt) .await .expect("the session is created"); let chosen = daemon .session_status(session.id) .await .expect("the session is running") .control_port; daemon .set_enabled(session.id, false) .await .expect("the session is disabled"); let enabled = daemon .set_enabled(session.id, true) .await .expect("the session is enabled again"); let status = daemon .session_status(session.id) .await .expect("enabling a session started nothing"); assert_eq!( status.control_port, chosen, "the session came back on a different UDP port" ); assert!( port_is_held(status.control_port), "the session enabled again says it runs but does not listen" ); let midi_harbor_core::endpoint::EndpointKind::NetworkSession(config) = enabled.kind else { panic!("the endpoint enabled is not a session"); }; assert_eq!( config.control_port, chosen, "the chosen UDP port is not kept in the configuration" ); } /// Proves that a session losing its peer and getting it back records both in the history, and /// that a peer switching its session off is recorded as a disconnection. /// /// Regression: sessions logged their connecting and losing connection but never recorded either, /// so the history, which is what a user reads, showed a session that had dropped as never having /// done anything at all. Connecting twice (once at the start, once after the far side returns) /// makes two connection records. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn a_session_losing_its_peer_and_getting_it_back_is_in_the_history() { let far = daemon("history-far").await; let far_session = far .create_network_session("Stage In", 0, InvitationPolicy::AcceptAll) .await .expect("the far session is created"); let port = far .session_status(far_session.id) .await .expect("the far session is running") .control_port; let near = daemon("history-near").await; let near_session = near .create_network_session("Stage Out", 0, InvitationPolicy::Prompt) .await .expect("the near session is created"); near.connect_peer(near_session.id, SocketAddr::from(([127, 0, 0, 1], port))) .await .expect("the near session invites the far one"); let recorded = |daemon: Arc, text: &'static str, times: usize| async move { for _ in 0..200 { let found = daemon .events(None, 200) .await .iter() .filter(|event| { event.endpoint == Some(near_session.id) && event.detail.contains(text) }) .count(); if found >= times { return true; } tokio::time::sleep(Duration::from_millis(100)).await; } false }; assert!( recorded(Arc::clone(&near), "Stage Out connected to", 1).await, "connecting was not recorded" ); // Switching the far session off ends the session from its side, which is what the history // says: a peer that said goodbye is not one that stopped answering. far.set_enabled(far_session.id, false) .await .expect("the far session is switched off"); assert!( recorded(Arc::clone(&near), "disconnected from Stage Out", 1).await, "losing the connection was not recorded" ); far.set_enabled(far_session.id, true) .await .expect("the far session is switched on again"); assert!( recorded(Arc::clone(&near), "Stage Out connected to", 2).await, "reconnecting was not recorded" ); } /// Proves that a daemon on its way out ends its sessions at the far end, so a peer does not keep /// a dead session listed. /// /// Regression: stopping the daemon silenced notes but left every session open at the far end, so /// Apple's Network MIDI kept the old session listed and sent its MIDI there (R-068). The far /// daemon gets a runtime of its own, torn down afterwards as a process exit tears one down, /// because a session left running on a live runtime says goodbye by itself once dropped. #[test] fn a_daemon_on_its_way_out_tells_its_peers() { let far_runtime = tokio::runtime::Runtime::new().expect("the far machine's runtime starts"); let near_runtime = tokio::runtime::Runtime::new().expect("the near machine's runtime starts"); let (far, port) = far_runtime.block_on(async { let far = daemon("exit-far").await; let session = far .create_network_session("Stage In", 0, InvitationPolicy::AcceptAll) .await .expect("the far session is created"); let port = far .session_status(session.id) .await .expect("the far session is running") .control_port; (far, port) }); let (near, near_session) = near_runtime.block_on(async { let near = daemon("exit-near").await; let session = near .create_network_session("Stage Out", 0, InvitationPolicy::Prompt) .await .expect("the near session is created"); near.connect_peer(session.id, SocketAddr::from(([127, 0, 0, 1], port))) .await .expect("the near session invites the far one"); (near, session.id) }); let recorded = |text: &'static str| { near_runtime.block_on(async { for _ in 0..100 { if near.events(None, 200).await.iter().any(|event| { event.endpoint == Some(near_session) && event.detail.contains(text) }) { return true; } tokio::time::sleep(Duration::from_millis(50)).await; } false }) }; assert!(recorded("Stage Out connected to"), "never connected"); far_runtime.block_on(far.end_sessions()); far_runtime.shutdown_background(); assert!( recorded("disconnected from Stage Out"), "the peer was not told" ); } /// Proves that what a session carries is counted on both sides and can be watched while it /// runs. /// /// Regression: a session had no runtime entry, so nothing it carried was counted and monitoring /// it said it was not running while it carried MIDI. One note is fed, so each side counts at /// least one message. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn what_a_session_carries_is_counted_and_can_be_watched() { use midi_harbor_core::midi::{Channel, MidiMessage}; let far_platform = Arc::new(FakeMidiPlatform::new()); let far = Daemon::start( common::quiet( common::scratch("midi-harbor-sessions") .join(format!("counted-far-{}", uuid::Uuid::new_v4())), ), Arc::clone(&far_platform) as Arc, ) .await .expect("the far daemon starts over a scratch directory"); far.create_virtual_port("Synth", 1, 1) .await .expect("the far port Synth is created"); let far_session = far .create_network_session("Stage In", 0, InvitationPolicy::AcceptAll) .await .expect("the far session is created"); far.create_route("Stage In", "Synth") .await .expect("the route from Stage In to Synth is created"); let port = far .session_status(far_session.id) .await .expect("the far session is running") .control_port; let near_platform = Arc::new(FakeMidiPlatform::new()); let near = Daemon::start( common::quiet( common::scratch("midi-harbor-sessions") .join(format!("counted-near-{}", uuid::Uuid::new_v4())), ), Arc::clone(&near_platform) as Arc, ) .await .expect("the near daemon starts over a scratch directory"); near.create_virtual_port("Keys", 1, 1) .await .expect("the near port Keys is created"); let near_session = near .create_network_session("Stage Out", 0, InvitationPolicy::Prompt) .await .expect("the near session is created"); near.create_route("Keys", "Stage Out") .await .expect("the route from Keys to Stage Out is created"); near.connect_peer(near_session.id, SocketAddr::from(([127, 0, 0, 1], port))) .await .expect("the near session invites the far one"); assert!( connected_within(&near, near_session.id).await, "a far session accepting everyone lets the near one in" ); let mut watching = far .watch_endpoint(far_session.id) .await .expect("a running session can be watched"); let note = MidiMessage::NoteOn { channel: Channel::new(0).expect("channel 0 is valid"), note: 60, velocity: 100, }; assert!( near_platform.feed( near_platform .port_handle("Keys") .expect("Keys has a platform port"), &[note] ), "the fake platform takes the note fed into Keys" ); let seen = tokio::time::timeout(Duration::from_secs(2), watching.recv()) .await .expect("the note should have been seen") .expect("the feed stays open"); assert!( matches!(seen.seen, midi_harbor_daemon::state::Seen::Message(m) if m == note), "the far session's watcher saw something other than the note sent: {:?}", seen.seen ); assert!( !seen.outbound, "a note arriving from the network is seen as inbound" ); let received = far .counters(far_session.id) .await .expect("the far session has counters") .messages_received; let sent = near .counters(near_session.id) .await .expect("the near session has counters") .messages_sent; assert!(received >= 1, "the far session counted {received} received"); assert!(sent >= 1, "the near session counted {sent} sent"); } /// Proves that announcing sessions can be switched off and on in the configuration file and /// applies on reload without restarting any session. /// /// A network where announcing is unwelcome, and every test run, needs sessions that work without /// being seen. #[tokio::test] async fn announcing_sessions_can_be_switched_off_and_on() { let root = common::scratch("midi-harbor-sessions").join(format!("quiet-{}", uuid::Uuid::new_v4())); let paths = Paths::rooted_at(&root); std::fs::create_dir_all(paths.config_dir()).expect("the configuration directory is created"); std::fs::write( paths.config_file(), "preferences:\n advertise_sessions: false\nendpoints:\n - name: Quiet Stage\n kind: network_session\n", ) .expect("the configuration with announcing off is written"); let daemon = Daemon::start( paths.clone(), Arc::new(FakeMidiPlatform::new()) as Arc, ) .await .expect("the daemon starts over the written configuration"); let id = daemon .resolve("Quiet Stage") .await .expect("the configured session exists"); let port = daemon .session_status(id) .await .expect("the session is running") .control_port; assert!( port_is_held(port), "a session that is not announced still listens" ); assert!( !daemon .announced_sessions() .contains(&"Quiet Stage".to_owned()), "announced with announcing off" ); let text = std::fs::read_to_string(paths.config_file()).expect("the configuration file is readable"); std::fs::write( paths.config_file(), text.replace("advertise_sessions: false", "advertise_sessions: true"), ) .expect("the configuration with announcing on is written"); daemon .reload_configuration() .await .expect("the configuration reloads"); assert!( daemon .announced_sessions() .contains(&"Quiet Stage".to_owned()), "switching announcing on in the file does not announce the session" ); assert_eq!( daemon .session_status(id) .await .expect("the session is running") .control_port, port, "the session restarted to be announced" ); let text = std::fs::read_to_string(paths.config_file()).expect("the configuration file is readable"); std::fs::write( paths.config_file(), text.replace("advertise_sessions: true", "advertise_sessions: false"), ) .expect("the configuration with announcing off is written again"); daemon .reload_configuration() .await .expect("the configuration reloads"); assert!( !daemon .announced_sessions() .contains(&"Quiet Stage".to_owned()), "switching announcing off in the file leaves the session announced" ); } /// Waits up to five seconds for a session to be connected, reporting whether it was. async fn connected_within(daemon: &Arc, id: midi_harbor_core::ids::EndpointId) -> bool { for _ in 0..100 { let phase = daemon .session_status(id) .await .map(|status| status.state.phase()); if phase == Some(midi_harbor_core::state::ConnectionPhase::Connected) { return true; } tokio::time::sleep(Duration::from_millis(50)).await; } false } /// Proves that a session comes back from a restart connected to its peer when it was left /// connected, and stays disconnected when the user disconnected it (FR-015). /// /// Regression: the peer was held only in the running supervisor, so a restart or a reboot brought /// the session back listening and nothing connected it again. The disconnected row only means /// something beside the connected one, which shows a restart can reconnect at all. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn a_session_comes_back_connected_after_a_restart_only_if_it_was_left_connected() { struct Case { name: &'static str, disconnect_first: bool, want_connected: bool, } let cases = [ Case { name: "left connected", disconnect_first: false, want_connected: true, }, Case { name: "disconnected by the user", disconnect_first: true, want_connected: false, }, ]; for case in cases { let far = daemon("restart-far").await; let far_session = far .create_network_session("Stage In", 0, InvitationPolicy::AcceptAll) .await .expect("the far session is created"); let port = far .session_status(far_session.id) .await .expect("the far session is running") .control_port; let root = common::scratch("midi-harbor-sessions") .join(format!("restart-near-{}", uuid::Uuid::new_v4())); // Quieted once: `quiet` rewrites the configuration file, which a restart must find as it // was left. let paths = common::quiet(root); let start = || { Daemon::start( paths.clone(), Arc::new(FakeMidiPlatform::new()) as Arc, ) }; let first = start() .await .expect("the near daemon starts over a scratch directory"); let near = first .create_network_session("Stage Out", 0, InvitationPolicy::Prompt) .await .expect("the near session is created"); first .connect_peer(near.id, SocketAddr::from(([127, 0, 0, 1], port))) .await .expect("the near session invites the far one"); assert!( connected_within(&first, near.id).await, "{}: the near session never connected before the restart", case.name ); if case.disconnect_first { first .disconnect_peer(near.id) .await .expect("the near session disconnects from the far one"); } first.end_sessions().await; let second = start() .await .expect("the near daemon starts again over the same directory"); assert_eq!( connected_within(&second, near.id).await, case.want_connected, "{}: after a restart the session should be connected only if it was left connected", case.name ); } }