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:
@@ -37,4 +37,7 @@
|
|||||||
- Nothing in the suite drives the real cursor any more; the drag arithmetic is pure (`DragReorderMathTests`).
|
- 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`.
|
- 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.
|
- 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"`).
|
- Bundled FFmpeg stays outside the single-file exe (`ExcludeFromSingleFile="true"`).
|
||||||
|
|||||||
@@ -69,6 +69,18 @@ public static class InstanceProfile
|
|||||||
public static string? WebViewDataFolder => null;
|
public static string? WebViewDataFolder => null;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public static string AudioPipeName(string baseName)
|
||||||
|
#if DEBUG
|
||||||
|
=> Id is { } id ? $"{baseName}_{id}" : baseName;
|
||||||
|
#else
|
||||||
|
=> baseName;
|
||||||
|
#endif
|
||||||
|
|
||||||
/// <summary>The one real user profile: <c>%APPDATA%\ytLlive</c>. Note the historical
|
/// <summary>The one real user profile: <c>%APPDATA%\ytLlive</c>. Note the historical
|
||||||
/// double-L — do not "fix" it, users already have their layouts there.</summary>
|
/// double-L — do not "fix" it, users already have their layouts there.</summary>
|
||||||
public static string DefaultRoot => Path.Combine(
|
public static string DefaultRoot => Path.Combine(
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ public partial class MainViewModel
|
|||||||
// to be recreated before ffmpeg can attach — otherwise the restart fails on a
|
// to be recreated before ffmpeg can attach — otherwise the restart fails on a
|
||||||
// missing input.
|
// missing input.
|
||||||
_audioMixer.StopLive();
|
_audioMixer.StopLive();
|
||||||
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
|
_audioMixer.StartLive(InstanceProfile.AudioPipeName(EncoderOptions.DefaultAudioPipeName));
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
},
|
},
|
||||||
StartPush = async ct =>
|
StartPush = async ct =>
|
||||||
|
|||||||
@@ -66,12 +66,34 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
_liveElapsed = TimeSpan.Zero;
|
_liveElapsed = TimeSpan.Zero;
|
||||||
_recordLength = TimeSpan.Zero;
|
_recordLength = TimeSpan.Zero;
|
||||||
ResetHealth(StreamStatus.Offline);
|
ResetHealth(StreamStatus.Offline);
|
||||||
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
|
if (!TryStartAudioPipe()) return;
|
||||||
IsRecording = true;
|
IsRecording = true;
|
||||||
LiveScene = StagedScene;
|
LiveScene = StagedScene;
|
||||||
await _framePump.StartAsync();
|
await _framePump.StartAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Starts the live audio pipe, or explains why it cannot and rolls the session back.
|
||||||
|
/// <para>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.</para>
|
||||||
|
/// </summary>
|
||||||
|
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)
|
private void BeginGoLive(bool alsoRecord)
|
||||||
{
|
{
|
||||||
var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
|
var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
|
||||||
@@ -124,7 +146,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
// persisted, and reset when the session ends.
|
// persisted, and reset when the session ends.
|
||||||
_sessionStartedAtUtc = DateTime.UtcNow;
|
_sessionStartedAtUtc = DateTime.UtcNow;
|
||||||
ResetHealth(StreamStatus.Streaming);
|
ResetHealth(StreamStatus.Streaming);
|
||||||
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
|
if (!TryStartAudioPipe()) return;
|
||||||
_liveElapsed = TimeSpan.Zero;
|
_liveElapsed = TimeSpan.Zero;
|
||||||
_recordLength = TimeSpan.Zero;
|
_recordLength = TimeSpan.Zero;
|
||||||
|
|
||||||
@@ -471,6 +493,8 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
InputHeight = compositor.OutputHeight,
|
InputHeight = compositor.OutputHeight,
|
||||||
Fps = quality.Fps,
|
Fps = quality.Fps,
|
||||||
BitrateKbps = (int)Math.Round(quality.Bitrate * 1000),
|
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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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