midi-harbor/specs/005-network-ports/tasks.md
2026-09-28 13:59:10 -05:00

19 KiB

Tasks: Network Ports

Tasks from the task list, under the phase each was done in and by their original numbers. Phases are the order the project was built in, across every spec; 001's tasks give that order in full.

Phase 1: Setup (Shared Infrastructure)

Stage 0 spikes (retire RISK-2 and RISK-3 before committing dependencies)

  • T007 [P] Spike in spikes/mdns-coexist/ proving mdns-sd 0.21 binds UDP 5353 alongside a running mDNSResponder on macOS and avahi-daemon on Linux, and that an advertised _apple-midi._udp service is visible to Apple's Network MIDI panel — resolves RISK-3 (research R-005)

Phase 5: User Story 3 — Self-healing network MIDI (Priority: P2)

Goal: RTP-MIDI sessions that discover peers, interoperate with existing implementations, and survive every failure mode without losing MIDI state.

Independent Test: Establish a session, induce network loss, sleep/wake, peer restart and Wi-Fi roam in turn, and verify automatic recovery with no stuck notes (quickstart scenario 2).

Tests for User Story 3

  • T077 [P] [US3] Property tests in crates/rtpmidi/tests/packet.rs asserting round-trip of RTP-MIDI payloads across short and long header forms, delta times and running status
  • T078 [P] [US3] Property tests in crates/rtpmidi/tests/journal.rs asserting that after arbitrary packet loss the reconstructed state matches the sender for notes, control changes, program, pitch wheel and aftertouch
  • T079 [P] [US3] Fuzz targets in fuzz/fuzz_targets/rtpmidi_packet.rs and fuzz/fuzz_targets/rtpmidi_journal.rs asserting no panic, no unbounded allocation and no out-of-bounds access on hostile input (Principle V)
  • T080 [P] [US3] Loss-injection harness in tests/soak/packet_loss.rs asserting zero stuck notes and controller convergence within 1 second at 5% loss (SC-006)
  • T081 [P] [US3] Interoperability tests in tests/interop/ replaying recorded captures from Apple Network MIDI, rtpMIDI and rtpmidid against the parser (FR-011, SC-011) — done: packets from all three, recorded from sessions each side invited, replay through the parser, and sessions with each carry MIDI both ways whichever side invites (R-065, R-068, R-071, R-073)
  • T082 [P] [US3] Recovery tests in tests/integration/network_recovery.rs inducing network loss, peer timeout, sleep/wake and address change over the fake, asserting recovery with platform system events disabled (research R-010)
  • T082a [P] [US3] Recovery-latency tests in crates/daemon/tests/recovery_timing.rs over the real backoff asserting MIDI resumes within 10 s of network restoration in at least 99 of 100 trials (SC-003) and within 15 s of wake (SC-004) — the two numbers that define self-healing

