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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user