289 lines
9.9 KiB
Rust
289 lines
9.9 KiB
Rust
//! How long a message takes to cross the daemon between two virtual ports (SC-008).
|
|
//!
|
|
//! Ignored by default because it needs the machine's real MIDI system. Run it with:
|
|
//!
|
|
//! ```text
|
|
//! cargo test --release --test latency -- --ignored --nocapture
|
|
//! ```
|
|
//!
|
|
//! A daemon runs in this process on the real backend, with two ports and a route between them. A
|
|
//! second backend instance stands in for another application: it sends into one port and listens
|
|
//! on the other, the way a DAW and a synth would, and times each message across.
|
|
|
|
#![allow(
|
|
clippy::expect_used,
|
|
clippy::indexing_slicing,
|
|
clippy::panic,
|
|
clippy::unwrap_used
|
|
)]
|
|
|
|
use midi_harbor_core::ids::EndpointId;
|
|
use midi_harbor_core::midi::{Channel, MidiMessage};
|
|
use midi_harbor_core::paths::Paths;
|
|
use midi_harbor_core::rtchannel::{self, Drained};
|
|
use midi_harbor_daemon::Daemon;
|
|
use midi_harbor_platform::midi::{DiscoveredDevice, MidiPlatform};
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// Messages timed, after the warm-up.
|
|
const SAMPLES: usize = 2_000;
|
|
|
|
/// Messages sent and discarded first, so opening costs are not counted.
|
|
const WARM_UP: usize = 50;
|
|
|
|
/// SC-008: the mean a message may take across a virtual port.
|
|
const MEAN_BUDGET: Duration = Duration::from_millis(1);
|
|
|
|
/// SC-008: the 99th percentile a message may take across a virtual port.
|
|
const P99_BUDGET: Duration = Duration::from_millis(3);
|
|
|
|
/// How long to wait for one message before calling it lost.
|
|
const GIVE_UP: Duration = Duration::from_millis(250);
|
|
|
|
/// Locks SC-008: a message crosses the daemon between two virtual ports in under 1 ms on
|
|
/// average and under 3 ms at the 99th percentile, on the machine's real MIDI system.
|
|
///
|
|
/// Measured end to end as another application sees it, so the platform's own delivery counts
|
|
/// against the budget as it does for a user.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
#[ignore = "needs the machine's real MIDI system"]
|
|
async fn a_message_crosses_a_virtual_port_within_budget() {
|
|
// The daemon, on the real backend, in a directory of its own.
|
|
let root = std::env::temp_dir().join(format!("mh-latency-{}", std::process::id()));
|
|
let daemon = Daemon::start(
|
|
Paths::rooted_at(&root),
|
|
midi_harbor_platform::midi_backend().expect("the real backend"),
|
|
)
|
|
.await
|
|
.expect("a daemon");
|
|
daemon
|
|
.create_virtual_port("MH Latency In", 1, 1)
|
|
.await
|
|
.expect("a port");
|
|
daemon
|
|
.create_virtual_port("MH Latency Out", 1, 1)
|
|
.await
|
|
.expect("a port");
|
|
daemon
|
|
.create_route("MH Latency In", "MH Latency Out")
|
|
.await
|
|
.expect("a route");
|
|
|
|
// Another application, as far as the daemon can tell.
|
|
let app = midi_harbor_platform::midi_backend().expect("a second client");
|
|
let into = app
|
|
.open_device(&appeared(app.as_ref(), "MH Latency In").fingerprint)
|
|
.expect("to send into the port");
|
|
let (heard, mut listening) = rtchannel::channel(EndpointId::new());
|
|
app.open_device_with_sink(
|
|
&appeared(app.as_ref(), "MH Latency Out").fingerprint,
|
|
Some(heard),
|
|
)
|
|
.expect("to listen on the port");
|
|
|
|
let timings = tokio::task::spawn_blocking(move || measure(app.as_ref(), into, &mut listening))
|
|
.await
|
|
.expect("the measurement");
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
report_and_check(timings);
|
|
}
|
|
|
|
/// Waits for a port the daemon made to appear to another application, as a DAW would find it.
|
|
fn appeared(app: &dyn MidiPlatform, name: &str) -> DiscoveredDevice {
|
|
for _ in 0..50 {
|
|
if let Some(found) = app
|
|
.list_devices()
|
|
.unwrap()
|
|
.into_iter()
|
|
.find(|device| device.fingerprint.name == name)
|
|
{
|
|
return found;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
}
|
|
panic!("{name} never appeared to another application");
|
|
}
|
|
|
|
/// Returns the timing a fraction of the way through timings sorted from fastest to slowest.
|
|
fn percentile(sorted: &[Duration], fraction: f64) -> Duration {
|
|
let at = ((sorted.len() as f64 * fraction) as usize).min(sorted.len() - 1);
|
|
sorted[at]
|
|
}
|
|
|
|
/// Sends messages one at a time and times each until it is heard on the far side.
|
|
fn measure(
|
|
app: &dyn MidiPlatform,
|
|
into: midi_harbor_platform::midi::PortHandle,
|
|
listening: &mut rtchannel::RtConsumer,
|
|
) -> Vec<Duration> {
|
|
let channel = Channel::new(0).unwrap();
|
|
let mut timings = Vec::with_capacity(SAMPLES);
|
|
// A small generator for the gap between messages. A fixed gap would land at the same point
|
|
// in the daemon's own cycle every time, and measure that point rather than the average.
|
|
let mut seed: u32 = 0x9E37_79B9;
|
|
|
|
for index in 0..WARM_UP + SAMPLES {
|
|
let note = u8::try_from(index % 128).unwrap();
|
|
let message = MidiMessage::NoteOn {
|
|
channel,
|
|
note,
|
|
velocity: 64,
|
|
};
|
|
let sent = Instant::now();
|
|
app.send(into, &[message]).expect("sent");
|
|
|
|
let heard = loop {
|
|
let found = listening.drain(64).into_iter().any(
|
|
|drained| matches!(drained, Drained::Message { message: m, .. } if m == message),
|
|
);
|
|
if found {
|
|
break Some(sent.elapsed());
|
|
}
|
|
if sent.elapsed() > GIVE_UP {
|
|
break None;
|
|
}
|
|
std::hint::spin_loop();
|
|
};
|
|
let taken = heard.unwrap_or_else(|| panic!("message {index} was never heard"));
|
|
if index >= WARM_UP {
|
|
timings.push(taken);
|
|
}
|
|
|
|
seed ^= seed << 13;
|
|
seed ^= seed >> 17;
|
|
seed ^= seed << 5;
|
|
std::thread::sleep(Duration::from_micros(1_000 + u64::from(seed % 4_000)));
|
|
}
|
|
timings
|
|
}
|
|
|
|
/// Prints the distribution and holds it to SC-008.
|
|
fn report_and_check(mut timings: Vec<Duration>) {
|
|
timings.sort();
|
|
let total: Duration = timings.iter().sum();
|
|
let mean = total / u32::try_from(timings.len()).unwrap();
|
|
let (p50, p99, max) = (
|
|
percentile(&timings, 0.50),
|
|
percentile(&timings, 0.99),
|
|
percentile(&timings, 1.0),
|
|
);
|
|
println!(
|
|
"{} messages: mean {mean:?}, p50 {p50:?}, p99 {p99:?}, max {max:?}",
|
|
timings.len()
|
|
);
|
|
assert!(
|
|
mean < MEAN_BUDGET,
|
|
"mean {mean:?} is over SC-008's {MEAN_BUDGET:?}"
|
|
);
|
|
assert!(
|
|
p99 < P99_BUDGET,
|
|
"p99 {p99:?} is over SC-008's {P99_BUDGET:?}"
|
|
);
|
|
}
|
|
|
|
/// Locks SC-009: half the 99th-percentile round trip over a network session to another machine
|
|
/// stays under 5 ms.
|
|
///
|
|
/// Ignored, and needs a machine to echo. On the far machine, run a daemon with a session that
|
|
/// accepts invitations routed into a port, that port routed back into the session, and the
|
|
/// port's output connected to its own input (on Linux, `aconnect` the port to itself). Then:
|
|
///
|
|
/// ```text
|
|
/// HARBOR_ECHO=192.0.2.13:5104 cargo test --release --test latency round_trip -- --ignored --nocapture
|
|
/// ```
|
|
///
|
|
/// Half the round trip bounds the one-way time without needing the two clocks to agree.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
#[ignore = "needs a machine to echo; set HARBOR_ECHO"]
|
|
async fn a_round_trip_over_a_network_session() {
|
|
let Ok(echo) = std::env::var("HARBOR_ECHO") else {
|
|
panic!("set HARBOR_ECHO to the echoing machine's session, such as 192.0.2.13:5104");
|
|
};
|
|
let echo: std::net::SocketAddr = echo.parse().expect("HARBOR_ECHO as host:port");
|
|
|
|
let root = std::env::temp_dir().join(format!("mh-echo-{}", std::process::id()));
|
|
let daemon = Daemon::start(
|
|
Paths::rooted_at(&root),
|
|
midi_harbor_platform::midi_backend().expect("the real backend"),
|
|
)
|
|
.await
|
|
.expect("a daemon");
|
|
for name in ["MH Echo Out", "MH Echo Back"] {
|
|
daemon
|
|
.create_virtual_port(name, 1, 1)
|
|
.await
|
|
.expect("a port");
|
|
}
|
|
let session = daemon
|
|
.create_network_session(
|
|
"MH Echo",
|
|
0,
|
|
midi_harbor_core::endpoint::InvitationPolicy::Prompt,
|
|
)
|
|
.await
|
|
.expect("a session");
|
|
daemon
|
|
.create_route("MH Echo Out", "MH Echo")
|
|
.await
|
|
.expect("out");
|
|
daemon
|
|
.create_route("MH Echo", "MH Echo Back")
|
|
.await
|
|
.expect("back");
|
|
daemon
|
|
.connect_peer(session.id, echo)
|
|
.await
|
|
.expect("connect");
|
|
let mut connected = false;
|
|
for _ in 0..100 {
|
|
let phase = daemon
|
|
.session_status(session.id)
|
|
.await
|
|
.map(|status| status.state.phase());
|
|
if phase == Some(midi_harbor_core::state::ConnectionPhase::Connected) {
|
|
connected = true;
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
}
|
|
assert!(
|
|
connected,
|
|
"the session must connect to the echoing machine at {echo}"
|
|
);
|
|
|
|
let app = midi_harbor_platform::midi_backend().expect("a second client");
|
|
let into = app
|
|
.open_device(&appeared(app.as_ref(), "MH Echo Out").fingerprint)
|
|
.expect("to send");
|
|
let (heard, mut listening) = rtchannel::channel(EndpointId::new());
|
|
app.open_device_with_sink(
|
|
&appeared(app.as_ref(), "MH Echo Back").fingerprint,
|
|
Some(heard),
|
|
)
|
|
.expect("to listen");
|
|
|
|
let round_trips =
|
|
tokio::task::spawn_blocking(move || measure(app.as_ref(), into, &mut listening))
|
|
.await
|
|
.expect("the measurement");
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
|
|
let mut sorted = round_trips;
|
|
sorted.sort();
|
|
let (p50, p99, max) = (
|
|
percentile(&sorted, 0.50),
|
|
percentile(&sorted, 0.99),
|
|
percentile(&sorted, 1.0),
|
|
);
|
|
println!(
|
|
"{} round trips to {echo}: p50 {p50:?}, p99 {p99:?}, max {max:?}; one way at most p99 {:?}",
|
|
sorted.len(),
|
|
p99 / 2
|
|
);
|
|
assert!(
|
|
p99 / 2 < Duration::from_millis(5),
|
|
"half the p99 round trip, {:?}, is over SC-009's 5 ms",
|
|
p99 / 2
|
|
);
|
|
}
|