From 4b2ecca960cd48591616c8516048842f5e55cc43 Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:59:40 +0100 Subject: [PATCH] Service: always-on update log + "View service update log" menu item Local checkpoint - NOT for public release. Ed wants logging for the service updating. - New ALWAYS-ON update log at ProgramData\RemSound\service\update.log (not gated on the service-logging toggle - updates are rare but important). It records the full sequence: * "update detected: newer RemSound.exe (5.3) found, running 5.2 - restarting" * the restarter's own "stopping" / "service started" (or "START FAILED - ") * "update complete: now running version 5.3" The restart PowerShell writes its stop/start outcome itself, so the part that runs AFTER the old service process is gone - and any failed start - is still captured. A pending marker set at detection and consumed on the next start closes the loop (its absence flags a stuck update). - Service menu gains "View service update log" to open it (friendly message if none yet). Update log + pending-marker round-trip covered by the isolation self-test. Gate 27/27. Co-Authored-By: Claude Opus 4.8 --- src/RemSound.App/MainForm.cs | 17 +++++++++++++- src/RemSound.App/RemSoundService.cs | 7 ++++++ src/RemSound.App/SelfTest.cs | 8 +++++++ src/RemSound.App/ServiceUpdate.cs | 35 ++++++++++++++++++++--------- src/RemSound.Core/ServiceStore.cs | 31 +++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 11 deletions(-) diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index 5d75536..dc726dc 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -2194,12 +2194,15 @@ public sealed class MainForm : Form start.Click += (_, _) => ServiceAction(ServiceControl.StartVerb, "start", confirm: false); var stop = new ToolStripMenuItem("Sto&p service") { AccessibleName = "Stop service" }; stop.Click += (_, _) => ServiceAction(ServiceControl.StopVerb, "stop", confirm: false); + var updateLog = new ToolStripMenuItem("View service update &log") { AccessibleName = "View service update log" }; + updateLog.Click += (_, _) => OpenServiceUpdateLog(); serviceMenu.DropDownItems.AddRange(new ToolStripItem[] { status, new ToolStripSeparator(), configure, new ToolStripSeparator(), - install, uninstall, start, stop, + install, uninstall, start, stop, new ToolStripSeparator(), + updateLog, }); serviceMenu.DropDownOpening += (_, _) => { @@ -2220,6 +2223,18 @@ public sealed class MainForm : Form return serviceMenu; } + private void OpenServiceUpdateLog() + { + var path = ServiceStore.UpdateLogPath; + if (!File.Exists(path)) + { + MessageBox.Show(this, "No service update log yet — it's written the first time the service updates itself.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true }); } + catch (Exception ex) { MessageBox.Show(this, $"Could not open the update log ({path}): {ex.Message}", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning); } + } + private static string DescribeAgo(DateTime utc) { var span = DateTime.UtcNow - utc; diff --git a/src/RemSound.App/RemSoundService.cs b/src/RemSound.App/RemSoundService.cs index ac9381d..f604e45 100644 --- a/src/RemSound.App/RemSoundService.cs +++ b/src/RemSound.App/RemSoundService.cs @@ -46,6 +46,8 @@ public sealed class RemSoundService : ServiceBase // Record the running version + start time so the app's Service menu can show them — this is how a // self-update is visible (the version bumps and the start time is recent). try { ServiceStore.SaveStatus(new ServiceStore.ServiceStatus { Version = versionText, StartedUtc = DateTime.UtcNow }); } catch { } + // If this start is the completion of a self-update restart, close the loop in the update log. + try { if (ServiceStore.ConsumeUpdatePending()) ServiceStore.AppendUpdateLog($"update complete: now running version {versionText}"); } catch { } host = ServiceSendHost.FromConfig(msg => log?.Event(msg)); worker = new Thread(() => { @@ -66,6 +68,11 @@ public sealed class RemSoundService : ServiceBase if (restartScheduled) return; if (!ServiceUpdate.UpdateLanded()) return; restartScheduled = true; + var running = ServiceUpdate.RunningVersion(); + var runningText = running is null ? "?" : $"{running.Major}.{running.Minor}"; + // Always-on update log (not gated on the service-logging toggle) — updates are rare + important. + ServiceStore.AppendUpdateLog($"update detected: newer RemSound.exe ({ServiceUpdate.OnDiskVersion()}) found, running {runningText} — restarting to update"); + ServiceStore.SetUpdatePending(); log?.Event("service: a newer RemSound version was installed — restarting to update"); ServiceUpdate.RestartSelf(); } diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index adc7ee7..8ae50be 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -736,6 +736,14 @@ internal static class SelfTest // Running status (version + start time) round-trips — this is what the Service menu shows. ServiceStore.SaveStatus(new ServiceStore.ServiceStatus { Version = "5.3", StartedUtc = DateTime.UtcNow }); Check(ServiceStore.LoadStatus()?.Version == "5.3", "the service running-status version must round-trip"); + + // Update log + pending marker: the always-on trail of a self-update. + ServiceStore.AppendUpdateLog("update detected: test"); + Check(File.Exists(ServiceStore.UpdateLogPath) && File.ReadAllText(ServiceStore.UpdateLogPath).Contains("update detected: test"), + "the update log must be written"); + ServiceStore.SetUpdatePending(); + Check(ServiceStore.ConsumeUpdatePending(), "a set update-pending marker must be consumed once"); + Check(!ServiceStore.ConsumeUpdatePending(), "the update-pending marker must not be consumed twice"); } finally { ServiceStore.TestDirectoryOverride = saved; } diff --git a/src/RemSound.App/ServiceUpdate.cs b/src/RemSound.App/ServiceUpdate.cs index a174a1f..5df3d0b 100644 --- a/src/RemSound.App/ServiceUpdate.cs +++ b/src/RemSound.App/ServiceUpdate.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Reflection; +using RemSound.Core; namespace RemSound.App; @@ -25,32 +26,46 @@ internal static class ServiceUpdate /// True when a strictly-newer RemSound.exe sits next to the running service binary (i.e. an /// update landed). Reads the on-disk exe's file version; never throws. - public static bool UpdateLanded() + public static bool UpdateLanded() => IsNewer(RunningVersion(), OnDiskVersion()); + + /// The version string of the RemSound.exe sitting next to the service, or null if unreadable. + public static string? OnDiskVersion() { try { var onDiskExe = Path.Combine(AppContext.BaseDirectory, "RemSound.exe"); - if (!File.Exists(onDiskExe)) return false; - var running = Assembly.GetExecutingAssembly().GetName().Version; - var onDisk = FileVersionInfo.GetVersionInfo(onDiskExe).FileVersion; - return IsNewer(running, onDisk); + return File.Exists(onDiskExe) ? FileVersionInfo.GetVersionInfo(onDiskExe).FileVersion : null; } - catch { return false; } + catch { return null; } } + public static Version? RunningVersion() => Assembly.GetExecutingAssembly().GetName().Version; + /// Restart the service onto the new binary. Spawns a DETACHED PowerShell (as SYSTEM, inherited /// from the service) that stops this service — which exits this process — then starts it again, so the - /// SCM launches the freshly-installed exe. Never throws. + /// SCM launches the freshly-installed exe. The script LOGS its own stop/start outcome to the update log, + /// so even the part that runs after this process is gone (and any failed start) is recorded. Never + /// throws; worst case the service picks up the update on the next reboot. public static void RestartSelf() { try { + var dir = ServiceStore.Directory; + System.IO.Directory.CreateDirectory(dir); + var script = Path.Combine(dir, "restart.ps1"); + var log = ServiceStore.UpdateLogPath; + var svc = ServiceControl.ServiceName; + var content = + "$ts = { (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') }\r\n" + + $"Add-Content -LiteralPath '{log}' -Value \"$(& $ts) restarter: stopping {svc}\"\r\n" + + $"Stop-Service -Name {svc} -Force -ErrorAction SilentlyContinue\r\n" + + $"try {{ Start-Service -Name {svc} -ErrorAction Stop; $r = 'restart: service started' }} catch {{ $r = 'restart: START FAILED - ' + $_.Exception.Message }}\r\n" + + $"Add-Content -LiteralPath '{log}' -Value \"$(& $ts) $r\"\r\n"; + File.WriteAllText(script, content); var psi = new ProcessStartInfo { FileName = "powershell.exe", - Arguments = "-NonInteractive -WindowStyle Hidden -Command " + - $"\"Stop-Service -Name {ServiceControl.ServiceName} -Force -ErrorAction SilentlyContinue; " + - $"Start-Service -Name {ServiceControl.ServiceName} -ErrorAction SilentlyContinue\"", + Arguments = $"-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File \"{script}\"", UseShellExecute = false, CreateNoWindow = true, }; diff --git a/src/RemSound.Core/ServiceStore.cs b/src/RemSound.Core/ServiceStore.cs index 76ae3d4..d9528a1 100644 --- a/src/RemSound.Core/ServiceStore.cs +++ b/src/RemSound.Core/ServiceStore.cs @@ -89,5 +89,36 @@ public static class ServiceStore catch { return null; } } + // === Update log (ALWAYS written, not gated on the service-logging toggle) === + // Updates are rare but important, so we always keep a small trail of them where the user can find it. + public static string UpdateLogPath => Path.Combine(Directory, "update.log"); + private static string UpdatePendingPath => Path.Combine(Directory, "update-pending"); + + /// Append a timestamped line to the service update log. Never throws. Truncates if it ever + /// grows large (update events are infrequent, so it normally stays tiny). + public static void AppendUpdateLog(string line) + { + try + { + System.IO.Directory.CreateDirectory(Directory); + try { if (File.Exists(UpdateLogPath) && new FileInfo(UpdateLogPath).Length > 200_000) File.Delete(UpdateLogPath); } catch { } + File.AppendAllText(UpdateLogPath, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} {line}{Environment.NewLine}"); + } + catch { /* best-effort */ } + } + + /// Marker dropped when a self-update restart is triggered; the next start consumes it and logs + /// completion — so the update log shows the update actually finished (and its absence flags a stuck one). + public static void SetUpdatePending() + { + try { System.IO.Directory.CreateDirectory(Directory); File.WriteAllText(UpdatePendingPath, ""); } catch { } + } + + public static bool ConsumeUpdatePending() + { + try { if (File.Exists(UpdatePendingPath)) { File.Delete(UpdatePendingPath); return true; } } catch { } + return false; + } + private sealed class ServiceSettings { public bool LoggingEnabled { get; set; } } }