Implementation for User Story 3

  • T083 [P] [US3] Implement the AppleMIDI session control state machine (IN, OK, NO, BY) in crates/rtpmidi/src/session.rs over the control/data UDP port pair (N, N+1)
  • T084 [P] [US3] Implement the CK clock synchronisation exchange with offset and round-trip estimation in crates/rtpmidi/src/clock.rs, exposing time since last successful exchange as the authoritative liveness signal (FR-013, research R-010)
  • T085 [US3] Implement RTP-MIDI payload encoding and decoding in crates/rtpmidi/src/packet.rs — short and long header forms, delta-time encoding, running status
  • T086 [US3] Implement the recovery journal framing and sender-side journal accumulation in crates/rtpmidi/src/journal/mod.rs per RFC 6295
  • T087 [US3] Implement Chapter N (note on/off) in crates/rtpmidi/src/journal/chapter_n.rs — the chapter that prevents stuck notes (FR-012, FR-026, SC-005)
  • T088 [P] [US3] Implement Chapter C (control change) in crates/rtpmidi/src/journal/chapter_c.rs (FR-027)
  • T089 [P] [US3] Implement Chapter P (program change) in crates/rtpmidi/src/journal/chapter_p.rs
  • T090 [P] [US3] Implement Chapter W (pitch wheel) in crates/rtpmidi/src/journal/chapter_w.rs
  • T091 [P] [US3] Implement Chapter T (channel aftertouch) in crates/rtpmidi/src/journal/chapter_t.rs
  • T092 [US3] Implement receiver-side journal recovery and RS feedback in crates/rtpmidi/src/journal/recover.rs, incrementing messages_lost and messages_recovered distinctly (FR-045)
  • T093 [US3] Implement journal trimming driven by the receiver's highest-received sequence number in crates/rtpmidi/src/journal/mod.rs so the journal cannot grow without bound
  • T094 [US3] Implement mDNS advertising and browsing of _apple-midi._udp in crates/daemon/src/discovery.rs using the backend chosen by spike T007, excluding this machine's own session from results (FR-008, FR-009, edge case)
  • T095 [US3] Implement duplicate-peer-name disambiguation by PeerId and address in crates/daemon/src/discovery.rs (edge case)
  • T096 [US3] Implement the UDP socket layer with IPv4 and IPv6 support and address-change rebinding in crates/daemon/src/net.rs (FR-025, edge case)
  • T100 [US3] Implement invitation handling and InvitationPolicy (Prompt default, AcceptKnown, AcceptAll, RejectAll) in crates/daemon/src/handlers/sessions.rs, handling simultaneous invitations without loss (FR-014, edge case)
  • T101 [US3] Implement the no-network state in crates/daemon/src/supervisor.rs reporting waiting-for-network rather than repeated connection failures (edge case) — in crates/daemon/src/session.rs, decided by the kernel refusing a send for want of a route rather than by the machine's addresses; see R-045
  • T102 [US3] Implement session request handlers (CreateNetworkSession, ConnectPeer, AddManualPeer, DisconnectPeer, RemovePeer, RespondToInvitation, SetInvitationPolicy) in crates/daemon/src/handlers/sessions.rs (FR-010, FR-015)
  • T103 [US3] Implement session list|create|discover|connect|disconnect|peer|policy commands in crates/cli/src/session_cmd.rs (contracts/cli-interface.md §3)

Checkpoint: The headline capability works and is proven against real third-party peers.

Phase 11: Convergence

  • T159 Save the peer a session connects to in NetworkSession.peer, clear it on disconnect, and reconnect to it when the session starts, so a connected session survives a restart, per FR-015 (partial) — done: session connect remembers the peer, untrusted, and session disconnect forgets it; a restart reconnects, tested both ways
  • T168 Handle two peers inviting at the same moment without losing either, or record in research.md why one peer per session holds, with a test of simultaneous invitations, per Edge Case: simultaneous invitations (partial) — done: at the owner's direction a session carries guests beside its peer, each with its own session machine, and hands over to one when the peer leaves (R-076)
  • T176 Recognise this machine's own advertised sessions by address and port, or the name the responder registered, rather than the name before the first dot, per Edge Case: self-discovery (partial) — done: a record is ours when its port is one we advertise and an address is one this machine holds; instance names keep their dots; two sessions from one machine are no longer folded into one entry
  • T179 Apply a machine name change to discovery without a restart, or record the restart as a deviation in research.md, per FR-050 (partial) — done: the name is read from the configuration when it is used, so a reload applies it to the next Bluetooth advertisement; discovery never used it and no longer takes it

