Always-on service events log + show failure reasons on screen

So that if the service breaks on a machine where nobody enabled logging first
(e.g. a Win7 tester), we still learn WHY:

- New always-on ServiceStore.AppendServiceEvent -> service-events.log in
  ProgramData, independent of any logging toggle (like the update log).
- Service menu status-query failure now shows the actual reason IN the status
  line (a screen reader reads it) AND records it to the events log, instead of a
  bare 'status unavailable' whose reason only went to the off-by-default app log.
- ServiceAction (install/uninstall/start/stop) records request + outcome to the
  events log.
- ServiceEntry (the elevated child) logs each verb's start, and CATCHES a
  System.ServiceProcess load failure to record its reason before rethrowing (so
  the crash file still gets the full stack) — this is the likely Win7 failure.
- 'View service log' falls back to the events log when no runtime diagnostic log
  exists, so there's always something to view after any service interaction.

Recap of coverage: hard crashes -> crash-*.txt (always, no setup). Graceful
service failures -> now the events log + on-screen reason (no setup). Gate 35/35.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-15 19:08:49 +01:00
co-authored by Claude Opus 4.8
parent 4d1815e071
commit 5129aa2a39
4 changed files with 74 additions and 11 deletions
+15 -4
View File
@@ -2254,8 +2254,12 @@ public sealed class MainForm : Form
}
catch (Exception ex)
{
status.Text = "Service: status unavailable";
// Show the reason right in the status line (a screen reader reads it), AND record it to the
// always-on service events log — so even with app logging off (e.g. a Win7 tester) we learn
// WHY the service machinery couldn't load, not just that it didn't.
status.Text = $"Service: unavailable — {ex.GetType().Name}: {ex.Message}";
logFile.Event($"service menu: status query failed {ex.GetType().Name}: {ex.Message}");
ServiceStore.AppendServiceEvent($"menu status query FAILED: {ex.GetType().Name}: {ex.Message}");
}
};
return serviceMenu;
@@ -2268,12 +2272,16 @@ public sealed class MainForm : Form
/// there's nothing here yet, that's the first thing to turn on.</summary>
private void OpenServiceLog()
{
// Prefer the service's runtime diagnostic log (only exists if the service actually ran with logging
// on); otherwise fall back to the ALWAYS-ON service events log, which records every menu/install/
// start/stop and any failure reason — so there's a trail to view even if nobody enabled logging.
var path = ServiceStore.NewestLogFile();
if (path is null && File.Exists(ServiceStore.ServiceEventsLogPath)) path = ServiceStore.ServiceEventsLogPath;
if (path is null)
{
var msg = ServiceStore.LoadLoggingEnabled()
? "No service log yet. The service writes one once it starts with logging on — start (or restart) the service, then check back here."
: "No service log yet. Turn on logging first: Service menu → Configure service profile → Logging tab → enable service logging, then start (or restart) the service. The log records what the service does and why it is or isn't sending.";
? "No service log yet. The service writes one once it starts with logging on — start (or restart) the service, then check back here. (The service events log appears here too once you install/start it.)"
: "No service log yet. Once you install or start the service, its events (and any failure reason) are recorded here automatically. For the fuller runtime log, also turn on logging: Service menu → Configure service profile → Logging tab.";
MessageBox.Show(this, msg, AppName, MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
@@ -2359,8 +2367,11 @@ public sealed class MainForm : Form
if (MessageBox.Show(this, msg, AppName, MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes) return;
}
logFile.Event($"service: {label} requested (elevated)");
ServiceStore.AppendServiceEvent($"{label} requested (elevated)");
var rc = ServiceControl.RunElevated(verb);
logFile.Event($"service: {label} finished with code {rc} ({(rc == 0 ? "success" : rc == -1 ? "cancelled/declined" : "failed")})");
var outcome = rc == 0 ? "success" : rc == -1 ? "cancelled/declined" : "failed";
logFile.Event($"service: {label} finished with code {rc} ({outcome})");
ServiceStore.AppendServiceEvent($"{label} finished: code {rc} ({outcome})");
if (rc == 0)
MessageBox.Show(this, $"Service {label} succeeded.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Information);
else if (rc == -1)
+6 -1
View File
@@ -1340,7 +1340,12 @@ internal static class SelfTest
File.SetLastWriteTimeUtc(older, new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc));
File.SetLastWriteTimeUtc(newer, new DateTime(2020, 1, 2, 0, 0, 0, DateTimeKind.Utc));
Check(ServiceStore.NewestLogFile() == newer, "NewestLogFile must return the most recently written .log");
return "log folder resolves under the service data dir; newest .log is found; empty case handled";
// Always-on service events log: writes without any toggle, and is where a Win7 failure reason lands.
ServiceStore.AppendServiceEvent("selftest: install THREW FileLoadException: could not load System.ServiceProcess");
Check(File.Exists(ServiceStore.ServiceEventsLogPath), "AppendServiceEvent must write even with no logging enabled");
Check(File.ReadAllText(ServiceStore.ServiceEventsLogPath).Contains("install THREW"), "the service events log must contain the recorded event");
return "log folder resolves; newest .log found; empty case handled; always-on events log records without a toggle";
}
finally
{
+34 -6
View File
@@ -28,14 +28,42 @@ internal static class ServiceEntry
// profile — otherwise a headless SYSTEM service logs into the SYSTEM account's AppData, which
// is near-impossible to find. (The profile itself always comes from ServiceStore.)
AppConfig.SetUserDataDirectoryOverride(ServiceStore.Directory);
RemSoundService.RunAsService();
ServiceStore.AppendServiceEvent("elevated run-service: starting host");
try { RemSoundService.RunAsService(); }
catch (Exception ex) { ServiceStore.AppendServiceEvent($"elevated run-service: THREW {ex.GetType().Name}: {ex.Message}"); throw; }
return 0;
}
if (Has(args, ServiceControl.InstallVerb)) return ServiceControl.DoInstall();
if (Has(args, ServiceControl.UninstallVerb)) return ServiceControl.DoUninstall();
if (Has(args, ServiceControl.StartVerb)) return ServiceControl.DoStart();
if (Has(args, ServiceControl.StopVerb)) return ServiceControl.DoStop();
return 0;
var verb = Has(args, ServiceControl.InstallVerb) ? "install"
: Has(args, ServiceControl.UninstallVerb) ? "uninstall"
: Has(args, ServiceControl.StartVerb) ? "start"
: Has(args, ServiceControl.StopVerb) ? "stop"
: null;
if (verb is null) return 0;
// Log to the always-on events log BEFORE touching the service machinery, and wrap the call: if
// System.ServiceProcess can't load on this OS (the Win7 unknown), the exception is caught HERE and
// its reason recorded — then rethrown so the crash file also captures the full stack. Either way we
// learn WHY, with no logging toggle needed.
ServiceStore.AppendServiceEvent($"elevated {verb}: starting (loading service machinery)");
try
{
var rc = verb switch
{
"install" => ServiceControl.DoInstall(),
"uninstall" => ServiceControl.DoUninstall(),
"start" => ServiceControl.DoStart(),
"stop" => ServiceControl.DoStop(),
_ => 0,
};
ServiceStore.AppendServiceEvent($"elevated {verb}: finished with code {rc}");
return rc;
}
catch (Exception ex)
{
ServiceStore.AppendServiceEvent($"elevated {verb}: THREW {ex.GetType().Name}: {ex.Message}");
throw;
}
}
private static bool Has(string[] args, string flag) =>
+19
View File
@@ -130,6 +130,25 @@ public static class ServiceStore
catch { /* best-effort */ }
}
// === Service events log (ALWAYS written, not gated on any logging toggle) ===
// The trail of what the app did to the service — open the menu, install, start, stop, configure — AND
// why any of it failed. Always on, so if the service breaks on a machine where nobody thought to turn
// logging on first (e.g. a Win7 tester), the reason is still captured. Distinct from the service's own
// runtime diagnostic log (that one only exists while the service is actually running with logging on).
public static string ServiceEventsLogPath => Path.Combine(Directory, "service-events.log");
/// <summary>Append a timestamped line to the always-on service events log. Never throws; caps its size.</summary>
public static void AppendServiceEvent(string line)
{
try
{
System.IO.Directory.CreateDirectory(Directory);
try { if (File.Exists(ServiceEventsLogPath) && new FileInfo(ServiceEventsLogPath).Length > 200_000) File.Delete(ServiceEventsLogPath); } catch { }
File.AppendAllText(ServiceEventsLogPath, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} {line}{Environment.NewLine}");
}
catch { /* best-effort */ }
}
/// <summary>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).</summary>
public static void SetUpdatePending()