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

344 lines
12 KiB
Rust

//! System-exclusive messages travelling along a route.
//!
//! A dump is the one MIDI message with no fixed size, arriving in as many pieces as the platform
//! chose to split it into. These tests exist because every one of those pieces is a chance to
//! forward something that frames correctly to a receiver but is not what the sender sent.
#![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_core::state::ConnectionPhase;
use midi_harbor_daemon::Daemon;
use midi_harbor_platform::fake::{FakeMidiPlatform, Outgoing};
use midi_harbor_platform::midi::{MidiPlatform, PortHandle};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
/// A short universal device-inquiry dump, which is the smallest realistic whole message.
const INQUIRY: [u8; 6] = [0xF0, 0x7E, 0x00, 0x06, 0x01, 0xF7];
/// Builds a daemon over a fake platform with one route from Keyboard to Synth.
async fn routed(label: &str) -> (Arc<Daemon>, Arc<FakeMidiPlatform>) {
let root =
common::scratch("midi-harbor-sysex").join(format!("{label}-{}", uuid::Uuid::new_v4()));
let platform = Arc::new(FakeMidiPlatform::new());
let daemon = Daemon::start(
Paths::rooted_at(root),
Arc::clone(&platform) as Arc<dyn MidiPlatform>,
)
.await
.expect("the daemon starts over a scratch directory");
for name in ["Keyboard", "Synth"] {
daemon
.create_virtual_port(name, 1, 1)
.await
.unwrap_or_else(|error| panic!("the port {name} is created: {error}"));
}
daemon
.create_route("Keyboard", "Synth")
.await
.expect("the route from Keyboard to Synth is created");
(daemon, platform)
}
/// Returns the platform handle backing a named port.
fn handle_for(platform: &FakeMidiPlatform, name: &str) -> PortHandle {
platform
.port_handle(name)
.unwrap_or_else(|| panic!("no platform handle for {name}"))
}
/// Waits for a condition, so the test does not depend on dispatch timing.
async fn eventually(mut check: impl FnMut() -> bool) -> bool {
for _ in 0..200 {
if check() {
return true;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
false
}
/// Gives dispatch time to do the wrong thing, for the tests that assert nothing arrives.
async fn settle() {
tokio::time::sleep(Duration::from_millis(100)).await;
}
/// Proves that a dump arriving in several reads is forwarded once, whole, and only after its
/// terminator.
///
/// A patch dump is larger than one platform read, so arriving in pieces is the ordinary case.
#[tokio::test]
async fn a_dump_split_across_reads_arrives_whole_and_once() {
let (_daemon, platform) = routed("split").await;
let keyboard = handle_for(&platform, "Keyboard");
assert!(
platform.feed_bytes(keyboard, &[0xF0, 0x43, 0x10]),
"the fake accepts the first piece of the dump"
);
assert!(
platform.feed_bytes(keyboard, &[0x4C, 0x00]),
"the fake accepts the second piece of the dump"
);
let synth = handle_for(&platform, "Synth");
settle().await;
assert!(
platform.sent_sysex(synth).is_empty(),
"an unfinished dump was forwarded before its terminator arrived"
);
assert!(
platform.feed_bytes(keyboard, &[0x01, 0xF7]),
"the fake accepts the last piece of the dump"
);
assert!(
eventually(|| !platform.sent_sysex(synth).is_empty()).await,
"the finished dump did not reach the destination"
);
assert_eq!(
platform.sent_sysex(synth),
vec![vec![0xF0, 0x43, 0x10, 0x4C, 0x00, 0x01, 0xF7]],
"the three pieces must arrive as one dump, once"
);
}
/// Proves that a dump the sender abandons for another message is dropped, while the message
/// that interrupted it gets through.
///
/// A truncated dump still frames correctly to a receiver, which will act on whatever arrived.
#[tokio::test]
async fn an_abandoned_dump_is_not_forwarded_but_what_follows_is() {
let (_daemon, platform) = routed("abandoned").await;
let keyboard = handle_for(&platform, "Keyboard");
// The sender starts a dump and then sends a note instead of finishing it.
assert!(
platform.feed_bytes(keyboard, &[0xF0, 0x43, 0x10, 0x90, 0x3C, 0x64]),
"the fake accepts the interrupted dump and the note"
);
let synth = handle_for(&platform, "Synth");
assert!(
eventually(|| !platform.sent(synth).is_empty()).await,
"the note that interrupted the dump did not arrive"
);
settle().await;
assert!(
platform.sent_sysex(synth).is_empty(),
"a dump the sender abandoned was forwarded anyway"
);
assert_eq!(
platform.sent(synth).len(),
1,
"the note must arrive exactly once"
);
}
/// Proves that a dump sent in one read arrives whole and keeps its place between the messages
/// around it.
///
/// A program change that follows a dump has to arrive after it: the dump usually is the program
/// the change then selects. A note, the dump and a program change are three messages.
#[tokio::test]
async fn a_dump_keeps_its_place_among_the_messages_around_it() {
let (_daemon, platform) = routed("order").await;
let keyboard = handle_for(&platform, "Keyboard");
let mut stream = vec![0x90, 0x3C, 0x64];
stream.extend_from_slice(&INQUIRY);
stream.extend_from_slice(&[0xC0, 0x05]);
assert!(
platform.feed_bytes(keyboard, &stream),
"the fake accepts the note, dump and program change"
);
let synth = handle_for(&platform, "Synth");
assert!(
eventually(|| platform.outgoing(synth).len() >= 3).await,
"the three messages did not all arrive"
);
let outgoing = platform.outgoing(synth);
assert_eq!(
outgoing.len(),
3,
"a note, a dump and a program change are three messages: {outgoing:?}"
);
assert!(
matches!(outgoing[0], Outgoing::Message(_)),
"the note must come first: {outgoing:?}"
);
assert_eq!(
outgoing[1],
Outgoing::SysEx(INQUIRY.to_vec()),
"the dump must arrive whole and second"
);
assert!(
matches!(outgoing[2], Outgoing::Message(_)),
"the program change must come after the dump: {outgoing:?}"
);
}
/// Proves that a real-time clock byte inside a dump does not break the dump.
///
/// Real-time bytes are legal in the middle of a dump, and a sequencer sending clock while a
/// patch transfers is ordinary rather than exotic.
#[tokio::test]
async fn a_clock_running_through_a_dump_does_not_break_it() {
let (_daemon, platform) = routed("clocked").await;
let keyboard = handle_for(&platform, "Keyboard");
assert!(
platform.feed_bytes(keyboard, &[0xF0, 0x7E, 0xF8, 0x00, 0x06, 0x01, 0xF7]),
"the fake accepts the dump with a clock inside it"
);
let synth = handle_for(&platform, "Synth");
assert!(
eventually(|| !platform.sent_sysex(synth).is_empty()).await,
"a dump with a clock inside it did not arrive"
);
assert_eq!(
platform.sent_sysex(synth),
vec![INQUIRY.to_vec()],
"the dump must arrive whole, without the clock byte inside it"
);
}
/// Proves that a dump counts as one message received, by its full size in bytes.
#[tokio::test]
async fn a_dump_counts_as_one_message_of_its_full_size() {
let (daemon, platform) = routed("counted").await;
assert!(
platform.feed_bytes(handle_for(&platform, "Keyboard"), &INQUIRY),
"the fake accepts the dump"
);
let synth = handle_for(&platform, "Synth");
assert!(
eventually(|| !platform.sent_sysex(synth).is_empty()).await,
"the dump did not reach the destination"
);
let counters = daemon.all_counters().await;
let total_received: u64 = counters.iter().map(|(_, c)| c.messages_received).sum();
let total_bytes: u64 = counters.iter().map(|(_, c)| c.bytes_received).sum();
assert_eq!(total_received, 1, "the dump was not counted as one message");
assert_eq!(
total_bytes,
INQUIRY.len() as u64,
"the dump was not counted by its size"
);
}
/// Starts a daemon whose sessions are not announced, for tests that run two over loopback.
async fn machine(label: &str) -> (Arc<Daemon>, Arc<FakeMidiPlatform>) {
let root =
common::scratch("midi-harbor-sysex").join(format!("{label}-{}", uuid::Uuid::new_v4()));
let platform = Arc::new(FakeMidiPlatform::new());
let daemon = Daemon::start(
common::quiet(root),
Arc::clone(&platform) as Arc<dyn MidiPlatform>,
)
.await
.expect("the daemon starts over a scratch directory");
(daemon, platform)
}
/// Proves that a dump longer than one RTP-MIDI packet crosses a network session whole, and that
/// a note sent after it does not overtake it.
///
/// Sessions counted every dump as undelivered, and one arriving from a peer made the whole
/// packet unreadable, the notes beside it included. The dump is 3000 data bytes plus its start
/// and end bytes, 3002 in all, which is three packets' worth.
#[tokio::test]
async fn a_long_dump_crosses_a_network_session_whole_and_in_order() {
let (far, far_platform) = machine("far").await;
far.create_virtual_port("Synth", 1, 1)
.await
.expect("the far machine's Synth port is created");
let stage = far
.create_network_session("Stage", 0, InvitationPolicy::AcceptAll)
.await
.expect("the far machine's session is created");
far.create_route("Stage", "Synth")
.await
.expect("the route from Stage to Synth is created");
let port = far
.session_status(stage.id)
.await
.expect("the far session reports its status")
.control_port;
let (near, near_platform) = machine("near").await;
near.create_virtual_port("Keys", 1, 1)
.await
.expect("the near machine's Keys port is created");
let link = near
.create_network_session("Link", 0, InvitationPolicy::Prompt)
.await
.expect("the near machine's session is created");
near.create_route("Keys", "Link")
.await
.expect("the route from Keys to Link is created");
near.connect_peer(link.id, SocketAddr::from(([127, 0, 0, 1], port)))
.await
.expect("the near session invites the far one");
let mut up = false;
for _ in 0..100 {
let phase = near.session_status(link.id).await.map(|s| s.state.phase());
if phase == Some(ConnectionPhase::Connected) {
up = true;
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(up, "the session never connected");
let mut dump = vec![0xF0];
dump.extend((0..3000).map(|index: u32| u8::try_from(index % 128).expect("below 128")));
dump.push(0xF7);
let mut played = dump.clone();
played.extend_from_slice(&[0x90, 0x3C, 0x64]);
assert!(
near_platform.feed_bytes(handle_for(&near_platform, "Keys"), &played),
"the fake accepts the dump and the note"
);
let synth = handle_for(&far_platform, "Synth");
let arrived = eventually(|| far_platform.outgoing(synth).len() >= 2).await;
assert!(
arrived,
"the dump and the note did not both cross the session: {:?}",
far_platform.outgoing(synth)
);
let outgoing = far_platform.outgoing(synth);
assert_eq!(
outgoing.first(),
Some(&Outgoing::SysEx(dump)),
"the dump must arrive first and whole"
);
assert!(
matches!(
outgoing.get(1),
Some(Outgoing::Message(
midi_harbor_core::midi::MidiMessage::NoteOn { note: 0x3C, .. }
))
),
"the note must arrive after the dump rather than overtake it: {outgoing:?}"
);
}