Phase 12: The redesigned window

  • T181 Call network sessions "network ports" throughout: the window and docs; network CLI commands with session accepted as an alias; a network_port configuration kind with network_session still read, per R-078 — done: network is the CLI's command group with session accepted, network_port is the configuration kind written with network_session still read (endpoints and routes), and the CLI, history and docs say network port; log lines, JSON keys, the network_sessions capability id and the advertise_sessions preference keep their names
  • T184 Give network sessions an automatic virtual port, on by default: a configuration and contract setting, the daemon opening a platform port named after the session and carrying MIDI both ways between them, renamed and removed with the session and never listed apart from it, and the window's switch, per FR-015h — done: automatic_port (on unless turned off) with pinned port_input_id/port_output_id on a network port in the configuration, NetworkSessionDetail.automatic_port (9), CreateNetworkSessionRequest.automatic_port (4, unset meaning on) and a new UpdateNetworkPort RPC that T185 extends; the daemon opens a platform port of the network port's name when it starts, sends what other applications play into it out over the network and nothing else, plays what arrives over the network out of it beside the network port's routes, SysEx included, opens it again under a new name on rename with the same identifiers, silences it before closing it when the network port stops or the daemon exits, and keeps it out of the device list by identifier or, before it has one, by name; network create --no-automatic-port, network edit --automatic-port on|off, an AUTOMATIC PORT column in network list, and the window's switch in the network port dialog and a line in its panel. Every check was mutation-tested. On a real Mac a virtual port of the same name shows beside it indistinguishably, which the configuration doc says. Checked live on the Mac with two scratch daemons joined over loopback: CoreMIDI listed each network port as a source and a destination under its identifiers, a note sent into one came out of the other in both directions within 1 ms, switching the port off removed it and on restored it with the same identifiers, a rename moved it and MIDI still crossed, each daemon listed the other's port as an application's and hid its own, and both were gone after the daemons stopped.
  • T185 Let a network session be edited after it is made: its Bonjour name (local_name) at creation and after, its UDP port and its invitation policy, through one additive RPC, and the window's edit dialog, per FR-015, R-078 — done: UpdateNetworkPortRequest gains local_name (3), control_port (4) and invitation_policy (5), and CreateNetworkSessionRequest.local_name (5) sets the Bonjour name at creation; a new Bonjour name reaches machines that connect from then on while connected ones stay, and swaps the advertisement; a new UDP port restarts the network port on it and reconnects the machine it had connected to; a UDP port that is odd, taken, or another network port's is refused and the network port keeps every setting it had, where binding used to move silently to another pair; a Bonjour name another network port shows is refused at creation and after; the service refuses a number no UDP port has instead of reading it as any; network create --bonjour-name, network edit --bonjour-name --udp-port --policy, and the window's network port dialog edits the Bonjour name and UDP port and sends only what changed, so an untouched UDP port does not restart it. SetInvitationPolicy stays and goes through the same path. Every check was mutation-tested. Checked live on the Mac with two scratch daemons: dns-sd showed the Bonjour name given at creation, then only the new one after a live rename while both sides stayed connected; UDP ports 5021 (odd) and 5010 (the other daemon's) were refused, and 5020 was taken, the old port released, the connection restored and a note carried in 1 ms.
  • T186 Report each machine in a network session with its address, role and latency, disconnect any one of them, and invite further machines into a session that already has a peer, carried beside it as guests are: contract (additive), daemon, and the window's Machines list, per FR-015i, FR-010 — done: each network port reports NetworkSessionDetail.machines (10), a NetworkMachine per machine taking part with its address, advertised name, whether this side connected to it, whether it has joined and its round trip, the peer first; DisconnectMachine ends one machine's part, a guest simply, the peer with a remaining machine taking its place and the peer forgotten so it is not reconnected on start; ConnectPeerRequest.alongside (3) invites a machine beside those connected, carried as a guest is and reported as could-not-connect when it never answers, while a plain connect still replaces the peer. Machines are now kept by canonical address: a machine this side invited answered from its IPv4-mapped address, was taken for an outsider and sent a goodbye. network connect --alongside, network machines, network disconnect --machine, and the window's Machines list with each machine's address, latency on its own line and Disconnect, then the machines it could connect to, which connect alongside when any is connected. Every check was mutation-tested. Checked live on the Mac with three scratch daemons: two machines joined one network port over UDP with latency reported for each, a note sent into its automatic port reached both, and the window's Disconnect on one left the other connected.
  • T187 Let a known machine be added with a name, address and port (defaulting to 5004), and its "let in without asking" switched on and off, over the contract, and in the window's Settings, per FR-010, FR-014 — done: adding by name, address and port with 5004 as the default was already there; AddManualPeerRequest.trusted (4, unset meaning trusted) adds a machine without letting it in unasked, adding a known machine again takes the trust given, and the new SetPeerTrusted RPC switches it; switching trust off, adding untrusted, and forgetting a machine each also drop an invitation answered for this run, which used to let the machine in until a restart whatever trust said; network peer add --no-trust, network peer trust <PEER> on|off, and in the window's Settings a switch on each known machine and one in the Add a machine dialog. Every check was mutation-tested; the Settings switch was checked live against a scratch daemon, and saved.
  • T191 Let a network port be deleted: its peer and its automatic port silenced and let go, its advertisement withdrawn, its routes left broken to mend if one of its name returns, over one additive RPC, network delete, and the window's network port panel, per FR-015h — done: DeleteNetworkPort (a new RPC with its own response naming the routes left broken), network delete <NETWORK_PORT> --yes, and Delete in the window's network port panel and edit dialog, confirmed first; the daemon silences the routes it played into, stops its session, which silences its machines, closes its automatic port and withdraws its advertisement, drops invitations waiting on it, and removes it, refusing anything that is not a network port. Every check was mutation-tested; the one mutation that survives removes the synchronous silencing of its routes, which the session's own goodbye silencing also covers when it arrives before the removal, as it did in every test. Also corrected the virtual port delete dialog and a daemon comment, which both said a deleted port's routes are removed; they stay, waiting.

