Fix UPnP close-hang, and add mnemonics to every dialog's buttons (Andre)

UPnP close hang:
- RouterPortMapper.Stop()/Dispose() does a SYNCHRONOUS DeletePortMap call to the router
  (plus StopDiscovery). When the router is slow or unresponsive that blocks — and it ran on
  the UI thread in FormClosing, so enabling UPnP could make RemSound impossible to close
  (Andre, this morning; his logs show UPnP teardown taking several seconds even on a good
  run). Moved the router teardown into the same bounded background-task pattern already used
  for the ASIO audio dispose: UPnP + audio now tear down off the UI thread, in parallel, under
  one 3s cap. Anything unfinished is reclaimed on process exit, so the window always closes.

Mnemonics on dialog buttons:
- Andre flagged the "RemSound is already running" dialog (single-instance) having no shortcut
  keys. Its three TaskDialog buttons had no & mnemonics; added Alt+S / Alt+F / Alt+C.
- Swept every dialog. Custom TaskDialog buttons and Form OK/Cancel/Close buttons that lacked
  mnemonics now have them: single-instance (Switch/Force/Cancel), save-onto-read-only
  (Save/Cancel), Add EQ band, Change/Manage profile password, Quick profile switch (Close),
  Rename peer (OK), About (Close). Cancel stays on Esc in the few dialogs where Alt+C is
  already taken (Rename peer's Clear, Service profile's peers list) — by design, not a miss.
- The rest already had mnemonics (Keyboard-shortcut import, Update-install notice, Profile
  selection, Recording settings). The dialog-accessibility gate (mnemonic uniqueness + names)
  stays green, so nothing collides.

