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
+28 -12
View File
@@ -2182,7 +2182,13 @@ public sealed class MainForm : Form
// and the arrow keys.
menu.Items.Add(fileMenu);
menu.Items.Add(recordMenu);
menu.Items.Add(BuildServiceMenu());
// The send-only Windows service is a Windows 10+ feature: it was built and tested there, and on
// older Windows the System.ServiceProcess assembly it relies on won't even load under .NET 10. Only
// offer the Service menu where it can actually work — on Win7/8 it simply isn't shown and no service
// code is ever reached. (Mirrors how "capture individual apps" is gated to the versions that support
// it.) The startup path is already load-safe via ServiceEntry; this keeps the menu load-safe too.
if (OperatingSystem.IsWindowsVersionAtLeast(10, 0))
menu.Items.Add(BuildServiceMenu());
menu.Items.Add(optionsMenu);
menu.Items.Add(helpMenu);
return menu;
@@ -2218,19 +2224,29 @@ public sealed class MainForm : Form
});
serviceMenu.DropDownOpening += (_, _) =>
{
var state = ServiceControl.Query();
status.Text = "Service: " + DescribeServiceState(state);
// Surface the running version + when it (re)started, so a self-update is visible at a glance.
if (state is ServiceState.Running or ServiceState.Stopped && ServiceStore.LoadStatus() is { Version: { } ver } st)
// Never let a status-query failure crash the menu (and with it the app). The menu only appears
// on Win10+ where the service assembly loads fine, but a defensive net here is cheap insurance.
try
{
status.Text += $" — version {ver}";
if (state == ServiceState.Running && st.StartedUtc != default) status.Text += $", running since {DescribeAgo(st.StartedUtc)}";
var state = ServiceControl.Query();
status.Text = "Service: " + DescribeServiceState(state);
// Surface the running version + when it (re)started, so a self-update is visible at a glance.
if (state is ServiceState.Running or ServiceState.Stopped && ServiceStore.LoadStatus() is { Version: { } ver } st)
{
status.Text += $" — version {ver}";
if (state == ServiceState.Running && st.StartedUtc != default) status.Text += $", running since {DescribeAgo(st.StartedUtc)}";
}
var installed = state != ServiceState.NotInstalled;
install.Enabled = !installed;
uninstall.Enabled = installed;
start.Enabled = installed && state is ServiceState.Stopped;
stop.Enabled = installed && state is ServiceState.Running;
}
catch (Exception ex)
{
status.Text = "Service: status unavailable";
logFile.Event($"service menu: status query failed {ex.GetType().Name}: {ex.Message}");
}
var installed = state != ServiceState.NotInstalled;
install.Enabled = !installed;
uninstall.Enabled = installed;
start.Enabled = installed && state is ServiceState.Stopped;
stop.Enabled = installed && state is ServiceState.Running;
};
return serviceMenu;
}
+20 -14
View File
@@ -16,6 +16,15 @@ internal static class Program
private static bool HasArg(string[] args, string flag) =>
Array.Exists(args, a => string.Equals(a, flag, StringComparison.OrdinalIgnoreCase));
/// <summary>True when any Windows-service verb is present. Uses only the inlined const verb strings, so
/// it references no service type and loads no service assembly — see the dispatch call in <see cref="Main"/>
/// for why that matters on older Windows. When this is false (every normal launch) the service code is
/// never reached.</summary>
internal static bool IsServiceInvocation(string[] args) =>
HasArg(args, ServiceControl.RunVerb) || HasArg(args, ServiceControl.InstallVerb)
|| HasArg(args, ServiceControl.UninstallVerb) || HasArg(args, ServiceControl.StartVerb)
|| HasArg(args, ServiceControl.StopVerb);
// Writes an otherwise-fatal exception to a timestamped crash file in the logs folder, so a
// "RemSound just disappeared, no dialog" report (#16) leaves a stack behind to diagnose instead
// of nothing. Best-effort and self-contained — a crash handler must never throw.
@@ -64,21 +73,18 @@ internal static class Program
// The service is a SEPARATE role — it must never take the interactive single-instance lock — and
// the elevated one-shot verbs (install/uninstall/start/stop) just do their SCM work and exit with
// a status code. --run-service blocks in the SCM dispatcher until Windows stops the service.
if (args.Length > 0)
//
// CRITICAL (2026-07-14): the dispatch lives in ServiceEntry, NOT inline here. RemSoundService
// derives from ServiceBase, so naming it in THIS method's body would make the JIT load the
// System.ServiceProcess assembly the instant Main is compiled — at the very start of every launch,
// before any argument is read. On older Windows (Win7) that assembly won't load under .NET 10, so
// the app crashed before it could open a window. The check below uses only inlined const verb
// strings, and ServiceEntry.Dispatch is only reached when a service verb is genuinely present, so a
// normal launch never touches that assembly.
if (args.Length > 0 && IsServiceInvocation(args))
{
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.UninstallVerb)) { Environment.ExitCode = ServiceControl.DoUninstall(); return; }
if (HasArg(args, ServiceControl.StartVerb)) { Environment.ExitCode = ServiceControl.DoStart(); return; }
if (HasArg(args, ServiceControl.StopVerb)) { Environment.ExitCode = ServiceControl.DoStop(); return; }
Environment.ExitCode = ServiceEntry.Dispatch(args);
return;
}
// --config-dir <folder> (test / portable isolation): redirect ALL user state - config,
+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;
+43
View File
@@ -0,0 +1,43 @@
using RemSound.Core;
namespace RemSound.App;
/// <summary>
/// Runs the one-shot Windows-service CLI verbs (<c>--run-service</c>, <c>--install-service</c>, …).
///
/// <para>Deliberately a SEPARATE class from <see cref="Program"/>. <see cref="RemSoundService"/> derives
/// from <c>ServiceBase</c> (in the <c>System.ServiceProcess</c> assembly), so if <c>Program.Main</c> named
/// it directly the JIT would have to load that assembly the instant <c>Main</c> is compiled — at the very
/// start of EVERY launch, before a single argument is read. On older Windows (Windows 7) that assembly
/// won't load under the .NET 10 runtime, so the app crashed before it could open a window (reported
/// 2026-07-14). Keeping every service-type reference in here, reached only once a service verb is confirmed
/// present (<see cref="Program.IsServiceInvocation"/>), means a normal launch never loads the service
/// assembly and starts exactly as before.</para>
/// </summary>
internal static class ServiceEntry
{
/// <summary>Runs whichever service verb <paramref name="args"/> contains and returns the process exit
/// code. <c>--run-service</c> blocks in the SCM dispatcher until Windows stops the service; the others
/// do their elevated SCM work and return. The caller must have already confirmed a verb is present via
/// <see cref="Program.IsServiceInvocation"/>.</summary>
public static int Dispatch(string[] args)
{
if (Has(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 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;
}
private static bool Has(string[] args, string flag) =>
Array.Exists(args, a => string.Equals(a, flag, StringComparison.OrdinalIgnoreCase));
}