diff --git a/ViewModels/MainViewModel.Trax.cs b/ViewModels/MainViewModel.Trax.cs
index 6afb6aa..8b7da8b 100644
--- a/ViewModels/MainViewModel.Trax.cs
+++ b/ViewModels/MainViewModel.Trax.cs
@@ -15,6 +15,15 @@ public partial class MainViewModel
private readonly MusicPlayer _musicPlayer = new();
private Music? _music;
private bool _musicPlaying;
+ private bool _traxLoadInFlight;
+
+ /// Hard ceiling on the TRAX reachability probe. A track on an offline
+ /// network share blocks ~40s inside MediaFoundationReader 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.
+ internal static readonly TimeSpan TraxProbeBudget = TimeSpan.FromSeconds(2);
private static readonly SolidColorBrush TraxIdleBrush = CreateBrush("#555");
/// True while the TRAX background track is playing.
@@ -77,27 +86,70 @@ public partial class MainViewModel
LoadTrax(play: true);
}
- private void LoadTrax(bool play)
+ /// Probes a path off the UI thread and gives up at .
+ /// 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.
+ internal static async Task IsTrackReachableAsync(string path, TimeSpan budget, Func 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();
diff --git a/ai.md b/ai.md
index d179a77..0e58dc8 100644
--- a/ai.md
+++ b/ai.md
@@ -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:
diff --git a/ytLive.Tests/TraxProbeTests.cs b/ytLive.Tests/TraxProbeTests.cs
new file mode 100644
index 0000000..a94dd95
--- /dev/null
+++ b/ytLive.Tests/TraxProbeTests.cs
@@ -0,0 +1,50 @@
+using Xunit;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+///
+/// TRAX load-path guarding (2026-10-04). Startup used to freeze for ~40s when the
+/// music track lived on an offline network share: LoadLayout called
+/// MusicPlayer.Load synchronously, and new MediaFoundationReader(path)
+/// 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.
+///
+public class TraxProbeTests
+{
+ /// 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.
+ [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();
+ }
+ }
+}