Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions DevProxy.Integration.Tests/ProcessSmokeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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('{')))
Expand Down
11 changes: 11 additions & 0 deletions DevProxy/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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);
Expand All @@ -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();
Expand Down
72 changes: 72 additions & 0 deletions DevProxy/Proxy/StandardHandleInheritance.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Keeps a spawned detached daemon from holding on to the caller's stdio.
/// </summary>
/// <remarks>
/// On Windows, <see cref="System.Diagnostics.Process.Start()"/> calls <c>CreateProcess</c>
/// with <c>bInheritHandles = TRUE</c>, 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 <c>devproxy --detach | Out-String</c>) are inheritable, so the
/// long-running daemon would keep the caller's pipe open and the caller would never see EOF.
/// On Unix, <c>fork</c>/<c>exec</c> replaces the child's fds 0-2 with the redirected pipes, so
/// no extra work is needed there.
/// </remarks>
internal static class StandardHandleInheritance
{
private const int StdInputHandle = -10;
private const int StdOutputHandle = -11;
private const int StdErrorHandle = -12;
private const uint HandleFlagInherit = 0x00000001;

/// <summary>
/// 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.
/// </summary>
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
}
Loading