From 0567ea22c3913cfacbfd0ea15203d941d81354e5 Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:03:17 +0100 Subject: [PATCH] Restore Win7 launch safety: version-gate the Service menu, don't probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/RemSound.App/MainForm.cs | 18 +++++----- src/RemSound.App/SelfTest.cs | 27 +++++++++++---- src/RemSound.App/ServiceCapability.cs | 48 --------------------------- 3 files changed, 30 insertions(+), 63 deletions(-) delete mode 100644 src/RemSound.App/ServiceCapability.cs diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index 073b33c..0b6ccd0 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -2182,14 +2182,16 @@ public sealed class MainForm : Form // and the arrow keys. menu.Items.Add(fileMenu); menu.Items.Add(recordMenu); - // Offer the Service menu wherever the send-only service can actually run — feature-detected, not - // gated on a Windows version, so it appears on Windows 7 too if the .NET service layer loads there - // (Ed's ask: install on "Win7 or above" wherever it's supported). ServiceCapability probes safely: - // if System.ServiceProcess can't load on this OS, it catches that and the menu is simply hidden, so - // an older Windows can never be crashed by it. The startup path is already service-type-free via - // ServiceEntry, so this probe (at window construction, never at launch) is the only place that - // decides visibility. - if (ServiceCapability.IsAvailable()) + // Offer the Service menu on Windows 10+ only. This is a VERSION check on purpose, not a runtime + // probe: a probe would have to construct a ServiceController to test it, which loads + // System.ServiceProcess — the exact assembly that won't load on Windows 7 — during window + // construction on EVERY launch. Deciding by version is free (it touches no service type), so on + // Windows 7/8 the menu is simply hidden and that assembly is never referenced at launch at all, + // which is what keeps the app launching there. On Windows 10+ the assembly loads only later, if the + // user actually opens the Service menu (the DropDownOpening handler, wrapped in try/catch). Feature- + // 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(optionsMenu); menu.Items.Add(helpMenu); diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index 145f581..4e196cf 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -81,7 +81,7 @@ internal static class SelfTest 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); - 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, "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 /// assembly genuinely won't load, but the try/catch that guarantees it degrades safely is verified here /// by the probe never throwing. - private static string? ServiceCapabilityProbe() + /// 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. + private static string? MainWindowServiceAssemblyFree() { - bool first = ServiceCapability.IsAvailable(); - bool second = ServiceCapability.IsAvailable(); - 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"); - return "service machinery feature-detected as available, and the result is cached"; + const string svcAsm = "System.ServiceProcess.ServiceController"; + bool loadedBefore = IsAssemblyLoaded(svcAsm); + + Form form; + 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)"; } /// A top-level menu opens on Alt+<its mnemonic> — but a VISIBLE control that owns the same diff --git a/src/RemSound.App/ServiceCapability.cs b/src/RemSound.App/ServiceCapability.cs deleted file mode 100644 index d2524e7..0000000 --- a/src/RemSound.App/ServiceCapability.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Runtime.CompilerServices; - -namespace RemSound.App; - -/// -/// 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. -/// -/// 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 (System.ServiceProcess) 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. -/// -/// The load attempt is isolated in and marked NoInlining on purpose: the reference -/// to the System.ServiceProcess types lives ONLY there, so the assembly load is triggered by the -/// call to Probe (inside '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. -/// -internal static class ServiceCapability -{ - private static bool? cached; - - /// True when the Windows service machinery loads and is usable on this OS/runtime. Cached after - /// the first check; never throws. - 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; - } -}