Gate 46/46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-18 09:17:49 +01:00
co-authored by Claude Opus 4.8
parent a433645fd4
commit 8fe52047be
8 changed files with 32 additions and 20 deletions
+1 -1
View File
@@ -1351,7 +1351,7 @@ internal sealed class AboutDialog : Form
var closeButton = new Button var closeButton = new Button
{ {
Text = "Close", Text = "&Close",
AutoSize = true, AutoSize = true,
DialogResult = DialogResult.OK, DialogResult = DialogResult.OK,
TabIndex = 1, TabIndex = 1,
+2 -2
View File
@@ -89,8 +89,8 @@ internal sealed class AddBandDialog : Form
AddRow(grid, 2, "&End frequency in Hz (Alt+E)", endFreq); AddRow(grid, 2, "&End frequency in Hz (Alt+E)", endFreq);
AddRow(grid, 3, "&Gain in dB (Alt+G)", gainDb); AddRow(grid, 3, "&Gain in dB (Alt+G)", gainDb);
var okButton = new Button { Text = "OK", AutoSize = true, DialogResult = DialogResult.None, TabIndex = 0 }; var okButton = new Button { Text = "&OK", AutoSize = true, DialogResult = DialogResult.None, TabIndex = 0 };
var cancelButton = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel, TabIndex = 1 }; var cancelButton = new Button { Text = "&Cancel", AutoSize = true, DialogResult = DialogResult.Cancel, TabIndex = 1 };
okButton.Click += (_, _) => TryAccept(); okButton.Click += (_, _) => TryAccept();
// OK before Cancel, in both tab order and left-to-right layout. // OK before Cancel, in both tab order and left-to-right layout.
+17 -6
View File
@@ -1434,7 +1434,10 @@ public sealed class MainForm : Form
try { processSelfMeter.Dispose(); } catch { } try { processSelfMeter.Dispose(); } catch { }
try { deviceChangeNotifier?.Dispose(); } catch { } try { deviceChangeNotifier?.Dispose(); } catch { }
try { powerResumeHandler?.Dispose(); } catch { } try { powerResumeHandler?.Dispose(); } catch { }
try { routerPortMapper?.Dispose(); } catch { } // NOTE: routerPortMapper.Dispose() is NOT called here — it's moved into the bounded
// background teardown below. Its Stop() does a SYNCHRONOUS DeletePortMap call to the router,
// which blocks (or hangs) when the router is slow/unresponsive; on the UI thread that froze the
// close, so enabling UPnP made RemSound impossible to shut (Andre, 2026-07-18).
// Reverse every Win32 lever PerformanceMode applied. The kernel would clean // Reverse every Win32 lever PerformanceMode applied. The kernel would clean
// these up on process exit anyway, but doing it explicitly releases the power // these up on process exit anyway, but doing it explicitly releases the power
// request handle and matches our timeBeginPeriod with a timeEndPeriod. // request handle and matches our timeBeginPeriod with a timeEndPeriod.
@@ -1449,14 +1452,22 @@ public sealed class MainForm : Form
// the form-close path run; the OS reclaims any audio resources on process exit. // the form-close path run; the OS reclaims any audio resources on process exit.
// Worst case the user sees a brief tray-icon stutter; before this they saw a // Worst case the user sees a brief tray-icon stutter; before this they saw a
// ~16 s frozen window before the form went away. // ~16 s frozen window before the form went away.
var audioDispose = Task.Run(() => // The UPnP router teardown (DeletePortMap — a synchronous call to a possibly-unresponsive
// router) belongs here too: like the ASIO dispose it can block for many seconds, and on the UI
// thread it froze the close. Run both OFF the UI thread, in parallel, under one hard timeout.
var slowTeardown = Task.Run(() =>
{
var upnp = Task.Run(() => { try { routerPortMapper?.Dispose(); } catch { /* ignore */ } });
var audio = Task.Run(() =>
{ {
try { sender.Dispose(); } catch { /* ignore */ } try { sender.Dispose(); } catch { /* ignore */ }
try { receiver.Dispose(); } catch { /* ignore */ } try { receiver.Dispose(); } catch { /* ignore */ }
}); });
if (!audioDispose.Wait(TimeSpan.FromSeconds(2))) try { Task.WaitAll(upnp, audio); } catch { /* ignore */ }
});
if (!slowTeardown.Wait(TimeSpan.FromSeconds(3)))
{ {
try { logFile.Event("close: audio dispose taking >2s; letting process exit reclaim"); } catch { } try { logFile.Event("close: UPnP/audio teardown taking >3s; letting process exit reclaim"); } catch { }
} }
hotkeyController.Dispose(); hotkeyController.Dispose();
@@ -2812,8 +2823,8 @@ public sealed class MainForm : Form
private bool ShowSaveOnReadOnlyWarningDialog() private bool ShowSaveOnReadOnlyWarningDialog()
{ {
var verification = new TaskDialogVerificationCheckBox("Do not show me this message again"); var verification = new TaskDialogVerificationCheckBox("Do not show me this message again");
var saveButton = new TaskDialogButton("Save anyway"); var saveButton = new TaskDialogButton("&Save anyway");
var cancelButton = new TaskDialogButton("Cancel") { AllowCloseDialog = true }; var cancelButton = new TaskDialogButton("&Cancel") { AllowCloseDialog = true };
var page = new TaskDialogPage var page = new TaskDialogPage
{ {
Caption = AppName, Caption = AppName,
+2 -2
View File
@@ -47,8 +47,8 @@ internal static class ProfilePasswordDialog
AutoSize = true, AutoSize = true,
}; };
var okButton = new Button { Text = "OK", AutoSize = true }; var okButton = new Button { Text = "&OK", AutoSize = true };
var cancelButton = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel }; var cancelButton = new Button { Text = "&Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
// OK is validated by hand (no auto-close DialogResult) so we can block an empty entry when // OK is validated by hand (no auto-close DialogResult) so we can block an empty entry when
// a password is being REQUIRED. The trigger is the OK/Enter action, not typing — and // a password is being REQUIRED. The trigger is the OK/Enter action, not typing — and
@@ -66,8 +66,8 @@ internal static class ProfilePasswordManagerDialog
rows.Add((title, current, box)); rows.Add((title, current, box));
} }
var okButton = new Button { Text = "OK", AutoSize = true, DialogResult = DialogResult.OK }; var okButton = new Button { Text = "&OK", AutoSize = true, DialogResult = DialogResult.OK };
var cancelButton = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel }; var cancelButton = new Button { Text = "&Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
var buttons = new FlowLayoutPanel { Dock = DockStyle.Bottom, FlowDirection = FlowDirection.RightToLeft, AutoSize = true, Padding = new Padding(8) }; var buttons = new FlowLayoutPanel { Dock = DockStyle.Bottom, FlowDirection = FlowDirection.RightToLeft, AutoSize = true, Padding = new Padding(8) };
buttons.Controls.Add(okButton); buttons.Controls.Add(okButton);
buttons.Controls.Add(cancelButton); buttons.Controls.Add(cancelButton);
+1 -1
View File
@@ -80,7 +80,7 @@ internal sealed class QuickProfileSwitchDialog
AutoSize = true, AutoSize = true,
Padding = new Padding(0, 8, 0, 0), Padding = new Padding(0, 8, 0, 0),
}; };
var closeButton = new Button { Text = "Close", AutoSize = true, DialogResult = DialogResult.Cancel, TabIndex = 1 }; var closeButton = new Button { Text = "&Close", AutoSize = true, DialogResult = DialogResult.Cancel, TabIndex = 1 };
buttons.Controls.Add(closeButton); buttons.Controls.Add(closeButton);
root.Controls.Add(buttons, 0, 2); root.Controls.Add(buttons, 0, 2);
+2 -1
View File
@@ -58,7 +58,8 @@ internal sealed class RenamePeerDialog : Form
grid.Controls.Add(nameBox, 1, 2); grid.Controls.Add(nameBox, 1, 2);
var clearButton = new Button { Text = "&Clear custom name (Alt+C)", AutoSize = true, AccessibleName = "Clear custom name", TabIndex = 1 }; var clearButton = new Button { Text = "&Clear custom name (Alt+C)", AutoSize = true, AccessibleName = "Clear custom name", TabIndex = 1 };
var okButton = new Button { Text = "OK", AutoSize = true, DialogResult = DialogResult.OK, TabIndex = 2 }; var okButton = new Button { Text = "&OK", AutoSize = true, DialogResult = DialogResult.OK, TabIndex = 2 };
// Cancel keeps Esc (it's the CancelButton) rather than a mnemonic — Alt+C is the Clear button here.
var cancelButton = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel, TabIndex = 3 }; var cancelButton = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel, TabIndex = 3 };
clearButton.Click += (_, _) => clearButton.Click += (_, _) =>
+3 -3
View File
@@ -28,9 +28,9 @@ internal static class SingleInstanceDialog
{ {
public static SingleInstanceDecision Ask() public static SingleInstanceDecision Ask()
{ {
var switchButton = new TaskDialogButton("Switch to the running copy"); var switchButton = new TaskDialogButton("&Switch to the running copy");
var forceButton = new TaskDialogButton("Force the running copy to close and start fresh"); var forceButton = new TaskDialogButton("&Force the running copy to close and start fresh");
var cancelButton = new TaskDialogButton("Cancel") { AllowCloseDialog = true }; var cancelButton = new TaskDialogButton("&Cancel") { AllowCloseDialog = true };
var page = new TaskDialogPage var page = new TaskDialogPage
{ {