midi-harbor/tests/latency.rs
2026-09-28 13:59:10 -05:00

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
);
}