Skip to content

File: low-level API for creating pipe #122806

@adamsitnik

Description

@adamsitnik

Background and motivation

The recently approved SafeProcessHandle.Start API allows the users to explictly specify the handles for standard input, output and error when starting a process.

.NET already provides File.OpenHandle to open a regular file. We have recently introduced File.OpenNullHandle to open a handle to the null device. Console.OpenStandard*Handle APIs were introduced in order to let the users inherit the standard handles from the parent process.

To enable more scenarios (including piping), we need an API to create anonymous pipes as well.

As of today, this can't be done without resorting to P/Invoke or taking dependency on System.IO.Pipes. But even when using System.IO.Pipes, we can't take advantage of all of the capabilities of the underlying OS APIs, such as opening the handles for async IO.

Pipes opened for async IO allow for (Windows implementation, Unix implementation):

  • cancellation support (required by #123959),
  • better scalability (see #8189),
  • draining remaining data without risk of getting blocked (required by #123959).

Pipes are specific files that require special handling in the process spawning code. For example, the parent copy of the pipe write handle needs to be closed after the process is spawned in order to allow receiving EOF when child process exits.

Introducing bool SafeFileHandle.IsPipe() would be too limited, so based on the existing UnixFileType proposal (#85148) I've used GH Copilot to implement a prototype of an API that handles all kinds of files on every OS.

API Proposal

namespace System.IO;
{
    public enum FileType
    {
        Unknown = 0,
        RegularFile,
        Pipe,
        Socket,
        CharacterDevice,
        Directory,
        SymbolicLink,
        BlockDevice
    }
}

namespace Microsoft.Win32.SafeHandles
{
    public sealed class SafeFileHandle
    {
        public FileType GetFileType();
        public static void CreateAnonymousPipe(out SafeFileHandle read, out SafeFileHandle write, bool asyncRead = false, bool asyncWrite = false);
    }
}

FileType implementation: #124561

API Usage

SafeFileHandle.CreateAnonymousPipe(out SafeFileHandle readPipe, out SafeFileHandle writePipe);

using (readPipe)
using (writePipe)
{
    ProcessStartOptions producer = new("sh")
    {
        Arguments = { "-c", "printf 'hello world\\ntest line\\nanother test\\n'" }
    };
    ProcessStartOptions consumer= new("grep")
    {
        Arguments = { "test" }
    };

    using SafeProcessHandle producerHandle = SafeProcessHandle.Start(producer, input: null, output: writePipe, error: null);

    using (SafeFileHandle outputHandle = File.OpenHandle("output.txt", FileMode.Create, FileAccess.Write, FileShare.ReadWrite))
    {
        using SafeProcessHandle consumerHandle = SafeProcessHandle.Start(consumer, readPipe, outputHandle, error: null);

        await producerHandle.WaitForExitAsync();
        await consumerHandle.WaitForExitAsync();
    }
}

Alternative Designs

PipePair

As pointed by @davidfowl in an offline API review, the method could return a PipePair struct instead of using out parameters. With a deconstruct method to allow tuple like unpacking:

public readonly struct PipePair(SafeFileHandle read, SafeFileHandle write) : IDisposable
{
    public SafeFileHandle Read { get; } = read;

    public SafeFileHandle Write { get; } = write;

    public void Deconstruct(out SafeFileHandle read, out SafeFileHandle write)
    {
        read = Read;
        write = Write;
    }

    public void Dispose()
    {
        Read.Dispose();
        Write.Dispose();
    }
}

Then the users could use it like this:

var (read, write) = File.CreatePipe();

But it does not work well with using statements, as following code would not compile:

using var (read, write) = File.CreatePipe();

And according to this SO answer, we would still need two lines of code:

using var pipePair = File.CreatePipe();
var (read, write) = pipePair;

Which is not much different from the out parameter approach, but adds one more type to the API surface.

Option bag

Another alternative would be to have an option bag instead of multiple parameters for the CreateAnonymousPipe method. From the sys-call level, we can control following options for the pipe handles:

  • O_NONBLOCK (Unix) / FILE_FLAG_OVERLAPPED (Windows) for async IO.
  • Inheritance (Windows) / CLOEXEC (Unix) to control whether the handle is inherited by child processes. I don't want to expose this option, because such handle can be by accident inherited by another process that is started by a different thread. That is why the new SafeProcessHandle APIs accept non-inheritable handles and enable the inheritance in the safe way:
    • On Unix: after fork, before exec, the handle is marked as inheritable by removing CLOEXEC flag in the child process. So the parent copy remains non-inheritable and won't be leaked to other processes.
    • On Windows: the handle is duplicated as inheritable under a reader-writer lock shared with Process.Start (the allow list mechanism exposed by Windows requires inheritable handles..).
  • n[In/Out]BufferSize (Windows) / F_SETPIPE_SZ (Linux) to control the buffer size of the pipe.
  • PIPE_TYPE_MESSAGE (Windows) / O_DIRECT (Linux) for message mode.
    • On Linux, if the read buffer is smaller than the packet, extra bytes are discarded (data loss). There is no way to read the remainder of the packet later.
    • On Windows, if the read buffer is smaller than the packet, the read operation return partial data AND GetLastError reports ERROR_MORE_DATA. The remainder of the packet can be read later.
    • I don't want to expose this option, because it is not portable and can lead to data loss if used incorrectly.

If we decide to introduce an option bag, the most natural namespace for it would be System.IO.Pipes

namespace System.IO.Pipes
{
    public sealed class AnonymousPipeOptions
    {
        public bool AsyncRead { get; set; }
        public bool AsyncWrite { get; set; }
        public int? ReadBufferSize { get; set; }
        public int? WriteBufferSize { get; set; }
    }
}

If we decide to put the factory method in SafePipeHandle, the SafeProcessHandle.Start method would need to be updated to accept SafeHandle instead of SafeFileHandle for the standard input, output and error parameters:

namespace Microsoft.Win32.SafeHandles
{
    public sealed partial class SafePipeHandle
    {
        public static void CreateAnonymousPipe(out SafePipeHandle read, out SafePipeHandle write, AnonymousPipeOptions? options = null);
    }

    public partial class SafeProcessHandle
    {
        public static SafeProcessHandle Start(ProcessStartOptions options, SafeHandle? input, SafeHandle? output, SafeHandle? error);
    }
}

Risks

See the inheritance notes above.

Metadata

Metadata

Assignees

Labels

api-approvedAPI was approved in API review, it can be implementedarea-System.IO

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions