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();
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using Xunit;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// TRAX load-path guarding (2026-10-04). Startup used to freeze for ~40s when the
|
||||
/// music track lived on an offline network share: <c>LoadLayout</c> called
|
||||
/// <c>MusicPlayer.Load</c> synchronously, and <c>new MediaFoundationReader(path)</c>
|
||||
/// does not fail fast on a dead SMB session — it burns the full timeout and then
|
||||
/// throws 0x80070035. The fix is a bounded reachability probe on a background thread,
|
||||
/// so an unreachable share can never sit on the path the creator waits on.
|
||||
/// </summary>
|
||||
public class TraxProbeTests
|
||||
{
|
||||
/// <summary>The regression this guards: a probe that hangs must be abandoned at the
|
||||
/// budget and reported offline. Without the ceiling the caller inherits the SMB
|
||||
/// timeout, which is the 40-second frozen startup window. The orphaned probe thread
|
||||
/// is released at the end so the test does not leak it.</summary>
|
||||
[Fact]
|
||||
public async Task AHangingProbeIsAbandonedAtTheBudget_AndReportsOffline()
|
||||
{
|
||||
var orphan = new ManualResetEventSlim(false);
|
||||
var started = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
var reachable = await MainViewModel.IsTrackReachableAsync(
|
||||
@"\\llamavault\llamaVault\Assets\Music\VBI - Mayhem OST.mp3",
|
||||
TimeSpan.FromMilliseconds(150),
|
||||
// Stands in for a share that has gone away: the open never completes.
|
||||
_ =>
|
||||
{
|
||||
orphan.Wait(TimeSpan.FromSeconds(30));
|
||||
return true;
|
||||
});
|
||||
|
||||
var elapsed = DateTime.UtcNow - started;
|
||||
|
||||
Assert.False(reachable);
|
||||
Assert.True(
|
||||
elapsed < TimeSpan.FromSeconds(5),
|
||||
$"probe was not cut off at the budget — took {elapsed.TotalSeconds:0.0}s, which is the frozen-startup bug returning");
|
||||
}
|
||||
finally
|
||||
{
|
||||
orphan.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user