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
+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, "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 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, "Service registration args", ServiceRegistrationArgs);
RunStep(results, "Service self-contained install (own bin + user stop rights)", ServiceSelfContainedInstall);
@@ -1717,6 +1719,68 @@ internal static class SelfTest
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
/// 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