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:
co-authored by
Claude Opus 4.8
parent
26903950f7
commit
9392cc1fcc
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user