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

995 lines
57 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Research: Network Ports
Entries from the research log, under their original numbers. Findings marked **VERIFIED** were
proven in this repository; **ASSUMED** ones rest on the literature.
---
## R-004: RTP-MIDI implementation
**Decision**: Implement AppleMIDI/RTP-MIDI ourselves in a dedicated `no_std`-friendly protocol
crate, including the RFC 6295 recovery journal.
**Status**: **ASSUMED**; this is the largest single body of work in the project.
**Rationale**: The existing `rtpmidi` crate explicitly does not implement the recovery journal.
The journal is precisely the mechanism that prevents stuck notes and stale controller values when
UDP packets are dropped — FR-012, FR-026 and FR-027 are unachievable without it, and SC-005 and
SC-006 measure it directly. A reconnect-only implementation would not deliver the product's
defining value.
**Protocol scope to implement**:
- **Session control (AppleMIDI)**: a UDP port pair — control on port `N`, data on port `N+1`. The
five commands are `IN` (invitation), `OK` (accept), `NO` (reject), `BY` (end session), and `CK`
(clock synchronisation), plus `RS` (receiver feedback) on the data channel.
- **Clock synchronisation**: the three-message `CK` exchange, repeated periodically, establishing
the offset and latency used to order incoming messages. Also the liveness signal — a peer that
stops answering `CK` is how a silent disconnection is detected.
- **MIDI payload**: RTP packets with the RTP-MIDI payload header, supporting both the short and
long header forms, delta-time encoding between messages in a packet, and running status.
- **Recovery journal (RFC 6295)**: the session journal plus per-channel chapters. Minimum viable
chapter set for correctness: Chapter P (program change), Chapter C (control change), Chapter W
(pitch wheel), Chapter N (note on/off — the one that prevents stuck notes), and Chapter T
(channel aftertouch). Chapter M (parameter system) and system chapters are lower priority.
- **Journal trimming** driven by the receiver's reported highest-received sequence number, so the
journal does not grow without bound.
**Interoperability targets (FR-011)**: Apple Network MIDI (macOS), rtpMIDI (Windows), rtpmidid
(Linux), and hardware RTP-MIDI endpoints. Each must be tested explicitly; interoperability is not
assumed.
**Apple interoperability VERIFIED (2026-09-20), before the journal was written.** A full session
was driven against a Mac running Network MIDI using this project's own codec: the control channel
was invited and accepted, the data channel one port above was invited and accepted, a clock
exchange completed with a 2 ms round trip, and three RTP-MIDI note packets were sent and the
session closed cleanly. The wire format therefore comes from a live peer rather than from reading
documentation — Apple's acceptance packet is kept as a fixture in `control.rs`, and the live run
is `tests/applemidi_live.rs`, ignored by default and pointed at a peer with `HARBOR_PEER`.
One correction this produced: the documentation summary omits the leading `0xFF 0xFF` signature on
control packets, which is present on the wire and is what distinguishes control traffic from RTP
on the shared port pair.
**Alternatives considered**: `rtpmidi` crate as-is — rejected, no journal. Wrapping the crate now
and replacing later — considered and rejected by the owner, because the journal shapes the packet
and session data structures deeply enough that retrofitting it would mean a rewrite anyway.
---
## R-015: The journal only repairs on a later packet
**Decision**: A session must keep emitting journal-carrying packets while any state is
unacknowledged, rather than falling silent after its last MIDI message.
**Status**: **VERIFIED** (2026-09-20). Found by the loss-injection harness, not by reading.
**What the harness showed**: with 5% loss, one seed in twenty left a note sounding. The cause was
not a journal bug. The recovery journal rides on *subsequent* packets, so if the final packet of a
phrase carries a note off and that packet is lost, nothing follows to repair it. The receiver is
left holding a note with no way to learn otherwise.
This is inherent to the format and cannot be fixed inside the journal. It is a requirement on the
session layer:
- After its last MIDI message, a sender MUST continue emitting packets carrying the journal until
the receiver acknowledges the sequence that covers them. Modelled in the harness as a fixed
number of trailing journal-only packets; the session implementation should instead keep emitting
while `JournalState::build()` returns anything, at a modest interval.
- A receiver MUST still silence everything when a session times out, because a sender that is
switched off mid-phrase cannot send anything at all. The journal narrows that window; it does
not remove it.
**Why it matters**: this is exactly the failure the product exists to prevent, it appears only
under loss at the end of a phrase, and it would have shipped looking correct — the journal
encodes, decodes and recovers perfectly in every other case.
---
## R-016: RTP streams start at a random sequence number
**Decision**: The journal checkpoint is established from the first packet actually sent, not
assumed to be zero.
**Status**: **VERIFIED** (2026-09-20), after the first implementation got it wrong.
**What went wrong**: `JournalState` initialised its checkpoint to zero. Sequence numbers are
16-bit and wrap, so comparisons are done in wrapped space. On a session that happened to start at
a high sequence number — which RTP senders choose at random — every acknowledgement looked like it
came from *before* the checkpoint, so trimming never ran and the journal grew for the life of the
session. Sessions starting at a low number worked perfectly, which is what made it easy to miss.
The checkpoint is now unset until the first packet is sent. A stale acknowledgement still cannot
move the history backwards.
**Bound confirmed**: 20,000 note pairs with the receiver acknowledging slightly behind keeps the
encoded journal under 512 octets.
---
## R-005: mDNS / DNS-SD service discovery
**Decision**: `mdns-sd` 0.21.3, a pure-Rust implementation with both responder and querier roles.
Service type `_apple-midi._udp`.
**Status**: **REVISED** (2026-09-20). Browsing stays on `mdns-sd`; advertising moved to the
platform responder, which is the fallback this decision originally named. See R-022.
**Original status**: VERIFIED on macOS (spike T007). RISK-3 retired.
**Spike results**: `mdns-sd` 0.21.3 started, registered an `_apple-midi._udp` service, and browsed
successfully **while `mDNSResponder` held port 5353** — no special socket configuration was
needed. It resolved a real Apple Network MIDI session already advertising on the LAN
(`Studio Mac._apple-midi._udp.local.` at `192.0.2.4:5004`), over both IPv4 and IPv6, which also
gives early evidence for the FR-011 interoperability path.
It also resolved **its own** advertisement, confirming that the self-exclusion edge case in the
spec is real and must be implemented (T094), not theoretical.
**Rationale**: Pure Rust avoids depending on Avahi being installed on Linux or on linking Apple's
`dns_sd` on macOS, which keeps the headless Linux build dependency-light (FR-039b) and keeps
behaviour identical across platforms (Principle IV). `mdns-sd` supports both registering
(FR-009) and browsing (FR-008), which the browse-only crates do not.
**Port 5353 coexistence — resolved.** The concern was that `mDNSResponder` (macOS) and
`avahi-daemon` (Linux) already hold UDP 5353. On macOS this is a non-issue: `mdns-sd` handles the
socket options itself and coexists cleanly. Linux coexistence with `avahi-daemon` was
verified on 2026-09-25, which retired RISK-3.
One consequence for addressing: the spike returned a large set of addresses per service across
every interface, including link-local IPv6 with scope ids and bridge interfaces. Peer address
selection must therefore be deliberate — prefer a routable address on the interface the session
was discovered on — rather than taking the first address returned.
**Alternatives considered**: `zeroconf` (wraps Bonjour/Avahi) — the fallback. `simple-mdns` —
smaller community, less proven. `mdns_sd_discovery` — browse-only, cannot advertise.
---
## R-017: A dual-stack socket refuses a plain IPv4 address
**Decision**: Rewrite IPv4 targets into their v4-mapped IPv6 form before sending from a
dual-stack socket.
**Status**: **VERIFIED** (2026-09-20), after two local sessions failed to reach each other.
**What happened**: the session transport binds `[::]` so that a peer reachable only over IPv6 is
still reachable, which on every supported platform also serves IPv4. Sending to a plain IPv4
`SocketAddr` from that socket fails outright:
```
plain ipv4 target: Err(Os { code: 22, kind: InvalidInput, message: "Invalid argument" })
v4-mapped target : Ok(2)
```
IPv4 peers are the common case on a local network, so this made **almost every peer unreachable**.
It did not show up in the live Apple interoperability run, because that test binds its own
IPv4-only socket — a reminder that a passing interop test does not exercise the daemon's transport.
**Fix**: the socket records whether it bound dual-stack, and maps `IpAddr::V4` targets through
`to_ipv6_mapped()` on the way out. A test sends from a dual-stack socket to an IPv4 listener and
asserts the datagram arrives, rather than asserting the send returned `Ok`.
---
## R-018: Both ends of a session must drive the same handshake
**Decision**: Session phases name the handshake stage rather than the role, and a responder
advances through them as invitations arrive.
**Status**: **VERIFIED** (2026-09-20).
**What was missing**: the first implementation only handled the initiator. A peer inviting us was
answered with an acceptance but never advanced a phase, so an inbound session stalled and this
machine was effectively unreachable — other machines could see the advertisement and never
connect. Naming the phases after the initiator's view (`InvitingControl`, `InvitingData`) is what
made the gap easy to miss.
Two further rules fell out of it:
- A responder does **not** open the clock exchange. Both ends opening one at once wastes packets
and muddles the estimate.
- A responder does **not** time out waiting to be invited. The peer that invited us knows where we
are and will retry; chasing it is not our job, and treating silence as failure would tear down a
session that is merely waiting.
---
## R-020: Publishing an mDNS service that peers can actually see
**Decision**: Advertise under a hostname derived from the machine's but distinct from it, with
explicitly enumerated routable addresses.
**Status**: **VERIFIED** on two machines (2026-09-20). Three separate faults, each of which made
the service look advertised while being invisible to every peer.
This was only found by testing against a second machine. On one machine everything appeared
correct at every step.
**Fault 1 — automatic address detection published loopback only.** `enable_addr_auto()` resolved
to `127.0.0.1` on the second machine. The service appeared in that machine's own browser, pointing
at itself. Fixed by enumerating interface addresses with `if-addrs` and passing them explicitly,
filtering out loopback and link-local.
**Fault 2 — the machine's own hostname is already taken.** Publishing an A record for
`Studio-Mac.local.` conflicts with the system `mDNSResponder`, which owns it. The record is
accepted, appears in a browser, and is then **withdrawn a second or two later**:
```
13:06:37 Add 3 22 _apple-midi._udp. Studio Rig
13:06:39 Rmv 0 22 _apple-midi._udp. Studio Rig
```
A browse that happens to run in that window sees a healthy service. Fixed by publishing under
`<machine>-midiharbor.local.`, which nothing else owns.
**Fault 3 — self-exclusion compared the wrong name.** It checked the resolved instance against
the *machine* name, but a session advertises its *own* name. So every session this machine
published came back as a connectable peer — the machine offered to connect to itself. Fixed by
tracking the set of names actually advertised.
**What this says about testing**: all three are invisible from one machine. The service registers
without error, appears in the local browser, and resolves. Only a second machine, and Apple's own
`dns-sd` for a neutral view of the wire, distinguished "published" from "reachable".
**Still outstanding**: with all three fixed the record persists on the real interfaces and the
remote machine discovers us, but we do not discover the remote. Discovery is therefore one-way
between two Midi Harbor machines, while both discover Apple's Network MIDI. Connecting by address
works in both directions, so this is a propagation problem in the responder rather than anything
in the session layer. Carried as RISK-11.
---
## R-021: Two Midi Harbor machines carrying MIDI end to end
**Status**: **VERIFIED** on two machines (2026-09-20).
Notes played by an unrelated application into a virtual port on one Mac arrived on a second Mac
across the network, through the whole chain: CoreMIDI callback, real-time ring buffer, dispatch
task, route graph, RTP-MIDI session over UDP, the peer's session, and its own route graph. Zero
delivery failures and zero drops.
This required one late correction: MIDI arriving from a peer was being logged rather than
dispatched, so a session was a route *destination* but not a route *source*. A repeater built that
way carries traffic one way only, and looks entirely healthy from the sending end.
---
## R-022: The pure-Rust responder does not answer cross-machine queries
**Decision**: Advertise through the platform responder (`zeroconf`: Bonjour on macOS, Avahi on
Linux). Keep browsing on `mdns-sd`.
**Status**: **VERIFIED** on two machines (2026-09-20). Resolves RISK-11.
**The symptom**: two Midi Harbor machines could not discover each other, while both discovered
Apple's Network MIDI. Earlier notes called this "one-way discovery"; that was wrong, and the
earlier reading was an artefact of testing inside the hostname-conflict window described in R-020.
With those faults fixed it was symmetric: neither saw the other.
**The isolating test**, which is what made this tractable:
| Advertised by | Seen by our browser | Seen by Apple's browser |
|---|---|---|
| Apple `dns-sd -R` on the remote | **yes** | **yes** |
| `mdns-sd` on the remote | no | no |
| `zeroconf` (Bonjour) on this machine | **yes** | **yes** |
The same machine, the same service type, the same browsers. **Browsing was never the problem.**
`mdns-sd`'s responder announces once at registration and then does not answer continuing queries
from other machines, so a service registered through it is visible for a moment — if something
happens to be listening at that instant — and invisible afterwards to every peer, including to
Apple's own browser.
That intermittency is what made it confusing: a browse started at the right moment does see the
service, which looks like a flaky network rather than a responder that has stopped answering.
**Why the split rather than one stack**: `mdns-sd` browses reliably and reports usable addresses;
`zeroconf`'s browser returned `0.0.0.0` for most entries in testing. Each library is used where it
demonstrably works, which is worth more than the tidiness of a single dependency.
**Cost accepted**: Linux now needs Avahi for advertising, which R-005 identified as the price of
this fallback. Added to CI. Browsing still needs nothing.
---
## R-036: The invitation policy was a setting that did nothing
**Status**: **VERIFIED** (2026-09-20) between the macOS and Linux machines, all three outcomes.
`InvitationPolicy` was stored per session, persisted, displayed, settable from
`session create --policy`, mapped both ways across the contract, and defaulted to `Prompt` with a
comment saying *"silently accepting inbound connections is a surprising thing for a program to
do"*. It was never consulted. `adopt_invitation` accepted every invitation that arrived.
Anything that could reach the control port was in, and could inject MIDI into whatever the
session was routed to, while the user's configuration said they would be asked first.
`EventKind::InvitationReceived` was declared, given a wire name, and never emitted — the same
shape of gap as `NotesSilenced`.
**Answering a prompt is a decision about a machine, not about a packet.** An invitation has a few
seconds of patience — three attempts, two seconds apart — and a person does not. So `Ask` leaves
the invitation unanswered and records who is knocking, and an answer of yes marks the peer
accepted, which the invitation already in flight or the next retry then satisfies. `always`
persists that; without it the peer is accepted for this run only.
Unanswered rather than refused, on purpose: a refusal tells the initiator it was turned away, and
the user may be seconds from saying yes. `RejectAll` does refuse explicitly, because there the
answer will not change.
**Two defects found while testing it:**
- Reporting an invitation awaited a bounded channel. A peer invites repeatedly, so a queue nobody
was draining would have blocked the supervisor — and with it every message that session carries.
Offered rather than awaited now: the same question being asked again is not worth stalling MIDI
for.
- The sockets are dual-stack, so an IPv4 peer arrives as `::ffff:192.0.2.94`, which never equals
the plain address a user typed or a configuration stored. Trust would have been recorded and
then silently never matched. Addresses are compared canonically.
**Measured** across two machines: the default held the connection at `connecting` and listed the
caller by name; answering with `--accept --always` connected both ends; the peer was let straight
in after a daemon restart with no prompt; and switching to `reject` turned that same remembered
machine away, ten refusals logged, with the caller told it was rejected rather than left hanging.
**The window has to be able to answer too.** Making the default enforce turned a GUI-only setup
into one where an inbound session hangs with nothing on screen explaining why, which is a worse
failure than the one being fixed. The prompt is a banner above whatever page is open rather than a
page of its own: a machine is waiting and will stop waiting, so a prompt the user has to think to
go and look for is a prompt nobody sees. Verified with the Linux machine inviting: the banner named
it, pressing Always connected both ends, and the banner went away.
---
## R-037: Trust you can grant but not see or revoke
**Status**: **VERIFIED** (2026-09-20) between the two machines, granting and revoking.
Answering a prompt with "always" wrote a trusted peer into the configuration. `ListPeers` returned
only what discovery could see this instant, with `trusted: false` hardcoded, so a machine the user
had trusted never appeared in any list. `RemovePeer` was a stub. Trust could be granted and then
neither seen nor taken back, except by editing the file by hand.
The contract's `Peer` already carries `discovered` and `trusted` as separate fields, which is the
shape this needed: one list of machines this one knows about, each marked with whether it is
advertising now and whether it is let in without asking. A machine the user trusted is worth
showing whether or not it is switched on this minute, and it cannot be forgotten if it cannot be
seen.
Revocation takes effect at once rather than at the next restart, because trust that stays in force
until a restart is not revoked.
**Matching is by host, not by socket.** A peer is remembered at the standard port and invites from
whatever port its session bound, so comparing sockets would never match. What a user trusts is a
machine; the port is neither chosen by them nor stable across restarts.
**A defect this found in the previous commit**: an invitation recorded as waiting was only ever
removed by being answered. Trusting the machine by address instead, or the session connecting some
other way, left the prompt on screen forever — a question already settled, asking the user to
decide it again. Invitations are now dropped when the peer becomes trusted or the session
connects.
**Measured**: `session peer add 192.0.2.20` on one machine let in the invitation the other was
already sending, with no prompt answered; `session discover` showed it as remembered and trusted
beside two machines that were merely advertising; `session peer remove` held the next invitation
again without a restart.
---
## R-046: Our own responder dropped our own initiator every thirteen seconds
**Status**: **FIXED** (2026-09-21), verified between this Mac and the Linux machine.
T106 asks that a keyboard routed to another machine survive being unplugged with no note left
sounding on the far side. Writing it turned up two defects, one per machine.
**Silencing never reached another machine.** Stopping a route's notes sent the note offs through
the destination's platform port. A network session has no platform port, and neither does a
Bluetooth link, so the release was skipped, after the record of what was held had already been
cleared. A keyboard unplugged mid-note, or switched off, left the note ringing on the other
machine or the Bluetooth synth for good. Routing and silencing now share one delivery function
that knows every transport, so silencing cannot reach fewer places than routing does.
**The responder judged liveness against the wrong clock.** Run between two machines, the
responder dropped the session 5 ms after accepting it, again after 1.2 s, and then every ~13 s
for as long as it ran. Each drop sent a full sixteen-channel silence to the far synth, while the
initiator believed the link steady. The cause was in the clock exchange. On the responder, the
exchange's opening and closing timestamps belong to the initiator's clock, and the exchange was
dated by them. Liveness then measured the responder's own clock against that date:
- Initiator's clock behind by more than the 35 s timeout (here: started a minute later): the
session was dropped as soon as it was accepted, then after every clock exchange.
- Initiator's clock ahead: the date sat in the future, and a responder could never notice a peer
that had gone.
Each machine counts ticks from when its own session started, so which case applies depends only
on which side started first. That is why the in-process test, whose two sessions start
milliseconds apart, could not show it. Exchanges are now dated by the local clock on both sides.
The responder's offset had the opposite sign to the initiator's, which was latent since nothing
converts peer timestamps yet, and was fixed alongside.
Across the two machines afterwards: one "established" and no drop in 45 s. The far synth
received nothing until a note was played. Switching the source off on the Mac then released that
note on the Linux synth, on its own channel only.
---
## R-049: A session switched off and on came back somewhere its peer was not looking
**Status**: **FIXED** (2026-09-21). Found by T148's soak on its first fault.
The soak (`crates/daemon/tests/endurance.rs`) joins two daemons in one process with a loopback
session. A keyboard on one is routed through the session to a synth on the other, and notes play
throughout. It then injects faults in turn:
- the far session is switched off for two seconds;
- the keyboard is unplugged and replugged;
- the synth port is switched off and on.
After each fault, a probe message has to reach the synth within SC-003's ten seconds.
The first fault was never recovered. The near session retried for good, and the far session
listened, but on a different port. Three defects compounded:
- **A session left to the system's choice of port chose again on every start.** Switching it
off and on, or restarting the daemon, moved it. A peer that had connected by address went on
retrying the old port. Discovery would have found the new one, but a peer added by address
never looks. The first port the system gives is now written into the configuration and
reused, the way a virtual port keeps its CoreMIDI identifier.
- **Ports the system chose were not held to the even-port rule.** Binding moves an odd control
port up to an even one, because some implementations refuse an even data port. That applied
only to ports we asked for, and the system hands out odd ones as readily as even. An odd port,
once recorded, moved up by one on the next start, so pinning alone changed nothing. An odd
choice is now refused and the even port above it tried.
- **Stopping a session did not wait for it to stop.** `shutdown` sent the supervisor a command
and returned while it still held both sockets. A session switched straight back on found its
own port taken, and binding fell back to a nearby pair, away from the peer again. `shutdown`
now awaits the supervisor's task, so the ports are free when it returns.
`a_session_keeps_the_port_the_system_chose` covers the first and third defects, and
`a_port_the_system_chooses_is_even` covers the second. Each fails with its fix reverted. With
the pin reverted, the soak fails on its first fault again.
**The harness had a defect of its own.** It looked for the probe as the last message the synth
received, and background notes every 10 ms landed on top of it. The first probe passed only
because it ran before the notes started. Notes now pause while a probe is out and play through
every fault and recovery.
| Run | Faults | Recovered | Slowest recovery | Messages played |
|---|---|---|---|---|
| macOS, 2 minutes | 35 | 35 | 2.7 s | 14,660 |
| Linux desktop, first 6 h 49 min of a 24-hour run | 6,900 | 6,900 | 2.6 s | 3,267,162 |
The 24-hour run completed with every fault recovered; R-067 records it, and T148 is ticked.
---
## R-056: What running the quickstart between two machines found
**Status**: **FIXED** (2026-09-21), for T154. Scenario 2 run between this Mac and the Linux
desktop, each with a daemon on a scratch configuration.
Discovery found the Linux session by name, and `session connect "QS Near" "QS Far"` connected
to it with no address typed. A note, a controller change and a note off played into a port on
the Mac arrived in order at a synth port on Linux, read by `aseqdump`. The Linux daemon was then
stopped for 5 s and started again. The Mac noticed the loss and was reconnected about 7 s after
the peer came back, within SC-003's 10 s. Getting that far found two defects.
**A one-way port on macOS carried nothing.** The model names a port's direction from the routes'
side: an input port is one routes take MIDI from, so applications send into it. ALSA had it
right. CoreMIDI had it backwards:
- an input port was given a virtual source, which applications receive from and cannot send
into;
- an output port was given a destination, and the daemon had no source to send out of.
So `port create --direction in` on macOS gave applications nothing to send to. The quickstart's
sender reported "no destination". Two-way ports, the default, have both ends, which is why this
went unnoticed. The ends are now the right way round. The source is still created first, so a
two-way port pins its identity to the same endpoint as before, and existing configurations keep
their identifiers. `a_one_way_port_offers_applications_the_end_it_is_for` asks CoreMIDI what
another application sees, and fails with the ends swapped back.
**A session dropping never reached the history.** A session logged "network session lost" and
"established", but recorded neither. The history, which is what FR-046 exists for and what
scenario 4 checks, showed nothing having happened to the session at all. A session now reports
to the daemon, on the channel that already carried invitations:
- connecting;
- every loss;
- the first failure of a streak of failures.
Each is recorded like any endpoint's: "Stage Out lost its connection to Stage In: peer not
responding", then "Stage Out connected to Stage In". A notice is offered, never awaited,
so a full queue cannot stall the supervisor. `a_session_losing_its_peer_and_getting_it_back_is_in_the_history`
switches the far session off and on, and fails with the notices withheld.
---
## R-057: The journal over a real network, and what it repeated
**Status**: **FIXED and VERIFIED** (2026-09-21), for T154 scenario 2 and SC-006.
The quickstart's packet-loss check was run between this Mac and the Linux desktop. The Mac played
a note on, two controller changes and a note off into a port routed to the session, 630 rounds
in 60 s. On Linux, a rule in a table of its own dropped 5% of packets arriving at the session's
data port.
| | Before | After |
|---|---|---|
| Packets dropped | 125 | 132 |
| Counted lost / recovered | 124 / 272 | 132 / 132 |
| Note on repeated with no note off between | 55 | 0 |
| Notes left sounding | 0 | 0 |
| Final controller values | matched | matched |
Nothing was left sounding before either, but a synth that stacks voices would have sounded 55
notes doubled. Two defects together:
- **Each journal described its own packet.** The sender added a packet's messages to its
journal before building the journal it sent with them. RFC 6295 has a journal describe the
packets before the one carrying it. A receiver recovering from a gap applies the journal and
then the packet, so it played the packet's notes twice. That includes any RFC-following
receiver, Apple's among them. The journal is now taken first.
- **Recovery repeated what had arrived.** A journal covers everything since the last
acknowledgement, including packets that arrived. Replayed whole, it repeated them.
The receiver now keeps what it has delivered on each channel: notes sounding, controller
values, program, bend and pressure. It passes on only what changes that state, as RFC 6295
has a receiver do. A note off always passes, since one held back by a wrong state would leave
a note ringing.
With both fixed, each lost packet is rebuilt as exactly the one message it held.
**Found on the way, and fixed**:
- **Nothing a session carried was counted, and it could not be monitored.** Sessions have no
runtime entry, and counting and the monitor feed hung off one. `status` showed a session that
had carried 2,700 messages as having received none. `monitor` called a session carrying MIDI
"not running", against FR-045 and FR-047. Each running session now has counters and a feed
of its own.
- `status --json` left out `recovered`, the number that shows the journal working, and the
quickstart's `jq` path named fields that never existed.
- "connected after 1 failed attempts"; two tests asserted the wrong form with `contains`, which
the right one also satisfies, so they now check the end of the line.
**Seen, and left**: a lost link is silenced twice, once by the protocol layer and once by the
connection state machine. Each is a deliberate guarantee. The cost is 48 controller messages
repeated, harmless to a synth.
**Scenario 2 results**, Mac to Linux over the LAN:
- **Discovery:** the Linux session was found by name.
- **Connecting:** by name, with no address typed.
- **Peer restart:** reconnected about 7 s after the peer returned.
- **30 s network cut:** the held note was released on the far synth during the cut; reconnected
0.7 s after the cut ended.
- **5% loss:** results as in the table above.
Both histories showed every loss and reconnection. A peer running Apple's Network MIDI was run
in R-068, and Wi-Fi roaming and sleep in R-069 and R-070.
---
## R-060: Sessions that are not announced, and a port with nothing above it
**Status**: **DONE** (2026-09-21).
**Announcing is now a preference.** `advertise_sessions`, on by default, decides whether sessions
are announced on the network. Off, a session still listens and can be connected to by address;
no machine browsing sees it. It serves a network where announcing is unwelcome, and it keeps
test runs off the network. Every daemon test that starts a session now writes it off first. The
24-hour soak had announced "Soak In", which accepts anyone, on the local network for a day.
A change applies on `config reload`: each running session is announced or withdrawn, and none
restarts. `announcing_sessions_can_be_switched_off_and_on` checks all of this: a session
unannounced with the preference off but still listening, announced after a reload on the same
port, and withdrawn again. It fails with the preference ignored at start.
**A session could fail to bind when the system gave it port 65535.** Binding moves an odd
control port up to the even one above. For 65535 there is none: the move saturated, stayed on
65535, and repeated for every attempt until binding gave up with "no port pair". macOS hands out
ports from 49152 to 65535, so a session left to the system's choice could fail to start, rarely
and at random. It surfaced as one failure of `a_port_the_system_chooses_is_even` in a full run of
the suite, which 23 runs since did not repeat. Past the top, binding now asks the system again.
`the_highest_port_is_not_a_dead_end` binds starting from 65535. It failed with "no port pair"
before the change.
---
## R-063: A peer that restarts, and traffic from anyone else
**Status**: **VERIFIED** (2026-09-22) between two Midi Harbor daemons on one Mac. Untested against
Apple's Network MIDI and rtpmidid.
A daemon killed and started again keeps its session's port but has no session on it. Its peer did
not know. It went on sending clock exchanges, the new daemon ignored everything but an
invitation, and the peer found out only when its 35 second liveness timeout ran out. On this Mac,
with `kill -9` twelve seconds after the last exchange, Alice lost Bob 24 seconds after he came
back and reconnected 0.25 seconds later.
Three changes, in `crates/daemon/src/session.rs` and `crates/rtpmidi/src/session.rs`:
- A session with no peer answers a clock exchange, receiver feedback, MIDI, or a late acceptance
with `BY` on both ports. The peer ends its session and retries. The same run now loses Bob 0.5
seconds after he comes back, and reconnects 0.25 seconds after that. `IN` gets no `BY`, since it
asks for a session. `NO` and `BY` get none either, or two sides would trade them forever. One
goodbye a second goes to an address, however much it sends.
- A control `IN` under a new SSRC, once the handshake is over, is the peer restarting. The old
session ends silenced, since the restarted peer will never release what it held, and the same
invitation begins the next. The same SSRC is an acceptance that went missing, and is answered
again as before.
- A session with a peer no longer hears anyone else. Before, anything reaching its ports went
into the session. A `BY` from any machine ended it. An `IN` from another machine replaced the
peer's SSRC and name, while the acceptance went to the peer. Now an outsider's `IN` gets `NO`,
and its session traffic gets the goodbye above. The peer is matched by address and port, since
one machine can run several sessions.
Whether Apple honours a `BY` whose SSRC it has never seen is not known. The daemon that restarted
has a new random SSRC, and does not know the old one. Midi Harbor's own sessions do not check it.
Against Apple, the worst case is the old behaviour: the timeout.
The failure is still reported as `PeerTimeout`, "peer not responding". A peer that said goodbye
has no reason of its own in `FailureReason`, and adding one is a contract change.
Tests, each failing when its rule is removed:
- `a_peer_that_restarted_is_reconnected_in_seconds`: Bob crashes and restarts, and Alice hears of
it within five seconds.
- `a_peer_that_restarted_and_invites_again_replaces_its_old_session`: Bob drops the old session,
and Alice is connected within 1.5 seconds, less than her two second wait to invite again.
- `another_machine_cannot_end_or_join_a_session_with_a_peer`: a third socket's `BY` changes
nothing, and its `IN` gets `NO`. Matching the peer by address alone fails it.
- `a_peer_inviting_again_under_a_new_ssrc_has_restarted` and
`a_repeated_invitation_from_the_same_peer_is_answered_again`, in the session machine.
- Pure tests of which packets get a goodbye, and of the once-a-second limit.
---
## R-065: Recording against Apple's Network MIDI on the same Mac
**Status**: **SUPERSEDED** (2026-09-22) by R-068, for T081. The session control and clock
interoperated, but MIDI did not pass reliably in either direction. R-068 found the likely cause
the same day, a conflicted Bonjour name on this Mac, and carried MIDI both ways with Apple. What
follows is left as it was recorded.
Apple's session was "Session 1" in Audio MIDI Setup, on port 5004, macOS 15. `tests/interop.rs`
drove our side, with `HARBOR_RECORD_PEER` now taking an address as well as a local port. A Swift
client played the same script into "Network Session 1" and printed what it delivered.
`tcpdump` recorded both sides. There were three peers: the driver on this Mac, the same binary
on the Linux desktop (built on Debian), and a minimal initiator in Python written to rule our
encoder out.
What Apple did:
- With "Who may connect to me" left as it was, every invitation was answered with `NO`.
- Set to Anyone, it accepted both ports and kept the clock in sync. Apple opens its own exchange
a second after joining. It ends the session with `BY` about two seconds in, unless the initiator
has opened another exchange by then. Ours opens one every second at first, so it was never
ended.
- No MIDI arrived from Apple, and Apple sent no receiver feedback for ours, from any of the three
peers. That held for packets in Apple's own form too: no delta time and no journal.
- With two participants, Apple sent its MIDI, journal included, to the second only, and sent
feedback for the first one's notes. That did not repeat.
- The Swift client playing the moment the connection came up sent nothing on the wire. Apple's
own clock exchange comes about a second later, and the client now waits two seconds.
- Restarting `MIDIServer` did not change it. Nothing was logged under MIDIServer.
Apple's own packets from those runs are in `tests/interop/apple_network_midi.txt`: `OK` with its
name, `NO`, `BY`, `CK` in each role, and three MIDI packets. The first MIDI packet has no journal,
and the other two carry Chapter N. `everything_apple_sent_is_understood` parses each of them. Two
things in them are worth knowing:
- Apple's MIDI list uses no delta time before the first command. Our packets use one, which the
RFC allows.
- Apple clears the Y bit in every note log, which RFC 6295 (A.6) makes a recommendation to skip a
recovered note on rather than play it late. Our recovery honours it, so the journals recorded
here rebuild nothing.
Apple's `RS` was seen but its bytes were not kept.
Not tried: Apple initiating, from Audio MIDI Setup's directory, which is how most people
connect. The interop test is left for when someone is at the Mac.
---
## R-066: System-exclusive over network sessions
**Status**: **DONE** (2026-09-22). Tested between two daemons over loopback. Not yet seen against
another implementation.
Sessions did not carry dumps. A route into one counted each dump as undelivered (R-030), and a
dump arriving from a peer was worse. The packet parser read its `F0` as a one-byte message and the
first data byte after it as malformed, so it refused the whole packet, and the notes sharing it
were lost too.
RFC 6295 (section 3.2) carries a dump in the MIDI list, divided into segments when it is longer
than one packet should carry. A segment's first and last bytes say where it falls:
| Segment | Framing |
|---|---|
| a whole dump | `F0 ... F7` |
| the first of several | `F0 ... F0` |
| a middle one | `F7 ... F0` |
| the last | `F7 ... F7` |
| an abandoned dump | closed by `F4` |
What was built:
- **The packet codec** reads and writes segments in their place among the messages. A dump ends
running status. A real-time byte inside a dump is played as a message just before it, not kept
in the dump. A segment the list ends before closing is refused, as is any other status byte
inside one.
- **The session machine** sends a dump as whole segments of at most 1024 bytes, one per packet,
each with its journal like any packet. With the headers that stays inside one Ethernet frame.
On receipt it puts the segments back together and delivers the dump in its place among the
notes.
- **Loss** discards a dump in progress, because a gap in the sequence may have held one of its
segments, and part of a dump is never delivered. A dump that never ends is given up past
256 KB, the bound the daemon puts on one from a port. The journal carries no Chapter X, so a
lost dump is not rebuilt. It is simply not delivered.
- **The daemon** gives each session one ordered channel for messages and dumps, so a note sent
after a dump cannot overtake it. A route into a session now sends the dump.
Found on the way, and fixed separately: a packet arriving after a newer one, or repeated, was
delivered again. Played after its note off, a late note on left the note sounding. Such packets
are now dropped.
`a_long_dump_crosses_a_network_session_whole_and_in_order`, in `crates/daemon/tests/sysex.rs`,
sends 3000 bytes and a note from one daemon's port to another's, and gets both back in order.
The codec and session tests cover each framing, a clock inside a dump, a missing segment, the size
bound and delivery order, and each fails when its rule is removed. The packet fuzz target was
seeded with segmented packets and run for five minutes.
---
## R-068: Sessions with Apple, whichever side invites
**Status**: **DONE** (2026-09-22), for T081. Sessions with Apple carry MIDI both ways, whichever
side invites. rtpMIDI and rtpmidid are still not recorded.
The owner connected "Session 1" to a Midi Harbor session called "Harbor" from Audio MIDI Setup's
directory, on this Mac, macOS 15. The daemon ran from a scratch home, with a virtual port routed
to the session in both directions. A small CoreMIDI client played notes into each end and printed
what arrived at the other. `tcpdump` recorded loopback and Wi-Fi.
**Why Apple did nothing at first.** Clicking Connect put Harbor under Participants, but Apple
sent nothing: no packet on any port, on either interface, and nothing logged by `MIDIServer`.
Adding Harbor by address made no difference. The session's Bonjour name read "Studio Mac
(2)", the name macOS gives a service after a conflict. With the name set back to "Studio Mac", Apple
invited at once. That is probably what R-065 ran into as well: every one of
its runs used the conflicted name. R-065 is left as it was recorded.
**Apple inviting us** works. Apple invited over IPv6 loopback, `[::1]:5004` to `[::1]:5006`,
naming itself "Studio Mac". Notes 60, 62 and 64 went from Apple to Harbor, and 65, 67 and
69 from Harbor to Apple, each on and off, all in order. Apple's packets are in
`tests/interop/apple_network_midi_inviting.txt`, and `apple_inviting_us_is_understood` parses
each one: both invitations, the clock exchange Apple opens and closes, its receiver feedback, and
six MIDI packets. Each note off carries Chapter N for the note on before it. The Y bit is clear,
as in R-065, so recovery sounds nothing.
**Receiver feedback was the wrong shape.** Apple sends `RS` on the control port. The 32-bit
sequence field holds the RTP sequence number in its upper half: `ffff5253 37193c27 fde50000`
acknowledges packet `0xFDE5`. The lower half is not always zero: between two of Apple's own
sessions it read `907c`, so it is ignored. We read the lower half and sent ours there, on the
data port. Two Midi Harbors agreed with each other, so this never showed between
them. Against Apple, every `RS` Apple sent moved our journal checkpoint to 1. Our next packet's
journal read checkpoint `0x0001` instead of `0xFDE6`, and Apple got an acknowledgement of packet
0 each time. Both ends now use the upper half, and ours goes on the control port. A Midi Harbor
from before this change mistrims against one from after it, the same way it did against Apple.
**Stopping the daemon left its sessions open.** The daemon silenced notes on its way out and
exited, and no `BY` went out. Apple kept the old session under Participants and sent its MIDI to
the old ports. The next daemon did answer that stray MIDI with a goodbye, under its own SSRC.
Apple did not recognise it, so it went on sending there, and none of it reached the new session.
The daemon now ends every session before exiting. Checked against Apple: the exit sent `BY` on
both ports, and Apple removed the session from its list.
**Midi Harbor inviting Apple** connected and carried no MIDI at first. Apple accepted on both
ports, ran the clock exchange from each side, and listed the session under Participants, but it
neither delivered our MIDI nor sent its own. That held over IPv4 and IPv6, and under a session
name Apple had never seen.
Apple inviting another of its own sessions on this Mac carried MIDI both ways, so Apple's invited
side works. The difference was the clock exchange. Apple's initiator exchanges once on connecting,
again 1.5 s later, and then the two sides trade a burst of back-to-back exchanges. Ours ran one
on connecting and one a second later, then settled at every ten seconds. With notes played every
0.6 s after connecting, Apple's first arrived 21.5 s in, straight after our fourth exchange. Six
had completed by then, counting Apple's two. Apple-to-Apple reaches the same count within 1.5 s.
Apple, invited, evidently carries nothing until about six exchanges have completed. Ours now run
eight at 250 ms before settling. Measured with a note every 0.1 s from the moment of connecting,
Apple's first note arrived 1.73, 1.73 and 1.72 s in over three runs, and ours reached Apple after
1.2 and 1.73 s over two. Apple dropped the notes played before that, and the rest arrived.
---
## R-071: rtpmidid, each way
**Status**: **DONE** (2026-09-22), for T081. Sessions with rtpmidid carry MIDI both ways,
whichever side invites, and rtpmidid's packets replay through the parser. Its feedback put the
sequence number where we no longer looked, which is fixed. rtpMIDI, which runs only on Windows,
is still not recorded.
rtpmidid came from its main branch at `7f552d2`, built on an Arch Linux VM with a
self-contained cmake. The VM's own cmake needed a newer `jsoncpp` than the VM had, and the owner
then had the VM upgraded. It ran with discovery off, since mDNS does not cross between the VM's
subnet and the Mac's. The Midi Harbor end was a scratch daemon on the Mac, with a session routed
to a virtual port each way. `aseqsend` and `aseqdump` played and read rtpmidid's ALSA port, and
`tcpdump` on the VM recorded both.
**Both directions, both roles.** With the Mac inviting rtpmidid, three notes went from the Mac to
rtpmidid. Notes, a controller, a program change, pitch bend and a SysEx came back. rtpmidid invites
only when an ALSA client subscribes to the port its `connect` command creates, and it names the
invitation after that client, here "aseqdump". It invited from ports it picked, 51434 and 51435,
and said goodbye when the client went away. The same kinds of message crossed each way. Its
packets are in `tests/interop/rtpmidid.txt`, and `everything_rtpmidid_sent_is_understood` parses
every one: both acceptances and invitations, clock packets in each position, feedback, twelve
channel messages, two SysEx and both goodbyes. rtpmidid sends no journal, and no delta time
before its first command.
**Its feedback reads one half and writes the other.** rtpmidid reads `RS` from the upper half of
the 32-bit field, as Apple does, but writes the lower half: `ffff5253 00003325 00004711`. The
number it writes is not the last packet it received but the checkpoint of the journal in that
packet, so it names the packet before. Since R-068 we read the upper half only. rtpmidid's
feedback therefore named packet 0, which looked stale against sequence numbers near `0x4711`, so
it was ignored and our journal was never trimmed: every packet to rtpmidid carried checkpoint
`0x4711`. Had our numbers sat just below the wrap, the checkpoint would have gone back to 1
instead. `RS` now keeps the field whole, and the session takes whichever half names a packet sent
since the checkpoint, upper first. Checked against rtpmidid: each packet's checkpoint moved to the
one before it, `6fc2`, `6fc3` and on.
**What rtpmidid made of ours.** Its log reads "This RTP MIDI header has journal. WIP." and "This
RTP MIDI payload has delta time for the first command. Ignoring." Both are notes, not refusals:
every message arrived. Its journal support is unfinished, so it recovers nothing we lose. The
second came once for every packet we sent, since we always set the Z flag and wrote a first delta
of zero. Apple and rtpmidid both leave a zero first delta out, and so do we now. The Z flag and
the delta are still written when the first command waits. Against rtpmidid afterwards, six notes
arrived and the warning did not appear.
---
## R-073: rtpMIDI on Windows, each way
**Status**: **DONE** (2026-09-22), for T081. Sessions with rtpMIDI carry MIDI both ways,
whichever side invites, and its packets replay through the parser. That completes T081: Apple's
Network MIDI, rtpmidid and rtpMIDI are all recorded.
rtpMIDI 1.1.14.247, the current release, was installed silently on a Windows 11 VM. Its
configuration app and its virtual MIDI ports work only in the logged-in desktop session, so the
app was driven there through a one-off scheduled task: clicks by position, checked against
screenshots, and a small WinMM client to play into and read from the session's port. The Midi
Harbor end was a scratch daemon on the Mac, on another subnet, with `tcpdump` on the Mac.
**Both directions, both roles.** With the Mac inviting, three notes reached Windows, and notes, a
controller, a program change, pitch bend and a SysEx came back. rtpMIDI, given the Mac as a remote
peer, invited it, and the same crossed each way. Its packets are in `tests/interop/rtpmidi.txt`,
and `everything_rtpmidi_sent_is_understood` parses every one. Like rtpmidid, it sends no journal
and no delta before its first command. Its feedback puts the sequence number in the upper half, as
Apple's does, and its RTP sequence numbers start at zero, not at a random value.
**Worth knowing:**
- Inviting the Mac, rtpMIDI sent its control-port invitation four times, a second apart, and went
on to the data port only after the fourth. It did so twice, the second time by itself, and we
accepted each invitation within 2 ms. Inviting the Linux VM, on the same subnet as Windows, it
went on after the first. The firewall rule rtpMIDI installs allows any address, so that does not
explain it. Nor does the Bonjour conflict below: with the owner's hostname fixed and the machine
restarted, rtpMIDI again sent four, at 20:15:31.56, 32.57, 33.57 and 34.64, each answered within
3 ms. The session came up three seconds late and then worked.
- rtpMIDI keeps a peer it invited as a participant. Disconnecting from our side, it invited again
33 s later, so disconnecting from a Midi Harbor session does not stick against rtpMIDI. That is
rtpMIDI's choice, as the side that made the connection.
- Its Bonjour registration failed with -65548, a name conflict with another of the owner's cloned
Windows guests of the same hostname. The owner renamed the machine. rtpMIDI kept the old name as
the session's Bonjour name until it was changed by hand. By address, nothing depended on it.
- After the restart, the rtpMIDI service timed out at boot ("waiting for the rtpMIDIService service
to connect", after 45 s) and had to be started by hand.
---
## R-076: A session carrying several machines
**Status**: **DONE** (2026-09-22), for T168. Changed at the owner's direction.
The spec's edge case asks that two peers inviting at the same moment are both handled without
either being lost. A session held one peer and refused anyone else, so the second was turned away
until the first left. Offered keeping one peer and recording why, or carrying several, the owner
chose several, as Apple's own sessions do.
Decisions:
- **A peer and guests.** The session keeps one peer: the machine it connects to, retries,
reconnects after sleep and reports in its status. A machine that invites it while it has a
peer, and that the invitation policy lets in, becomes a guest. Each guest has a session machine
of its own, with its own handshake, clock exchange, sequence tracking and recovery journal.
- **Same MIDI.** What is routed into the session goes to the peer and every guest, and what any
of them sends arrives as the session's.
- **Let go, not chased.** A guest invited itself in, so one that says goodbye or stops answering
is dropped, with its notes released, rather than invited back.
- **Handover.** When the peer leaves and the session goes back to listening, a guest takes the
peer's place, so a session carrying a machine never reads as listening.
- **Visible.** The contract gains `NetworkSessionDetail.guests`, a new field, and `session list`
and the window name the guests. The history records each arriving and leaving.
Tests: `crates/daemon/tests/guests.rs`, in which two machines connect to one session, MIDI crosses
from each and to each, and the peer leaving hands the session to the guest. The session test that
expected a second invitation to be refused now expects it accepted beside the peer.
---
## R-080: A port macOS gives to two sockets
**Status**: **CLOSED** (2026-09-25). Fixed in T196.
The daemon's unit tests failed about one run in twenty, always a session test and a different one
each time: a session stayed connecting, or an invitation was never reported. Traced, the
datagrams were sent and never arrived. Bob answered Alice's invitation to `127.0.0.1:49996` three
times, each send succeeded, and the socket Alice held on `[::]:49996` received none of them. The
session tests alone never failed in 30 runs; with the transport tests beside them, two failed in
25.
The transport tests hold plain IPv4 sockets, and macOS lets a dual-stack socket share a port with
one. Measured on this Mac, with Python sockets:
| Held first | Then bound | macOS | Linux (Arch) |
|---|---|---|---|
| 3000 × `127.0.0.1:0` | 3000 × `[::]:0` | 534 on a held port | not tried |
| 3000 × `0.0.0.0:0` | 3000 × `[::]:0` | 564 on a held port | none |
| `0.0.0.0:P` | `[::]:P` by number | allowed | refused |
| `127.0.0.1:P` | `[::]:P` by number | refused | not tried |
| 3000 × `[::]:0` | 3000 × `127.0.0.1:0` | none | not tried |
In every shared case the IPv4 socket receives what is sent to the port over IPv4, and the
dual-stack socket gets nothing. For the daemon this is not only a test fault. A program holding
5004 over IPv4 alone leaves a session on 5004 deaf to every IPv4 peer, and a session whose port
the system chose can land on any port such a program holds. Either way the session binds cleanly
and reports nothing wrong.
Binding over IPv4 is checked against every IPv4 socket, and the system's IPv4 choice avoids ports
in use. So each port is now claimed over IPv4 first, by number or by letting the system choose,
and the dual-stack socket is then bound to that number. A test holds a pair's control port, then
its data port, over IPv4 alone, asks for that pair, and sends to both ports. It failed on every
run before the fix. The unit suite then passed 40 runs in a row.
A port the system chooses is not covered by a test that always fails without the fix: catching
the old behaviour needs the system to pick a held port, which it did for 534 binds in 3000 with 3000
ports held, and far less often with the few a test can hold. Only the explicit case is pinned.
---
## R-082: A port held by a program being started
**Status**: **CLOSED** (2026-09-25). Fixed in T200.
On the Arch VM the session tests failed about one run in thirty: a session switched off and
straight back on came back on another port pair. Less often a session restarted with its daemon
did the same, or one never connected at all. The Mac never showed it. Stopping a session waits
for its sockets to close, and the log showed them closed before the port was asked for again, yet
binding it failed with "address in use". Run with `ss` on every refusal, nothing held the port by
the time `ss` looked.
The holder was a program being started. Every daemon start runs `systemctl --user
show-environment` to learn whether systemd can run it as a service. On Linux the child begins as
a copy of the daemon's process, holding a copy of every socket it has, and gives them up only
once `systemctl` is running in its place. The audit log showed 33 attempts to run it in one run of the
session tests, three for each start as the child tried each place on `PATH`, and a child was
running one in the same millisecond as a refused bind. Tests run many daemons in one process, so one daemon starting held
another's port for those few milliseconds. On macOS a program is started in one step, and no copy
of a socket is ever held.
A daemon running as a service starts that program before any session binds, so the collision
needs two daemons in one process. Whatever the daemon or a library starts later would do the
same, though, and moving away from a port leaves every peer that knew it retrying a port with
nothing behind it. So a port pair asked for by number is now tried again while it is held, every
25 ms for up to 225 ms, before another pair is chosen.
Tracing the binds turned up a second fault. When the claim over IPv4 succeeded and the dual-stack
bind was then refused, the port was bound over IPv4 alone, a fallback meant for a system without
IPv6. The pair then had an IPv4 data socket beside a dual-stack control socket. Every send to an
IPv4 peer is addressed as IPv6 on such a pair, and an IPv4 socket refuses all of them, so the
session never got past inviting the data port. A port refused because it is in use is now treated
as taken.
| Session tests on the Arch VM | Failures |
|---|---|
| Before | 3 in 80 |
| After | 0 in 100 |