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