From 984bcd042e3f60a0743be07393663b95013be541 Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:49:01 +0100 Subject: [PATCH] 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 --- readme.html | 33 ++++------ src/RemSound.App/MainForm.cs | 8 +-- src/RemSound.App/SelfTest.cs | 64 +++++++++++++++++++ src/RemSound.App/ServiceSendHost.cs | 32 ++++++++-- src/RemSound.App/ServiceUpdate.cs | 6 +- src/RemSound.Core/AppConfig.cs | 9 ++- src/RemSound.Core/Profile.cs | 4 +- src/RemSound.Core/RemSoundSettingsStore.cs | 20 +++--- .../AudioSessionStartWatcher.cs | 3 + src/RemSound.Sender/PushModeWasapiBackend.cs | 8 ++- 10 files changed, 143 insertions(+), 44 deletions(-) diff --git a/readme.html b/readme.html index 5fd8189..cf8fee2 100644 --- a/readme.html +++ b/readme.html @@ -233,7 +233,7 @@ ul, ol { padding-left: 1.4em; } ConnectivityConnected, discovered and remembered peers. Adding a peer by address. A connection status read-out. Audio inputs and outputsThe 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 (none) hides them. Volume, pan and EQ for peers (optional)Shape each connected peer's sound on its own — their volume, pan (left/right) and EQ. Shown by default; untick “Show the volume, pan and EQ for peers tab” on the Appearance tab of Preferences to hide it. See Volume, pan and EQ for peers tab. -Audio profileCodec, packet size, lock-to-audio-clock, latency, continuous auto-tune, buffer smoothness, artefact sound. Split into an Audio send parameters group and an Audio receive parameters group. +Audio profileCodec, packet size, latency, continuous auto-tune, buffer smoothness, artefact sound. Split into an Audio send parameters group and an Audio receive parameters group.

The system tray icon and its menu

@@ -480,9 +480,10 @@ ul, ol { padding-left: 1.4em; } ControlShortcutWhat it does Audio codecAlt+CThe 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 codec choice. Packet sizeAlt+PStandard (the default) or Small (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. -Lock to audio clockAlt+DA 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. +

RemSound always locks its sending timing to the sound device's own hardware clock — this used to be a “Lock to audio clock” checkbox, but it is now always on, because turning it off only ever added delay. There is nothing to set.

+

Audio receive parameters

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 “Audio latency”). With an ASIO driver chosen, you see two delay settings — one for each sound path — each with its own auto-tune toggle. The two paths are independent: a problem on one doesn't affect the other.

@@ -736,13 +737,12 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L

14. Latency and audio quality

-

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:

+

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:

@@ -792,18 +792,12 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L

The saving is small — 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.

-

Lock to audio clock

+

Locking to the audio clock (automatic)

-

The Lock to audio clock 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:

- - - - - -
Your setupWhat “Lock to audio clock” does
No ASIO driver chosen (WASAPI only)The sender takes its timing from the WASAPI capture instead of from Windows' general timer. Tightens the sending delay.
An ASIO driver chosen (WASAPI and ASIO both running)Both paths tighten independently. Brief clicks are possible on either path if the connection can't keep up.
+

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 Lock to audio clock 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.

-Why you'd use it: 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 — the sound device itself drives the timing. +Why it matters: 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 — the sound device itself drives the timing.

Continuous auto-tune

@@ -884,7 +878,6 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L Alt+UToggle Use CPU and Windows performance settings in high priority mode (for this profile) Alt+CFocus Audio codec Alt+PFocus Packet size -Alt+DToggle Lock to audio clock Alt+LFocus the latency control — the ASIO path when an ASIO driver is chosen, otherwise the single Audio latency control Alt+TToggle continuous auto-tune — the ASIO path when an ASIO driver is chosen, otherwise the single Continuous auto-tune toggle Alt+W(Only when an ASIO driver is chosen.) Focus the WASAPI-path latency control @@ -1398,9 +1391,9 @@ RemSound.exe --connect 192.168.1.42

Setting it up

Everything lives in the Service menu on the menu bar:

    -
  1. Configure service profile… — 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 “send my audio” switch because the service always sends. The Additional options button lets you turn the connect/disconnect sounds and the service's own log on or off.
  2. -
  3. Install service — registers it with Windows so it starts automatically at every boot. Windows asks for administrator permission (one prompt). Do this once.
  4. -
  5. Start service / Stop service — run or halt it now without waiting for a reboot. Also administrator actions.
  6. +
  7. Configure service profile… — 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 “send my audio” 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 Additional options button lets you turn the connect/disconnect sounds and the service's own log on or off.
  8. +
  9. Install service — 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.)
  10. +
  11. Start service / Stop service — run or halt it now without waiting for a reboot.
  12. Uninstall service — removes it entirely.

