コンソールアプリケーションで出力がパイプされているときに,それが閉じられたのを知る方法

パイプ話が出たのでおまけ.
MS-DOS のパイプがしょぼかったのも今や昔,コマンドプロンプトでパイプ記号を書くとちゃんと無名パイプでプロセス同士を結んでくれます.
んでは C# で次のようなコードを書いてみましょう.

// yes
static class Program
{
    static void Main(string[] args)
    {
        while (true)
        {
            Console.WriteLine("yes");
        }
    }
}
// head
using System;

static class Program
{
    static void Main(string[] args)
    {
        var count = int.Parse(args[0]);

        for (int i = 0; i < count; ++i)
        {
            var line = Console.ReadLine();
            if (line == null) break;
            Console.WriteLine(line);
        }
    }
}

こいつらをコンパイルして,コマンドプロンプトからこんな感じで実行してみます.

yes | head 5

……止まりません.yes が.
これはどういうことかと,最近公開されたソースコードの海に潜ってみてよく分かりました.Microsoft による Console クラスの実装では,出力先が「パイプもう閉じられたよエラー」を返したときには,書き込みは成功したことにしてエラーを無視します.その結果,yes の Console.WriteLine はいつまで経っても成功し続けるというわけです.

workaround

出力先がパイプの時,既にパイプが閉じられていたら終了するよう明示的に実装するには,例えばこんな感じでどうでしょうか.

using System;
using System.Runtime.InteropServices;
using System.Security;
using Microsoft.Win32.SafeHandles;

static class Program
{
    static void Main(string[] args)
    {
        var handle = Win32.GetStdHandle(Win32_StandardIOType.STD_OUTPUT_HANDLE);
        bool stdoutIsPipe = Win32.GetFileType(handle) == Win32_FileType.FILE_TYPE_PIPE;

        while (true)
        {
            Win32_Error error;
            if (stdoutIsPipe && !Win32.TestWritable(handle, out error))
            {
                return;
            }

            Console.WriteLine("yes");
        }
    }
}

public enum Win32_FileType : int
{
    FILE_TYPE_UNKNOWN = 0x0000,
    FILE_TYPE_DISK = 0x0001,
    FILE_TYPE_CHAR = 0x0002,
    FILE_TYPE_PIPE = 0x0003,
    FILE_TYPE_REMOTE = 0x8000,
}
public enum Win32_StandardIOType : int
{
    STD_INPUT_HANDLE = -10,
    STD_OUTPUT_HANDLE = -11,
    STD_ERROR_HANDLE = -12,
}
public enum Win32_Error : int
{
    ERROR_BROKEN_PIPE = 109,
    ERROR_NO_DATA = 232,
    ERROR_PIPE_NOT_CONNECTED = 233,
}
public static class Win32
{
    const string kernel32 = "kernel32.dll";
    [DllImport(kernel32, EntryPoint = "GetStdHandle", SetLastError = true)]
    [SuppressUnmanagedCodeSecurity]
    public static extern IntPtr GetRawStdHandle(Win32_StandardIOType type);

    public static SafeHandle GetStdHandle(Win32_StandardIOType type)
    {
        return new SafeFileHandle(GetRawStdHandle(type), false);
    }

    [DllImport(kernel32, SetLastError = true)]
    [SuppressUnmanagedCodeSecurity]
    public static extern Win32_FileType GetFileType(SafeHandle handle);

    [DllImport(kernel32, SetLastError = true)]
    [SuppressUnmanagedCodeSecurity]
    [return: MarshalAs(UnmanagedType.Bool)]
    public static extern bool WriteFile(SafeHandle file, ref byte value, int numberOfBytesToWrite, out int numberOfBytesWritten, IntPtr overlapped);

    [DllImport(kernel32, SetLastError = true)]
    [SuppressUnmanagedCodeSecurity]
    [return: MarshalAs(UnmanagedType.Bool)]
    public unsafe static extern bool WriteFile(SafeHandle file, byte* buffer, int numberOfBytesToWrite, out int numberOfBytesWritten, IntPtr overlapped);

    public static bool TestWritable(SafeHandle file, out Win32_Error win32Error)
    {
        win32Error = 0;
        byte dummy = 0xfe;
        int numByteWritten = 0;

        if (WriteFile(file, ref dummy, 0, out numByteWritten, IntPtr.Zero))
        {
            return true;
        }

        win32Error = (Win32_Error)Marshal.GetLastWin32Error();
        return false;
    }
}

こつは,0 バイト書き込んでみてのエラーをチェックすることのようです.
上のコードでは,P/Invoke で標準出力のハンドルを開き,それがパイプである場合は,ダミーデータを 0 バイト書き込んでみて,その結果をチェックしています.
いわゆる「出力先のパイプはもう閉じられちゃってるエラー」のときは Win32_Error.ERROR_NO_DATA が返るのですが,このコードではとにかくエラーが起きたら常に終了するようにしてあります.