296 lines
16 KiB
Markdown
296 lines
16 KiB
Markdown
# Midi Harbor — Agent & Contributor Guide
|
|
|
|
Midi Harbor manages virtual MIDI ports, physical MIDI hardware, RTP-MIDI network sessions, and
|
|
Bluetooth LE MIDI links on macOS, Linux and Windows, with connection resilience as its defining
|
|
value.
|
|
|
|
Governance lives in `.specify/memory/constitution.md` and takes precedence over this file where
|
|
the two disagree. Specifications live in `specs/`.
|
|
|
|
---
|
|
|
|
## Code style
|
|
|
|
- Write pragmatic, systems-oriented Rust focused on correctness and clarity.
|
|
- Favor explicit control flow and simple data structures over abstraction. Traits exist only where
|
|
a real seam is needed (instrument drivers, transfer functions), not for hypothetical testability.
|
|
- Short receiver names (`self` for most types). Internal helpers are private and idiomatic
|
|
snake_case; exported names are clear and descriptive.
|
|
- Return `Result` when the caller can act on the failure. Otherwise log and continue safely. Errors
|
|
are lowercase, no trailing punctuation, and carry context about the attempted operation.
|
|
- Library code never panics: no `unwrap`, `expect`, `panic!`, or indexing that can go out of
|
|
bounds. Return errors or saturate deliberately. Tests may use `unwrap` and `assert_eq`.
|
|
- Log operational detail at debug, lifecycle events (start/stop of sessions, driver open/close) at
|
|
info, and failures at error. No exclamation marks in log lines.
|
|
- Comments are functional and intent-focused, written for someone maintaining or debugging the
|
|
system. The name of the element being commented is the first word, the comment is a complete
|
|
sentence ending in a period.
|
|
- Exported types and functions have short `///` doc comments describing responsibility.
|
|
- Functions with multiple logical steps use short section comments to label each phase ("Validate
|
|
input.", "Fit the response curve.").
|
|
- No conversational language, jokes, or speculative notes. The code reads without comments;
|
|
comments add clarity where reasoning is not obvious.
|
|
- Spell out arithmetic constants so numbers are not magic (for example `16.0 + 219.0 * v` for
|
|
video-range encoding).
|
|
|
|
---
|
|
|
|
## Where traits are warranted in this project
|
|
|
|
The style rule above says traits exist only for real seams. In Midi Harbor there are exactly four,
|
|
and they are genuine — each has two or more independent implementations that must coexist in
|
|
shipped code, not merely in tests:
|
|
|
|
| Seam | Implementations |
|
|
|---|---|
|
|
| Platform MIDI | CoreMIDI (macOS), ALSA sequencer (Linux), WinMM with Windows MIDI Services (Windows) |
|
|
| Platform Bluetooth | CoreBluetooth (macOS), BlueZ (Linux), WinRT through btleplug, central only (Windows) |
|
|
| System events | IOKit power + `SCNetworkReachability` (macOS), logind D-Bus + netlink (Linux), power manager callbacks (Windows) |
|
|
| Service manager | launchd (macOS), systemd user units (Linux), Task Scheduler with the daemon's own supervisor (Windows); the App Store build has none, its app supervising a bundled helper instead |
|
|
|
|
In-memory fakes for these seams exist because the seam already exists, not the other way round.
|
|
Do not introduce a trait anywhere else to make something mockable — if a type is hard to test,
|
|
restructure it into pure functions over plain data instead.
|
|
|
|
---
|
|
|
|
## Real-time discipline
|
|
|
|
Constitution Principle III is non-negotiable and is the rule most easily broken by accident.
|
|
|
|
Inside a CoreMIDI read callback, an ALSA sequencer read path, or any packet-dispatch hot path:
|
|
|
|
- No allocation or deallocation. No `Vec::push`, no `String`, no `Box`, no `format!`.
|
|
- No mutex, no `RwLock`, no channel that can block.
|
|
- No I/O and no `tracing` macro. Increment an atomic counter; a normal task reads it and logs.
|
|
- No `unwrap`, per the style rule above, and no arithmetic that can panic — saturate deliberately.
|
|
|
|
Cross the boundary with `rtrb` single-producer/single-consumer ring buffers carrying fixed-size
|
|
`Copy` events. System-exclusive payloads travel as handles into a pre-allocated pool, never
|
|
inline. Buffers are sized at link setup; overflow increments `messages_dropped` and is never
|
|
handled by growing a buffer.
|
|
|
|
A change touching the data path states in its description how this section is upheld.
|
|
|
|
---
|
|
|
|
## Unsafe code
|
|
|
|
Confined to the platform FFI crates. Every `unsafe` block carries a `// SAFETY:` comment stating
|
|
the invariant being upheld and why it holds here. No `unsafe` in core logic, protocol crates, the
|
|
CLI, or the GUI.
|
|
|
|
---
|
|
|
|
## Errors and logging
|
|
|
|
Error enums are closed and machine-readable — `FailureReason` in particular maps onto IPC error
|
|
codes and CLI exit codes, so adding a variant is a contract change. Never widen an error into a
|
|
`String` at a boundary a client switches on.
|
|
|
|
```rust
|
|
// Log lifecycle at info.
|
|
info!(endpoint = %id, "network session connected");
|
|
// Log failure with the attempted operation as context.
|
|
error!(endpoint = %id, error = %err, "failed to bind control port");
|
|
```
|
|
|
|
---
|
|
|
|
## Project layout
|
|
|
|
Single binary `midi-harbor`, one cargo workspace. The GUI is an optional feature; disabling it
|
|
must drop the entire libcosmic dependency tree, so no non-GUI crate may depend on it even
|
|
transitively.
|
|
|
|
```
|
|
proto/
|
|
midiharbor/v1/harbor.proto the daemon contract; generated from, never hand-edited
|
|
crates/
|
|
core/ domain types, state machines, routing, config (no platform; its own file only)
|
|
rtpmidi/ AppleMIDI session control + RFC 6295 journal (pure, no I/O)
|
|
blemidi/ BLE MIDI packet codec (pure, no I/O)
|
|
platform/ the four seams above, plus in-memory fakes
|
|
ipc/ generated gRPC client and server, plus status mapping
|
|
service/ launchd / systemd / Task Scheduler installation, and the supervisor
|
|
daemon/ the engine that owns all state
|
|
cli/ clap subcommands over the gRPC contract
|
|
gui/ libcosmic views — optional feature, thin
|
|
src/main.rs argument dispatch only
|
|
```
|
|
|
|
### The daemon contract
|
|
|
|
Clients reach the daemon over **gRPC on a Unix domain socket** — never TCP, so the control plane is
|
|
not reachable from the network. Windows uses a named pipe that refuses remote clients instead, with
|
|
a random name the daemon records in a file where the socket would be, so a file permission still
|
|
decides who can reach it (R-087). `proto/midiharbor/v1/harbor.proto` is the contract; changing it is
|
|
a contract change whether or not a Rust signature moves. Version lives in the package name, so a
|
|
breaking change means `midiharbor.v2`, never a reinterpreted field. Never reuse a field number;
|
|
reserve it.
|
|
|
|
Two streams — `MonitorEndpoint` and `WatchTraffic` — are **lossy by contract**. HTTP/2 flow control
|
|
would otherwise let a stalled GUI apply backpressure toward the MIDI data path. Drop the update,
|
|
count it, report the count on the next message. Never await capacity on those two.
|
|
|
|
### Discovery uses two stacks on purpose
|
|
|
|
Browsing goes through `mdns-sd`; advertising goes through the platform responder: through
|
|
`zeroconf` on macOS and Linux, and through the DNS Client service's DNS-SD functions on Windows.
|
|
That is not an oversight. `mdns-sd`'s responder announces once at registration and then stops
|
|
answering queries from other machines, so a service registered through it is invisible to every
|
|
peer — including Apple's own browser — while looking perfectly healthy locally. `zeroconf`'s
|
|
browser, meanwhile, returned `0.0.0.0` for most entries. Each is used where it works.
|
|
|
|
Do not consolidate onto one stack without re-running the two-machine test in R-022. A single
|
|
machine cannot tell the difference.
|
|
|
|
### The configuration file
|
|
|
|
One YAML document per user, in the platform's standard config directory. Use `serde_yaml_ng`;
|
|
`serde_yaml` and `serde_yml` are both deprecated.
|
|
|
|
People edit this file by hand, which constrains its shape: the endpoint kind is flattened rather
|
|
than nested, routes name their endpoints instead of referencing UUIDs, and identifiers are
|
|
optional on read so a config can be written from scratch with none. Renaming an endpoint must
|
|
rewrite every route that names it, in the same operation — that is what keeps connections intact
|
|
across a rename. Writes are atomic (temp file, flush, rename); an unparseable file is moved aside
|
|
with a timestamp and never deleted.
|
|
|
|
Keep `core/`, `rtpmidi/` and `blemidi/` free of platform code and of MIDI, network and radio I/O.
|
|
They are where the correctness lives and they must be testable on a machine with no MIDI, no
|
|
network, and no radio. The one exception is the configuration file: `core/` reads it, writes it
|
|
atomically and moves an unreadable one aside with `std::fs`, and resolves where it lives, as
|
|
plan.md places it. Any machine can test that. Nothing else in these crates touches the
|
|
filesystem.
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
Few tests, each worth its place, in two tiers. More tests are not better: never pad coverage, and
|
|
prune tests that break these rules when touching a suite.
|
|
|
|
### The two tiers
|
|
|
|
- **Unit tests** live in `#[cfg(test)] mod tests` beside the code and run with `make test`
|
|
(`cargo test --workspace --lib --bins`). They are small and fast, and mainly cover
|
|
serialization boundaries.
|
|
- **Integration tests** are the primary safety net. They live in each crate's `tests/` directory
|
|
and the root `tests/`, and run with `make test-integration`
|
|
(`cargo test --workspace --test '*'`). They drive the project as a whole: a real daemon over real files, real sockets on loopback, a
|
|
real gRPC client, real RTP-MIDI and BLE MIDI bytes. Every resilience behaviour in Constitution
|
|
Principle I is proved here, by inducing the failure and asserting recovery.
|
|
- **Live tests** are integration tests that need something a CI runner lacks: CoreMIDI, the ALSA
|
|
sequencer, WinMM and Windows MIDI Services, a Bluetooth radio, rtpmidid or Apple's session
|
|
peer. They are `#[ignore = "needs ..."]`, naming what they need, and run with `make test-live`
|
|
on a machine that has it.
|
|
|
|
### What deserves a unit test
|
|
|
|
- Serialization and parsing: configuration YAML, the proto mapping, RTP-MIDI packets and the
|
|
recovery journal, BLE MIDI and UMP codecs. Encode, decode and compare, with the invalid input
|
|
in the same test. Wire formats keep their proptest round-trips; parsers keep their fuzz targets.
|
|
- Invariants of external formats: what launchd, systemd, Task Scheduler, ALSA, WinMM or Windows
|
|
MIDI Services write or report, against fixtures shaped exactly as they produce them.
|
|
- Contracts with an external specification: RFC 6295, Apple's session protocol, the BLE MIDI
|
|
specification, the gRPC contract's error codes. The specification's own bytes or numbers may be
|
|
the assertion. Truncated or older input must not panic or misalign.
|
|
- Facts about the outside world the code depends on: an exact spelling a tool prints, a
|
|
protocol quirk, a platform limit. These stay even when the test is four lines.
|
|
- Real algorithmic logic with a non-obvious result: clock synchronisation, routing resolution,
|
|
backoff, note tracking. One representative test per axis, never an exhaustive fan-out.
|
|
|
|
### What must not be unit tested
|
|
|
|
- Simple logic: trivial predicates, a helper's body restated, a `Display` string nobody parses.
|
|
- Anything an integration test already proves through the daemon, the CLI or a real socket.
|
|
- A lone rejection. A validation test earns its place only by pinning the accepted boundary case
|
|
beside the rejected one.
|
|
- This codebase's own collaborators through a fake. Fakes belong only at the four platform seams,
|
|
where the real thing is hardware or the operating system.
|
|
|
|
For a borderline case, ask whether the test encodes a fact from outside this codebase. If it
|
|
pins a real spelling, format or quirk, keep it. If it only restates this codebase's own code,
|
|
delete it: a bug there fails loudly downstream. A throwaway test to check behaviour while
|
|
working is fine, but it is deleted before the work is done.
|
|
|
|
### Mechanics
|
|
|
|
- Plain `assert!`, `assert_eq!` and `assert_ne!`, each with a message that is a sentence giving
|
|
the reason: `assert_eq!(sent, 1, "a ready peer must not be invited twice")`. Setup that
|
|
invalidates the rest of the test uses `expect("...")` with the same kind of message.
|
|
- Table-driven wherever more than one input shape exists: an array of structs or tuples with a
|
|
`name`, the inputs, the wanted result and, where it helps, a `why`. The assert message names
|
|
the case.
|
|
- Every test has a `///` comment stating the invariant it locks and why, naming the real-world
|
|
source where there is one: the RFC section, the tool and version, the regression it pins.
|
|
Arithmetic behind an expected number is spelled out in the comment.
|
|
- Helpers are small, local to the file, and build fixtures laid out exactly as the real system
|
|
writes them. Real captured output goes in a `testdata/` directory beside the test, with its
|
|
provenance noted; synthetic samples are labelled synthetic.
|
|
- Re-read from the source of truth before asserting: reload the configuration from disk after a
|
|
write rather than trusting the daemon's memory of it.
|
|
- Time is injected, never read from the clock in logic under test. No `sleep` to advance a state
|
|
machine.
|
|
- Parsers treat peer and network input as hostile: malformed input produces an error, never a
|
|
panic, an unbounded allocation, or an out-of-bounds access.
|
|
|
|
### Real dependencies, never substitutes
|
|
|
|
Use the real thing, then the real thing isolated, and a fake only as the last resort. Files are
|
|
real files in a temporary directory; sockets are real sockets on loopback; a protocol peer is a
|
|
real in-process peer speaking the real wire format. The in-memory platform fakes exist because
|
|
the platform seams exist: they stand in for MIDI hardware, the Bluetooth radio, power events and
|
|
the service manager, which no CI runner has. No mocking library, and no trait added only to make
|
|
something testable.
|
|
|
|
---
|
|
|
|
## Quality gates
|
|
|
|
```bash
|
|
cargo fmt --all --check
|
|
cargo clippy --workspace --all-targets -- -D warnings
|
|
make test # unit tests
|
|
make test-integration # integration tests
|
|
cargo build --no-default-features # the headless build
|
|
! cargo tree --no-default-features | grep -qi cosmic # must not pull in libcosmic
|
|
```
|
|
|
|
All six pass on macOS, Linux and Windows before merge.
|
|
|
|
Windows is cross-compiled rather than built on the Windows machine, and its tests run there:
|
|
|
|
```bash
|
|
cargo clippy --target x86_64-pc-windows-gnu --workspace --all-targets -- -D warnings
|
|
cargo build --target x86_64-pc-windows-gnu --no-default-features
|
|
scripts/windows-test.sh --workspace --exclude midi-harbor-gui # builds test binaries, runs them on Windows
|
|
```
|
|
|
|
The script takes the machine from `MH_WINDOWS_HOST` (`user@host`), which needs only OpenSSH
|
|
server. Research R-089 describes what it does, including why the tree is mirrored on the Windows
|
|
machine.
|
|
|
|
The virtual port tests in `crates/platform/tests/windows_midi.rs` cannot run through the script:
|
|
Windows MIDI Services never answers a virtual device created from an SSH session (R-093). Copy the
|
|
test binary over and start it from the desktop session, as a scheduled task with an interactive
|
|
logon. Before Windows' late-2026 update the service also stops answering once a port closes, so
|
|
there each test runs alone, with the Windows MIDI Service restarted before it.
|
|
|
|
The fuzz targets under `fuzz/` are their own workspace, because libFuzzer needs a nightly
|
|
compiler, so none of the gates above builds them. Run them after changing a parser, and at
|
|
least build them after changing any API they call:
|
|
|
|
```bash
|
|
cd fuzz
|
|
cargo +nightly fuzz build
|
|
cargo +nightly fuzz run blemidi_codec -- -max_total_time=300 # likewise rtpmidi_packet, rtpmidi_journal
|
|
```
|
|
|
|
Leave `-rss_limit_mb` at its default: libFuzzer itself settles near 480 MB on these targets, so a
|
|
lower limit reports an out-of-memory that no input caused.
|
|
|
|
`--workspace` is not decoration. Without it, cargo lints only the root package and the other
|
|
crates as plain libraries, so no test module in any crate is ever linted — a dead import in a
|
|
test passes unnoticed.
|