Review sweep: fix real bugs found across the service + per-app + settings changes

Parallel code review of this session's changes surfaced several real bugs; fixed the
substantive ones (judgment/cleanup calls held for Ed):

- HIGH Clearing "remembered peers" was resurrected on the next launch: the per-profile
  -> global migration re-ran every startup and re-unioned the profile file's stale copy.
  Added a one-time AppConfig.RememberedPeersMigrated marker so migration runs once and a
  cleared list stays cleared. Self-test pins the clear-then-reload scenario.
- MED PushModeWasapiBackend.Start rethrew on a device-open failure; nothing up the stack
  wraps it, so device churn (a push-eligible single WASAPI source unplugged mid-open)
  could crash the app. Now logs and stays stopped like MixingEngine/ASIO; the device
  watcher / self-heal re-open when a device returns.
- MED Service self-heal: (a) the re-open "no send sources" path left PerformanceMode ON
  and presence up while streaming nothing - now releases cleanly; (b) the 3-attempt
  ladder never refunded, so 3 hiccups over a days-long stint meant permanent silence -
  now refunds when real audio is heard, and resets on a device hot-plug.
- MED ApplyProfile resolved peers (DNS) and enumerated devices INSIDE the gate lock -
  a boot-time DNS hang as SYSTEM stalled Suspend()/yield/self-heal. Moved outside the lock.
- LOW AudioSessionStartWatcher leaked the AudioSessionManager on every Rehook (the WASAPI
  handle-leak fingerprint) - now disposed. New lifecycle self-test.
- LOW stale docstrings (send-all master toggle; ServiceUpdate in-place scheme; Profile
  .SendAllApplications "neither reads nor writes").

