Service: pick up app updates by self-restarting onto the new binary
Local checkpoint - NOT for public release. Ed: an update should tear down the service, update it, and restart it. It didn't - the updater has no service awareness. Current (unchanged, verified correct): the auto-updater renames the install files aside and copies the new ones in, which a running service TOLERATES (no failed swap). The old version keeps streaming; the new files sit in place. New: the service now adopts the update itself. Because it runs as SYSTEM (which has the rights the non-elevated updater lacks), a 45s timer notices when a strictly-newer RemSound.exe has landed next to it and restarts itself onto the new binary (detached PowerShell Stop-Service+Start-Service). Loop-safe: only fires on a strictly-newer on-disk version; any uncertainty (file mid-swap, unparseable version) means don't restart, and after the restart on-disk == running so it never re-triggers. Test "Service registration args" now also covers the version-comparison logic (newer => restart; same/older/missing => no restart). 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
941e0df99f
commit
56dbd0b80f
@@ -15,6 +15,8 @@ public sealed class RemSoundService : ServiceBase
|
|||||||
private Thread? worker;
|
private Thread? worker;
|
||||||
private ServiceSendHost? host;
|
private ServiceSendHost? host;
|
||||||
private RemSoundLog? log;
|
private RemSoundLog? log;
|
||||||
|
private System.Threading.Timer? updateWatch;
|
||||||
|
private volatile bool restartScheduled;
|
||||||
|
|
||||||
public RemSoundService()
|
public RemSoundService()
|
||||||
{
|
{
|
||||||
@@ -47,11 +49,26 @@ public sealed class RemSoundService : ServiceBase
|
|||||||
})
|
})
|
||||||
{ IsBackground = true, Name = "remsound-service" };
|
{ IsBackground = true, Name = "remsound-service" };
|
||||||
worker.Start();
|
worker.Start();
|
||||||
|
|
||||||
|
// Self-update: the auto-updater swaps the files in place but can't restart us (no admin). We run
|
||||||
|
// as SYSTEM, so when a newer RemSound.exe lands next to us we restart onto it ourselves. Checked
|
||||||
|
// on a slow timer (an update is rare); loop-safe (only fires on a strictly-newer on-disk version).
|
||||||
|
updateWatch = new System.Threading.Timer(_ => CheckForUpdate(), null, TimeSpan.FromSeconds(45), TimeSpan.FromSeconds(45));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CheckForUpdate()
|
||||||
|
{
|
||||||
|
if (restartScheduled) return;
|
||||||
|
if (!ServiceUpdate.UpdateLanded()) return;
|
||||||
|
restartScheduled = true;
|
||||||
|
log?.Event("service: a newer RemSound version was installed — restarting to update");
|
||||||
|
ServiceUpdate.RestartSelf();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnStop()
|
protected override void OnStop()
|
||||||
{
|
{
|
||||||
log?.Event("service: OnStop");
|
log?.Event("service: OnStop");
|
||||||
|
try { updateWatch?.Dispose(); } catch { }
|
||||||
try { cts.Cancel(); } catch { }
|
try { cts.Cancel(); } catch { }
|
||||||
try { worker?.Join(5000); } catch { }
|
try { worker?.Join(5000); } catch { }
|
||||||
try { host?.Dispose(); } catch { }
|
try { host?.Dispose(); } catch { }
|
||||||
|
|||||||
@@ -682,7 +682,16 @@ internal static class SelfTest
|
|||||||
var fail = ServiceControl.BuildFailureArgs();
|
var fail = ServiceControl.BuildFailureArgs();
|
||||||
Check(fail.StartsWith($"failure {ServiceControl.ServiceName} ") && fail.Contains("actions= restart/"),
|
Check(fail.StartsWith($"failure {ServiceControl.ServiceName} ") && fail.Contains("actions= restart/"),
|
||||||
$"failure args must configure auto-restart (got: {fail})");
|
$"failure args must configure auto-restart (got: {fail})");
|
||||||
return "sc create + failure args are well-formed";
|
|
||||||
|
// Self-update version comparison — the service restarts itself ONLY on a strictly-newer on-disk
|
||||||
|
// version; any other case must be false so it can never loop.
|
||||||
|
var v = new Version(5, 2, 0, 0);
|
||||||
|
Check(ServiceUpdate.IsNewer(v, "5.3.0.0"), "a strictly-newer on-disk version must trigger a self-update");
|
||||||
|
Check(!ServiceUpdate.IsNewer(v, "5.2.0.0"), "the same version must NOT trigger a restart (loop-safe)");
|
||||||
|
Check(!ServiceUpdate.IsNewer(v, "5.1.0.0"), "an older on-disk version must NOT trigger a restart");
|
||||||
|
Check(!ServiceUpdate.IsNewer(v, null) && !ServiceUpdate.IsNewer(v, "garbage") && !ServiceUpdate.IsNewer(null, "5.3"),
|
||||||
|
"missing/unparseable versions must NOT trigger a restart");
|
||||||
|
return "sc create + failure args well-formed; self-update comparison loop-safe";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace RemSound.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lets the running Windows service pick up an app update on its own. The interactive auto-updater
|
||||||
|
/// swaps the install files in place (rename-aside, which a running service tolerates) but has no admin
|
||||||
|
/// rights to restart the service — so instead the SERVICE, which runs as SYSTEM and DOES have the rights,
|
||||||
|
/// notices that a newer RemSound.exe has landed next to it and restarts itself onto the new binary.
|
||||||
|
///
|
||||||
|
/// <para>Loop-safe by construction: it only restarts when the on-disk version is STRICTLY newer than the
|
||||||
|
/// running one, and any uncertainty (file missing mid-swap, unparseable version) means "don't restart".
|
||||||
|
/// After the restart the new process's on-disk == running, so it never re-triggers.</para>
|
||||||
|
/// </summary>
|
||||||
|
internal static class ServiceUpdate
|
||||||
|
{
|
||||||
|
/// <summary>Pure version comparison, unit-testable: is the on-disk version strictly newer than the
|
||||||
|
/// running one? False on any missing/unparseable input (so we never restart on uncertainty).</summary>
|
||||||
|
internal static bool IsNewer(Version? running, string? onDiskFileVersion)
|
||||||
|
{
|
||||||
|
if (running is null || string.IsNullOrWhiteSpace(onDiskFileVersion)) return false;
|
||||||
|
return Version.TryParse(onDiskFileVersion, out var onDisk) && onDisk > running;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public static bool UpdateLanded()
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public static void RestartSelf()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
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\"",
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
};
|
||||||
|
Process.Start(psi);
|
||||||
|
}
|
||||||
|
catch { /* best-effort; worst case the service picks up the update on next reboot */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user