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(); + } + } +}