TRAX: a share that is gone must never freeze the startup window

Startup took 44s and 42 of them were one line. Measured from startup.log:
OnStartup 08:42:59 -> MainWindow loaded 08:43:43, with the whole gap inside
LoadLayout, ending at

  TRAX: failed to load '\\192.168.50.86\llamaVault\Assets\Music\...':
  The network path was not found. (0x80070035)

LoadTrax called MusicPlayer.Load synchronously on the UI thread, and
new MediaFoundationReader(path) does not fail fast on a dead SMB session — it
burns the full ARP/TCP/SMB timeout and only then throws. The NAS had moved to
.87 after the power outage, so an unreachable path became a 40-second frozen
window on every launch.

The rule, from the creator: a probe may time out; a bind should return
immediately. Only one side of that is achievable here, and the reason matters —
an unreachable UNC path has no fast-fail at all, so probing it on the calling
thread is the worst possible probe. (A device open is the opposite: it normally
fails fast on a sharing violation, which is why this bug is network-specific.)

So: IsTrackReachableAsync runs the probe on a thread-pool thread under a
Task.WhenAny ceiling (TraxProbeBudget, 2s) and treats offline/missing/slow as a
normal state rather than an error. Only a probe that passed gets the real bind,
and that bind deliberately stays on the UI thread alongside every other
MusicPlayer mutation — moving the whole load off-thread would have introduced a
race against PlayTrax/PauseTrax that does not exist today. The orphan probe
thread stays parked until SMB gives up; it is not the thread anyone waits on.

One test, per the Good Dog rule: TraxProbeTests.
AHangingProbeIsAbandonedAtTheBudget_AndReportsOffline stands in a probe that
never returns and asserts the caller is released and told "offline". Drop the
ceiling and it fails on elapsed time — which is the frozen window returning.

Suite 381/381, 0 warnings.
This commit is contained in:
2026-10-04 09:24:12 -07:00
parent ad10176e8b
commit cc2d1ef622
3 changed files with 130 additions and 12 deletions
+16
View File
@@ -871,6 +871,22 @@ devices, no timers).
track persists via **schema v9** single-row `Music` (`TrackPath`/`IsEnabled`) in `LayoutStore`. Known
wrinkle (out of scope, future feature): YouTube mutes VODs carrying copyrighted music — "music on
live, off VOD" is tabled.
- **⛔ The load is behind a bounded probe, because a share that is gone must never
freeze startup (2026-10-04).** `LoadLayout` → `LoadTrax` used to call `MusicPlayer.Load`
synchronously on the UI thread, and `new MediaFoundationReader(path)` does **not** fail
fast on a dead SMB session — it burns the whole ARP/TCP/SMB timeout (~40s, measured)
and *then* throws `0x80070035`. Measured: `OnStartup` 08:42:59 → `MainWindow loaded`
08:43:43, with 42 of those 44s inside `LoadLayout` on that one track. The rule now:
**a probe may time out; only a bind is allowed to be on the creator's path.**
`IsTrackReachableAsync(path, budget, File.Exists)` runs the probe on a thread-pool
thread under a `Task.WhenAny` ceiling (`TraxProbeBudget` = **2s**) and reports
offline/missing/slow as a normal state, not an error. Only a probe that passed gets
the real bind, and **that bind stays on the UI thread** with every other
`MusicPlayer` mutation, so no new cross-thread race is introduced. The orphan probe
thread stays parked until SMB gives up — deliberate: it is not the thread anyone
waits on. Same principle covers the webcam: a device held by another app fails fast
(sharing violation) but a virtual-camera driver can stall enumeration, so device
probes belong off the startup path too, not behind a long timeout.
- **Mic status dot (`Models/MicStatus.cs`)** on the preview-bottom MIC button: green = `MicConnected`,
yellow = `MicFailed` (in use/unplugged), red = no mic device at startup (the mixer is never started,
so loopback and the game bar can't run either — no capture devices at all). **The picked mic persists: