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
+31
View File
@@ -20,6 +20,37 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary>
private const string ReleaseNotes =
"""
RemSound v3.1.3
An important reliability fix. After an update, a
chain of small faults could line up and leave
RemSound misbehaving in the worst case, more than
one copy running at once with the sound getting
louder and louder. This release breaks that chain.
Only one copy at a time: RemSound now refuses to run
as two copies at once. If you open it while it's
already running, it offers to switch you to the copy
that's already going (it may be down in the system
tray), or if that copy is stuck to force it
closed and start fresh. This makes the "stacking
copies" runaway impossible.
Updates can't be blocked any more: when RemSound
updates itself and restarts, nothing is allowed to
get in the way of that restart. Previously an
"unsaved changes?" question could pop up at that
moment and quietly cancel the update.
Locked profiles stay locked: marking a profile as
read-only now survives saving it. Before, if you
deliberately saved a locked profile the lock was
silently lost which brought back the "save your
changes?" question and, in turn, could trip up an
update. Locked means locked.
Everything else from v3.1 is unchanged.
RemSound v3.1.2
A small accessibility fix for the system tray
+54 -1
View File
@@ -493,6 +493,19 @@ public sealed class MainForm : Form
private bool unsavedChanges;
// Skip MarkProfileDirty calls while we're programmatically applying a loaded profile.
private bool applyingProfile;
// Set true the instant an auto-update hands off and we're about to exit to let the
// installer restart us. While this is set, the close path skips EVERY prompt — the
// update is a deliberate, unattended action and no dialog (least of all the unsaved-
// changes prompt, whose default button is Cancel) may be allowed to abort the restart.
// This was the second link in Andre's runaway: a stray Enter/Escape on the save prompt
// cancelled the update's exit, so the new version never came up cleanly.
private bool updatingInProgress;
// Set once an install has actually begun (download + stage + helper launch). Guards against
// firing the installer twice from a single process: the ~4 s startup check and the periodic
// background poll can both surface the same release in quick succession, and without this
// each would stage and spawn its own helper. Cross-PROCESS duplication is prevented by the
// single-instance lock in Program.Main; this is the within-process half of that protection.
private bool updateInstallStarted;
/// <summary>Set when the user changed the profiles FOLDER (not just switched profile)
/// via the Manage Profiles dialog. Program.cs reads this after the form closes; if true,
/// it re-runs the entire profile selection flow under the new folder rather than the
@@ -500,6 +513,24 @@ public sealed class MainForm : Form
/// <see cref="NextProfileTitleToLoad"/> in practice.</summary>
public bool ReloadFromScratch { get; private set; }
/// <summary>Bring this window to the front, restoring it from the system tray if it's
/// parked there. Called when the user launches a SECOND copy of RemSound and the single-
/// instance guard chooses "switch to the running copy" — the second copy signals this one
/// to surface instead of starting another process. Safe to call from any thread: it
/// marshals to the UI thread itself. Reuses the tray controller's Restore (Show +
/// SetForegroundWindow), which works whether the window is in the tray or just behind
/// other windows.</summary>
public void RestoreFromTray()
{
if (IsDisposed) return;
try
{
if (InvokeRequired) { BeginInvoke(new Action(RestoreFromTray)); return; }
trayController.Restore();
}
catch { /* best-effort — surfacing the window is a convenience, not load-critical */ }
}
public MainForm() : this(null, null, null, null) { }
public MainForm(ProfileStore? profileStore, Profile? profile, string? loadedTitle, string? loadedPath = null)
@@ -2042,14 +2073,29 @@ public sealed class MainForm : Form
// mid-session update would drop the session AND leave the user back at the picker —
// the session never resumes by itself. Null/empty title (blank template, no profile
// saved yet) skips the sentinel and the relaunch falls through to normal startup.
// Don't let two near-simultaneous checks both stage an install and spawn two helpers.
if (updateInstallStarted)
{
logFile.Event($"updater: install already in progress; ignoring repeat request for {info.Tag}");
return;
}
updateInstallStarted = true;
var ok = await updater.DownloadAndStageInstallAsync(info, currentProfileTitle).ConfigureAwait(true);
if (!ok)
{
// Nothing was staged — allow a later attempt rather than wedging the updater off
// for the rest of the session.
updateInstallStarted = false;
MessageBox.Show(this,
$"Could not download or stage the update. Try again later, or visit the release page in your browser:\n\n{info.ReleaseUrl}",
"Update failed", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
// The helper is staged and launched. We MUST now exit cleanly so it can replace our
// files and restart us — set updatingInProgress so the close path skips every prompt;
// no dialog (least of all the unsaved-changes prompt) may be allowed to cancel this.
updatingInProgress = true;
logFile.Event($"updater: install helper launched for {info.Tag}, exiting");
Application.Exit();
}
@@ -4977,6 +5023,13 @@ public sealed class MainForm : Form
{
var profile = new Profile { Title = title };
settings.CopyTo(profile);
// Carry the active read-only (lock) flag into the saved snapshot. Without this line a
// deliberate save of a locked profile — e.g. the "save through the lock" flow Andre
// uses — would write the profile back with ReadOnly defaulting to false, silently
// UNLOCKING it on disk. Next launch the profile would no longer be read-only, the
// unsaved-changes prompt would start firing again, and (worse) that prompt could block
// an unattended auto-update from restarting. The lock flag must survive every save.
profile.ReadOnly = currentProfileReadOnly;
profile.Volume = volumeBar.Value;
profile.Muted = receiver.IsMuted;
profile.ReceiveAudioOn = receiveAudioCheckbox.Checked;
@@ -6181,7 +6234,7 @@ public sealed class MainForm : Form
// is what unblocks NVDA-less or remote-session-dropped shutdowns from deadlocking
// on a dialog the user can't reach.
var skipPrompt = !string.IsNullOrEmpty(NextProfileTitleToLoad) || ReloadFromScratch
|| currentProfileReadOnly;
|| currentProfileReadOnly || updatingInProgress;
if (!skipPrompt && profileStore is not null && unsavedChanges)
{
+55
View File
@@ -6,6 +6,12 @@ namespace RemSound.App;
internal static class Program
{
// The MainForm currently open in the loop below, or null between profile-switch
// iterations. Tracked so the single-instance coordinator's activation callback (which
// fires on a background thread when a second copy asks us to surface) can reach the live
// window. volatile for cross-thread visibility; RestoreFromTray marshals to the UI thread.
private static volatile MainForm? activeMainForm;
[STAThread]
private static void Main()
{
@@ -18,6 +24,51 @@ internal static class Program
ApplicationConfiguration.Initialize();
// Single-instance guard. RemSound must never run as two copies at once: with the
// auto-updater relaunching the app, a copy that didn't exit cleanly used to leave two
// (then more) copies running, each playing received audio — Andre's "stacked and
// stacked", deafening-audio runaway (2026-05-30). The lock makes that structurally
// impossible. Acquired BEFORE anything user-visible so a second copy bows out (or
// takes over a stuck one) before it ever touches audio devices or the network.
using var instance = new SingleInstanceCoordinator();
if (!instance.TryAcquire(TimeSpan.Zero))
{
// If we can't even ask the user (dialog failed to show), the safe answer is "don't
// start a second copy" — bowing out is always safer than risking a duplicate.
SingleInstanceDecision decision;
try { decision = SingleInstanceDialog.Ask(); }
catch { return; }
switch (decision)
{
case SingleInstanceDecision.SwitchToRunning:
SingleInstanceCoordinator.SignalExistingToActivate();
return;
case SingleInstanceDecision.Cancel:
return;
case SingleInstanceDecision.ForceClose:
var cleared = SingleInstanceCoordinator.ForceCloseOtherInstances();
// Take the lock now the others should be gone. Allow a few seconds in case
// a killed copy is slow to release the abandoned mutex / its audio devices.
if (!instance.TryAcquire(TimeSpan.FromSeconds(5)))
{
MessageBox.Show(
cleared
? "RemSound closed the other copy but couldn't start cleanly. Please launch RemSound again."
: "RemSound couldn't close the copy that's already running — it may be running as administrator. Close it from Task Manager (or restart Windows), then try again.",
"RemSound is already running",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
break;
}
}
// We hold the single-instance lock. Listen for a later copy asking us to surface, and
// route that request to whichever main window is open at the time.
instance.StartActivationListener();
instance.ActivateRequested += () => activeMainForm?.RestoreFromTray();
// F1 anywhere = open the bundled manual. Installed *before* the first ShowDialog so
// it works on the profile picker (the very first thing the user sees). The filter
// is per-thread and modifier-aware: bare F1 only, so Shift/Ctrl/Alt+F1 stay free.
@@ -124,8 +175,12 @@ internal static class Program
// look hung. No-op for WASAPI-only profiles (construction is near-instant).
var splash = AsioLoadingSplash.StartIfNeeded(profile);
using var form = new MainForm(store, profile, title, nextPath);
// Expose the live window to the single-instance activation callback (a second
// copy choosing "switch to the running copy" signals us to surface this form).
activeMainForm = form;
splash?.Dismiss();
Application.Run(form);
activeMainForm = null;
if (form.ReloadFromScratch)
{
+1 -1
View File
@@ -14,7 +14,7 @@
tag_name on the latest GitHub release; bump it on every public release. The
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
is what the About dialog and the updater both read. -->
<Version>3.1.2</Version>
<Version>3.1.3</Version>
</PropertyGroup>
<ItemGroup>
@@ -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 */ }
}
}
+56
View File
@@ -0,0 +1,56 @@
using System.Windows.Forms;
namespace RemSound.App;
internal enum SingleInstanceDecision
{
/// <summary>Bring the already-running copy to the front; this copy exits.</summary>
SwitchToRunning,
/// <summary>Force the running copy to close, then start fresh.</summary>
ForceClose,
/// <summary>Do nothing; this copy exits.</summary>
Cancel,
}
/// <summary>
/// Shown when a SECOND copy of RemSound is launched while one is already running. Offers three
/// choices, with the safe one (switch to the running copy) as the default so a habitual Enter
/// can't accidentally kill a healthy session that might be mid-recording. The force-close
/// option is the deliberate recovery path for a stuck copy (Andre's "make it go away"). Built
/// as a native TaskDialog to match the rest of RemSound's dialogs and because NVDA reads its
/// heading, body and buttons cleanly. No owner window — the main window doesn't exist yet.
/// </summary>
internal static class SingleInstanceDialog
{
public static SingleInstanceDecision Ask()
{
var switchButton = new TaskDialogButton("Switch to the running copy");
var forceButton = new TaskDialogButton("Force the running copy to close and start fresh");
var cancelButton = new TaskDialogButton("Cancel") { AllowCloseDialog = true };
var page = new TaskDialogPage
{
Caption = "RemSound is already running",
Heading = "RemSound is already running",
Text =
"Only one copy of RemSound can run at a time. What would you like to do?\n\n"
+ "• Switch to the running copy — bring the copy that's already running back to the "
+ "front. It may be tucked away in the system tray, down by the clock.\n\n"
+ "• Force the running copy to close and start fresh — use this only if the running "
+ "copy is stuck or not responding. If it's in the middle of a recording, that "
+ "recording will be lost.\n\n"
+ "• Cancel — do nothing.",
Icon = TaskDialogIcon.Warning,
Buttons = { switchButton, forceButton, cancelButton },
DefaultButton = switchButton,
AllowCancel = true,
};
var clicked = TaskDialog.ShowDialog(page);
if (clicked == forceButton) return SingleInstanceDecision.ForceClose;
if (clicked == switchButton) return SingleInstanceDecision.SwitchToRunning;
return SingleInstanceDecision.Cancel;
}
}