From 9160eedd28d22cf05348874c6b43bc4af5d48245 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Thu, 5 Nov 2026 11:43:56 +0100 Subject: [PATCH 1/2] Keep detached daemon from holding the caller's stdio pipes On Windows, Process.Start uses CreateProcess with bInheritHandles=TRUE, so the daemon inherited the caller's inheritable stdout/stderr handles. Callers that read devproxy --detach output to EOF (| Out-String, $(...), subprocess pipes) then hung until the daemon exited. Clear the inherit flag on the parent's std handles before spawning, redirect the daemon's stdin as well, and add an integration test that captures --detach output and requires EOF. Closes dotnet/dev-proxy#2080 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ProcessSmokeTests.cs | 111 ++++++++++++++++++ DevProxy/Program.cs | 11 ++ DevProxy/Proxy/StandardHandleInheritance.cs | 72 ++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 DevProxy/Proxy/StandardHandleInheritance.cs diff --git a/DevProxy.Integration.Tests/ProcessSmokeTests.cs b/DevProxy.Integration.Tests/ProcessSmokeTests.cs index 8c6e8c85..dbd57b57 100644 --- a/DevProxy.Integration.Tests/ProcessSmokeTests.cs +++ b/DevProxy.Integration.Tests/ProcessSmokeTests.cs @@ -304,6 +304,117 @@ public async Task Status_HonorsInstanceTokenSuppression_ExplicitRetrievalRemains } } + [Theory] + [InlineData("text")] + [InlineData("json")] + public async Task Detach_WithCapturedOutput_ParentOutputReachesEofWhileDaemonKeepsRunning(string output) + { + var host = Path.Combine(Path.GetDirectoryName(LocateHostDll())!, + OperatingSystem.IsWindows() ? "devproxy.exe" : "devproxy"); + Assert.True(File.Exists(host), "Build DevProxy before running process smoke tests."); + + var workDir = Directory.CreateTempSubdirectory("devproxy-detach-capture-"); + int? pid = null; + string? logFile = null; + try + { + var config = JsonNode.Parse(ConfigFile)!.AsObject(); + config["installCert"] = false; + config["noFirstRun"] = true; + config["validateSchemas"] = false; + config["mocksPlugin"]!["mocksFile"] = Path.Combine(workDir.FullName, "mocks.json"); + await File.WriteAllTextAsync(Path.Combine(workDir.FullName, "mocks.json"), MocksFile); + await File.WriteAllTextAsync(Path.Combine(workDir.FullName, "devproxyrc.json"), config.ToJsonString()); + + // Mirrors callers such as `devproxy --detach | Out-String`, `$(devproxy --detach)`, + // or subprocess/execSync with pipes: they read our stdout/stderr until EOF, which only + // arrives once every process holding the pipe's write end has closed it. + var startInfo = new ProcessStartInfo(host) + { + WorkingDirectory = workDir.FullName, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + startInfo.Environment.Remove("CI"); + foreach (var arg in new[] + { + "--detach", "--as-system-proxy", "false", "--port", "0", "--api-port", "0", + "--install-cert", "false", "--no-first-run", "--output", output, + }) + { + startInfo.ArgumentList.Add(arg); + } + + using var process = new Process { StartInfo = startInfo }; + Assert.True(process.Start()); + process.StandardInput.Close(); + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + try + { + using var exitTimeout = new CancellationTokenSource(s_startupTimeout); + await process.WaitForExitAsync(exitTimeout.Token); + + // The parent has exited; if the daemon inherited our pipes, EOF never comes. + var eof = Task.WhenAll(stdout, stderr); + var completed = await Task.WhenAny(eof, Task.Delay(TimeSpan.FromSeconds(15))); + Assert.True(completed == eof, + "`devproxy --detach` exited but its captured output didn't reach EOF; the daemon is holding the caller's pipes."); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + + Assert.Equal(0, process.ExitCode); + var standardOutput = await stdout; + if (output == "json") + { + var started = GetResultData(standardOutput); + pid = started.GetProperty("pid").GetInt32(); + logFile = started.GetProperty("logFile").GetString(); + } + else + { + Assert.Contains("Dev Proxy started in background.", standardOutput, StringComparison.Ordinal); + var pidLine = standardOutput.Split('\n').Select(line => line.Trim()) + .Single(line => line.StartsWith("PID:", StringComparison.Ordinal)); + pid = int.Parse(pidLine["PID:".Length..].Trim(), System.Globalization.CultureInfo.InvariantCulture); + var logLine = standardOutput.Split('\n').Select(line => line.Trim()) + .Single(line => line.StartsWith("Log file:", StringComparison.Ordinal)); + logFile = logLine["Log file:".Length..].Trim(); + } + + using var daemon = Process.GetProcessById(pid.Value); + Assert.False(daemon.HasExited, "The detached Dev Proxy daemon should keep running after the parent exits."); + } + finally + { + try + { + if (pid.HasValue) + { + var stopped = await RunCommandAsync(host, workDir.FullName, + ["stop", "--pid", pid.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)]); + Assert.Equal(0, stopped.ExitCode); + } + } + finally + { + if (logFile is not null) + { + File.Delete(logFile); + } + workDir.Delete(recursive: true); + } + } + } + private static JsonElement GetResultData(string output) { foreach (var line in output.Split('\n').Where(line => line.StartsWith('{'))) diff --git a/DevProxy/Program.cs b/DevProxy/Program.cs index ec32588f..fcd991e2 100644 --- a/DevProxy/Program.cs +++ b/DevProxy/Program.cs @@ -150,6 +150,9 @@ await Console.Error.WriteLineAsync( Arguments = string.Join(" ", daemonArgs.Select(EscapeArgument)), UseShellExecute = false, CreateNoWindow = true, + // Redirect all std streams so the daemon doesn't hold on to the caller's + // stdin/stdout/stderr after this process exits. + RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, WorkingDirectory = Directory.GetCurrentDirectory() @@ -161,6 +164,11 @@ await Console.Error.WriteLineAsync( startInfo.WindowStyle = ProcessWindowStyle.Hidden; } + // Without this, on Windows the daemon inherits the caller's stdout/stderr + // handles and callers that read our output to EOF (`| Out-String`, + // `$(devproxy --detach)`, subprocess pipes) hang until the daemon exits. + StandardHandleInheritance.DisableForCurrentProcess(); + try { var process = Process.Start(startInfo); @@ -178,6 +186,9 @@ await Console.Error.WriteLineAsync( return 1; } + // The daemon never reads stdin; close it so it sees EOF immediately. + process.StandardInput.Close(); + // Wait a moment for the proxy to start and write its state file var timeout = TimeSpan.FromSeconds(10); var stopwatch = Stopwatch.StartNew(); diff --git a/DevProxy/Proxy/StandardHandleInheritance.cs b/DevProxy/Proxy/StandardHandleInheritance.cs new file mode 100644 index 00000000..1ef9973f --- /dev/null +++ b/DevProxy/Proxy/StandardHandleInheritance.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace DevProxy.Proxy; + +/// +/// Keeps a spawned detached daemon from holding on to the caller's stdio. +/// +/// +/// On Windows, calls CreateProcess +/// with bInheritHandles = TRUE, so the child inherits every inheritable handle of the +/// parent, not just the ones assigned as its std handles. The std handles a caller passes in +/// (for example the pipe behind devproxy --detach | Out-String) are inheritable, so the +/// long-running daemon would keep the caller's pipe open and the caller would never see EOF. +/// On Unix, fork/exec replaces the child's fds 0-2 with the redirected pipes, so +/// no extra work is needed there. +/// +internal static class StandardHandleInheritance +{ + private const int StdInputHandle = -10; + private const int StdOutputHandle = -11; + private const int StdErrorHandle = -12; + private const uint HandleFlagInherit = 0x00000001; + + /// + /// Marks this process's stdin, stdout, and stderr handles as non-inheritable so that child + /// processes started afterwards don't inherit them. This doesn't affect this process's own + /// use of the handles. No-op on non-Windows platforms. + /// + public static void DisableForCurrentProcess() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + foreach (var stdHandle in new[] { StdInputHandle, StdOutputHandle, StdErrorHandle }) + { + DisableInheritance(stdHandle); + } + } + + [SupportedOSPlatform("windows")] + private static void DisableInheritance(int stdHandle) + { + var handle = GetStdHandle(stdHandle); + if (handle == IntPtr.Zero || handle == new IntPtr(-1)) + { + return; + } + + // Best effort: if this fails, the daemon still starts; the caller may just not see EOF. + _ = SetHandleInformation(handle, HandleFlagInherit, 0); + } + + // DllImport (not LibraryImport) is used deliberately: LibraryImport's source generator + // requires AllowUnsafeBlocks project-wide, which we avoid for these system calls. +#pragma warning disable SYSLIB1054 + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GetStdHandle(int nStdHandle); + + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetHandleInformation(IntPtr hObject, uint dwMask, uint dwFlags); +#pragma warning restore SYSLIB1054 +} From 6028fbb08d3c39bc532c957f47c1018e4bfc0b00 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Thu, 5 Nov 2026 11:48:13 +0100 Subject: [PATCH 2/2] Capture daemon PID before asserting EOF so a failing test still stops it Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ProcessSmokeTests.cs | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/DevProxy.Integration.Tests/ProcessSmokeTests.cs b/DevProxy.Integration.Tests/ProcessSmokeTests.cs index dbd57b57..dcb47a96 100644 --- a/DevProxy.Integration.Tests/ProcessSmokeTests.cs +++ b/DevProxy.Integration.Tests/ProcessSmokeTests.cs @@ -350,18 +350,25 @@ public async Task Detach_WithCapturedOutput_ParentOutputReachesEofWhileDaemonKee using var process = new Process { StartInfo = startInfo }; Assert.True(process.Start()); process.StandardInput.Close(); - var stdout = process.StandardOutput.ReadToEndAsync(); + // Collect stdout line by line so the PID is available for cleanup even if EOF never comes. + var stdoutLines = new ConcurrentQueue(); + var stdout = Task.Run(async () => + { + while (await process.StandardOutput.ReadLineAsync() is { } line) + { + stdoutLines.Enqueue(line); + } + }); var stderr = process.StandardError.ReadToEndAsync(); + Task? completed = null; + var eof = Task.WhenAll(stdout, stderr); try { using var exitTimeout = new CancellationTokenSource(s_startupTimeout); await process.WaitForExitAsync(exitTimeout.Token); // The parent has exited; if the daemon inherited our pipes, EOF never comes. - var eof = Task.WhenAll(stdout, stderr); - var completed = await Task.WhenAny(eof, Task.Delay(TimeSpan.FromSeconds(15))); - Assert.True(completed == eof, - "`devproxy --detach` exited but its captured output didn't reach EOF; the daemon is holding the caller's pipes."); + completed = await Task.WhenAny(eof, Task.Delay(TimeSpan.FromSeconds(15))); } finally { @@ -372,7 +379,7 @@ public async Task Detach_WithCapturedOutput_ParentOutputReachesEofWhileDaemonKee } Assert.Equal(0, process.ExitCode); - var standardOutput = await stdout; + var standardOutput = string.Join('\n', stdoutLines); if (output == "json") { var started = GetResultData(standardOutput); @@ -382,14 +389,17 @@ public async Task Detach_WithCapturedOutput_ParentOutputReachesEofWhileDaemonKee else { Assert.Contains("Dev Proxy started in background.", standardOutput, StringComparison.Ordinal); - var pidLine = standardOutput.Split('\n').Select(line => line.Trim()) + var pidLine = stdoutLines.Select(line => line.Trim()) .Single(line => line.StartsWith("PID:", StringComparison.Ordinal)); pid = int.Parse(pidLine["PID:".Length..].Trim(), System.Globalization.CultureInfo.InvariantCulture); - var logLine = standardOutput.Split('\n').Select(line => line.Trim()) + var logLine = stdoutLines.Select(line => line.Trim()) .Single(line => line.StartsWith("Log file:", StringComparison.Ordinal)); logFile = logLine["Log file:".Length..].Trim(); } + Assert.True(completed == eof, + "`devproxy --detach` exited but its captured output didn't reach EOF; the daemon is holding the caller's pipes."); + using var daemon = Process.GetProcessById(pid.Value); Assert.False(daemon.HasExited, "The detached Dev Proxy daemon should keep running after the parent exits."); }