midi-harbor/specs/001-service-and-clients/contracts/ipc-protocol.md
2026-09-28 13:59:10 -05:00

10 KiB

Contract: Daemon gRPC Protocol

Feature: 001-service-and-clients, first written as 001-midi-connectivity-manager | Protocol: midiharbor.v1 | Date: 2026-09-20

The contract between midi-harbor daemon and its clients (the GUI and the CLI). Governed by FR-036, FR-037, FR-039c, FR-040, FR-041 and Constitution Principle II.

Revised 2026-09-20: gRPC replaces the original length-delimited JSON framing. See research.md R-009 for the decision and what it changes.


1. Transport

  • Protocol: gRPC over HTTP/2, using tonic.
  • Channel: a Unix domain socket, not a TCP port. Nothing about Midi Harbor's control plane should be reachable from the network, and a local socket gets per-user isolation from file permissions with no authentication layer to design.
  • Path:
    • Linux: $XDG_RUNTIME_DIR/midi-harbor/daemon.sock, falling back to $HOME/.cache/midi-harbor/daemon.sock when XDG_RUNTIME_DIR is unset.
    • macOS: $HOME/Library/Application Support/com.mrgeckosmedia.MidiHarbor/daemon.sock.
  • Permissions: socket file mode 0600, parent directory 0700.
  • Encoding: protocol buffers, proto3.
  • Schema location: proto/midiharbor/v1/harbor.proto, compiled by tonic-prost-build into the midi-harbor-ipc crate. The .proto file is the contract; the Rust types are generated from it and are never hand-edited.
  • Concurrency: HTTP/2 multiplexes many concurrent calls over one connection, and many clients may connect at once (FR-040). No call blocks another.

2. Versioning (FR-041)

Version lives in the proto package name: midiharbor.v1.

  • Major version = new package. A breaking change means midiharbor.v2, a new service path, and a daemon that may serve both during a migration. A client built against v1 calling a v2-only daemon gets gRPC UNIMPLEMENTED for the whole service, which is unambiguous.
  • Minor version = additive only. New RPCs, new message fields, new enum values. Protobuf ignores unknown fields on the wire, so an older client talking to a newer daemon keeps working without any negotiation step.
  • Clients still call GetServerInfo first, because UNIMPLEMENTED alone does not tell a user which side is old:
message ServerInfo {
  string daemon_version  = 1;  // "0.1.0"
  uint32 protocol_major  = 2;  // 1
  uint32 protocol_minor  = 3;  // 0
  google.protobuf.Timestamp started_at = 4;
  string config_path     = 5;
}

A client whose major version differs from the daemon's MUST refuse to proceed and say which component to update, naming both versions. This is the one place the product must not present a generic connection error.

Never reuse a field number. Removing a field means reserving its number, so a future field cannot silently inherit an old meaning.


3. Service surface

One service, Harbor. Every RPC operates on live daemon state, so the GUI and CLI are interchangeable (FR-039c).

Queries

RPC Request → Response
GetServerInfo GetServerInfoRequest → ServerInfo
ListEndpoints ListEndpointsRequest (optional kind filter) → ListEndpointsResponse
GetEndpoint GetEndpointRequest → Endpoint
ListRoutes ListRoutesRequest → ListRoutesResponse
ListPeers ListPeersRequest → ListPeersResponse
ListEvents ListEventsRequest (since, limit, endpoint) → ListEventsResponse
GetCapabilities GetCapabilitiesRequest → GetCapabilitiesResponse
GetStatus GetStatusRequest → Status

Mutations

Group RPCs
Virtual ports CreateVirtualPort, RenameEndpoint, DeleteVirtualPort, SetEndpointEnabled
Network sessions CreateNetworkSession, ConnectPeer, AddManualPeer, DisconnectPeer, RemovePeer, RespondToInvitation, SetInvitationPolicy
Physical devices ForgetPhysicalDevice, ResolveAmbiguousDevice
Bluetooth StartBluetoothScan, StopBluetoothScan, ConnectBluetoothDevice, DisconnectBluetoothDevice, ForgetBluetoothDevice, SetPeripheralAdvertising
Routes CreateRoute, DeleteRoute, SetRouteEnabled
Configuration ExportConfiguration, ImportConfiguration, ReloadConfiguration
Diagnostics ExportDiagnostics

Physical devices are discovered, never created, so no CreatePhysicalDevice exists.

RenameEndpoint without confirm = true fails FAILED_PRECONDITION carrying the applications that may need to reselect the port (FR-001 scenario 4).

CreateRoute succeeds even when it forms a cycle, returning the affected routes marked LOOP_DETECTED so the client can warn (FR-033).

Server-streaming RPCs

