Service: harden for unattended running (auto-restart, findable logs, resume)
Local checkpoint - NOT for public release. A "what else does a service need" pass. - AUTO-RESTART ON CRASH: DoInstall now sets sc failure actions (restart 5s/10s/then 60s, reset daily). Without this a crashed service stays dead until reboot - fatal for an always-on streamer. - FINDABLE LOGS: --run-service redirects the service's data dir to the machine-wide ProgramData\RemSound\service location, so its log sits next to its profile instead of buried in the SYSTEM account's AppData. - POWER RESUME: the service handles OnPowerEvent and re-opens capture on wake (audio devices re-initialise after sleep; the device-change watcher usually catches it, but a resume doesn't always fire an endpoint change, so we re-open explicitly). - Start/stop already auto-log to the Windows Event Log via ServiceBase. Test: "Service registration args" now also checks the audio-service dependency and the auto-restart failure args. Gate 27/27. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4f5265d8b0
commit
92ed477ddf
@@ -66,7 +66,15 @@ internal static class Program
|
|||||||
// a status code. --run-service blocks in the SCM dispatcher until Windows stops the service.
|
// a status code. --run-service blocks in the SCM dispatcher until Windows stops the service.
|
||||||
if (args.Length > 0)
|
if (args.Length > 0)
|
||||||
{
|
{
|
||||||
if (HasArg(args, ServiceControl.RunVerb)) { RemSoundService.RunAsService(); return; }
|
if (HasArg(args, ServiceControl.RunVerb))
|
||||||
|
{
|
||||||
|
// Point the service's data (its log) at the machine-wide ProgramData location, next to its
|
||||||
|
// 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();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (HasArg(args, ServiceControl.InstallVerb)) { Environment.ExitCode = ServiceControl.DoInstall(); return; }
|
if (HasArg(args, ServiceControl.InstallVerb)) { Environment.ExitCode = ServiceControl.DoInstall(); return; }
|
||||||
if (HasArg(args, ServiceControl.UninstallVerb)) { Environment.ExitCode = ServiceControl.DoUninstall(); return; }
|
if (HasArg(args, ServiceControl.UninstallVerb)) { Environment.ExitCode = ServiceControl.DoUninstall(); return; }
|
||||||
if (HasArg(args, ServiceControl.StartVerb)) { Environment.ExitCode = ServiceControl.DoStart(); return; }
|
if (HasArg(args, ServiceControl.StartVerb)) { Environment.ExitCode = ServiceControl.DoStart(); return; }
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ public sealed class RemSoundService : ServiceBase
|
|||||||
CanStop = true;
|
CanStop = true;
|
||||||
CanShutdown = true;
|
CanShutdown = true;
|
||||||
CanPauseAndContinue = false;
|
CanPauseAndContinue = false;
|
||||||
|
CanHandlePowerEvent = true; // so we can re-open capture after the machine wakes from sleep
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override bool OnPowerEvent(PowerBroadcastStatus powerStatus)
|
||||||
|
{
|
||||||
|
if (powerStatus is PowerBroadcastStatus.ResumeSuspend or PowerBroadcastStatus.ResumeAutomatic or PowerBroadcastStatus.ResumeCritical)
|
||||||
|
{
|
||||||
|
log?.Event("service: power resume — re-opening capture");
|
||||||
|
try { host?.ReopenAfterResume(); } catch (Exception ex) { log?.Event($"service: resume re-open failed {ex.GetType().Name}: {ex.Message}"); }
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnStart(string[] args)
|
protected override void OnStart(string[] args)
|
||||||
|
|||||||
@@ -676,7 +676,13 @@ internal static class SelfTest
|
|||||||
Check(args.Contains("\\\"" + exe + "\\\" " + ServiceControl.RunVerb),
|
Check(args.Contains("\\\"" + exe + "\\\" " + ServiceControl.RunVerb),
|
||||||
$"exe path must be escaped-quoted with the run verb (got: {args})");
|
$"exe path must be escaped-quoted with the run verb (got: {args})");
|
||||||
Check(args.Contains($"DisplayName= \"{ServiceControl.DisplayName}\""), "must set the display name");
|
Check(args.Contains($"DisplayName= \"{ServiceControl.DisplayName}\""), "must set the display name");
|
||||||
return "sc create args quoted correctly for a spaced path";
|
Check(args.Contains("depend= Audiosrv"), "must depend on the audio service so it starts after audio is up");
|
||||||
|
|
||||||
|
// Auto-restart-on-crash failure actions.
|
||||||
|
var fail = ServiceControl.BuildFailureArgs();
|
||||||
|
Check(fail.StartsWith($"failure {ServiceControl.ServiceName} ") && fail.Contains("actions= restart/"),
|
||||||
|
$"failure args must configure auto-restart (got: {fail})");
|
||||||
|
return "sc create + failure args are well-formed";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The service profile is fully isolated from the normal profile machinery: it lives in a
|
/// <summary>The service profile is fully isolated from the normal profile machinery: it lives in a
|
||||||
|
|||||||
@@ -98,9 +98,17 @@ public static class ServiceControl
|
|||||||
if (rc != 0) return rc;
|
if (rc != 0) return rc;
|
||||||
// Best-effort description; failure here doesn't fail the install.
|
// Best-effort description; failure here doesn't fail the install.
|
||||||
RunSc($"description {ServiceName} \"{Description}\"");
|
RunSc($"description {ServiceName} \"{Description}\"");
|
||||||
|
// Auto-restart on crash: without this a crashed service stays dead until reboot, which defeats
|
||||||
|
// an always-on streamer. Restart 5s / 10s / then every 60s; reset the failure counter daily.
|
||||||
|
RunSc(BuildFailureArgs());
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The sc.exe "failure" args that make the service auto-restart on a crash. Pure, so a
|
||||||
|
/// self-test can verify the format.</summary>
|
||||||
|
internal static string BuildFailureArgs() =>
|
||||||
|
$"failure {ServiceName} reset= 86400 actions= restart/5000/restart/10000/restart/60000";
|
||||||
|
|
||||||
/// <summary>Stops (if running) and deletes the service. Must be run elevated. Returns 0 on success or
|
/// <summary>Stops (if running) and deletes the service. Must be run elevated. Returns 0 on success or
|
||||||
/// if it wasn't installed.</summary>
|
/// if it wasn't installed.</summary>
|
||||||
public static int DoUninstall()
|
public static int DoUninstall()
|
||||||
|
|||||||
@@ -166,6 +166,20 @@ public sealed class ServiceSendHost : IDisposable
|
|||||||
Suspend();
|
Suspend();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Force a re-open of capture if we intend to send — called after a power resume, when the
|
||||||
|
/// audio devices have re-initialised and the current capture may be dead. The device-change watcher
|
||||||
|
/// usually catches this too, but a resume doesn't always fire an endpoint change, so we re-open
|
||||||
|
/// explicitly to be safe.</summary>
|
||||||
|
public void ReopenAfterResume()
|
||||||
|
{
|
||||||
|
if (!wantSending || disposed) return;
|
||||||
|
var profile = loadProfile();
|
||||||
|
if (profile is null) return;
|
||||||
|
log?.Invoke("service: re-opening capture after power resume");
|
||||||
|
Suspend();
|
||||||
|
ApplyProfile(profile);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Device-set change callback (COM thread). While we intend to send, (re)open capture — this
|
/// <summary>Device-set change callback (COM thread). While we intend to send, (re)open capture — this
|
||||||
/// is the event that fires when the audio stack finishes coming up at boot, a device is plugged or
|
/// is the event that fires when the audio stack finishes coming up at boot, a device is plugged or
|
||||||
/// unplugged, or the audio service restarts. Debounced: a single hot-plug fires several notifications.</summary>
|
/// unplugged, or the audio service restarts. Debounced: a single hot-plug fires several notifications.</summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user