390 lines
14 KiB
Rust
390 lines
14 KiB
Rust
//! Releasing held notes and ending sessions before the machine goes to sleep (FR-026).
|
|
//!
|
|
//! A machine that sleeps with a note held on another machine leaves it sounding there until the
|
|
//! far side's liveness check gives up, which is more than half a minute. A platform that says a
|
|
//! suspend is coming gives the daemon the chance to release it first, and to tell the far side
|
|
//! the session is over so it waits rather than chasing a sleeping machine.
|
|
|
|
#![allow(
|
|
clippy::expect_used,
|
|
clippy::indexing_slicing,
|
|
clippy::panic,
|
|
clippy::unwrap_used
|
|
)]
|
|
|
|
mod common;
|
|
|
|
use midi_harbor_core::endpoint::InvitationPolicy;
|
|
use midi_harbor_core::ids::EndpointId;
|
|
use midi_harbor_core::midi::{Channel, MidiMessage};
|
|
use midi_harbor_core::state::ConnectionPhase;
|
|
use midi_harbor_daemon::Daemon;
|
|
use midi_harbor_platform::fake::{FakeMidiPlatform, FakeSystemEvents};
|
|
use midi_harbor_platform::midi::MidiPlatform;
|
|
use midi_harbor_platform::sysevents::{SystemEvent, SystemEvents};
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
/// NOTE is the key held down through each sleep.
|
|
const NOTE: u8 = 60;
|
|
|
|
/// Starts a daemon over a scratch directory, with machine events the test drives.
|
|
async fn machine(label: &str) -> (Arc<Daemon>, Arc<FakeMidiPlatform>, Arc<FakeSystemEvents>) {
|
|
let root =
|
|
common::scratch("midi-harbor-suspend").join(format!("{label}-{}", uuid::Uuid::new_v4()));
|
|
let platform = Arc::new(FakeMidiPlatform::new());
|
|
let system = Arc::new(FakeSystemEvents::new());
|
|
let daemon = Daemon::start_with(
|
|
common::quiet(root),
|
|
Arc::clone(&platform) as Arc<dyn MidiPlatform>,
|
|
Arc::clone(&system) as Arc<dyn SystemEvents>,
|
|
)
|
|
.await
|
|
.expect("the daemon starts over a scratch directory");
|
|
(daemon, platform, system)
|
|
}
|
|
|
|
/// Reports whether anything received would stop the note.
|
|
fn released(received: &[MidiMessage]) -> bool {
|
|
received.iter().any(|message| match message {
|
|
MidiMessage::NoteOff { note, .. } => *note == NOTE,
|
|
MidiMessage::NoteOn { note, velocity, .. } => *note == NOTE && *velocity == 0,
|
|
MidiMessage::ControlChange { controller, .. } => *controller == 123,
|
|
_ => false,
|
|
})
|
|
}
|
|
|
|
/// Returns the note-on that starts the held note.
|
|
fn held() -> MidiMessage {
|
|
MidiMessage::NoteOn {
|
|
channel: Channel::new(0).expect("channel 0 is a valid MIDI channel"),
|
|
note: NOTE,
|
|
velocity: 100,
|
|
}
|
|
}
|
|
|
|
/// Reports whether `condition` holds within `within`, polling it.
|
|
async fn until(mut condition: impl FnMut() -> bool, within: Duration) -> bool {
|
|
let started = std::time::Instant::now();
|
|
while started.elapsed() < within {
|
|
if condition() {
|
|
return true;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
}
|
|
condition()
|
|
}
|
|
|
|
/// Returns the phase a session is in, if the daemon knows it.
|
|
async fn phase(daemon: &Daemon, id: EndpointId) -> Option<ConnectionPhase> {
|
|
daemon
|
|
.session_status(id)
|
|
.await
|
|
.map(|status| status.state.phase())
|
|
}
|
|
|
|
/// Reports whether a session reaches `wanted` within `within`, polling its phase.
|
|
async fn reaches(
|
|
daemon: &Daemon,
|
|
id: EndpointId,
|
|
wanted: ConnectionPhase,
|
|
within: Duration,
|
|
) -> bool {
|
|
let started = std::time::Instant::now();
|
|
while started.elapsed() < within {
|
|
if phase(daemon, id).await == Some(wanted) {
|
|
return true;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Proves that a note held across a network session is released on the far machine for a sleep
|
|
/// still to come, and not for one already over (FR-026).
|
|
///
|
|
/// A coming sleep must release the note and only then tell the platform the suspend may go ahead,
|
|
/// or it sounds on the far machine until that side's liveness check gives up. The polled watcher
|
|
/// reports a suspend only once the machine is back, paired with its resume; releasing then could
|
|
/// cut a note someone had just started to play, and the platform, already past the sleep, is not
|
|
/// told anything. A release is awaited for up to three seconds; its absence is judged after one
|
|
/// and a half, the watcher's one-second pass plus half a pass of margin.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn a_held_note_is_released_only_for_a_sleep_still_to_come() {
|
|
struct Case {
|
|
name: &'static str,
|
|
event: fn(&FakeSystemEvents),
|
|
want_released: bool,
|
|
want_readied: usize,
|
|
}
|
|
let cases = [
|
|
Case {
|
|
name: "coming",
|
|
event: |system| system.emit(SystemEvent::Suspending),
|
|
want_released: true,
|
|
want_readied: 1,
|
|
},
|
|
Case {
|
|
name: "over",
|
|
event: FakeSystemEvents::sleep_and_wake,
|
|
want_released: false,
|
|
want_readied: 0,
|
|
},
|
|
];
|
|
|
|
for case in cases {
|
|
// Set up the far machine: a session feeding a synth.
|
|
let (far, far_platform, _) = machine(&format!("{}-far", case.name)).await;
|
|
far.create_virtual_port("Synth", 1, 1)
|
|
.await
|
|
.expect("the Synth port is created");
|
|
let incoming = 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(incoming.id)
|
|
.await
|
|
.expect("the far session reports its status")
|
|
.control_port;
|
|
|
|
// Set up this machine: a keyboard routed into a session connected to the far one.
|
|
let (near, near_platform, system) = machine(&format!("{}-near", case.name)).await;
|
|
near.create_virtual_port("Keys", 1, 1)
|
|
.await
|
|
.expect("the Keys port is created");
|
|
let outgoing = 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(outgoing.id, SocketAddr::from(([127, 0, 0, 1], port)))
|
|
.await
|
|
.expect("the near session is pointed at the far one");
|
|
assert!(
|
|
reaches(
|
|
&near,
|
|
outgoing.id,
|
|
ConnectionPhase::Connected,
|
|
Duration::from_secs(5)
|
|
)
|
|
.await,
|
|
"{}: the sessions never connected over loopback",
|
|
case.name
|
|
);
|
|
|
|
// Hold the note on the far synth.
|
|
let keys = near_platform
|
|
.port_handle("Keys")
|
|
.expect("the Keys port has a platform handle");
|
|
let synth = far_platform
|
|
.port_handle("Synth")
|
|
.expect("the Synth port has a platform handle");
|
|
assert!(
|
|
near_platform.feed(keys, &[held()]),
|
|
"{}: the keyboard port accepts the note",
|
|
case.name
|
|
);
|
|
assert!(
|
|
until(
|
|
|| far_platform.sent(synth).contains(&held()),
|
|
Duration::from_secs(2)
|
|
)
|
|
.await,
|
|
"{}: the note never reached the far synth",
|
|
case.name
|
|
);
|
|
|
|
// Report the sleep and judge the release.
|
|
(case.event)(&system);
|
|
let within = if case.want_released {
|
|
Duration::from_secs(3)
|
|
} else {
|
|
Duration::from_millis(1_500)
|
|
};
|
|
assert_eq!(
|
|
until(|| released(&far_platform.sent(synth)), within).await,
|
|
case.want_released,
|
|
"{}: the note is released on the far machine exactly when the sleep is still to come",
|
|
case.name
|
|
);
|
|
assert!(
|
|
until(
|
|
|| system.times_readied() == case.want_readied,
|
|
Duration::from_secs(1)
|
|
)
|
|
.await,
|
|
"{}: the platform is told the suspend may go ahead once per sleep still to come, \
|
|
and was told {} times",
|
|
case.name,
|
|
system.times_readied()
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Proves that a session ended for sleep stays ended until the machine wakes, whichever side
|
|
/// made the connection, and then reconnects at once (R-070, R-072).
|
|
///
|
|
/// Left to find out for itself, a far machine that was invited invited again when its liveness
|
|
/// check gave up, and one that had made the connection invited again at once and every
|
|
/// thirty-five seconds after; each invitation woke the sleeping Mac for most of a minute (R-070).
|
|
/// So the sleeping machine tells the far side before the suspend goes ahead, and the far side
|
|
/// then waits rather than chasing. NetworkManager also takes the network down as the machine
|
|
/// goes, which reads as an address change, and acting on it reconnected the session just ended
|
|
/// for sleep (R-072); the "network down" row reports one change with the suspend and another
|
|
/// 1.2 s later, as the address settles. After two seconds left alone, waking must reconnect
|
|
/// within three, well inside the thirty-five a liveness timeout would take.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn a_session_ended_for_sleep_stays_ended_until_the_machine_wakes() {
|
|
struct Case {
|
|
name: &'static str,
|
|
sleeper_invites: bool,
|
|
network_drops: bool,
|
|
}
|
|
let cases = [
|
|
Case {
|
|
name: "inviting",
|
|
sleeper_invites: true,
|
|
network_drops: false,
|
|
},
|
|
Case {
|
|
name: "invited",
|
|
sleeper_invites: false,
|
|
network_drops: false,
|
|
},
|
|
Case {
|
|
name: "network down",
|
|
sleeper_invites: true,
|
|
network_drops: true,
|
|
},
|
|
];
|
|
|
|
for case in cases {
|
|
// Connect the machine that will sleep and the far one, in the row's direction.
|
|
let (near, _, system) = machine(&format!("{}-near", case.name)).await;
|
|
let (far, _, _) = machine(&format!("{}-far", case.name)).await;
|
|
let near_policy = if case.sleeper_invites {
|
|
InvitationPolicy::Prompt
|
|
} else {
|
|
InvitationPolicy::AcceptAll
|
|
};
|
|
let sleeper = near
|
|
.create_network_session("Laptop", 0, near_policy)
|
|
.await
|
|
.expect("the sleeping machine's session is created");
|
|
let desk = far
|
|
.create_network_session("Desk", 0, InvitationPolicy::AcceptAll)
|
|
.await
|
|
.expect("the far machine's session is created");
|
|
let ((inviter, from), (invited, to)) = if case.sleeper_invites {
|
|
((&near, sleeper.id), (&far, desk.id))
|
|
} else {
|
|
((&far, desk.id), (&near, sleeper.id))
|
|
};
|
|
let port = invited
|
|
.session_status(to)
|
|
.await
|
|
.expect("the invited session reports its status")
|
|
.control_port;
|
|
inviter
|
|
.connect_peer(from, SocketAddr::from(([127, 0, 0, 1], port)))
|
|
.await
|
|
.expect("the inviting session is pointed at the other");
|
|
assert!(
|
|
reaches(
|
|
&near,
|
|
sleeper.id,
|
|
ConnectionPhase::Connected,
|
|
Duration::from_secs(5)
|
|
)
|
|
.await,
|
|
"{}: the sessions never connected over loopback",
|
|
case.name
|
|
);
|
|
|
|
// Go to sleep: the far side is told before the platform may suspend.
|
|
system.emit(SystemEvent::Suspending);
|
|
if case.network_drops {
|
|
system.emit(SystemEvent::NetworkChanged);
|
|
}
|
|
assert!(
|
|
until(|| system.times_readied() == 1, Duration::from_secs(3)).await,
|
|
"{}: the platform was never told the suspend could go ahead",
|
|
case.name
|
|
);
|
|
assert!(
|
|
reaches(
|
|
&far,
|
|
desk.id,
|
|
ConnectionPhase::Disconnected,
|
|
Duration::from_secs(2)
|
|
)
|
|
.await,
|
|
"{}: the far session was not told the machine is going to sleep",
|
|
case.name
|
|
);
|
|
|
|
// Stay asleep: nothing reconnects, whatever the network does meanwhile.
|
|
if case.network_drops {
|
|
tokio::time::sleep(Duration::from_millis(1_200)).await;
|
|
system.emit(SystemEvent::NetworkChanged);
|
|
}
|
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
assert_eq!(
|
|
phase(&far, desk.id).await,
|
|
Some(ConnectionPhase::Disconnected),
|
|
"{}: the far session went after a machine that said it was going to sleep",
|
|
case.name
|
|
);
|
|
assert_ne!(
|
|
phase(&near, sleeper.id).await,
|
|
Some(ConnectionPhase::Connected),
|
|
"{}: the session reconnected on the way to sleep",
|
|
case.name
|
|
);
|
|
|
|
// Wake: the session comes back without waiting for a liveness timeout.
|
|
system.emit(SystemEvent::Resumed);
|
|
assert!(
|
|
reaches(
|
|
&near,
|
|
sleeper.id,
|
|
ConnectionPhase::Connected,
|
|
Duration::from_secs(3)
|
|
)
|
|
.await,
|
|
"{}: the machine that slept did not reconnect on waking",
|
|
case.name
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Proves that a coming sleep is acted on at once rather than on the watcher's next pass (R-072).
|
|
///
|
|
/// NetworkManager has the network down within tens of milliseconds of the sleep signal, so
|
|
/// waiting for the next once-a-second look sent the goodbye into no network. Each of three rounds
|
|
/// must be readied within 150 ms, well under the one-second pass, so a round that waited for the
|
|
/// pass fails; three rounds keep one lucky alignment with the pass from passing the test.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn a_coming_sleep_is_acted_on_at_once() {
|
|
let (_, _, system) = machine("prompt").await;
|
|
for round in 1..=3 {
|
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
|
system.emit(SystemEvent::Suspending);
|
|
assert!(
|
|
until(
|
|
|| system.times_readied() == round,
|
|
Duration::from_millis(150)
|
|
)
|
|
.await,
|
|
"round {round}: the suspend waited for the watcher's next pass"
|
|
);
|
|
system.emit(SystemEvent::Resumed);
|
|
}
|
|
}
|