Streams replace the original topic-subscription mechanism. A client opens the streams it needs.

RPC Yields Delivery
WatchState StateEvent — endpoint added/removed/changed, route validity, capabilities Lossless
WatchEvents Event — mirrors the bounded history Lossless
WatchInvitations Invitation — requires a RespondToInvitation (FR-014) Lossless
WatchTraffic TrafficUpdate — coalesced counters Lossy, see below
MonitorEndpoint MidiMessage — decoded messages on one endpoint (FR-047) Lossy, see below

4. Backpressure (Principle III)

gRPC has HTTP/2 flow control, which means a slow client can, by default, apply backpressure all the way back to the producer. On the two high-rate streams that is exactly the wrong behaviour: a stalled GUI must never slow down MIDI delivery.

So the daemon applies an explicit policy per stream:

  • WatchTraffic and MonitorEndpoint are lossy by contract. Each subscriber gets a bounded channel. When it is full the daemon drops the update and increments a counter rather than awaiting capacity. The next message delivered carries dropped: uint64 so the client can show that it fell behind. The daemon never blocks and never grows a buffer.
  • WatchState, WatchEvents and WatchInvitations are lossless. These are low-rate and correctness-relevant. A client that cannot keep up with them is disconnected with RESOURCE_EXHAUSTED rather than having its view silently diverge.
  • WatchTraffic coalesces to at most one update per endpoint per 250 ms. Counter updates are never emitted per-message.

Serialisation for every stream happens on ordinary async tasks reading atomics and ring buffers. No gRPC code runs on the MIDI data path.


5. Errors

gRPC status codes carry the class of failure; the domain reason rides along in the details.

Condition Status code
Endpoint, route, peer or device not found NOT_FOUND
Name already in use, duplicate route ALREADY_EXISTS
Confirmation required and not supplied FAILED_PRECONDITION
Capability unavailable on this system UNAVAILABLE
Malformed request, invalid name or port INVALID_ARGUMENT
Client speaks a major version the daemon does not serve UNIMPLEMENTED
Lossless-stream client fell too far behind RESOURCE_EXHAUSTED

Every error additionally carries a trailing metadata entry harbor-reason holding the stable slug from FailureReason::code() — name_conflict, permission_denied, adapter_unavailable, and so on. Clients switch on that slug, never on the message text, and the CLI derives its exit codes from it. The status message itself is human-readable and may change between releases.

Capabilities work the same way: Capability.id (such as bluetooth_central) is stable and is what a client decides from, and Capability.name is for people.

When the user can do something about the failure, the error also carries harbor-guidance-bin: UTF-8 text saying what, from FailureReason::guidance(). It is binary metadata because guidance names things the user chose, which need not be ASCII. Like the message, it is for people and may change; clients show it and never parse it.

This keeps one mapping — FailureReason → slug → gRPC status → CLI exit code — with the closed enum in midi-harbor-core as its single source of truth.


6. Behavioural guarantees

  1. The daemon never blocks the MIDI data path on IPC. Serialisation reads atomics and ring buffers from ordinary tasks (Principle III).
  2. State is authoritative in the daemon. Clients hold a cache fed by WatchState and must tolerate re-syncing at any time via ListEndpoints.
  3. Connecting or disconnecting a client never disturbs a connection (FR-037). No resource in this protocol is scoped to a client's lifetime.
  4. Requests are idempotent where they can be. SetEndpointEnabled to the current value succeeds as a no-op; DeleteRoute on an absent route returns NOT_FOUND, not a broken state.
  5. No RPC blocks indefinitely. Scanning and connecting return as soon as the state machine accepts the request, with the endpoint in CONNECTING; progress arrives on WatchState.
  6. The .proto file is the contract. Changing it is a contract change and requires a version decision, whether or not any Rust signature changes.

7. Why gRPC, and what it costs

The upsides that decided it: a schema-first contract that cannot drift from the implementation, generated clients in any language, server streaming with real flow control instead of hand-rolled subscriptions, and no bespoke framing code to get wrong.

The costs, accepted deliberately:

  • A build-time protobuf compilation step, which tonic-prost-build handles with a vendored protoc, so contributors need no system package.
  • A heavier dependency tree — tonic, prost, hyper, tower — in the headless build, which was a point in favour of the original hand-rolled JSON. Measured against the alternative of maintaining our own framing, versioning and streaming semantics, this is the better trade.
  • The wire is no longer human-readable. grpcurl against the socket, plus the --json CLI output, cover the debugging need that plain JSON would have given for free.
  • Flow control has to be actively overridden on the two lossy streams, as §4 sets out. This is the one place gRPC's defaults are wrong for this product, and it is a deliberate override rather than an oversight.