From 0f08fd7adeb13c4e97c2caeb327447357d1664f4 Mon Sep 17 00:00:00 2001 From: gramps Date: Fri, 6 Nov 2026 08:41:16 -0800 Subject: [PATCH] fix(start): a second instance can no longer kill the app on Go Live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported 2026-11-06: a Release instance crashed when clicking Go Live in test mode, while a Debug instance was recording its window. The Windows event log gave the cause: System.IO.IOException: All pipe instances are busy. at System.IO.Pipes.NamedPipeServerStream.Create(...) at ytLive.Services.Audio.NamedPipeAudioWriter.Start(...) at ytLive.Services.Audio.AudioMixer.StartLive(...) at MainViewModel.StartStreamingSession(...) at MainViewModel.BeginTestStream(...) at MainViewModel.StartFromPrimaryButton(...) at System.Windows.Controls.Button.OnClick() Two defects, both fixed: 1. **A button click could end the process.** `StartLive` throws when the pipe name is taken, and nothing on the go-live path caught it. `TryStartAudioPipe` now catches, logs, toasts ("Another copy of LlamaCasty is already using the audio pipe") and rolls the session back. Recording-only takes the same path. 2. **The pipe name was a shared constant**, so a second instance could never go live on the same machine. `InstanceProfile.AudioPipeName` appends the dev instance id (DEBUG only); Release keeps the plain name so the normal single-instance case is unchanged. The encoder reads the same derived name, so mixer and ffmpeg cannot disagree. Tests: `AudioPipeInstanceTests` — a second server on one name throws (the collision is real), dev names derive per instance, and a writer on a taken name throws rather than silently reusing the pipe. Debug and Release both build clean. --- HANDOFF.md | 3 + Helpers/InstanceProfile.cs | 12 +++ ViewModels/MainViewModel.Recovery.cs | 2 +- .../MainViewModel.Streaming.Operations.cs | 28 ++++++- ytLive.Tests/AudioPipeInstanceTests.cs | 82 +++++++++++++++++++ 5 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 ytLive.Tests/AudioPipeInstanceTests.cs 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(); + } + } +}