Creator was recording on the Release build and hit "Couldn't start the audio — another copy of LlamaCasty is already using the audio pipe" when going live on Debug. The guard did its job (no crash), but the collision should not have been possible: the pipe name only suffixed when `YTLIVE_INSTANCE` was set, and a Debug launched straight from the IDE has no id — so it asked for the same `ytllive_audio` the Release build was serving. DEBUG now always suffixes: the claimed instance id when there is one, otherwise the process id. Any number of dev builds can run beside the Release app, and each Debug process is distinct from every other. Release is unchanged — it resolves to the plain name, and the whole mechanism disappears in `#if DEBUG` as before. `TryStartAudioPipe` now logs the resolved name (`Audio pipe: …`), so the next report of this kind is one grep instead of a code hunt. Tests: AudioPipeInstanceTests pins that an id-less DEBUG process never resolves to the bare name; 572 green, 0 warnings.
90 lines
4.7 KiB
C#
90 lines
4.7 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 means the second app on the machine can never go live — it throws "All pipe instances
|
|
/// are busy" (2026-11-06: Release recording, Debug trying to go live). Release keeps the plain name;
|
|
/// a DEBUG build ALWAYS appends a suffix — its claimed id, or the process id when it has none — so a
|
|
/// debug run launched straight from the IDE can never collide with the Release build beside it.</summary>
|
|
public static string AudioPipeName(string baseName)
|
|
#if DEBUG
|
|
=> Id is { } id ? $"{baseName}_{id}" : $"{baseName}_pid{Environment.ProcessId}";
|
|
#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");
|
|
}
|