From 93bf1d86ebd9a5a0169717816c2a89139926a134 Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:36:54 +0100 Subject: [PATCH] Fix: service startup-volume re-punching on every restart + misleading suspend log Ed reported the service pulling his volume to 20% repeatedly. Diagnosis from the service logs: NOT a runaway timer - one service process ran untouched for 36 hours, proving there's no periodic restart. The volume-to-20 punches came from GENUINE service restarts (deploys, the one self-update, profile saves, handovers during active use), each re-applying because the mode was "every service restart". Two real faults found and fixed: 1. Re-apply burst guard. "Every service restart" (and boot-only as belt-and-braces) now skips a re-apply within ReapplyCooldown (5 min) of the last successful apply, persisted as startup-volume-last.txt. This kills the double-apply we saw in the log at 08:20:48 then 08:21:02 (a self-update restart immediately followed by a follow-on start, 14s apart, both punching the volume), and any rapid churn from self-update / profile-save / deploy. ShouldApply gains lastApplied+now params; future-dated stamp (clock moved back) can't wedge it. 2. Misleading log. ServiceSendHost.Suspend() hard-coded "interactive app present" on EVERY suspend, including plain shutdown - which sent this very investigation chasing phantom app-handovers. Suspend now takes a reason; the run-loop-ending path says so, only a real app yield says "interactive app present". readme: recommend "first start after boot" as the set-and-forget mode and explain that "every restart" also fires on routine internal restarts (and is now burst- guarded). Self-test extended: cooldown skip in both modes, 14s double-apply guard, future-stamp safety. Gate 71/71 + 7 relay tests. Co-Authored-By: Claude Fable 5 --- readme.html | 2 +- src/RemSound.App/SelfTest.cs | 26 +++++++++++++---- src/RemSound.App/ServiceSendHost.cs | 11 ++++--- src/RemSound.App/StartupVolume.cs | 45 ++++++++++++++++++++++++----- src/RemSound.Core/ServiceStore.cs | 22 ++++++++++++++ 5 files changed, 87 insertions(+), 19 deletions(-) diff --git a/readme.html b/readme.html index 0d3c4ee..43e70c4 100644 --- a/readme.html +++ b/readme.html @@ -1417,7 +1417,7 @@ 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 two tabs (Connectivity and Audio send) where you choose who to send to (plus a password) and what to send. On the Audio send tab, the first output choice is Use Windows default audio device, follows Windows changes — tick that to send whatever this machine is currently playing and keep following the Windows default if it later changes, rather than pinning one named card. (You can still pick specific devices, or a specific application, exactly as in the normal app.) 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 holds two extras. First, a switch for the service's own log. Second, Set the machine's volume when the service starts: tick it, pick a volume percent, and the service unmutes the machine and sets its Windows volume to that level when it starts — handy for an unattended PC that booted muted or was left turned down, so it's audible again with nobody at the keyboard. The When list chooses between Only the first start after each boot (the default — a mid-day service restart then leaves the volume alone while someone's using the machine) and Every time the service starts. Changes take effect from the service's next start, no reinstall needed. (The service itself never plays sounds — it streams silently in the background — so there are no cue options here.)
  2. +
  3. 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. On the Audio send tab, the first output choice is Use Windows default audio device, follows Windows changes — tick that to send whatever this machine is currently playing and keep following the Windows default if it later changes, rather than pinning one named card. (You can still pick specific devices, or a specific application, exactly as in the normal app.) 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 holds two extras. First, a switch for the service's own log. Second, Set the machine's volume when the service starts: tick it, pick a volume percent, and the service unmutes the machine and sets its Windows volume to that level when it starts — handy for an unattended PC that booted muted or was left turned down, so it's audible again with nobody at the keyboard. The When list chooses between Only the first start after each boot and Every time the service starts. First start after boot is the recommended, set-and-forget choice: it sets the volume once when the machine boots and never touches it again, so it won't fight you while you're using the machine. Every time the service starts re-applies on every service start — useful if you deliberately restart the service to reset the volume, but be aware the service also restarts by itself for routine reasons (installing a RemSound update, saving the service profile), and this mode re-applies on those too. To stop it ever machine-gunning your volume, either mode skips a re-apply if the volume was already set within the last few minutes. Changes take effect from the service's next start, no reinstall needed. (The service itself never plays sounds — it streams silently in the background — so there are no cue options here.)
  4. Install service — registers it with Windows so it starts automatically at every boot. Windows asks for administrator permission (one prompt). Do this once. Straight after installing, RemSound asks whether you'd like to start it now (otherwise it waits until the next reboot). (When you first install RemSound on a PC, the app installer also offers to set the service up — and start it — for you, so you may have done this already.)
  5. Start service / Stop service — run or halt it now without waiting for a reboot.
  6. Uninstall service — removes it entirely.
  7. diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index b1321a5..dfd55ff 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -2633,13 +2633,27 @@ internal static class SelfTest /// flag sharing its file (load-modify-save). private static string? ServiceStartupVolume() { - // Decision core. Boot instants within the tolerance are the SAME boot. + // Decision core. Boot instants within the tolerance are the SAME boot. now is well past any + // cooldown from lastApplied unless a test sets lastApplied recent. var boot = new DateTime(2026, 7, 26, 6, 0, 0, DateTimeKind.Utc); - Check(!StartupVolume.ShouldApply(false, true, null, boot), "disabled → never applies"); - Check(StartupVolume.ShouldApply(true, true, null, boot), "boot-only with no marker yet → applies (first ever start)"); - Check(!StartupVolume.ShouldApply(true, true, boot.AddSeconds(-30), boot), "boot-only, marker from THIS boot → skipped (a same-boot service restart must not re-blast the volume)"); - Check(StartupVolume.ShouldApply(true, true, boot.AddHours(-9), boot), "boot-only, marker from a PREVIOUS boot → applies again"); - Check(StartupVolume.ShouldApply(true, false, boot.AddSeconds(-30), boot), "every-start mode ignores the marker entirely"); + var now = new DateTime(2026, 7, 26, 12, 0, 0, DateTimeKind.Utc); + DateTime? noLast = null; + Check(!StartupVolume.ShouldApply(false, true, null, boot, noLast, now), "disabled → never applies"); + Check(StartupVolume.ShouldApply(true, true, null, boot, noLast, now), "boot-only with no marker yet → applies (first ever start)"); + Check(!StartupVolume.ShouldApply(true, true, boot.AddSeconds(-30), boot, noLast, now), "boot-only, marker from THIS boot → skipped (a same-boot service restart must not re-blast the volume)"); + Check(StartupVolume.ShouldApply(true, true, boot.AddHours(-9), boot, noLast, now), "boot-only, marker from a PREVIOUS boot → applies again"); + Check(StartupVolume.ShouldApply(true, false, boot.AddSeconds(-30), boot, noLast, now), "every-start mode past the cooldown ignores the boot marker"); + + // Re-apply burst guard (the fix for the volume-machine-gunning). A fresh apply within the + // cooldown of the last one is skipped in BOTH modes; past the cooldown it applies again. + Check(!StartupVolume.ShouldApply(true, false, null, boot, now.AddMinutes(-1), now), + "every-restart: a restart 1 min after the last apply must be SKIPPED (burst guard — self-update/deploy/handover churn)"); + Check(StartupVolume.ShouldApply(true, false, null, boot, now.AddMinutes(-10), now), + "every-restart: a restart 10 min later (past the 5-min cooldown) applies again"); + Check(!StartupVolume.ShouldApply(true, true, boot.AddHours(-9), boot, now.AddMinutes(-1), now), + "boot-only: even a genuine new boot is held off if the volume was applied seconds ago (double-apply guard — the 14s self-update case)"); + Check(StartupVolume.ShouldApply(true, false, null, boot, now.AddMinutes(5), now), + "a last-applied stamp in the FUTURE (clock moved back) must not wedge — it still applies"); // Settings round-trip in a throwaway store; the volume save must preserve the logging flag. var savedOverride = ServiceStore.TestDirectoryOverride; diff --git a/src/RemSound.App/ServiceSendHost.cs b/src/RemSound.App/ServiceSendHost.cs index 03e0b50..a8a0e26 100644 --- a/src/RemSound.App/ServiceSendHost.cs +++ b/src/RemSound.App/ServiceSendHost.cs @@ -391,8 +391,11 @@ public sealed class ServiceSendHost : IDisposable } } - /// Stops sending and releases capture. Safe to call when already stopped. - public void Suspend() + /// Stops sending and releases capture. Safe to call when already stopped. is logged so the diagnostic trail distinguishes a real yield-to-the-app from a + /// plain shutdown — the old hard-coded "interactive app present" was printed on BOTH, which sent a + /// bug hunt chasing phantom app-handovers (2026-07-27). + public void Suspend(string reason = "interactive app present") { lock (gate) { @@ -406,7 +409,7 @@ public sealed class ServiceSendHost : IDisposable sessionKick = null; try { PerformanceMode.Apply(false, msg => log?.Invoke($"service: {msg}")); } catch { /* best-effort */ } running = false; - log?.Invoke("service: suspended (interactive app present)"); + log?.Invoke($"service: suspended ({reason})"); } } @@ -513,7 +516,7 @@ public sealed class ServiceSendHost : IDisposable ct.WaitHandle.WaitOne(pollMs); } wantSending = false; - Suspend(); + Suspend("run loop ending — service stopping"); } /// Force a re-open of capture if we intend to send — called after a power resume, when the diff --git a/src/RemSound.App/StartupVolume.cs b/src/RemSound.App/StartupVolume.cs index 995c7aa..9e76996 100644 --- a/src/RemSound.App/StartupVolume.cs +++ b/src/RemSound.App/StartupVolume.cs @@ -17,14 +17,35 @@ internal static class StartupVolume /// granularity, clock adjustments); a real reboot separates instants by minutes at least. internal static readonly TimeSpan SameBootTolerance = TimeSpan.FromMinutes(2); + /// Minimum gap between two volume applications, whatever the mode (2026-07-27). The + /// service can restart in quick succession for reasons the user never asked for — a self-update + /// (we saw two applies 14 s apart: the update restart, then a follow-on start), a profile save, + /// a test deploy, the app handing back. "Every service restart" must not machine-gun the volume + /// down on each of those, so a fresh apply inside this window is skipped. A genuine "I restarted + /// the service to reset the volume" a few minutes later still applies; a reboot (boot-only) is + /// unaffected. NOTE: this is a burst guard — the set-and-forget choice is "first start after + /// boot", which applies once per boot and never re-punches. + internal static readonly TimeSpan ReapplyCooldown = TimeSpan.FromMinutes(5); + /// When THIS boot began (UTC), from the monotonic uptime counter. public static DateTime CurrentBootUtc() => DateTime.UtcNow - TimeSpan.FromMilliseconds(Environment.TickCount64); - /// Pure decision core, pinned by the self-test: apply when enabled, and — in boot-only - /// mode — when the recorded marker belongs to a DIFFERENT boot (or there is no marker yet). - internal static bool ShouldApply(bool enabled, bool bootOnly, DateTime? markerBootUtc, DateTime currentBootUtc) + /// Pure decision core, pinned by the self-test. Never apply within the re-apply cooldown + /// of the last successful apply (the burst guard — stops rapid/automatic restarts from re-punching + /// the volume). Otherwise: boot-only mode applies only when the boot marker belongs to a DIFFERENT + /// boot (or none yet); every-restart mode applies on any start past the cooldown. + internal static bool ShouldApply(bool enabled, bool bootOnly, DateTime? markerBootUtc, DateTime currentBootUtc, + DateTime? lastAppliedUtc, DateTime nowUtc) { if (!enabled) return false; + // Burst guard first, both modes: a fresh apply within the cooldown of the last one is skipped. + // Guard the negative case too (clock moved backwards) — treat only a positive, sub-cooldown + // gap as "too soon"; anything else falls through to the normal decision. + if (lastAppliedUtc is { } last) + { + var since = nowUtc - last; + if (since >= TimeSpan.Zero && since < ReapplyCooldown) return false; + } if (!bootOnly) return true; if (markerBootUtc is null) return true; return (currentBootUtc - markerBootUtc.Value).Duration() > SameBootTolerance; @@ -40,9 +61,13 @@ internal static class StartupVolume var (enabled, percent, bootOnly) = ServiceStore.LoadStartupVolume(); if (!enabled) return; var boot = CurrentBootUtc(); - if (!ShouldApply(enabled, bootOnly, ServiceStore.LoadStartupVolumeBootMarker(), boot)) + var nowUtc = DateTime.UtcNow; + if (!ShouldApply(enabled, bootOnly, ServiceStore.LoadStartupVolumeBootMarker(), boot, + ServiceStore.LoadStartupVolumeLastAppliedUtc(), nowUtc)) { - log?.Invoke("service: startup volume skipped — already applied this boot (boot-only mode)"); + log?.Invoke(bootOnly + ? "service: startup volume skipped — already applied this boot (boot-only mode)" + : "service: startup volume skipped — applied within the last few minutes (burst guard; a restart just happened)"); return; } var ok = SystemVolumeHelper.TrySetVolumeAndUnmute(percent); @@ -53,9 +78,13 @@ internal static class StartupVolume // Also into the always-on events log (not gated on the logging toggle): one line per // qualifying start, so "did it fire?" is answerable without turning full logging on. ServiceStore.AppendServiceEvent(outcome); - // Marker only on success: a boot-time failure (audio stack not up yet) leaves the next - // same-boot restart eligible to retry rather than silently never applying. - if (ok) ServiceStore.SaveStartupVolumeBootMarker(boot); + // Markers only on success: a boot-time failure (audio stack not up yet) leaves the next + // restart eligible to retry. The last-applied stamp drives the burst guard for both modes. + if (ok) + { + ServiceStore.SaveStartupVolumeBootMarker(boot); + ServiceStore.SaveStartupVolumeLastAppliedUtc(nowUtc); + } } catch (Exception ex) { diff --git a/src/RemSound.Core/ServiceStore.cs b/src/RemSound.Core/ServiceStore.cs index 5dad9a3..21a8707 100644 --- a/src/RemSound.Core/ServiceStore.cs +++ b/src/RemSound.Core/ServiceStore.cs @@ -279,4 +279,26 @@ public static class ServiceStore } catch { return null; } } + + // Wall-clock UTC of the last SUCCESSFUL startup-volume apply — drives the re-apply burst guard + // (StartupVolume.ReapplyCooldown), so a flurry of restarts can't machine-gun the volume down. + private static string StartupVolumeLastAppliedPath => Path.Combine(Directory, "startup-volume-last.txt"); + + public static void SaveStartupVolumeLastAppliedUtc(DateTime whenUtc) + { + try { System.IO.Directory.CreateDirectory(Directory); File.WriteAllText(StartupVolumeLastAppliedPath, whenUtc.ToString("o")); } + catch { /* best-effort */ } + } + + public static DateTime? LoadStartupVolumeLastAppliedUtc() + { + try + { + return File.Exists(StartupVolumeLastAppliedPath) + && DateTime.TryParse(File.ReadAllText(StartupVolumeLastAppliedPath).Trim(), null, + System.Globalization.DateTimeStyles.RoundtripKind, out var t) + ? t : null; + } + catch { return null; } + } }