diff --git a/DevProxy.Integration.Tests/ProcessSmokeTests.cs b/DevProxy.Integration.Tests/ProcessSmokeTests.cs index 8c6e8c85..dcb47a96 100644 --- a/DevProxy.Integration.Tests/ProcessSmokeTests.cs +++ b/DevProxy.Integration.Tests/ProcessSmokeTests.cs @@ -304,6 +304,127 @@ 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(); + // 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. + completed = await Task.WhenAny(eof, Task.Delay(TimeSpan.FromSeconds(15))); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + + Assert.Equal(0, process.ExitCode); + var standardOutput = string.Join('\n', stdoutLines); + 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 = 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 = 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."); + } + 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 +}