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 readonly MusicPlayer _musicPlayer = new();
|
||||||
private Music? _music;
|
private Music? _music;
|
||||||
private bool _musicPlaying;
|
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");
|
private static readonly SolidColorBrush TraxIdleBrush = CreateBrush("#555");
|
||||||
/// <summary>True while the TRAX background track is playing.</summary>
|
/// <summary>True while the TRAX background track is playing.</summary>
|
||||||
@@ -77,27 +86,70 @@ public partial class MainViewModel
|
|||||||
LoadTrax(play: true);
|
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;
|
return;
|
||||||
|
|
||||||
|
_traxLoadInFlight = true;
|
||||||
|
var loaded = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_musicPlayer.Load(_music.TrackPath);
|
var path = music.TrackPath;
|
||||||
UpdateTraxLocalGain();
|
if (await IsTrackReachableAsync(path, TraxProbeBudget, File.Exists))
|
||||||
_music.IsEnabled = true;
|
{
|
||||||
TraxError = null;
|
// Reached the share, so this bind is cheap and stays on the UI thread
|
||||||
if (play)
|
// with every other MusicPlayer mutation — no new race to reason about.
|
||||||
_musicPlayer.Play();
|
_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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AppLog.Write($"TRAX: failed to load '{_music.TrackPath}': {ex.Message}");
|
AppLog.Write($"TRAX: failed to load '{music.TrackPath}': {ex.Message}");
|
||||||
TraxError = $"Failed to load: {Path.GetFileName(_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();
|
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
|
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
|
wrinkle (out of scope, future feature): YouTube mutes VODs carrying copyrighted music — "music on
|
||||||
live, off VOD" is tabled.
|
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`,
|
- **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,
|
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:
|
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