Bump to v3.1.3: single-instance lock + update/read-only reliability fixes

Fixes the chained-fault runaway Andre hit after an update (multiple copies
stacking, audio climbing to deafening, terminal kill to recover).

- Single-instance lock (SingleInstanceCoordinator + SingleInstanceDialog,
  wired in Program.Main). A named mutex makes two copies impossible. A second
  launch offers: switch to the running copy (default; surfaces it from the
  tray via a named activation event), or force the running copy closed and
  start fresh (Process.Kill, retried elevated if the target is elevated).
  This is the structural fix that makes the stacking runaway impossible.
- Prompt-free update exit. InstallUpdateAsync sets updatingInProgress before
  Application.Exit(); the close path's skipPrompt now honours it, so no
  unsaved-changes dialog (whose default button is Cancel) can abort the
  update's restart.
- Read-only persistence fix. BuildCurrentProfile now carries
  currentProfileReadOnly into the saved snapshot. Previously a deliberate
  save of a locked profile wrote ReadOnly=false, silently unlocking it on
  disk — which re-armed the save prompt that then blocked the update.
- In-process double-install guard. updateInstallStarted stops the ~4 s
  startup check and the background poll both staging an install + helper.
- Docs: About box, RELEASE_NOTES.md, readme.html + MANUAL.md ("Only one copy
  of RemSound runs at a time").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-05-31 17:33:11 +01:00
co-authored by Claude Opus 4.8
parent baf50baf73
commit 9ab0a1a20a
9 changed files with 467 additions and 24 deletions
@@ -0,0 +1,233 @@
using System.ComponentModel;
using System.Diagnostics;
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;
}
}
}
/// <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".</summary>
public static void SignalExistingToActivate()
{
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.
}
}
/// <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 */ }
}
}