midi-harbor/specs/013-windows-support/research.md
James Coleman f059eaef65 docs(specs): drop the spec names used before the renumbering
- The specs index no longer carries a table mapping the three original spec names to the renumbered ones, and spec headers no longer record a feature branch or "first written as" name.
- A task that pointed at research under the old 001 directory now points at its current path, so every reference resolves to a spec that exists.
- The constitution's 1.4.1 amendment still records that the specs were split and renumbered, without listing the retired names.
2026-09-29 14:35:33 -05:00

16 KiB

Research: Windows support

Feature: 013-windows-support | Date: 2026-09-26

Numbered after the original specification's research, whose last entry was R-082, so every R number in the project names one entry. Findings marked VERIFIED were measured on the Windows test machine: Windows 11 Pro 25H2 (build 26200) in a virtual machine, with rtpMIDI 1.1.14 and teVirtualMIDI 1.3.0.43, reached over ssh as a standard administrator account and cross-compiled for from macOS.


R-083: Task Scheduler does not restart a program that fails

Status: VERIFIED (2026-09-26).

Decision: The logon task runs midi-harbor daemon --supervise, which runs the daemon as a child and starts it again after a failure: after 2 s, doubling while failures come less than a minute apart, up to 60 s. A successful exit, which is what a daemon asked to stop returns, ends supervision.

Evidence: A task with RestartOnFailure set to every minute, three times, ran cmd /c exit 3. Task Scheduler recorded last result 3 and never ran it again. The setting covers a task that fails to start, not a program that exits with an error.

Rejected: A trigger repeating every minute with IgnoreNew. It restarts after up to a minute instead of two seconds, and it would start again a daemon the user had stopped.

Also decided: The task runs as the user with an interactive token and least privilege, so a standard user can install it and the daemon runs in the user's session, where MIDI devices and teVirtualMIDI ports are. A Windows service would run in session 0 and needs an administrator. The command is conhost.exe --headless, so the console program opens no window at logon. The priority is 4, Task Scheduler's normal; its default, 7, is below normal.

R-084: The DNS Client service's DNS-SD and instance names

Status: VERIFIED (2026-09-26), with a capture on the Arch VM on the same subnet.

Decision: Advertise through DnsServiceRegister, looked up in dnsapi.dll at run time. A dot in a session's name is sent as a hyphen.

Evidence: Registered with no addresses, the service answers with the host's own and keeps answering: avahi-browse -r on the Arch VM resolved WinHarbor to 192.0.2.47:58332, and the records went out with goodbyes when the daemon stopped. A name with a dot, escaped as DNS-SD defines, went out as two labels, Win Harbor\ (eleven bytes, the backslash kept) and Test, and no browser listed it. A name with any character outside ASCII is lowercased: Café Test was announced as café test, while WinHarbor kept its case. So the dot's stand-in is ASCII; a lookalike dot lowercased the whole name. This daemon recognises its own records by address and port, so the changed name does not make it list itself.

Consequence: A session whose name holds non-ASCII characters is advertised lowercased on Windows. Nothing breaks; the name looks different on other machines.

Browsing through mdns-sd works on Windows as elsewhere: a Windows daemon found a Linux daemon's port and resolved it.

R-085: Windows makes a socket on [::] IPv6-only

Status: VERIFIED (2026-09-26). Fixed.

Every invitation a Windows daemon sent to an IPv4 peer failed with "the requested address is not valid in its context" (10049), and the session waited for a network that was there. Linux and macOS make a socket bound to the unspecified IPv6 address dual-stack; Windows sets IPV6_V6ONLY by default. The session sockets now clear it explicitly through socket2. The existing test that sends to an IPv4 peer fails on Windows without the change. With it, a Windows daemon joined a Linux daemon's session at 1.0 ms latency.

R-086: teVirtualMIDI and WinMM under churn

Status: VERIFIED (2026-09-26), by PowerShell probes against the driver and by the Windows integration tests.

  • A slot reused at once keeps its old name. Closing a port and creating another under a new name straight away left WinMM showing the old name, in every process, on six of ten tries, for at least five seconds. Created while the old port was still open, the new port showed its own name within 28 to 54 ms on ten of ten. With half a second or more between the close and the create, the new port always appeared. Renaming in the daemon is a destroy and a create, so the backend retires a destroyed port and closes it once the next port exists or after a second. With retirement off, the eight-rename test failed five runs of five, on the first rename each time; with it, it passed five of five.
  • A closed port lingers. WinMM often lists a closed port, under its old name or as (unavailable), until the driver's next change, in fresh processes too; this is the driver, not a cache in one process. The backend keeps names of ports it closed out of its device list for up to a minute, and never offers (unavailable) application ports.
  • The halves of one port are listed separately. A new port's input can be listed before its output, and each can carry a different tevmidiN slot number. Application ports are therefore identified by name, which the driver keeps unique.
  • A name already taken is refused with ERROR_ALIAS_EXISTS (1379) by driver 1.3.0.43, not the ERROR_ALREADY_EXISTS its header suggests. Both are reported as a naming conflict.
  • The first message after another application opens a port can be lost. In four of thirteen runs of the suite, one note sent a few milliseconds after the open arrived only when sent again. The tests send until heard and report how many sends it took.
  • Positions move. WinMM opens by position, and a port closing while another is opened shifted the list under the open: a handle once opened a closing port and failed with "undefined external error". The backend reads the lists until two readings agree, checks each opened handle's own position against what was meant, and retries.

