Files
RemSound/src/RemSound.App/SingleInstanceCoordinator.cs
T

262 lines
10 KiB
C#
Raw Normal View History

using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace RemSound.App;
/// <summary>
/// Enforces "only one RemSound at a time" and provides the plumbing to either surface the
/// already-running copy or force a stuck one to close. Created once in Program.Main and held
/// for the whole app lifetime.
///
/// Why this exists: RemSound had no single-instance guard at all — it only ever NOTICED a
/// second copy when a global hotkey failed to register. With the auto-updater relaunching the
/// app, a copy that didn't exit cleanly could leave two (then more) copies running at once,
/// each playing received audio. Andre hit exactly that on 2026-05-30: copies "stacked and
/// stacked", the audio got deafening, and the only way out was force-killing them all from a
/// terminal. A real lock makes that structurally impossible.
///
/// Mechanism:
/// * A named system <see cref="Mutex"/> is the lock. The first copy to start owns it; a
/// later copy fails to acquire it and so KNOWS another copy is live.
/// * A named auto-reset <see cref="EventWaitHandle"/> is the "come to the front" signal.
/// The owning copy runs a background thread waiting on it; when a second copy sets it
/// (the user chose "switch to the running copy"), the thread raises
/// <see cref="ActivateRequested"/>, which Program.Main routes to the live window.
/// * <see cref="ForceCloseOtherInstances"/> terminates any other RemSound process with
/// Process.Kill (TerminateProcess), so a hung copy dies regardless of its message-loop
/// state. If a copy is running elevated and we are not, the kill is retried via an
/// elevated taskkill (one UAC prompt).
///
/// Names are in the per-session (Local) namespace and are fixed strings — not version- or
/// path-derived — so ANY RemSound.exe blocks ANY other, which is the "refuses to load unless
/// previous copies are gone" guarantee Andre asked for. 2026-05-31.
/// </summary>
internal sealed class SingleInstanceCoordinator : IDisposable
{
private const string MutexName = "RemSound.SingleInstance.Mutex.v1";
private const string ActivateEventName = "RemSound.SingleInstance.Activate.v1";
private readonly Mutex mutex;
private bool ownsMutex;
private EventWaitHandle? activateEvent;
private Thread? listenerThread;
private volatile bool stopListener;
/// <summary>Raised on a background thread when another copy asks this one to surface.
/// Program.Main marshals it onto the running window.</summary>
public event Action? ActivateRequested;
public SingleInstanceCoordinator()
{
mutex = new Mutex(initiallyOwned: false, MutexName);
}
/// <summary>True once we hold the single-instance lock.</summary>
public bool IsPrimaryInstance => ownsMutex;
/// <summary>Try to take the single-instance lock, waiting up to <paramref name="timeout"/>.
/// An abandoned mutex (previous owner crashed or was force-killed) counts as acquired — we
/// become the new owner.</summary>
public bool TryAcquire(TimeSpan timeout)
{
if (ownsMutex) return true;
try
{
ownsMutex = mutex.WaitOne(timeout);
}
catch (AbandonedMutexException)
{
// Previous owner died without releasing. Ownership passes to us.
ownsMutex = true;
}
return ownsMutex;
}
/// <summary>Start the background listener that surfaces this copy when a later copy
/// signals it. Only meaningful on the primary instance.</summary>
public void StartActivationListener()
{
try
{
activateEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ActivateEventName);
}
catch
{
// Can't create the signal — "switch to the running copy" just won't surface this
// window automatically. Not fatal; the user can still reach it via the tray.
activateEvent = null;
return;
}
listenerThread = new Thread(ListenLoop) { IsBackground = true, Name = "RemSound-Activation" };
listenerThread.Start();
}
private void ListenLoop()
{
var ev = activateEvent;
if (ev is null) return;
while (!stopListener)
{
try
{
// Short timeout so Dispose can stop us promptly even if no signal arrives.
if (ev.WaitOne(500))
{
if (stopListener) return;
ActivateRequested?.Invoke();
}
}
catch
{
return;
}
}
}
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool AllowSetForegroundWindow(int dwProcessId);
/// <summary>Signal whichever copy currently owns the lock to bring itself to the front.
/// Called by a SECOND copy that chose "switch to the running copy".
///
/// Windows blocks <c>SetForegroundWindow</c> from a process that didn't receive the most
/// recent user input. When the user launches this second copy from (say) an Explorer
/// window and clicks "switch", the input belongs to US, not the running copy — so the
/// running copy's own SetForegroundWindow is denied and its window only flashes in the
/// taskbar instead of coming forward (the bug Ed reported). The documented fix is for the
/// process that currently holds the foreground right — us, right now — to hand it to the
/// running copy via <see cref="AllowSetForegroundWindow"/> BEFORE signalling. We then
/// linger briefly so the running copy can raise itself while the grant is fresh and the
/// foreground hasn't churned from us exiting.</summary>
public static void SignalExistingToActivate()
{
// Grant every other RemSound process the one-shot right to pull itself to the front.
foreach (var p in OtherInstances(Environment.ProcessId))
{
try { AllowSetForegroundWindow(p.Id); } catch { /* best-effort */ }
p.Dispose();
}
try
{
if (EventWaitHandle.TryOpenExisting(ActivateEventName, out var ev))
{
using (ev) ev.Set();
}
}
catch
{
// Best-effort — if the signal can't be delivered the user can click the tray icon.
}
// Stay alive a beat so the running copy can raise itself while we're still the
// foreground process and the grant is fresh. If we vanished instantly the foreground
// would churn and the grant could be consumed before it's used. Invisible to the user —
// our dialog has already closed and we have no window.
try { Thread.Sleep(600); } catch { /* ignore */ }
}
/// <summary>Force every OTHER RemSound process to terminate. Returns true if no other
/// RemSound process remains after the attempt. Uses Process.Kill (TerminateProcess) so a
/// hung copy dies regardless of its state; PIDs we can't reach (an elevated copy while we
/// run normally) are retried via an elevated taskkill.</summary>
public static bool ForceCloseOtherInstances()
{
var me = Environment.ProcessId;
var deniedPids = new List<int>();
foreach (var p in OtherInstances(me))
{
try
{
p.Kill();
p.WaitForExit(4000);
}
catch (Win32Exception)
{
// Access denied — almost always an elevated target we can't reach unelevated.
deniedPids.Add(p.Id);
}
catch (InvalidOperationException)
{
// Already exited between enumeration and Kill — fine.
}
catch
{
// Ignore — the post-check below is the source of truth.
}
finally
{
p.Dispose();
}
}
if (deniedPids.Count > 0)
{
TryElevatedKill(deniedPids);
}
// Source of truth: is the field actually clear now?
var remaining = OtherInstances(me).ToList();
var clear = remaining.Count == 0;
foreach (var p in remaining) p.Dispose();
return clear;
}
private static List<Process> OtherInstances(int selfPid)
{
Process[] all;
try { all = Process.GetProcessesByName("RemSound"); }
catch { return []; }
var others = new List<Process>(all.Length);
foreach (var p in all)
{
if (p.Id == selfPid) { p.Dispose(); continue; }
others.Add(p);
}
return others;
}
private static void TryElevatedKill(List<int> pids)
{
try
{
// Target exact PIDs, never /IM RemSound.exe — an image-name kill would also take
// out this very process. Verb=runas raises the one UAC prompt that lets a normal
// process terminate an elevated one.
var args = "/F " + string.Join(" ", pids.ConvertAll(id => $"/PID {id}"));
var psi = new ProcessStartInfo("taskkill.exe", args)
{
UseShellExecute = true,
Verb = "runas",
WindowStyle = ProcessWindowStyle.Hidden,
CreateNoWindow = true,
};
var proc = Process.Start(psi);
proc?.WaitForExit(5000);
proc?.Dispose();
}
catch
{
// User declined UAC, or taskkill wasn't available. The caller's post-check reports
// the field still isn't clear and the UI surfaces a message.
}
}
public void Dispose()
{
stopListener = true;
try { activateEvent?.Set(); } catch { /* wake the listener so it can exit */ }
try { listenerThread?.Join(1000); } catch { /* ignore */ }
try { activateEvent?.Dispose(); } catch { /* ignore */ }
if (ownsMutex)
{
try { mutex.ReleaseMutex(); } catch { /* ignore */ }
}
try { mutex.Dispose(); } catch { /* ignore */ }
}
}