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.
90 lines
4.5 KiB
C#
90 lines
4.5 KiB
C#
using System;
|
|
using System.IO;
|
|
|
|
namespace ytLive.Helpers;
|
|
|
|
/// <summary>
|
|
/// Which run of LlamaCasty this process is, and where its private state lives.
|
|
/// <para><b>Dev-only affordance (creator directive, 2026-09-26).</b> Pre-1.0 the creator
|
|
/// needs two instances side by side: one streaming, one screen-capturing the other.
|
|
/// Two instances on one Windows profile collide on state, and two of those collisions
|
|
/// are hard failures rather than annoyances:
|
|
/// <list type="bullet">
|
|
/// <item><b>The SQLite layout DB.</b> The app's model is read-whole-scene / write-whole-scene,
|
|
/// so two instances saving different layouts clobber each other.</item>
|
|
/// <item><b>The WebView2 user data folder.</b> Chromium takes an exclusive lock on it; the
|
|
/// second instance of the same exe simply fails to start.</item>
|
|
/// <item><b>The auth token store.</b> A test instance would overwrite the real YouTube
|
|
/// sign-in.</item>
|
|
/// <item><b>startup.log.</b> Two appenders interleave into one file, so a crash in either
|
|
/// instance is ambiguous.</item>
|
|
/// </list>
|
|
/// So in <c>DEBUG</c> a process can claim an identity and gets its own private root
|
|
/// under <c>instances\<id></c>: <c>$env:YTLIVE_INSTANCE=2; dotnet run</c>.
|
|
/// Recording, screen capture and the ffmpeg <c>tools\</c> cache are deliberately LEFT
|
|
/// shared — the cache should be shared, and the creator chooses the record folder.
|
|
/// Global hotkeys are also left alone: if both instances register the same one, Windows
|
|
/// refuses the second, which is the correct answer.</para>
|
|
/// <para><b>At 1.0 this disappears.</b> The whole implementation is inside
|
|
/// <c>#if DEBUG</c>; a Release build compiles to the two pass-throughs at the bottom —
|
|
/// no environment variable, no alternate folder, not even the variable's NAME in the
|
|
/// binary. Call sites are unconditional, so Release cannot accidentally diverge: it
|
|
/// always resolves the one real user profile.</para>
|
|
/// </summary>
|
|
public static class InstanceProfile
|
|
{
|
|
#if DEBUG
|
|
/// <summary>Environment variable naming this instance. Absent or blank = the primary
|
|
/// instance on the real user profile.</summary>
|
|
public const string InstanceVariable = "YTLIVE_INSTANCE";
|
|
|
|
/// <summary>This instance's id, or null for the primary instance.</summary>
|
|
public static string? Id => Sanitize(Environment.GetEnvironmentVariable(InstanceVariable));
|
|
|
|
/// <summary>Private state root: the real profile normally, a per-instance subfolder
|
|
/// when an id is claimed.</summary>
|
|
public static string DataRoot => Id is { } id
|
|
? Path.Combine(DefaultRoot, "instances", id)
|
|
: DefaultRoot;
|
|
|
|
/// <summary>Chromium's user data folder, or null to let WebView2 pick its default
|
|
/// (which is what Release always does). Must be per-instance or the second
|
|
/// instance dies on Chromium's exclusive lock.</summary>
|
|
public static string? WebViewDataFolder => Id is null ? null : Path.Combine(DataRoot, "webview2");
|
|
|
|
/// <summary>Reject anything that is not a plain path segment, so the variable can
|
|
/// never be used to walk out of the profile directory or name a UNC path.</summary>
|
|
private static string? Sanitize(string? raw)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(raw)) return null;
|
|
var trimmed = raw.Trim();
|
|
foreach (var c in trimmed)
|
|
if (!char.IsLetterOrDigit(c) && c != '-' && c != '_') return null;
|
|
return trimmed;
|
|
}
|
|
#else
|
|
// Release: one profile, no alternate paths, no environment variable. Multi-instance
|
|
// is a development affordance, not a shipping feature.
|
|
public static string DataRoot => DefaultRoot;
|
|
public static string? WebViewDataFolder => null;
|
|
#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
|
|
/// double-L — do not "fix" it, users already have their layouts there.</summary>
|
|
public static string DefaultRoot => Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
"ytLlive");
|
|
}
|