Timing: a new port appeared in WinMM 30 to 390 ms after creation.

R-087: The control channel on Windows

Status: VERIFIED (2026-09-26).

Decision: A named pipe with a random name, \\.\pipe\midi-harbor-<uuid>, which refuses remote clients. The daemon creates it before recording its name in a file at the socket path, in the user's temporary directory, and clients read the name from there. A record naming anything but one of our pipes is not followed.

Rationale: Tokio has no AF_UNIX on Windows. A fixed pipe name could be created first by another local user, who would then receive every command the CLI sent. A random name recorded in a file only the user can read keeps a file permission as the gate, as the socket's mode is on Unix. The pipe's default access lets other users open it only for reading, which is not enough to send a request.

Stopping: Windows has no terminate signal to send. The daemon waits on an event named Local\midi-harbor-<uuid>-stop, in the session's namespace, and service stop sets it, so the daemon silences held notes and ends sessions before exiting. Ending the task instead is the last resort after five seconds. Verified: service stop against a running daemon logged "daemon stopped" after its shutdown path.

R-088: Windows lets a dual-stack socket share its port

Status: VERIFIED (2026-09-26). Fixed.

The daemon's session tests failed on Windows: a switched-off session's port read as free while the session still held it. Probed from Rust on the test machine, with a dual-stack socket2 socket bound, a plain IPv4 socket and a plain IPv6 socket could each bind the same port; a second IPv4 socket on an IPv4 socket's port was refused; and a dual-stack socket bound a port an IPv4 socket already held. Any program could take a session's datagrams. With SO_EXCLUSIVEADDRUSE set before binding, the other binds were refused with 10013, and the exclusive socket was refused a port held by either family with 10048. Session sockets set it on Windows. It is the Windows form of the sharing R-080 found on macOS.

R-089: Cross-compiling and testing

Status: VERIFIED (2026-09-26).

x86_64-pc-windows-gnu with Homebrew's mingw-w64 builds the whole workspace, the libcosmic interface included, on the development Mac. zeroconf does not build for Windows, since it wants Apple's Bonjour SDK, so it moved behind the platform crate's responder module.

Test binaries are built with cargo test --no-run and run on the test machine by scripts/windows-test.sh. Tests that find the binary or the sources through paths compiled in on the Mac, CARGO_BIN_EXE_midi-harbor and CARGO_MANIFEST_DIR, read them from the root of the current drive on Windows, so the script mirrors the tree under C:\mh-win\root and runs the tests from a drive substituted onto it.

The interface cross-builds at the pinned libcosmic revision 87ab8179 without the accesskit override the owner's notes (veris/docs/libcosmic.md) need for newer libcosmic, and it rendered on the test machine: the Endpoints page listed the daemon's ports. The executable is a console program, so the interface releases a console only it is attached to, which closes the empty window Windows otherwise opens beside it.

R-090: A daemon that did not finish exiting

Status: CLOSED (2026-09-26). Mitigated.

A daemon run as the service for 27 minutes, with a teVirtualMIDI port, the software synthesiser open, and a session whose peer had just left, logged "daemon stopped" on service stop and never exited. One thread remained, using no CPU; taskkill /F and Stop-Process -Force both failed, and it still held its executable open. A process that cannot be killed is waiting in kernel mode, so a driver's cleanup at process exit is the likely place. It no longer held a teVirtualMIDI port.

Fifteen start and stop cycles since, from the service and from a shell, with and without a virtual port and the synthesiser open, all exited. The daemon now closes every port and device it holds after ending its sessions, before returning, so no driver's close runs in the process's teardown. Whether that removes the cause is unknown until it recurs or does not.

R-091: Moving a network port without stopping it first

Status: CLOSED (2026-09-26). Fixed in T230.