Gate: 42/42 (added peers-migration + session-watcher-lifecycle tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-17 15:49:01 +01:00
co-authored by Claude Opus 4.8
parent 4b2baa58b5
commit 984bcd042e
10 changed files with 143 additions and 44 deletions
+13 -20
View File
@@ -233,7 +233,7 @@ ul, ol { padding-left: 1.4em; }
<tr><td><strong>Connectivity</strong></td><td>Connected, discovered and remembered peers. Adding a peer by address. A connection status read-out.</td></tr> <tr><td><strong>Connectivity</strong></td><td>Connected, discovered and remembered peers. Adding a peer by address. A connection status read-out.</td></tr>
<tr><td><strong>Audio inputs and outputs</strong></td><td>The ASIO driver picker (when an ASIO driver is installed), the Receive audio and Send my audio checkboxes, and all the device lists. Choosing a real driver in the picker brings up the ASIO device lists alongside the ordinary Windows ones; choosing <em>(none)</em> hides them.</td></tr> <tr><td><strong>Audio inputs and outputs</strong></td><td>The ASIO driver picker (when an ASIO driver is installed), the Receive audio and Send my audio checkboxes, and all the device lists. Choosing a real driver in the picker brings up the ASIO device lists alongside the ordinary Windows ones; choosing <em>(none)</em> hides them.</td></tr>
<tr><td><strong>Volume, pan and EQ for peers</strong> (optional)</td><td>Shape each connected peer's sound on its own &mdash; their volume, pan (left/right) and EQ. Shown by default; untick &ldquo;Show the volume, pan and EQ for peers tab&rdquo; on the Appearance tab of Preferences to hide it. See <a href="#pan-eq">Volume, pan and EQ for peers tab</a>.</td></tr> <tr><td><strong>Volume, pan and EQ for peers</strong> (optional)</td><td>Shape each connected peer's sound on its own &mdash; their volume, pan (left/right) and EQ. Shown by default; untick &ldquo;Show the volume, pan and EQ for peers tab&rdquo; on the Appearance tab of Preferences to hide it. See <a href="#pan-eq">Volume, pan and EQ for peers tab</a>.</td></tr>
<tr><td><strong>Audio profile</strong></td><td>Codec, packet size, lock-to-audio-clock, latency, continuous auto-tune, buffer smoothness, artefact sound. Split into an <em>Audio send parameters</em> group and an <em>Audio receive parameters</em> group.</td></tr> <tr><td><strong>Audio profile</strong></td><td>Codec, packet size, latency, continuous auto-tune, buffer smoothness, artefact sound. Split into an <em>Audio send parameters</em> group and an <em>Audio receive parameters</em> group.</td></tr>
</table> </table>
<h3>The system tray icon and its menu</h3> <h3>The system tray icon and its menu</h3>
@@ -480,9 +480,10 @@ ul, ol { padding-left: 1.4em; }
<tr><th>Control</th><th>Shortcut</th><th>What it does</th></tr> <tr><th>Control</th><th>Shortcut</th><th>What it does</th></tr>
<tr><td><strong>Audio codec</strong></td><td>Alt+C</td><td>The codec is the method RemSound uses to package the sound before sending it. Three choices: PCM 48k 24-bit (uncompressed), Opus broadcast quality (loss tolerant), or Opus live latency (for jamming and monitoring). See <a href="#latency">codec choice</a>.</td></tr> <tr><td><strong>Audio codec</strong></td><td>Alt+C</td><td>The codec is the method RemSound uses to package the sound before sending it. Three choices: PCM 48k 24-bit (uncompressed), Opus broadcast quality (loss tolerant), or Opus live latency (for jamming and monitoring). See <a href="#latency">codec choice</a>.</td></tr>
<tr><td><strong>Packet size</strong></td><td>Alt+P</td><td><em>Standard</em> (the default) or <em>Small</em> (for a local network only). Smaller packets save a couple of milliseconds of delay on the sending side, but they double how many packets are sent.</td></tr> <tr><td><strong>Packet size</strong></td><td>Alt+P</td><td><em>Standard</em> (the default) or <em>Small</em> (for a local network only). Smaller packets save a couple of milliseconds of delay on the sending side, but they double how many packets are sent.</td></tr>
<tr><td><strong>Lock to audio clock</strong></td><td>Alt+D</td><td>A timing setting on the sending side. It ties the sending of packets to the sound device's own hardware clock, which removes a little jitter (jitter means uneven packet timing). Brief clicks are possible if the connection can't keep up. The label changes depending on whether ASIO is in use, so it always describes what it does in your setup.</td></tr>
</table> </table>
<p>RemSound always <strong>locks its sending timing to the sound device's own hardware clock</strong> &mdash; this used to be a &ldquo;Lock to audio clock&rdquo; checkbox, but it is now always on, because turning it off only ever added delay. There is nothing to set.</p>
<h3>Audio receive parameters</h3> <h3>Audio receive parameters</h3>
<p>What you see in this section depends on whether an ASIO driver is chosen on the Audio inputs and outputs tab. With no ASIO driver, you see one delay setting (labelled simply &ldquo;Audio latency&rdquo;). With an ASIO driver chosen, you see two delay settings &mdash; one for each sound path &mdash; each with its own auto-tune toggle. The two paths are independent: a problem on one doesn't affect the other.</p> <p>What you see in this section depends on whether an ASIO driver is chosen on the Audio inputs and outputs tab. With no ASIO driver, you see one delay setting (labelled simply &ldquo;Audio latency&rdquo;). With an ASIO driver chosen, you see two delay settings &mdash; one for each sound path &mdash; each with its own auto-tune toggle. The two paths are independent: a problem on one doesn't affect the other.</p>
@@ -736,13 +737,12 @@ Audient USB Audio ASIO Driver &mdash; Pair 3 (channels 5/6): Loop-back 1 (L) / L
<h2 id="latency">14. Latency and audio quality</h2> <h2 id="latency">14. Latency and audio quality</h2>
<p>Latency is the small delay between sound leaving one computer and arriving at the other. Five controls together shape the trade-off between latency and sound quality, all on the Audio profile tab:</p> <p>Latency is the small delay between sound leaving one computer and arriving at the other. Four controls together shape the trade-off between latency and sound quality, all on the Audio profile tab:</p>
<ul> <ul>
<li><strong>Audio latency in milliseconds (Alt+L)</strong> &mdash; the main target for how much sound the receiving side keeps in reserve.</li> <li><strong>Audio latency in milliseconds (Alt+L)</strong> &mdash; the main target for how much sound the receiving side keeps in reserve.</li>
<li><strong>Buffer smoothness (Alt+B)</strong> &mdash; how hard the receiving side works to protect against sudden jitter.</li> <li><strong>Buffer smoothness (Alt+B)</strong> &mdash; how hard the receiving side works to protect against sudden jitter.</li>
<li><strong>Packet size (Alt+P)</strong> &mdash; Standard or Small. Small packets shave a couple of milliseconds off the sending delay, but double how many packets are sent.</li> <li><strong>Packet size (Alt+P)</strong> &mdash; Standard or Small. Small packets shave a couple of milliseconds off the sending delay, but double how many packets are sent.</li>
<li><strong>Lock to audio clock (Alt+D)</strong> &mdash; ties the timing of packets to the sound device's hardware clock, removing wobble caused by Windows.</li>
<li><strong>Continuous auto-tune</strong> &mdash; lets the receiving side choose the latency target for you, re-checking every few seconds.</li> <li><strong>Continuous auto-tune</strong> &mdash; lets the receiving side choose the latency target for you, re-checking every few seconds.</li>
</ul> </ul>
@@ -792,18 +792,12 @@ Audient USB Audio ASIO Driver &mdash; Pair 3 (channels 5/6): Loop-back 1 (L) / L
<p>The saving is small &mdash; at most a few milliseconds end to end. Small packets are useful when you and your collaborator are on the same local network and want to chase every last millisecond. For any internet connection it's a false economy, because doubling how many packets are sent also doubles the chance of running into jitter at the wrong moment, which you hear as clicks.</p> <p>The saving is small &mdash; at most a few milliseconds end to end. Small packets are useful when you and your collaborator are on the same local network and want to chase every last millisecond. For any internet connection it's a false economy, because doubling how many packets are sent also doubles the chance of running into jitter at the wrong moment, which you hear as clicks.</p>
<h3>Lock to audio clock</h3> <h3>Locking to the audio clock (automatic)</h3>
<p>The <strong>Lock to audio clock</strong> checkbox ties RemSound's sending timing to the sound device's own hardware clock, instead of letting Windows decide the pace. It's off by default. The label tells you what it does in your particular setup:</p> <p>RemSound always ties its sending timing to the sound device's own hardware clock, instead of letting Windows decide the pace. This used to be a <strong>Lock to audio clock</strong> checkbox that was off by default; it is now always on and there is nothing to set, because turning it off only ever added delay. On a WASAPI-only setup the sender takes its timing from the WASAPI capture; when an ASIO driver is also in use, both paths tighten independently.</p>
<table>
<tr><th>Your setup</th><th>What &ldquo;Lock to audio clock&rdquo; does</th></tr>
<tr><td>No ASIO driver chosen (WASAPI only)</td><td>The sender takes its timing from the WASAPI capture instead of from Windows' general timer. Tightens the sending delay.</td></tr>
<tr><td>An ASIO driver chosen (WASAPI and ASIO both running)</td><td>Both paths tighten independently. Brief clicks are possible on either path if the connection can't keep up.</td></tr>
</table>
<div class="note"> <div class="note">
<strong>Why you'd use it:</strong> Windows' general timer can wake the audio loop with up to about 6 ms of wobble, even at top priority. At target latencies under about 15 ms, that wobble shows up as clicks. Locking to the audio clock takes Windows' timer out of the picture &mdash; the sound device itself drives the timing. <strong>Why it matters:</strong> Windows' general timer can wake the audio loop with up to about 6 ms of wobble, even at top priority. At target latencies under about 15 ms, that wobble shows up as clicks. Locking to the audio clock takes Windows' timer out of the picture &mdash; the sound device itself drives the timing.
</div> </div>
<h3>Continuous auto-tune</h3> <h3>Continuous auto-tune</h3>
@@ -884,7 +878,6 @@ Audient USB Audio ASIO Driver &mdash; Pair 3 (channels 5/6): Loop-back 1 (L) / L
<tr><td>Alt+U</td><td>Toggle Use CPU and Windows performance settings in high priority mode (for this profile)</td></tr> <tr><td>Alt+U</td><td>Toggle Use CPU and Windows performance settings in high priority mode (for this profile)</td></tr>
<tr><td>Alt+C</td><td>Focus Audio codec</td></tr> <tr><td>Alt+C</td><td>Focus Audio codec</td></tr>
<tr><td>Alt+P</td><td>Focus Packet size</td></tr> <tr><td>Alt+P</td><td>Focus Packet size</td></tr>
<tr><td>Alt+D</td><td>Toggle Lock to audio clock</td></tr>
<tr><td>Alt+L</td><td>Focus the latency control &mdash; the ASIO path when an ASIO driver is chosen, otherwise the single Audio latency control</td></tr> <tr><td>Alt+L</td><td>Focus the latency control &mdash; the ASIO path when an ASIO driver is chosen, otherwise the single Audio latency control</td></tr>
<tr><td>Alt+T</td><td>Toggle continuous auto-tune &mdash; the ASIO path when an ASIO driver is chosen, otherwise the single Continuous auto-tune toggle</td></tr> <tr><td>Alt+T</td><td>Toggle continuous auto-tune &mdash; the ASIO path when an ASIO driver is chosen, otherwise the single Continuous auto-tune toggle</td></tr>
<tr><td>Alt+W</td><td>(Only when an ASIO driver is chosen.) Focus the WASAPI-path latency control</td></tr> <tr><td>Alt+W</td><td>(Only when an ASIO driver is chosen.) Focus the WASAPI-path latency control</td></tr>
@@ -1398,9 +1391,9 @@ RemSound.exe --connect 192.168.1.42
<h3>Setting it up</h3> <h3>Setting it up</h3>
<p>Everything lives in the <strong>Service</strong> menu on the menu bar:</p> <p>Everything lives in the <strong>Service</strong> menu on the menu bar:</p>
<ol> <ol>
<li><strong>Configure service profile&hellip;</strong> &mdash; opens a small window with three tabs (Audio send, Audio profile, Connectivity) where you choose what to send, how to send it, and who to send it to, plus a password. This is a separate profile from your normal ones and does not appear in the usual profile list. There is no &ldquo;send my audio&rdquo; switch because the service always sends. The <strong>Additional options</strong> button lets you turn the connect/disconnect sounds and the service's own log on or off.</li> <li><strong>Configure service profile&hellip;</strong> &mdash; opens a small window with two tabs (Connectivity and Audio send) where you choose who to send to (plus a password) and what to send. There is no &ldquo;send my audio&rdquo; switch because the service always sends, and there is no audio-quality tab to fiddle with: the service always uses the settings that work best for live streaming (the Opus live-latency codec, small packets, locked to the audio clock), so it just sounds right. This is a separate profile from your normal ones and does not appear in the usual profile list. The <strong>Additional options</strong> button lets you turn the connect/disconnect sounds and the service's own log on or off.</li>
<li><strong>Install service</strong> &mdash; registers it with Windows so it starts automatically at every boot. Windows asks for administrator permission (one prompt). Do this once.</li> <li><strong>Install service</strong> &mdash; registers it with Windows so it starts automatically at every boot. Windows asks for administrator permission (one prompt). Do this once. (When you first install RemSound on a PC, the installer also offers to set the service up for you, so you may have done this already.)</li>
<li><strong>Start service</strong> / <strong>Stop service</strong> &mdash; run or halt it now without waiting for a reboot. Also administrator actions.</li> <li><strong>Start service</strong> / <strong>Stop service</strong> &mdash; run or halt it now without waiting for a reboot.</li>
<li><strong>Uninstall service</strong> &mdash; removes it entirely.</li> <li><strong>Uninstall service</strong> &mdash; removes it entirely.</li>
</ol> </ol>
<p>The top of the Service menu always shows the current state: not installed, installed and running, or installed and stopped.</p> <p>The top of the Service menu always shows the current state: not installed, installed and running, or installed and stopped.</p>
@@ -1408,8 +1401,8 @@ RemSound.exe --connect 192.168.1.42
<h3>Good to know</h3> <h3>Good to know</h3>
<ul> <ul>
<li>The service sends to the exact peer addresses you list in its profile (a computer on your network, or a reachable address across the internet). Automatic peer discovery and hole-punching are handled by the normal app, not the service.</li> <li>The service sends to the exact peer addresses you list in its profile (a computer on your network, or a reachable address across the internet). Automatic peer discovery and hole-punching are handled by the normal app, not the service.</li>
<li>Because the service runs even when you are not logged in, it sends from the system account &mdash; the installer arranges the firewall so its audio can get out.</li> <li>Because the service runs even when you are not logged in, it sends from the system account. If Windows Firewall ever prompts about RemSound, allow it so the audio can get out.</li>
<li>When RemSound updates itself, the service keeps running the previous version until its next restart (or the next reboot), then picks up the new one. Nothing breaks in the meantime.</li> <li>When RemSound updates itself, the service updates itself too, automatically &mdash; it notices the newer version, copies it in and restarts onto it on its own, with no prompt and nothing for you to do. (This happens on the service's own schedule shortly after the app updates; nothing breaks in the meantime.)</li>
<li>If two computers should each stream to the other unattended, install the service on both.</li> <li>If two computers should each stream to the other unattended, install the service on both.</li>
</ul> </ul>
@@ -1512,7 +1505,7 @@ RemSound.exe --connect 192.168.1.42
<tr><td>Auto-tune</td><td>RemSound automatically adjusting the latency target based on how evenly packets are arriving. Off by default; turn it on with the Continuous auto-tune checkbox on the Audio profile tab.</td></tr> <tr><td>Auto-tune</td><td>RemSound automatically adjusting the latency target based on how evenly packets are arriving. Off by default; turn it on with the Continuous auto-tune checkbox on the Audio profile tab.</td></tr>
<tr><td>Profile</td><td>A saved snapshot of every RemSound setting and choice &mdash; device ticks, send / receive states, codec, latency, peers, ASIO driver choice, the lot. (Keyboard shortcuts are the exception &mdash; they're shared across all profiles, not saved per profile.) Stored as one settings file. You pick one at startup, and can switch with File &rarr; Open profile.</td></tr> <tr><td>Profile</td><td>A saved snapshot of every RemSound setting and choice &mdash; device ticks, send / receive states, codec, latency, peers, ASIO driver choice, the lot. (Keyboard shortcuts are the exception &mdash; they're shared across all profiles, not saved per profile.) Stored as one settings file. You pick one at startup, and can switch with File &rarr; Open profile.</td></tr>
<tr><td>New profile</td><td>An entry in the startup profile picker that begins a session with all the defaults &mdash; nothing ticked, no peers, no saved name. A clean starting point for a new profile, or for a one-off session you don't plan to save.</td></tr> <tr><td>New profile</td><td>An entry in the startup profile picker that begins a session with all the defaults &mdash; nothing ticked, no peers, no saved name. A clean starting point for a new profile, or for a one-off session you don't plan to save.</td></tr>
<tr><td>Lock to audio clock</td><td>A sending-side timing mode that takes its timing straight from the sound device's hardware clock instead of from Windows. Removes a few milliseconds of wobble at tight latency targets. Off by default. Set with the checkbox of the same name on the Audio profile tab.</td></tr> <tr><td>Lock to audio clock</td><td>A sending-side timing mode that takes its timing straight from the sound device's hardware clock instead of from Windows. Removes a few milliseconds of wobble at tight latency targets. RemSound now does this always &mdash; it used to be a checkbox on the Audio profile tab, but it is on permanently and no longer a setting.</td></tr>
<tr><td>Concealment</td><td>A receiving-side feature that fills brief gaps in the playback reserve with a small noise burst (the default) or an obvious click. You choose which on the Audio profile tab, in the <em>Artefact sound type</em> list. Opus also has its own repair of lost packets on top of this.</td></tr> <tr><td>Concealment</td><td>A receiving-side feature that fills brief gaps in the playback reserve with a small noise burst (the default) or an obvious click. You choose which on the Audio profile tab, in the <em>Artefact sound type</em> list. Opus also has its own repair of lost packets on top of this.</td></tr>
<tr><td>Remote control</td><td>A RemSound feature that lets one connected peer adjust another peer's listening volume (or toggle their receive mute) using global hotkeys. There are two sets of commands: one adjusts the receiver's RemSound volume slider, the other adjusts the receiver's Windows volume. Off by default on both ends; the receiver opts in via &ldquo;Accept remote volume commands from peers&rdquo; in the Preferences dialog (Ctrl+P), and the sender sets up hotkeys in the Keyboard shortcuts dialog (Ctrl+K). Designed for the &ldquo;I'm NVDA-Remote'd into my desktop and want to nudge the laptop's volume&rdquo; case. See <a href="#remote-control">Remote control</a>.</td></tr> <tr><td>Remote control</td><td>A RemSound feature that lets one connected peer adjust another peer's listening volume (or toggle their receive mute) using global hotkeys. There are two sets of commands: one adjusts the receiver's RemSound volume slider, the other adjusts the receiver's Windows volume. Off by default on both ends; the receiver opts in via &ldquo;Accept remote volume commands from peers&rdquo; in the Preferences dialog (Ctrl+P), and the sender sets up hotkeys in the Keyboard shortcuts dialog (Ctrl+K). Designed for the &ldquo;I'm NVDA-Remote'd into my desktop and want to nudge the laptop's volume&rdquo; case. See <a href="#remote-control">Remote control</a>.</td></tr>
</table> </table>
+4 -4
View File
@@ -3870,10 +3870,10 @@ public sealed class MainForm : Form
sessionStartWatcher = null; sessionStartWatcher = null;
} }
/// <summary>Restores the WASAPI send mode, the "Send all applications" master toggle and the ticked /// <summary>Restores the WASAPI send mode and the ticked app names from a loaded profile (the main
/// app names from a loaded profile. Remembered apps that aren't running right now are seeded into the /// window's "Send all applications" master toggle was removed 2026-07-16). Remembered apps that aren't
/// list (ticked, marked "not running") so they resume capture the moment they reappear. On Windows /// running right now are seeded into the list (ticked, marked "not running") so they resume capture the
/// too old for process loopback the mode is forced back to devices.</summary> /// moment they reappear. On Windows too old for process loopback the mode is forced back to devices.</summary>
private void RestoreSendModeFromProfile(Profile p) private void RestoreSendModeFromProfile(Profile p)
{ {
suppressSendAppEvents = true; suppressSendAppEvents = true;
+64
View File
@@ -71,6 +71,8 @@ internal static class SelfTest
RunStep(results, "Service silent-capture self-heal (issue #23 boot re-open ladder)", ServiceSilentCaptureSelfHeal); RunStep(results, "Service silent-capture self-heal (issue #23 boot re-open ladder)", ServiceSilentCaptureSelfHeal);
RunStep(results, "Send-app capture change-detection (catch an app the instant it opens)", SendAppCaptureChangeDetection); RunStep(results, "Send-app capture change-detection (catch an app the instant it opens)", SendAppCaptureChangeDetection);
RunStep(results, "Remembered applications list is global + clearable", RememberedApplicationsGlobal); RunStep(results, "Remembered applications list is global + clearable", RememberedApplicationsGlobal);
RunStep(results, "Remembered peers migrate once (cleared list not resurrected)", RememberedPeersMigrationOnce);
RunStep(results, "Session-start watcher lifecycle (construct/rehook/dispose)", SessionStartWatcher);
RunStep(results, "Send-app lists semantics (ticked → Active, out of Remembered)", SendAppListSemantics); RunStep(results, "Send-app lists semantics (ticked → Active, out of Remembered)", SendAppListSemantics);
RunStep(results, "Service registration args", ServiceRegistrationArgs); RunStep(results, "Service registration args", ServiceRegistrationArgs);
RunStep(results, "Service self-contained install (own bin + user stop rights)", ServiceSelfContainedInstall); RunStep(results, "Service self-contained install (own bin + user stop rights)", ServiceSelfContainedInstall);
@@ -1717,6 +1719,68 @@ internal static class SelfTest
finally { store.SaveRememberedApplications(original); } finally { store.SaveRememberedApplications(original); }
} }
/// <summary>The peers list went machine-wide (AppConfig) with a ONE-TIME migration from each old
/// profile's per-profile list. Regression guard for the bug where the migration re-ran every launch
/// and RESURRECTED peers the user had just cleared: after a clear, re-loading the same profile (whose
/// JSON still holds the old peers) must NOT bring them back. Touches the real AppConfig; saves/restores.</summary>
private static string? RememberedPeersMigrationOnce()
{
var store = new RemSoundSettingsStore("RemSound");
var saved = AppConfig.Load();
var savedPeers = saved.RememberedPeers;
var savedMigrated = saved.RememberedPeersMigrated;
try
{
// Clean slate: no global peers, migration not yet done.
var c0 = AppConfig.Load(); c0.RememberedPeers = new(); c0.RememberedPeersMigrated = false; c0.Save();
var p = new Profile { Title = "peers-migration-selftest" };
p.RememberedPeers = new List<string> { "Alice", "Bob" };
// First load of a profile that has legacy peers migrates them and sets the one-time flag.
store.ApplyProfile(p);
var migrated = store.LoadRememberedPeers();
Check(migrated.Contains("Alice") && migrated.Contains("Bob"), "legacy per-profile peers must migrate into the global list");
Check(AppConfig.Load().RememberedPeersMigrated, "the one-time migration flag must be set after migrating");
// User clears the global peers list (the Preferences button).
store.SaveRememberedPeers(Array.Empty<string>());
Check(store.LoadRememberedPeers().Count == 0, "clearing must empty the global peers list");
// Re-loading the SAME profile (its JSON still lists Alice/Bob) must NOT resurrect them.
store.ApplyProfile(p);
Check(store.LoadRememberedPeers().Count == 0,
"a cleared peers list must NOT be resurrected by re-loading a profile (migration is one-time)");
return "peers migrate once; a cleared list stays cleared across profile re-loads";
}
finally
{
var c = AppConfig.Load(); c.RememberedPeers = savedPeers; c.RememberedPeersMigrated = savedMigrated; c.Save();
}
}
/// <summary>The instant capture-on-app-open watcher (AudioSessionStartWatcher) must construct, re-hook
/// its default-device notification without throwing, and dispose idempotently — the plumbing behind
/// "catch a per-app send from its very start" and the service's boot session-kick. (It hooks live
/// WASAPI, so this proves lifecycle safety, not delivery of a real session event.)</summary>
private static string? SessionStartWatcher()
{
RemSound.Sender.AudioSessionStartWatcher w;
try { w = new RemSound.Sender.AudioSessionStartWatcher(_ => { }, _ => { }); }
catch (Exception ex) { return Skip($"session watcher could not construct (no audio endpoint?): {ex.GetType().Name}: {ex.Message}"); }
try
{
w.Rehook(); // re-point at the current default device — must never throw
w.Rehook();
return "constructed, re-hooked twice, and disposed idempotently without throwing";
}
finally
{
w.Dispose();
w.Dispose(); // idempotent
}
}
/// <summary>Pins the two-list semantics Ed specified 2026-07-16 (no send-all option): a TICKED app /// <summary>Pins the two-list semantics Ed specified 2026-07-16 (no send-all option): a TICKED app
/// must live in the Active list — even when it isn't running, marked "(not running)", so it can /// must live in the Active list — even when it isn't running, marked "(not running)", so it can
/// always be found and unticked — and must NOT appear in Remembered; an UNTICKED remembered app /// always be found and unticked — and must NOT appear in Remembered; an UNTICKED remembered app
+28 -4
View File
@@ -137,7 +137,9 @@ public sealed class ServiceSendHost : IDisposable
// silence keepalive feeding back, which is why the peak matters. // silence keepalive feeding back, which is why the peak matters.
var peak = sender.TakeMaxSenderPreEncodePeak(); var peak = sender.TakeMaxSenderPreEncodePeak();
if (peak > pulsePeakMax) pulsePeakMax = peak; if (peak > pulsePeakMax) pulsePeakMax = peak;
if (peak >= SilentPeak) { everHeardAudio = true; deafSinceTick = 0; } // Real audio proves the capture works — refund the self-heal ladder so a LATER hiccup (hours or
// days into an always-on stream) gets fresh re-open attempts instead of finding the budget spent.
if (peak >= SilentPeak) { everHeardAudio = true; deafSinceTick = 0; reopenAttempts = 0; }
pulseFramesSent += sender.TakeSenderAudioFramesSent(); pulseFramesSent += sender.TakeSenderAudioFramesSent();
// What is the DEVICE playing? The endpoint's own meter, independent of our capture stream. // What is the DEVICE playing? The endpoint's own meter, independent of our capture stream.
@@ -256,7 +258,20 @@ public sealed class ServiceSendHost : IDisposable
{ {
sender.Stop(); sender.Stop();
var specs = BuildSendSpecs(profile); // re-resolve (the default device may have moved) var specs = BuildSendSpecs(profile); // re-resolve (the default device may have moved)
if (specs.Count == 0) { log?.Invoke("service: re-open found no send sources — capture left stopped"); running = false; return; } if (specs.Count == 0)
{
// The source went away (e.g. the only loopback device was unplugged). Release the whole
// send stack — presence, meter readers, the session watcher AND the perf-mode overrides
// — instead of sitting "running" with High priority / EcoQoS-off held while streaming
// nothing. The device-change watcher re-opens (via ApplyProfile) when a device returns.
log?.Invoke("service: re-open found no send sources — releasing until a device returns");
try { presence.Stop(); } catch { }
SwapMeterDevices(Array.Empty<CaptureSourceSpec>());
try { sessionKick?.Dispose(); } catch { } sessionKick = null;
try { PerformanceMode.Apply(false, msg => log?.Invoke($"service: {msg}")); } catch { }
running = false;
return;
}
sender.Configure(specs); sender.Configure(specs);
sender.Start(); sender.Start();
SwapMeterDevices(specs); SwapMeterDevices(specs);
@@ -297,11 +312,16 @@ public sealed class ServiceSendHost : IDisposable
/// false (and stays stopped) if the profile has nothing to send or no reachable peers.</summary> /// false (and stays stopped) if the profile has nothing to send or no reachable peers.</summary>
public bool ApplyProfile(Profile profile) public bool ApplyProfile(Profile profile)
{ {
lock (gate) // Resolve sources + peer addresses OUTSIDE the lock. BuildEndpoints does DNS (Dns.GetHostAddresses)
{ // and BuildSendSpecs enumerates devices — either can block for seconds at boot as SYSTEM before the
// network/audio stack is fully up. Doing that while holding `gate` would stall Suspend() (yielding
// to the interactive app), the RunLoop tick and the self-heal for the whole timeout.
if (disposed) return false; if (disposed) return false;
var specs = BuildSendSpecs(profile); var specs = BuildSendSpecs(profile);
var endpoints = BuildEndpoints(profile); var endpoints = BuildEndpoints(profile);
lock (gate)
{
if (disposed) return false;
if (specs.Count == 0) { log?.Invoke("service: profile has no WASAPI send sources — nothing to stream"); return false; } if (specs.Count == 0) { log?.Invoke("service: profile has no WASAPI send sources — nothing to stream"); return false; }
if (endpoints.Count == 0) { log?.Invoke("service: profile has no reachable peers — nothing to stream to"); return false; } if (endpoints.Count == 0) { log?.Invoke("service: profile has no reachable peers — nothing to stream to"); return false; }
@@ -536,6 +556,10 @@ public sealed class ServiceSendHost : IDisposable
} }
var profile = loadProfile(); var profile = loadProfile();
if (profile is null) return; if (profile is null) return;
// A device hot-plug re-plumbs the audio graph much like a power resume or a fresh boot — refill the
// self-heal ladder so a brand-new device that comes up momentarily deaf still gets its re-opens.
everHeardAudio = false;
reopenAttempts = 0;
Suspend(); Suspend();
ApplyProfile(profile); ApplyProfile(profile);
} }
+3 -3
View File
@@ -17,9 +17,9 @@ namespace RemSound.App;
/// uncertainty (folder unknown, file missing mid-swap, unparseable version) means "don't act". After the /// uncertainty (folder unknown, file missing mid-swap, unparseable version) means "don't act". After the
/// copy+restart the running bin == the app version, so it never re-triggers.</para> /// copy+restart the running bin == the app version, so it never re-triggers.</para>
/// ///
/// <para>Trust note: the service copies from a user-writable folder and runs it as SYSTEM — the same trust /// <para>Trust note: the service copies from a user-writable folder (the app's install location) and runs
/// posture as the previous in-place scheme. Acceptable for this personal app; a hardened build would /// it as SYSTEM. That is a local-privilege-escalation surface — a hardened build would code-sign the app
/// code-sign and verify before copying.</para> /// and verify the signature before copying. Accepted deliberately for this personal app.</para>
/// </summary> /// </summary>
internal static class ServiceUpdate internal static class ServiceUpdate
{ {
+7 -2
View File
@@ -99,10 +99,15 @@ public sealed class AppConfig
/// <summary>Machine-wide remembered PEER entries — ONE shared address book across all profiles /// <summary>Machine-wide remembered PEER entries — ONE shared address book across all profiles
/// (Ed, 2026-07: both remembered lists live in global, not the profile). Before this the list rode /// (Ed, 2026-07: both remembered lists live in global, not the profile). Before this the list rode
/// in each profile's JSON, so it was per-profile in practice; each old profile's legacy list is /// in each profile's JSON, so it was per-profile in practice; each old profile's legacy list is
/// unioned in here the first time it's opened (RemSoundSettingsStore.ApplyProfile). Null = none yet. /// unioned in here ONCE (RemSoundSettingsStore.MigrateRememberedPeersToGlobal, gated by
/// Cleared from Preferences → General.</summary> /// <see cref="RememberedPeersMigrated"/>). Null = none yet. Cleared from Preferences → General.</summary>
public List<string>? RememberedPeers { get; set; } public List<string>? RememberedPeers { get; set; }
/// <summary>Set true after the one-time migration of a profile's legacy per-profile peers into
/// <see cref="RememberedPeers"/>. Without this the migration re-ran every launch and re-unioned the
/// profile file's stale copy — which silently resurrected peers the user had just cleared.</summary>
public bool RememberedPeersMigrated { get; set; }
/// <summary>Machine-wide remembered APPLICATION process names (lower-case) — the shared "apps I /// <summary>Machine-wide remembered APPLICATION process names (lower-case) — the shared "apps I
/// send" address book, companion to <see cref="RememberedPeers"/>. Before 2026-07-16 this only /// send" address book, companion to <see cref="RememberedPeers"/>. Before 2026-07-16 this only
/// lived in the in-memory settings cache, which silently forgot the list on every app exit. Null = /// lived in the in-memory settings cache, which silently forgot the list on every app exit. Null =
+3 -1
View File
@@ -81,7 +81,9 @@ public sealed class Profile
/// the whole system's default output. When false, only the apps named in /// the whole system's default output. When false, only the apps named in
/// <see cref="SelectedSendApplications"/> are sent. The MAIN window no longer has a "send all /// <see cref="SelectedSendApplications"/> are sent. The MAIN window no longer has a "send all
/// applications" option (removed 2026-07-16): there, applications mode always means specific ticked /// applications" option (removed 2026-07-16): there, applications mode always means specific ticked
/// apps, and whole-system audio is devices mode's job. The main window neither reads nor writes this.</summary> /// apps, and whole-system audio is devices mode's job. The main window never READS this to drive
/// behaviour; it does still write the default true when it saves a profile (harmless — main-app and
/// service profiles live in separate stores and are never the same file).</summary>
public bool SendAllApplications { get; set; } = true; public bool SendAllApplications { get; set; } = true;
/// <summary>In "applications" send mode: the process /// <summary>In "applications" send mode: the process
/// names (lower-case, no path/extension, e.g. "vlc", "firefox") whose audio to send. Tracked by /// names (lower-case, no path/extension, e.g. "vlc", "firefox") whose audio to send. Tracked by
+12 -8
View File
@@ -588,22 +588,26 @@ public sealed class RemSoundSettingsStore
MigrateRememberedPeersToGlobal(profile); MigrateRememberedPeersToGlobal(profile);
} }
/// <summary>Migration for the peers list going machine-wide (2026-07-16): profiles written by older /// <summary>ONE-TIME migration for the peers list going machine-wide (2026-07-16): profiles written by
/// builds carry their own remembered-peers list, so the first time each one is opened its entries /// older builds carry their own remembered-peers list, so the first opened profile that has peers has
/// are UNIONED into the AppConfig book nothing is lost, nothing is overwritten. Once the sets /// them UNIONED into the AppConfig book (nothing lost, nothing overwritten), and a marker
/// match this is a no-op (no file write).</summary> /// (<see cref="AppConfig.RememberedPeersMigrated"/>) then stops it re-running — otherwise a cleared
/// list would be resurrected from the profile file on the next launch.</summary>
private static void MigrateRememberedPeersToGlobal(Profile profile) private static void MigrateRememberedPeersToGlobal(Profile profile)
{ {
if (profile.RememberedPeers is not { Count: > 0 } legacy) return;
try try
{ {
var c = AppConfig.Load(); var c = AppConfig.Load();
// ONE-TIME only. Re-running every launch re-unioned the profile file's stale copy, which
// resurrected peers the user had just cleared in Preferences (the global store was emptied
// but the profile JSON still held them). The marker stops that.
if (c.RememberedPeersMigrated) return;
if (profile.RememberedPeers is not { Count: > 0 } legacy) return; // nothing to migrate yet; try again with a profile that has peers
var current = c.RememberedPeers ?? []; var current = c.RememberedPeers ?? [];
var merged = current c.RememberedPeers = current
.Concat(legacy.Where(static v => !string.IsNullOrWhiteSpace(v)).Select(static v => v.Trim())) .Concat(legacy.Where(static v => !string.IsNullOrWhiteSpace(v)).Select(static v => v.Trim()))
.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); .Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (merged.Count == current.Count) return; // nothing new — skip the write c.RememberedPeersMigrated = true;
c.RememberedPeers = merged;
c.Save(); c.Save();
} }
catch { /* best-effort, like the app's other AppConfig writes */ } catch { /* best-effort, like the app's other AppConfig writes */ }
@@ -73,6 +73,9 @@ public sealed class AudioSessionStartWatcher : IDisposable
private void UnhookLocked() private void UnhookLocked()
{ {
try { if (manager is not null) manager.OnSessionCreated -= HandleSessionCreated; } catch { } try { if (manager is not null) manager.OnSessionCreated -= HandleSessionCreated; } catch { }
// Dispose the session manager too, not just the device — it holds its own WASAPI COM state, and
// Rehook() runs on every default-device change, so leaking it here is a slow WASAPI handle drip.
try { (manager as IDisposable)?.Dispose(); } catch { }
manager = null; manager = null;
try { device?.Dispose(); } catch { } try { device?.Dispose(); } catch { }
device = null; device = null;
+6 -2
View File
@@ -210,10 +210,14 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
} }
catch (Exception ex) catch (Exception ex)
{ {
// A device-open failure (device disabled/unplugged between enumeration and GetDevice/
// Initialize) must NOT propagate: it used to be rethrown, and because CompositeCaptureBackend
// and AudioSender don't wrap the engine's Start, it could crash the whole app during device
// churn. Match MixingEngine/AsioCaptureBackend — log, stay stopped, let the caller carry on
// (the device-change watcher / capture self-heal re-open when a good device appears).
lastError = ex.Message; lastError = ex.Message;
onDiagnostic?.Invoke($"push-wasapi start failed for \"{spec.Name}\": {ex.GetType().Name}: {ex.Message}"); onDiagnostic?.Invoke($"push-wasapi start failed for \"{spec.Name}\": {ex.GetType().Name}: {ex.Message} — staying stopped (will re-open when the device is available)");
StopInternal(); StopInternal();
throw;
} }
} }
} }