Phase 13: Surviving the MIDI server

  • T196 Find why session::tests::two_sessions_establish_with_each_other sometimes stays in Connecting past its 5 s deadline when the daemon's unit tests run together on a loaded Mac (2 of about 14 runs on 2026-09-25, never alone in 10), and make the handshake or the test hold, so cargo test --workspace passes every time as the quality gates require — done: not load. macOS binds a dual-stack socket to a port a plain IPv4 socket already holds, and the IPv4 socket then takes every IPv4 datagram for it; the transport tests' IPv4 sockets made session ports deaf, and a program holding 5004 over IPv4 would do the same to a real session (R-080). Each port is now claimed over IPv4 before the dual-stack socket binds it. The new transport test fails on every run without the fix; the daemon's unit suite passed 40 runs in a row with it. A port the system chooses has no test that always fails without the fix
  • T198 Remember every machine the user connected to a network port, not only the first, and reconnect each like the first, after a restart and when its own link is lost, until the user disconnects that machine, per FR-015i, Constitution Principle I — done: an other_peers list of remembered machines beside peer on a network port in the configuration, read as empty when absent and written only when there are some; the daemon remembers a machine connected alongside there, invites each again when the network port starts, forgets one the user disconnects, forgets them all on a whole disconnect, and when the peer is disconnected and a remembered machine takes its place, remembers that one as the peer. The supervisor chases a machine it invited with a backoff of its own on the peer's terms, independent of the peer's link: one that stops answering, refuses or says goodbye is invited again and stays listed as not joined, one that went to sleep is let go, and those it invited are invited again after this machine sleeps; a lost link silences notes and a recovered one gets its controller state back, as the peer's does. A machine that invited itself in is still let go when it leaves. No contract change: the machines list already reports a machine waiting to come back as not joined. Every new check was mutation-tested
  • T199 Find why every_machine_connected_comes_back_after_a_restart_until_it_is_disconnected loses the first machine after a restart on Linux about 2 runs in 10, and fix it, per FR-015i, Constitution Principle I — done: not the restart. Inviting a machine read the session's status to decide whether it became the peer, and the status still showed no peer when a connect had been sent just before and not yet acted on, so the machine invited was remembered as the peer and the real peer was forgotten; after a restart only the machine invited came back. The session now answers the invitation with the place the machine took, peer or beside the peer, in order with the commands ahead of it, and that answer decides what is remembered. A new test connects and invites at once on one thread and fails on every run without the fix; on the Arch VM the network port tests passed 100 runs in a row, against 5 failures in 20 before
  • T200 Find why a_session_keeps_the_port_the_system_chose moves to another port pair on Linux about 1 run in 30, and fix it, per FR-015, Constitution Principle I — done: every daemon start runs systemctl --user show-environment, and on Linux the child holds a copy of every socket in the process until it starts, so a test switching a session off and on, or restarting a daemon, while another test's daemon started found its own port held for a few milliseconds and moved (R-082). A port pair asked for by number is now tried again for up to 225 ms while it is held before another is chosen. A port held over IPv6 is no longer bound over IPv4 alone, which had left a pair with an IPv4 data socket beside a dual-stack control socket that could send to no IPv4 peer. Each has a transport test that fails on every run without its fix. On the Arch VM the session tests passed 100 runs in a row, against 3 failures in 80 before, and the other session tests that restart or switch a session back on no longer fail beside it