a_udp_port_that_cannot_be_bound_leaves_the_network_port_where_it_was failed in two of the Linux gate runs, the network port found on a third pair after a refused move was undone. It passed in 71 isolated runs, and in three full runs each of this branch and of the commit before it. Moving a port stopped it, tried the new pair, and on finding it taken started it again on the old one; a daemon starting beside it in the same test process could hold the old pair for longer than the bind waits (R-082). The new pair is now bound and released before the port is touched, so a taken pair is refused while the port still runs where it was, and the test checks the port was never restarted.

R-092: Windows Defender Firewall

Status: VERIFIED (2026-09-26).

Windows asked whether to allow midi-harbor on public and private networks the first time the daemon opened a network port, with the test machine's network marked Public. Sessions the Windows daemon started worked before any answer. Once the owner allowed it, a Linux daemon invited the Windows port by address and joined at 0.9 ms. The rules Windows makes are for one program path: a copy at another path, C:\mh-win\bin\midi-harbor.exe, was prompted for again, and ended with block rules when the prompt went unanswered.

R-093: Windows MIDI Services

Status: VERIFIED (2026-09-26) through the App SDK; the in-box API awaits Windows' late-2026 update.

Windows MIDI Services has two forms of its API over one service. Windows.Devices.Midi2 is part of Windows from the update Microsoft's repository says ships starting the last week of November 2026. Its preview packages are for development only. The App SDK, Microsoft.Windows.Devices.Midi2, RC4 1.0.17-rc.4.25, which upstream now labels old, works with the service Windows already has (10.0.26100.7705 on the test machine) once the user installs the "Windows MIDI Services SDK Runtime and Tools". A program reaches it through the COM class MidiClientInitializer, created first and kept. The backend uses the in-box API whenever Windows registers Windows.Devices.Midi2.MidiSession under ActivatableClassId, and the App SDK otherwise. It asks the registry rather than activating the class: the preview API copied beside the program activated on today's Windows, and CreateVirtualDevice through it never returned.

  • Session 0. A virtual device created from an SSH session is never answered. From the desktop session, run as an interactive scheduled task, the same code creates it at once. The daemon runs at logon in the desktop session; the integration tests have to be started there.
  • The identifier. The service refused a product instance identifier containing spaces, with status 808. It takes ASCII letters, digits, - and _, 32 at most, so the port's name is hashed: mh- and sixteen hex digits of its 64-bit FNV-1a. A second device of the same name has the same identifier and is refused with no object and no error code, which the bindings report as "The operation completed successfully"; the backend refuses a name its own port already shows before asking.
  • Receiving. A port sends as soon as its connection opens, but receives nothing until the device is added to the connection as a message processing plugin, as Microsoft's sample does.
  • Naming. Through RC4 against today's service, a port of two connectors appeared in WinMM as "Harbor Split 8640" and "Harbor Split 8640 Gr 2". The service ignored the function block names and fell back to naming by group. Upstream's current source calls that fallback a last chance that should not be seen in production. The backend recognises its own ports under either naming.
  • Closing. DisconnectEndpointConnection never returns, and from then on the service answers nothing: the next CreateVirtualDevice waited for good. This is Microsoft's issue #1236, a duplicate of #1047, fixed for the late-2026 update. Keeping the device object until after the disconnect, as the sample does, changed nothing. Ending MidiSrv and starting the service recovered it every time. So every call into the service runs on a thread of its own and is waited for 5 s. A call that outlives that wait is left running and counted. While any is, opening a session or creating a port is refused at once, and virtual ports are reported unavailable, naming the service to restart. With all seven integration tests in one process, the first passed, its close was given up after 5 s, and the other six were refused within the same run of 7.4 s, none waiting on the service.
  • Starting. Opening a session on a service that had just been stopped took more than 5 s once, and was refused. Opening a session is waited for 30 s.

With the service restarted before each, six of the seven integration tests passed: MIDI and a system-exclusive dump both ways, own ports kept out of the device list, two connectors in the right directions, a taken name refused, another application's port announced. The rename test closes a port and creates another, which today's service cannot do.

Notes

  • Distributing the Windows build. The teVirtualMIDI SDK page (2026-09-26) says: "Software linking to this SDK MAY NOT BE DISTRIBUTED in any way without prior clearance with me (Tobias Erichsen)", and offers the MSI module that installs the driver to licensees only. Clearance is requested at info@tobias-erichsen.de. Midi Harbor no longer uses it: virtual ports go through Windows MIDI Services (R-093).
  • USB hardware exclusivity. WinMM gave the synthesiser and teVirtualMIDI ports to two processes at once on the test machine. The daemon opens every present device, as it does on the other platforms; if a Windows release opens USB MIDI devices exclusively, the daemon would keep other applications from them.