Restore Win7 launch safety: version-gate the Service menu, don't probe

The capability PROBE (added earlier for feature-detect) constructed a
ServiceController during window construction, which loaded System.ServiceProcess
on EVERY launch. On Win11 that's harmless, but on Win7 it meant the app was
attempting that risky load at startup, surviving only on a catch — too fragile
for a guarantee. Verified empirically: a normal launch was loading
System.ServiceProcess.ServiceController.dll again.

Fix: decide Service-menu visibility with a Windows-VERSION check
(OperatingSystem.IsWindowsVersionAtLeast(10,0)) instead of a runtime probe. A
version check touches no service type, so the service assembly is NOT referenced
at launch on any OS. On Win7/8 the menu is hidden and that assembly is never
loaded; on Win10+ it loads only later, if the user actually opens the Service
menu (already wrapped in try/catch). Feature-detecting on Win7 and keeping Win7
launch-safe are mutually exclusive (you can't test the load without doing it),
so we choose guaranteed launch safety.

- Deleted ServiceCapability (the probe) — now unused.
- Replaced its self-test with "Main window builds without loading the service
  assembly (Win7-safe)": constructing the main window must not load
  System.ServiceProcess. Verified empirically too: normal launch now loads 0
  service modules (was 1).

Gate 33/33.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-14 20:03:17 +01:00
co-authored by Claude Opus 4.8
parent 541f66d379
commit 0567ea22c3
3 changed files with 30 additions and 63 deletions
+10 -8
View File
@@ -2182,14 +2182,16 @@ public sealed class MainForm : Form
// and the arrow keys. // and the arrow keys.
menu.Items.Add(fileMenu); menu.Items.Add(fileMenu);
menu.Items.Add(recordMenu); menu.Items.Add(recordMenu);
// Offer the Service menu wherever the send-only service can actually run — feature-detected, not // Offer the Service menu on Windows 10+ only. This is a VERSION check on purpose, not a runtime
// gated on a Windows version, so it appears on Windows 7 too if the .NET service layer loads there // probe: a probe would have to construct a ServiceController to test it, which loads
// (Ed's ask: install on "Win7 or above" wherever it's supported). ServiceCapability probes safely: // System.ServiceProcess — the exact assembly that won't load on Windows 7 — during window
// if System.ServiceProcess can't load on this OS, it catches that and the menu is simply hidden, so // construction on EVERY launch. Deciding by version is free (it touches no service type), so on
// an older Windows can never be crashed by it. The startup path is already service-type-free via // Windows 7/8 the menu is simply hidden and that assembly is never referenced at launch at all,
// ServiceEntry, so this probe (at window construction, never at launch) is the only place that // which is what keeps the app launching there. On Windows 10+ the assembly loads only later, if the
// decides visibility. // user actually opens the Service menu (the DropDownOpening handler, wrapped in try/catch). Feature-
if (ServiceCapability.IsAvailable()) // detecting on Win7 and keeping Win7 launch-safe are mutually exclusive — you can't test whether the
// assembly loads without loading it — so we choose guaranteed launch safety.
if (OperatingSystem.IsWindowsVersionAtLeast(10, 0))
menu.Items.Add(BuildServiceMenu()); menu.Items.Add(BuildServiceMenu());
menu.Items.Add(optionsMenu); menu.Items.Add(optionsMenu);
menu.Items.Add(helpMenu); menu.Items.Add(helpMenu);
+20 -7
View File
@@ -81,7 +81,7 @@ internal static class SelfTest
RunStep(results, "Main window profile round-trip (controls load + save)", MainWindowProfileRoundTrip); 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, "Auto-save non-read-only profiles (options + guard + silent timer)", AutoSaveNonReadOnlyProfiles);
RunStep(results, "Service verb gate (normal launch stays load-safe)", ServiceVerbGate); RunStep(results, "Service verb gate (normal launch stays load-safe)", ServiceVerbGate);
RunStep(results, "Service capability probe (feature-detect, cached, safe)", ServiceCapabilityProbe); RunStep(results, "Main window builds without loading the service assembly (Win7-safe)", MainWindowServiceAssemblyFree);
RunStep(results, "Menu shortcuts don't clash with controls", MenuShortcutsDontClashWithControls); RunStep(results, "Menu shortcuts don't clash with controls", MenuShortcutsDontClashWithControls);
RunStep(results, "Service log discovery (newest activity log)", ServiceLogDiscovery); RunStep(results, "Service log discovery (newest activity log)", ServiceLogDiscovery);
@@ -1356,13 +1356,26 @@ internal static class SelfTest
/// so it must report available; the "can't load → hidden" path can only be exercised on an OS where the /// so it must report available; the "can't load → hidden" path can only be exercised on an OS where the
/// assembly genuinely won't load, but the try/catch that guarantees it degrades safely is verified here /// assembly genuinely won't load, but the try/catch that guarantees it degrades safely is verified here
/// by the probe never throwing.</summary> /// by the probe never throwing.</summary>
private static string? ServiceCapabilityProbe() /// <summary>The Win7 launch guarantee, directly: building the main window (which builds the menu bar)
/// must NOT load System.ServiceProcess. The Service menu's visibility is decided by a Windows-VERSION
/// check, which touches no service type — so the service assembly is only ever loaded later, if a
/// Windows-10+ user opens the Service menu. On Windows 7 that decision hides the menu and the assembly
/// (which won't load there) is never referenced at launch, which is what keeps the app starting.</summary>
private static string? MainWindowServiceAssemblyFree()
{ {
bool first = ServiceCapability.IsAvailable(); const string svcAsm = "System.ServiceProcess.ServiceController";
bool second = ServiceCapability.IsAvailable(); bool loadedBefore = IsAssemblyLoaded(svcAsm);
Check(first == second, "the capability result must be stable across calls (cached)");
Check(first, "the Windows service machinery must be detected as available on this Windows 10/11 test runner"); Form form;
return "service machinery feature-detected as available, and the result is cached"; try { form = new MainForm(null, RemSound.Core.Profile.NewBlank(), null, null, headless: true); }
catch (Exception ex) { return Skip($"headless MainForm could not be constructed: {ex.GetType().Name}: {ex.Message}"); }
using (form) { }
if (loadedBefore)
return "service assembly already loaded by an earlier step; main-window load-safety not re-checked this run";
Check(!IsAssemblyLoaded(svcAsm),
"constructing the main window must NOT load System.ServiceProcess (Service menu is version-gated, not probed) — this is what keeps the app launching on Windows 7");
return "the main window builds without loading the Windows-service assembly (Win7 launch-safe)";
} }
/// <summary>A top-level menu opens on Alt+&lt;its mnemonic&gt; — but a VISIBLE control that owns the same /// <summary>A top-level menu opens on Alt+&lt;its mnemonic&gt; — but a VISIBLE control that owns the same
-48
View File
@@ -1,48 +0,0 @@
using System.Runtime.CompilerServices;
namespace RemSound.App;
/// <summary>
/// Feature-detects whether the send-only Windows service can be offered on THIS machine — by actually
/// trying to load the service machinery, not by checking a Windows version number.
///
/// <para>Why feature-detect instead of "Windows 10 or newer": Windows 7 has a full Service Control Manager,
/// so the service could work there IF the .NET service layer (<c>System.ServiceProcess</c>) loads on it.
/// That's the real unknown — .NET 10 isn't officially supported on Win7 — so we simply try, and offer the
/// service wherever the attempt succeeds (Win7 included) while hiding it safely wherever it doesn't.</para>
///
/// <para>The load attempt is isolated in <see cref="Probe"/> and marked NoInlining on purpose: the reference
/// to the <c>System.ServiceProcess</c> types lives ONLY there, so the assembly load is triggered by the
/// <em>call</em> to Probe (inside <see cref="IsAvailable"/>'s try/catch) and is therefore catchable — instead
/// of happening when IsAvailable itself is compiled, which would be an unrecoverable crash on an OS where
/// that assembly won't load. This is the same failure that took the app down at launch before the startup
/// path was made service-type-free; here it degrades to "hide the menu" instead.</para>
/// </summary>
internal static class ServiceCapability
{
private static bool? cached;
/// <summary>True when the Windows service machinery loads and is usable on this OS/runtime. Cached after
/// the first check; never throws.</summary>
public static bool IsAvailable()
{
if (cached is { } c) return c;
bool ok;
try { ok = Probe(); }
catch { ok = false; } // System.ServiceProcess couldn't load here — offer nothing, stay safe
cached = ok;
return ok;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static bool Probe()
{
// Constructing a ServiceController forces the System.ServiceProcess assembly to load — the exact
// step that fails on an OS the .NET service layer doesn't support. The PARAMETERLESS ctor touches
// no service and no Service Control Manager, so it can't throw for a benign reason (naming a made-up
// service and reading a property WOULD hit the SCM and throw "not found"). The construction succeeding
// is all we need to know the machinery loads here; a real service is opened later via ServiceControl.
using var probe = new System.ServiceProcess.ServiceController();
return true;
}
}