Fix the two remaining UI-thread elevation freezes (profile save + installer)

Review finding, same class as the shipped install-hang fix: two paths still ran the
elevated service helper synchronously on the UI thread, freezing the window and live
audio for the duration (worst case minutes across two UAC prompts).

1) Service-profile save (MainForm.ConfigureServiceProfile): saving while the service
   runs did RunElevated(stop) + RunElevated(start) inline. Now: the restart runs on a
   background task, and tries a NO-UAC restart first - ServiceControl.TryRestartNoAdmin
   uses the start/stop rights the installer grants the installing account, so the
   normal case has no elevation prompt at all. Elevated verbs remain the fallback
   (service installed by a different account). Success is silent; only a failed
   restart reports back. The save popup now says the service is restarting.

2) App installer's optional service step (AppInstaller): the install + start-now calls
   ran RunElevated inline. The flow is sequential (can't fire-and-forget - the installer
   relaunches and exits afterwards), so RunElevatedResponsive runs the helper on a
   worker while a small modal "working..." shell pumps messages: UI and audio stay
   live, nothing can be double-triggered, NVDA announces the step, and the exit code
   still returns inline.

Test: "No-admin service restart fails safe" - TryRestartNoAdmin against a missing
service returns false promptly without throwing (that false routes callers onto the
elevated fallback). The success path needs the real SCM + grant, covered by hand-test.
Gate 47/47.

Part of the review-fix batch; no release until the whole plan lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-23 15:40:30 +01:00
co-authored by Claude Fable 5
parent 97c96b4fcb
commit cf1eb92f11
4 changed files with 117 additions and 6 deletions
+15
View File
@@ -65,6 +65,7 @@ internal static class SelfTest
RunStep(results, "Service app-yield token", ServiceInteractivePresence);
RunStep(results, "Service sender parity (crypto + Opus frame)", ServiceSenderParity);
RunStep(results, "Elevated helper: no pipe deadlock on flooded output", ServiceProcessCaptureNoDeadlock);
RunStep(results, "No-admin service restart fails safe (missing service)", ServiceRestartNoAdminFailsSafe);
RunStep(results, "Default-output follower (service follows Windows default)", DefaultOutputFollower);
RunStep(results, "Default follower exclusivity (locks out specific cards)", DefaultFollowerExclusivity);
RunStep(results, "Service profile isolation (location + hidden from pickers)", ServiceProfileIsolation);
@@ -945,6 +946,20 @@ internal static class SelfTest
return "follower flagged + sentinel shared with the app; service resolves it to the live default render endpoint";
}
/// <summary>The no-UAC restart used after a service-profile save (TryRestartNoAdmin) must FAIL SAFE:
/// against a service that doesn't exist it returns false, promptly, and never throws — that false is
/// what routes the caller onto the elevated fallback. (The success path needs the real installed
/// service + granted rights, so it's covered by hand-testing, not the gate.)</summary>
private static string? ServiceRestartNoAdminFailsSafe()
{
var sw = Stopwatch.StartNew();
var ok = ServiceControl.TryRestartNoAdmin("RemSoundSelfTestNoSuchService");
sw.Stop();
Check(!ok, "restarting a non-existent service must report false, not throw");
Check(sw.ElapsedMilliseconds < 5000, $"the failure must be prompt (took {sw.ElapsedMilliseconds} ms)");
return $"missing service → false in {sw.ElapsedMilliseconds} ms, no throw";
}
/// <summary>Reproduces the install-hang condition and proves it's fixed: a child that floods BOTH
/// stdout and stderr far past the ~4 KB pipe buffer (a big directory listing plus a failing dir). The
/// old "read stderr to end, then stdout" order deadlocked exactly here (icacls /T over the 100-file