Fix the service install hang (pipe deadlock) + stop it from ever freezing the app

Ed uninstalled then reinstalled the service from the app and the app "kind of crashed":
its audio froze while the connection stayed alive. The logs told the whole story - the
app logged "install requested" then never "install finished"; the elevated helper
(child of the app) was still running 40+ minutes later, and the app sat blocked on it,
UI thread frozen, so streaming died but the background heartbeat kept ticking.

Root cause - a classic pipe deadlock in the elevated installer. GrantUsersWriteToBin ran
`icacls /T` over the service bin (100+ files) and read STDERR to end, THEN stdout. icacls
floods stdout far past the ~4 KB pipe buffer, so it blocked writing stdout while we blocked
reading stderr - forever. That hung DoInstall, which hung the app waiting on it.

Fixes (root cause + defence in depth, so a stuck helper can never freeze the app again):
- RunProcessCaptured: one safe process runner that drains stdout AND stderr concurrently
  (async), bounded by a timeout, and kills the child (whole tree) if it overruns. RunSc,
  RunScCapture and GrantUsersWriteToBin all go through it now. This kills the deadlock.
- RunElevated now waits with a 120s cap and returns ElevatedTimedOut instead of blocking
  forever.
- ServiceAction runs the elevated helper OFF the UI thread and reports the result back, so
  even a slow/stuck helper can't stall the window or its audio. New "timed out" message.
- Program.cs Environment.Exit()s after a one-shot service verb, so a helper that finished
  its work can never linger (non-background thread) with the app waiting on it.