The top of the Service menu always shows the current state: not installed, installed and running, or installed and stopped.

@@ -1408,8 +1401,8 @@ RemSound.exe --connect 192.168.1.42

Good to know

@@ -1512,7 +1505,7 @@ RemSound.exe --connect 192.168.1.42 Auto-tuneRemSound 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. ProfileA saved snapshot of every RemSound setting and choice — device ticks, send / receive states, codec, latency, peers, ASIO driver choice, the lot. (Keyboard shortcuts are the exception — 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 → Open profile. New profileAn entry in the startup profile picker that begins a session with all the defaults — 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. -Lock to audio clockA 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. +Lock to audio clockA 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 — it used to be a checkbox on the Audio profile tab, but it is on permanently and no longer a setting. ConcealmentA 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 Artefact sound type list. Opus also has its own repair of lost packets on top of this. Remote controlA 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 “Accept remote volume commands from peers” in the Preferences dialog (Ctrl+P), and the sender sets up hotkeys in the Keyboard shortcuts dialog (Ctrl+K). Designed for the “I'm NVDA-Remote'd into my desktop and want to nudge the laptop's volume” case. See Remote control. diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index 08c040e..f766f5c 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -3870,10 +3870,10 @@ public sealed class MainForm : Form sessionStartWatcher = null; } - /// Restores the WASAPI send mode, the "Send all applications" master toggle and the ticked - /// app names from a loaded profile. Remembered apps that aren't running right now are seeded into the - /// list (ticked, marked "not running") so they resume capture the moment they reappear. On Windows - /// too old for process loopback the mode is forced back to devices. + /// Restores the WASAPI send mode and the ticked app names from a loaded profile (the main + /// window's "Send all applications" master toggle was removed 2026-07-16). Remembered apps that aren't + /// running right now are seeded into the list (ticked, marked "not running") so they resume capture the + /// moment they reappear. On Windows too old for process loopback the mode is forced back to devices. private void RestoreSendModeFromProfile(Profile p) { suppressSendAppEvents = true; diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index 945d4d3..e211465 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -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); } } + /// 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. + 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 { "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()); + 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(); + } + } + + /// 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.) + 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 + } + } + /// 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 diff --git a/src/RemSound.App/ServiceSendHost.cs b/src/RemSound.App/ServiceSendHost.cs index 26ec9a4..1639955 100644 --- a/src/RemSound.App/ServiceSendHost.cs +++ b/src/RemSound.App/ServiceSendHost.cs @@ -137,7 +137,9 @@ public sealed class ServiceSendHost : IDisposable // silence keepalive feeding back, which is why the peak matters. var peak = sender.TakeMaxSenderPreEncodePeak(); 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(); // 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(); 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()); + try { sessionKick?.Dispose(); } catch { } sessionKick = null; + try { PerformanceMode.Apply(false, msg => log?.Invoke($"service: {msg}")); } catch { } + running = false; + return; + } sender.Configure(specs); sender.Start(); 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. public bool ApplyProfile(Profile profile) { + // 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; + var specs = BuildSendSpecs(profile); + var endpoints = BuildEndpoints(profile); lock (gate) { if (disposed) return false; - var specs = BuildSendSpecs(profile); - var endpoints = BuildEndpoints(profile); 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; } @@ -536,6 +556,10 @@ public sealed class ServiceSendHost : IDisposable } var profile = loadProfile(); 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(); ApplyProfile(profile); } diff --git a/src/RemSound.App/ServiceUpdate.cs b/src/RemSound.App/ServiceUpdate.cs index b4a09b9..34fda2d 100644 --- a/src/RemSound.App/ServiceUpdate.cs +++ b/src/RemSound.App/ServiceUpdate.cs @@ -17,9 +17,9 @@ namespace RemSound.App; /// 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. /// -/// Trust note: the service copies from a user-writable folder and runs it as SYSTEM — the same trust -/// posture as the previous in-place scheme. Acceptable for this personal app; a hardened build would -/// code-sign and verify before copying. +/// Trust note: the service copies from a user-writable folder (the app's install location) and runs +/// it as SYSTEM. That is a local-privilege-escalation surface — a hardened build would code-sign the app +/// and verify the signature before copying. Accepted deliberately for this personal app. /// internal static class ServiceUpdate { diff --git a/src/RemSound.Core/AppConfig.cs b/src/RemSound.Core/AppConfig.cs index 0924a36..353fa14 100644 --- a/src/RemSound.Core/AppConfig.cs +++ b/src/RemSound.Core/AppConfig.cs @@ -99,10 +99,15 @@ public sealed class AppConfig /// 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 /// 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. - /// Cleared from Preferences → General. + /// unioned in here ONCE (RemSoundSettingsStore.MigrateRememberedPeersToGlobal, gated by + /// ). Null = none yet. Cleared from Preferences → General. public List? RememberedPeers { get; set; } + /// Set true after the one-time migration of a profile's legacy per-profile peers into + /// . 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. + public bool RememberedPeersMigrated { get; set; } + /// Machine-wide remembered APPLICATION process names (lower-case) — the shared "apps I /// send" address book, companion to . Before 2026-07-16 this only /// lived in the in-memory settings cache, which silently forgot the list on every app exit. Null = diff --git a/src/RemSound.Core/Profile.cs b/src/RemSound.Core/Profile.cs index eee527f..ec90ccb 100644 --- a/src/RemSound.Core/Profile.cs +++ b/src/RemSound.Core/Profile.cs @@ -81,7 +81,9 @@ public sealed class Profile /// the whole system's default output. When false, only the apps named in /// are sent. The MAIN window no longer has a "send all /// 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. + /// 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). public bool SendAllApplications { get; set; } = true; /// In "applications" send mode: the process /// names (lower-case, no path/extension, e.g. "vlc", "firefox") whose audio to send. Tracked by diff --git a/src/RemSound.Core/RemSoundSettingsStore.cs b/src/RemSound.Core/RemSoundSettingsStore.cs index d882c0f..280d0b3 100644 --- a/src/RemSound.Core/RemSoundSettingsStore.cs +++ b/src/RemSound.Core/RemSoundSettingsStore.cs @@ -588,22 +588,26 @@ public sealed class RemSoundSettingsStore MigrateRememberedPeersToGlobal(profile); } - /// Migration for the peers list going machine-wide (2026-07-16): profiles written by older - /// builds carry their own remembered-peers list, so the first time each one is opened its entries - /// are UNIONED into the AppConfig book — nothing is lost, nothing is overwritten. Once the sets - /// match this is a no-op (no file write). + /// ONE-TIME migration for the peers list going machine-wide (2026-07-16): profiles written by + /// older builds carry their own remembered-peers list, so the first opened profile that has peers has + /// them UNIONED into the AppConfig book (nothing lost, nothing overwritten), and a marker + /// () then stops it re-running — otherwise a cleared + /// list would be resurrected from the profile file on the next launch. private static void MigrateRememberedPeersToGlobal(Profile profile) { - if (profile.RememberedPeers is not { Count: > 0 } legacy) return; try { 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 merged = current + c.RememberedPeers = current .Concat(legacy.Where(static v => !string.IsNullOrWhiteSpace(v)).Select(static v => v.Trim())) .Distinct(StringComparer.OrdinalIgnoreCase).ToList(); - if (merged.Count == current.Count) return; // nothing new — skip the write - c.RememberedPeers = merged; + c.RememberedPeersMigrated = true; c.Save(); } catch { /* best-effort, like the app's other AppConfig writes */ } diff --git a/src/RemSound.Sender/AudioSessionStartWatcher.cs b/src/RemSound.Sender/AudioSessionStartWatcher.cs index 6ab8ac8..0208ebf 100644 --- a/src/RemSound.Sender/AudioSessionStartWatcher.cs +++ b/src/RemSound.Sender/AudioSessionStartWatcher.cs @@ -73,6 +73,9 @@ public sealed class AudioSessionStartWatcher : IDisposable private void UnhookLocked() { 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; try { device?.Dispose(); } catch { } device = null; diff --git a/src/RemSound.Sender/PushModeWasapiBackend.cs b/src/RemSound.Sender/PushModeWasapiBackend.cs index b2ea04e..e74432d 100644 --- a/src/RemSound.Sender/PushModeWasapiBackend.cs +++ b/src/RemSound.Sender/PushModeWasapiBackend.cs @@ -210,10 +210,14 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend } 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; - 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(); - throw; } } }