fix(start): a second instance can no longer kill the app on Go Live
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.
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 2026-11-06 crash: two LlamaCasty instances on one machine, the second one died on the Go Live click with
|
||||
/// <c>IOException: All pipe instances are busy</c>. 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.
|
||||
/// </summary>
|
||||
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<IOException>(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<string> 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<IOException>(second);
|
||||
}
|
||||
finally
|
||||
{
|
||||
writer.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user