Fix Win7 launch crash: keep service types off the startup path

Reported: RemSound no longer launches at all on Windows 7 since the
send-only service was added. Cause: RemSoundService derives from
ServiceBase (System.ServiceProcess), and Program.Main called
RemSoundService.RunAsService() directly in its body. The runtime resolves
every type a method names when it JIT-compiles that method -- so the moment
Main was compiled, at the very start of every launch and before any argument
was read, it force-loaded System.ServiceProcess. That assembly won't load on
Windows 7 under the .NET 10 runtime, so Main failed to compile and the app
died with no window. net10 has always been the target, so this was a pure
regression from the service work, not a framework change.

Fix:
- Move the whole service-verb dispatch into a separate ServiceEntry class.
  Program.Main now only calls it (a) after a cheap check that uses only
  inlined const verb strings, and (b) only when a service verb is actually
  present. A normal launch never JIT-compiles anything that names a service
  type, so System.ServiceProcess is never loaded. Verified empirically:
  a normal launch loads 107 modules, none of them System.ServiceProcess.
- Gate the Service menu to Windows 10+ (OperatingSystem.IsWindowsVersionAtLeast),
  mirroring how the "capture individual apps" feature is gated. On Win7/8 the
  menu isn't shown and no service code is reachable. Made the status-query
  handler defensive too, so a query failure can never crash the menu.

New self-test "Service verb gate": normal launches (no args, --silent,
--profile, --connect, --minimized, --config-dir) are never treated as a
service invocation; all five service verbs are recognised case-insensitively;
and deciding a normal launch loads no service assembly. Gate 29/29.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-14 08:41:56 +01:00
co-authored by Claude Opus 4.8
parent 26903950f7
commit 9392cc1fcc
4 changed files with 142 additions and 26 deletions
+51
View File
@@ -79,6 +79,7 @@ internal static class SelfTest
RunStep(results, "Main window coverage (all tabs + controls)", MainWindowCoverage);
RunStep(results, "Main window profile round-trip (controls load + save)", MainWindowProfileRoundTrip);
RunStep(results, "Auto-save non-read-only profiles (options + guard + silent timer)", AutoSaveNonReadOnlyProfiles);
RunStep(results, "Service verb gate (normal launch stays load-safe)", ServiceVerbGate);
var failed = results.Count(r => r.Status == "FAIL");
var skipped = results.Count(r => r.Status == "SKIP");
@@ -1262,6 +1263,56 @@ internal static class SelfTest
return "options, persistence, guard (read-only/blank/unchanged skipped), and silent timer all verified";
}
/// <summary>Guards the fix for the 2026-07-14 Win7 launch crash: RemSoundService derives from ServiceBase,
/// so if Program.Main reached the service dispatch on a normal launch, the JIT would load the
/// System.ServiceProcess assembly at startup — which won't load on Win7 under .NET 10 and crashed the app
/// before it could open. A normal launch must therefore NOT be treated as a service invocation (so the
/// dispatch — the only place that names the service types — is never JIT-compiled), while every real
/// service verb must be recognised.</summary>
private static string? ServiceVerbGate()
{
// Real launches that must NOT route to the service dispatch.
string[][] normal =
{
Array.Empty<string>(),
new[] { "--silent" },
new[] { "--profile", "My Profile" },
new[] { "--connect", "10.0.0.5" },
new[] { "--minimized" },
new[] { "--config-dir", @"C:\temp\x" },
};
foreach (var args in normal)
Check(!Program.IsServiceInvocation(args),
$"a normal launch ({(args.Length == 0 ? "no args" : string.Join(' ', args))}) must not be treated as a service invocation");
// Every service verb must be recognised, case-insensitively (so it DOES route to the dispatch).
foreach (var verb in new[]
{
ServiceControl.RunVerb, ServiceControl.InstallVerb, ServiceControl.UninstallVerb,
ServiceControl.StartVerb, ServiceControl.StopVerb,
})
{
Check(Program.IsServiceInvocation(new[] { verb }), $"'{verb}' must be recognised as a service invocation");
Check(Program.IsServiceInvocation(new[] { verb.ToUpperInvariant() }), $"'{verb}' must be recognised case-insensitively");
// A service verb mixed in with other args still counts.
Check(Program.IsServiceInvocation(new[] { "--silent", verb }), $"'{verb}' must be recognised even alongside other args");
}
// Belt-and-braces: evaluating the gate on a normal launch must not itself drag in the service
// assembly. (If nothing loaded it yet — most likely — this proves the gate references no service
// type; if an earlier step already loaded it, we can't re-check and just pass.)
const string svcAsm = "System.ServiceProcess.ServiceController";
bool loadedBefore = IsAssemblyLoaded(svcAsm);
_ = Program.IsServiceInvocation(new[] { "--silent" });
if (!loadedBefore)
Check(!IsAssemblyLoaded(svcAsm), "deciding a normal launch must not load the Windows-service assembly");
return "normal launches stay load-safe; all five service verbs recognised (case-insensitive)";
}
private static bool IsAssemblyLoaded(string simpleName) =>
AppDomain.CurrentDomain.GetAssemblies().Any(a => string.Equals(a.GetName().Name, simpleName, StringComparison.OrdinalIgnoreCase));
private static int CountControls(Control root, Func<Control, bool> predicate)
{
var n = 0;