diff --git a/HANDOFF.md b/HANDOFF.md index 18ecf99..9459936 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -37,4 +37,7 @@ - Nothing in the suite drives the real cursor any more; the drag arithmetic is pure (`DragReorderMathTests`). - Integration tests constructing `MainWindow` must set a temporary `LayoutPathOverride` and clear it in `finally`. - Media sessions are keyed by **path + look** (`MainViewModel.MediaSessionKey`); never acquire by raw path. +- **The audio pipe is one instance per machine.** `NamedPipeAudioWriter` allows a single server, so two + LlamaCasty processes cannot both go live. A DEBUG instance appends its id (`InstanceProfile.AudioPipeName`), + Release keeps the plain name; the Go-Live path catches the failure and reports instead of dying. - Bundled FFmpeg stays outside the single-file exe (`ExcludeFromSingleFile="true"`). diff --git a/Helpers/InstanceProfile.cs b/Helpers/InstanceProfile.cs index e4a69bd..d066915 100644 --- a/Helpers/InstanceProfile.cs +++ b/Helpers/InstanceProfile.cs @@ -69,6 +69,18 @@ public static class InstanceProfile public static string? WebViewDataFolder => null; #endif + /// The audio pipe name for this process. The pipe server allows exactly ONE instance, so a + /// shared name meant the second app on the machine could never go live — it threw + /// "All pipe instances are busy" out of the Go Live click and ended the process (2026-11-06: two + /// instances, one recording the other). A dev instance appends its id; Release, and therefore the + /// first instance, keeps the plain name. + public static string AudioPipeName(string baseName) +#if DEBUG + => Id is { } id ? $"{baseName}_{id}" : baseName; +#else + => baseName; +#endif + /// The one real user profile: %APPDATA%\ytLlive. Note the historical /// double-L — do not "fix" it, users already have their layouts there. public static string DefaultRoot => Path.Combine( diff --git a/ViewModels/MainViewModel.Recovery.cs b/ViewModels/MainViewModel.Recovery.cs index 966036e..0f80b86 100644 --- a/ViewModels/MainViewModel.Recovery.cs +++ b/ViewModels/MainViewModel.Recovery.cs @@ -183,7 +183,7 @@ public partial class MainViewModel // to be recreated before ffmpeg can attach — otherwise the restart fails on a // missing input. _audioMixer.StopLive(); - _audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName); + _audioMixer.StartLive(InstanceProfile.AudioPipeName(EncoderOptions.DefaultAudioPipeName)); return Task.CompletedTask; }, StartPush = async ct => diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs index 1b988e8..7332e0b 100644 --- a/ViewModels/MainViewModel.Streaming.Operations.cs +++ b/ViewModels/MainViewModel.Streaming.Operations.cs @@ -66,12 +66,34 @@ public partial class MainViewModel : ViewModelBase _liveElapsed = TimeSpan.Zero; _recordLength = TimeSpan.Zero; ResetHealth(StreamStatus.Offline); - _audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName); + if (!TryStartAudioPipe()) return; IsRecording = true; LiveScene = StagedScene; await _framePump.StartAsync(); } + /// Starts the live audio pipe, or explains why it cannot and rolls the session back. + /// This used to throw straight out of the Go Live button: the pipe server allows one instance, + /// so with a second LlamaCasty running on the same machine the click killed the app + /// ("All pipe instances are busy", 2026-11-06). A click must never be able to end the process. + /// + private bool TryStartAudioPipe() + { + try + { + _audioMixer.StartLive(InstanceProfile.AudioPipeName(EncoderOptions.DefaultAudioPipeName)); + return true; + } + catch (Exception ex) + { + AppLog.Write(ex, "Audio pipe could not be started — session aborted"); + _notifications.Error("Couldn't start the audio", + "Another copy of LlamaCasty is already using the audio pipe. Close it, then try again."); + StopStream(); + return false; + } + } + private void BeginGoLive(bool alsoRecord) { var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel) @@ -124,7 +146,7 @@ public partial class MainViewModel : ViewModelBase // persisted, and reset when the session ends. _sessionStartedAtUtc = DateTime.UtcNow; ResetHealth(StreamStatus.Streaming); - _audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName); + if (!TryStartAudioPipe()) return; _liveElapsed = TimeSpan.Zero; _recordLength = TimeSpan.Zero; @@ -471,6 +493,8 @@ public partial class MainViewModel : ViewModelBase InputHeight = compositor.OutputHeight, Fps = quality.Fps, BitrateKbps = (int)Math.Round(quality.Bitrate * 1000), + // Same derivation as the mixer's StartLive call: a dev instance serves its own pipe. + AudioPipeName = InstanceProfile.AudioPipeName(EncoderOptions.DefaultAudioPipeName), }; } diff --git a/ytLive.Tests/AudioPipeInstanceTests.cs b/ytLive.Tests/AudioPipeInstanceTests.cs new file mode 100644 index 0000000..c864351 --- /dev/null +++ b/ytLive.Tests/AudioPipeInstanceTests.cs @@ -0,0 +1,82 @@ +using System; +using System.IO; +using System.IO.Pipes; +using Xunit; +using ytLive.Helpers; +using ytLive.Services.Audio; + +namespace ytLive.Tests; + +/// +/// 2026-11-06 crash: two LlamaCasty instances on one machine, the second one died on the Go Live click with +/// IOException: All pipe instances are busy. The audio pipe server allows exactly ONE instance and the +/// name was a shared constant, so this pins both halves of the fix — the collision is real, and each dev +/// instance now gets its own name. +/// +public sealed class AudioPipeInstanceTests : IDisposable +{ + private readonly string? _original = Environment.GetEnvironmentVariable(InstanceProfile.InstanceVariable); + + public void Dispose() => Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, _original); + + [Fact] + public void ASecondServerOnTheSameName_CannotStart_WhichIsWhatKilledTheSecondInstance() + { + var name = $"ytllive_test_{Guid.NewGuid():N}"; + using var first = new NamedPipeServerStream(name, PipeDirection.Out, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous); + + var error = Record.Exception(() => + new NamedPipeServerStream(name, PipeDirection.Out, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous)); + + Assert.NotNull(error); + Assert.IsType(error); + } + + [Fact] + public void ADevInstanceServesItsOwnPipeName() + { + Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, null); + Assert.Equal("ytllive_audio", InstanceProfile.AudioPipeName("ytllive_audio")); + + Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, "2"); + Assert.Equal("ytllive_audio_2", InstanceProfile.AudioPipeName("ytllive_audio")); + + Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, "qa"); + Assert.Equal("ytllive_audio_qa", InstanceProfile.AudioPipeName("ytllive_audio")); + Assert.NotEqual(InstanceProfile.AudioPipeName("ytllive_audio"), + WithInstance("3", () => InstanceProfile.AudioPipeName("ytllive_audio"))); + } + + private static string WithInstance(string id, Func read) + { + var original = Environment.GetEnvironmentVariable(InstanceProfile.InstanceVariable); + try + { + Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, id); + return read(); + } + finally + { + Environment.SetEnvironmentVariable(InstanceProfile.InstanceVariable, original); + } + } + + [Fact] + public void TheMixerRefusesAStartedSecondTime_ButAWriterOnATakenNameThrows() + { + // The mixer's own guard is idempotent (StartLive twice is a no-op); the exception came from the + // pipe itself, which the VM now catches and reports instead of letting it end the process. + var name = $"ytllive_test_{Guid.NewGuid():N}"; + var writer = new NamedPipeAudioWriter(); + writer.Start(name); + try + { + var second = Record.Exception(() => new NamedPipeAudioWriter().Start(name)); + Assert.IsType(second); + } + finally + { + writer.Stop(); + } + } +}