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
+64 -12
View File
@@ -15,6 +15,15 @@ public partial class MainViewModel
private readonly MusicPlayer _musicPlayer = new();
private Music? _music;
private bool _musicPlaying;
private bool _traxLoadInFlight;
/// <summary>Hard ceiling on the TRAX reachability probe. A track on an offline
/// network share blocks ~40s inside <c>MediaFoundationReader</c> before it throws
/// 0x80070035, and that call sat on the startup path inside LoadLayout, so the
/// window froze for the whole SMB timeout. The probe runs off the UI thread and is
/// abandoned at this budget; the orphan thread stays parked until SMB gives up,
/// which is the point — it is not the thread the creator is waiting on.</summary>
internal static readonly TimeSpan TraxProbeBudget = TimeSpan.FromSeconds(2);
private static readonly SolidColorBrush TraxIdleBrush = CreateBrush("#555");
/// <summary>True while the TRAX background track is playing.</summary>
@@ -77,27 +86,70 @@ public partial class MainViewModel
LoadTrax(play: true);
}
private void LoadTrax(bool play)
/// <summary>Probes a path off the UI thread and gives up at <paramref name="budget"/>.
/// A probe may time out — that is the whole point. False means offline, missing, or
/// slower than the budget, and every one of those is a normal state, not an error.</summary>
internal static async Task<bool> IsTrackReachableAsync(string path, TimeSpan budget, Func<string, bool> probe)
{
if (_music == null || string.IsNullOrWhiteSpace(_music.TrackPath))
var probeTask = Task.Run(() =>
{
try
{
return probe(path);
}
catch
{
return false;
}
});
var finished = await Task.WhenAny(probeTask, Task.Delay(budget)).ConfigureAwait(false);
return finished == probeTask && await probeTask.ConfigureAwait(false);
}
private void LoadTrax(bool play) => _ = LoadTraxAsync(play);
private async Task LoadTraxAsync(bool play)
{
var music = _music;
if (music == null || string.IsNullOrWhiteSpace(music.TrackPath) || _traxLoadInFlight)
return;
_traxLoadInFlight = true;
var loaded = false;
try
{
_musicPlayer.Load(_music.TrackPath);
UpdateTraxLocalGain();
_music.IsEnabled = true;
TraxError = null;
if (play)
_musicPlayer.Play();
var path = music.TrackPath;
if (await IsTrackReachableAsync(path, TraxProbeBudget, File.Exists))
{
// Reached the share, so this bind is cheap and stays on the UI thread
// with every other MusicPlayer mutation — no new race to reason about.
_musicPlayer.Load(path);
UpdateTraxLocalGain();
music.IsEnabled = true;
TraxError = null;
if (play)
_musicPlayer.Play();
loaded = true;
}
else
{
AppLog.Write($"TRAX: '{path}' unreachable within {TraxProbeBudget.TotalSeconds:0}s — not loading");
TraxError = $"Can't reach {Path.GetFileName(path)} — is the drive on?";
}
}
catch (Exception ex)
{
AppLog.Write($"TRAX: failed to load '{_music.TrackPath}': {ex.Message}");
TraxError = $"Failed to load: {Path.GetFileName(_music.TrackPath)} — {ex.Message}";
AppLog.Write($"TRAX: failed to load '{music.TrackPath}': {ex.Message}");
TraxError = $"Failed to load: {Path.GetFileName(music.TrackPath)} — {ex.Message}";
}
finally
{
_traxLoadInFlight = false;
RefreshTraxUi();
if (loaded)
ScheduleSave();
}
RefreshTraxUi();
ScheduleSave();
}
private void PlayTrax() => _musicPlayer.Play();