Test: "Elevated helper: no pipe deadlock on flooded output" - RunProcessCaptured against a
child that floods both pipes with ~260 KB (a big dir listing + a failing dir); it must
return promptly with the full output. The pre-fix order would have hung. Gate 46/46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-17 23:56:26 +01:00
co-authored by Claude Opus 4.8
parent 5fc1b5bd75
commit aa5707f8e1
4 changed files with 97 additions and 53 deletions
+19 -1
View File
@@ -2380,14 +2380,32 @@ public sealed class MainForm : Form
} }
logFile.Event($"service: {label} requested (elevated)"); logFile.Event($"service: {label} requested (elevated)");
ServiceStore.AppendServiceEvent($"{label} requested (elevated)"); ServiceStore.AppendServiceEvent($"{label} requested (elevated)");
// Run the elevated helper OFF the UI thread. A slow or stuck helper must never freeze the window or
// its audio — that was the install hang: the UI thread sat in WaitForExit while the pipe-deadlocked
// installer never returned, so the app locked up and streaming died. The UI stays live; we report
// the outcome (on the UI thread) when the helper comes back or the wait times out.
Task.Run(() =>
{
var rc = ServiceControl.RunElevated(verb); var rc = ServiceControl.RunElevated(verb);
var outcome = rc == 0 ? "success" : rc == -1 ? "cancelled/declined" : "failed"; if (IsDisposed) return;
try { BeginInvoke(new Action(() => ReportServiceActionResult(label, rc))); } catch { /* window closing */ }
});
}
private void ReportServiceActionResult(string label, int rc)
{
var outcome = rc == 0 ? "success"
: rc == -1 ? "cancelled/declined"
: rc == ServiceControl.ElevatedTimedOut ? "timed out"
: "failed";
logFile.Event($"service: {label} finished with code {rc} ({outcome})"); logFile.Event($"service: {label} finished with code {rc} ({outcome})");
ServiceStore.AppendServiceEvent($"{label} finished: code {rc} ({outcome})"); ServiceStore.AppendServiceEvent($"{label} finished: code {rc} ({outcome})");
if (rc == 0) if (rc == 0)
MessageBox.Show(this, $"Service {label} succeeded.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Information); MessageBox.Show(this, $"Service {label} succeeded.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Information);
else if (rc == -1) else if (rc == -1)
MessageBox.Show(this, $"Service {label} was cancelled, or administrator rights were declined.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning); MessageBox.Show(this, $"Service {label} was cancelled, or administrator rights were declined.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning);
else if (rc == ServiceControl.ElevatedTimedOut)
MessageBox.Show(this, $"Service {label} is taking longer than expected and hasn't finished yet. It may still complete on its own — check the Service menu status in a moment.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning);
else else
MessageBox.Show(this, $"Service {label} failed (code {rc}).", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning); MessageBox.Show(this, $"Service {label} failed (code {rc}).", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning);
} }
+7 -2
View File
@@ -83,8 +83,13 @@ internal static class Program
// normal launch never touches that assembly. // normal launch never touches that assembly.
if (args.Length > 0 && IsServiceInvocation(args)) if (args.Length > 0 && IsServiceInvocation(args))
{ {
Environment.ExitCode = ServiceEntry.Dispatch(args); var serviceRc = ServiceEntry.Dispatch(args);
return; // Terminate decisively. These verbs run in an elevated helper the app is waiting on; if any
// library it touched left a non-background thread alive, a plain return would leave the process
// lingering and the app blocked on it (part of the install-hang, 2026-07-17). Environment.Exit
// guarantees the helper dies once its one-shot work is done. (--run-service returns here only
// after the SCM has stopped the service, so exiting then is correct too.)
Environment.Exit(serviceRc);
} }
// --config-dir <folder> (test / portable isolation): redirect ALL user state - config, // --config-dir <folder> (test / portable isolation): redirect ALL user state - config,
+15
View File
@@ -64,6 +64,7 @@ internal static class SelfTest
RunStep(results, "Lifecycle churn (modes, sources, pan/EQ, send/receive)", LifecycleChurn); RunStep(results, "Lifecycle churn (modes, sources, pan/EQ, send/receive)", LifecycleChurn);
RunStep(results, "Service app-yield token", ServiceInteractivePresence); RunStep(results, "Service app-yield token", ServiceInteractivePresence);
RunStep(results, "Service sender parity (crypto + Opus frame)", ServiceSenderParity); RunStep(results, "Service sender parity (crypto + Opus frame)", ServiceSenderParity);
RunStep(results, "Elevated helper: no pipe deadlock on flooded output", ServiceProcessCaptureNoDeadlock);
RunStep(results, "Default-output follower (service follows Windows default)", DefaultOutputFollower); RunStep(results, "Default-output follower (service follows Windows default)", DefaultOutputFollower);
RunStep(results, "Default follower exclusivity (locks out specific cards)", DefaultFollowerExclusivity); RunStep(results, "Default follower exclusivity (locks out specific cards)", DefaultFollowerExclusivity);
RunStep(results, "Service profile isolation (location + hidden from pickers)", ServiceProfileIsolation); RunStep(results, "Service profile isolation (location + hidden from pickers)", ServiceProfileIsolation);
@@ -944,6 +945,20 @@ internal static class SelfTest
return "follower flagged + sentinel shared with the app; service resolves it to the live default render endpoint"; return "follower flagged + sentinel shared with the app; service resolves it to the live default render endpoint";
} }
/// <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
/// service bin); RunProcessCaptured drains both pipes concurrently and must return promptly, in full.</summary>
private static string? ServiceProcessCaptureNoDeadlock()
{
var r = ServiceControl.RunProcessCaptured("cmd.exe",
"/c dir \"%SystemRoot%\\System32\" & dir \"%SystemRoot%\\__no_such_dir_remsound_test__\"", 20000);
Check(r.Started, "the test child process must launch");
Check(r.Exited, "RunProcessCaptured must NOT hang on a child whose output overflows the pipe buffer");
Check(r.StdOut.Length > 4096, $"the full flooded stdout must be captured, past the pipe buffer (got {r.StdOut.Length} bytes)");
return $"drained {r.StdOut.Length} bytes stdout + {r.StdErr.Length} stderr concurrently, no deadlock";
}
private static string? ServiceSenderParity() private static string? ServiceSenderParity()
{ {
// The profile deliberately carries the WRONG audio transport (raw PCM, broadcast frame, Standard // The profile deliberately carries the WRONG audio transport (raw PCM, broadcast frame, Standard
+54 -48
View File
@@ -62,8 +62,20 @@ public static class ServiceControl
// ---- UI-side (unprivileged): re-launch self elevated to do the work -------------------------- // ---- UI-side (unprivileged): re-launch self elevated to do the work --------------------------
/// <summary>Re-launch this exe elevated with <paramref name="verb"/>, wait, and return its exit code /// <summary>Returned by <see cref="RunElevated"/> when the elevated helper didn't finish within the
/// (0 = success). Returns -1 if the user declined the UAC prompt or elevation failed.</summary> /// time limit (it hung, or the user left the UAC prompt sitting). Distinct from -1 (declined/failed).</summary>
public const int ElevatedTimedOut = -2;
/// <summary>How long to wait for an elevated one-shot verb to finish. Generous: it covers the UAC
/// prompt, the file copy into the service bin, and the SCM calls. If it's exceeded, the helper is
/// assumed stuck and we stop waiting rather than block the caller forever.</summary>
private const int ElevatedTimeoutMs = 120000;
/// <summary>Re-launch this exe elevated with <paramref name="verb"/>, wait (bounded), and return its
/// exit code (0 = success). Returns -1 if the user declined the UAC prompt or elevation failed, or
/// <see cref="ElevatedTimedOut"/> if it didn't finish in time. NEVER waits forever — a stuck helper
/// must not be able to freeze the caller (that was the install-hang, 2026-07-17). Best called off the
/// UI thread so even the bounded wait can't stall the window or its audio.</summary>
public static int RunElevated(string verb) public static int RunElevated(string verb)
{ {
var exe = Environment.ProcessPath; var exe = Environment.ProcessPath;
@@ -80,7 +92,7 @@ public static class ServiceControl
{ {
using var p = Process.Start(psi); using var p = Process.Start(psi);
if (p is null) return -1; if (p is null) return -1;
p.WaitForExit(); if (!p.WaitForExit(ElevatedTimeoutMs)) return ElevatedTimedOut;
return p.ExitCode; return p.ExitCode;
} }
catch (Win32Exception) { return -1; } // user cancelled the UAC prompt catch (Win32Exception) { return -1; } // user cancelled the UAC prompt
@@ -143,29 +155,17 @@ public static class ServiceControl
/// <summary>Grant the installing user Modify rights on the service's bin folder (via icacls), so a /// <summary>Grant the installing user Modify rights on the service's bin folder (via icacls), so a
/// stopped service's binaries can be refreshed without administrator rights. Best-effort.</summary> /// stopped service's binaries can be refreshed without administrator rights. Best-effort.</summary>
private static void GrantUsersWriteToBin() private static void GrantUsersWriteToBin()
{
try
{ {
// Grant the installing user (see InstallingUserSid) Modify. (OI)(CI) = inherit to files + // Grant the installing user (see InstallingUserSid) Modify. (OI)(CI) = inherit to files +
// subfolders; (M) = Modify. /T applies to the existing contents too (the bin was just // subfolders; (M) = Modify. /T applies to the existing contents too (the bin was just populated),
// populated), /C keeps going past any single-file error. Capture stderr so a real failure is // /C keeps going past any single-file error. Runs through RunProcessCaptured, which drains both
// logged rather than swallowed. // pipes concurrently — icacls /T over 100+ files emits far more than the pipe buffer holds, and the
var psi = new ProcessStartInfo // old read-stderr-then-stdout order deadlocked here (the install hang Ed hit, 2026-07-17).
{ var r = RunProcessCaptured("icacls.exe",
FileName = "icacls.exe", $"\"{ServiceStore.BinDirectory}\" /grant \"*{InstallingUserSid()}:(OI)(CI)(M)\" /T /C", 30000);
Arguments = $"\"{ServiceStore.BinDirectory}\" /grant \"*{InstallingUserSid()}:(OI)(CI)(M)\" /T /C", if (!r.Started) ServiceStore.AppendServiceEvent("install: grant-write on bin failed to launch icacls");
UseShellExecute = false, else if (!r.Exited) ServiceStore.AppendServiceEvent("install: grant-write on bin timed out (icacls killed)");
CreateNoWindow = true, else if (r.ExitCode != 0) ServiceStore.AppendServiceEvent($"install: icacls grant-write on bin returned {r.ExitCode}: {r.StdErr}{r.StdOut}");
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using var p = Process.Start(psi);
var err = p?.StandardError.ReadToEnd();
var outp = p?.StandardOutput.ReadToEnd();
p?.WaitForExit(20000);
if (p is { ExitCode: not 0 }) ServiceStore.AppendServiceEvent($"install: icacls grant-write on bin returned {p.ExitCode}: {err}{outp}");
}
catch (Exception ex) { ServiceStore.AppendServiceEvent($"install: grant-write on bin failed: {ex.GetType().Name}: {ex.Message}"); }
} }
/// <summary>Copies the program files from <paramref name="sourceDir"/> to <paramref name="destDir"/>, /// <summary>Copies the program files from <paramref name="sourceDir"/> to <paramref name="destDir"/>,
@@ -281,34 +281,30 @@ public static class ServiceControl
private static int RunSc(string arguments) private static int RunSc(string arguments)
{ {
try var r = RunProcessCaptured("sc.exe", arguments, 20000);
{ return r.Started ? (r.Exited ? r.ExitCode : 3) : 4;
var psi = new ProcessStartInfo
{
FileName = "sc.exe",
Arguments = arguments,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using var p = Process.Start(psi);
if (p is null) return 2;
p.WaitForExit(20000);
return p.HasExited ? p.ExitCode : 3;
}
catch { return 4; }
} }
/// <summary>Runs sc.exe and returns its stdout (empty on failure). Used to read the service's security /// <summary>Runs sc.exe and returns its stdout (empty on failure). Used to read the service's security
/// descriptor (<c>sdshow</c>) before amending it.</summary> /// descriptor (<c>sdshow</c>) before amending it.</summary>
private static string RunScCapture(string arguments) private static string RunScCapture(string arguments) => RunProcessCaptured("sc.exe", arguments, 20000).StdOut;
internal readonly record struct ProcResult(bool Started, bool Exited, int ExitCode, string StdOut, string StdErr);
/// <summary>Run a console tool and capture its output SAFELY. Both stdout and stderr are drained
/// CONCURRENTLY (async) while the process runs, then bounded by <paramref name="timeoutMs"/>. This is
/// the fix for a real hang: reading one pipe to end before the other deadlocks whenever the child's
/// output exceeds the ~4 KB pipe buffer — e.g. <c>icacls /T</c> over the service bin's 100+ files
/// blocked writing stdout while we blocked reading stderr, hanging the elevated installer forever (and
/// with it the app that was waiting on it). On timeout the child is killed (whole tree) so nothing is
/// left stuck. Never throws.</summary>
internal static ProcResult RunProcessCaptured(string fileName, string arguments, int timeoutMs)
{ {
try try
{ {
var psi = new ProcessStartInfo var psi = new ProcessStartInfo
{ {
FileName = "sc.exe", FileName = fileName,
Arguments = arguments, Arguments = arguments,
UseShellExecute = false, UseShellExecute = false,
CreateNoWindow = true, CreateNoWindow = true,
@@ -316,12 +312,22 @@ public static class ServiceControl
RedirectStandardError = true, RedirectStandardError = true,
}; };
using var p = Process.Start(psi); using var p = Process.Start(psi);
if (p is null) return ""; if (p is null) return new ProcResult(false, false, -1, "", "");
var stdout = p.StandardOutput.ReadToEnd(); var outTask = p.StandardOutput.ReadToEndAsync();
p.WaitForExit(20000); var errTask = p.StandardError.ReadToEndAsync();
return stdout; if (!p.WaitForExit(timeoutMs))
{
try { p.Kill(entireProcessTree: true); } catch { /* best-effort */ }
return new ProcResult(true, false, -1, Drain(outTask), Drain(errTask));
}
return new ProcResult(true, true, p.ExitCode, Drain(outTask), Drain(errTask));
}
catch { return new ProcResult(false, false, -1, "", ""); }
static string Drain(Task<string> t)
{
try { return t.Wait(2000) ? t.Result : ""; } catch { return ""; }
} }
catch { return ""; }
} }
/// <summary>Builds the exact sc.exe "create" argument string for a given exe path. Pure and /// <summary>Builds the exact sc.exe "create" argument string for a given exe path. Pure and