非同期外部プロセス起動で標準出力を受け取る際の注意点

# はじめに

C#というか.NETの世界では、外部プロセスを起動する際に`System.Diagnostics.Process`を使用するということになっている。
この`Process`クラスには非同期イベントベースで標準出力をやり取りするためのAPIが存在するが、意図しない動作になる場合があるので要注意という話。

# Processの非同期APIについて

非同期で標準出力、エラー出力を受け取り、かつ非同期でプロセス終了を待ちたい場合、以下のようにする。

1. `ProcessStartInfo`を以下のように設定する
    * UseShellExecute=false
    * RedirectStandardOutput=true
    * RedirectStandardError=true
2. `Process`をnewする(Startはしない)
3. `Process`を以下のように設定する
    * `OutputDataReceived`にコールバック登録
    * `ErrorDataReceived`にコールバック登録
    * `EnableRaisingEvents`=true
    * `Exited`にコールバック登録
        * 中で終了イベント通知を入れる
4. `Process.Start`を実行
5. `proc.BeginOutputReadLine()`を実行
6. `proc.BeginErrorReadLine()`を実行
7. Exitedイベントを何らかの方法で待つ(ManualResetEventやCancellationToken等)
    * 中断等を実装したい場合、[CreateLinkedTokenSource](https://docs.microsoft.com/en-us/dotnet/api/system.threading.cancellationtokensource.createlinkedtokensource?view=netframework-4.7.2)等を駆使する
        * **ja版の説明だと`WhenAll`みたいな説明だが、実際は`WhenAny`なので要注意**

コードに直すと以下のようになる。

```csharp
void AsyncProcTest()
{
    var si = new ProcessStartInfo("dotnet", "--info");
    // ウィンドウ表示を完全に消したい場合
    // si.CreateNoWindow = true;
    si.RedirectStandardError = true;
    si.RedirectStandardOutput = true;
    si.UseShellExecute = false;
    using(var proc = new Process())
    using(var ctoken = new CancellationTokenSource())
    {
        proc.EnableRaisingEvents = true;
        proc.StartInfo = si;
        // コールバックの設定
        proc.OutputDataReceived += (sender, ev) =>
        {
            Console.WriteLine($"stdout={ev.Data}");
        };
        proc.ErrorDataReceived += (sender, ev) =>
        {
            Console.WriteLine($"stderr={ev.Data}");
        };
        proc.Exited += (sender, ev) =>
        {
            Console.WriteLine($"exited");
            // プロセスが終了すると呼ばれる
            ctoken.Cancel();
        };
        // プロセスの開始
        proc.Start();
        // 非同期出力読出し開始
        proc.BeginErrorReadLine();
        proc.BeginOutputReadLine();
        // 終了まで待つ
        ctoken.Token.WaitHandle.WaitOne();
    }
}
```

# 出力が全部出ない

さて、上記コード、一見問題ないように見える。
しかし、出力が全て表示される場合もあるが、途中で途切れてしまう場合もある。

これはなぜかというと、`Exited`イベントが発生した時でも、`Process`にある標準出力の内部保持データが残っている場合があるからである。
内部バッファデータを確実に全て消化させるには、`Process.WaitForExit()`を実行する必要がある。プロセスは全て終わっているので、ほとんどすぐ終わるはず。

# 出力コールバック内でキャッチされない例外が稀に発生する

例えば、

```csharp
var sb = new StringBuilder();
proc.OutputDataReceived += (sender, ev) =>
{
    sb.Append(ev.Data);
};
```

のようなコードを書いた場合、正しく終了した後にも関わらず、稀に不定期なタイミングでキャッチされない例外が発生する場合がある(ObjectDisposedExceptionあるいはNullReferenceException)。
これは、**usingブロックを抜けた後にイベントが発生する場合があるから**である。

なぜそうなるか、という所は未解明だが、コールバックの中でキャッチするか、あるいは終了後に`Process.CancelOutputRead()`と`Process.CancelErrorRead()`を呼び出すことで回避ができる。

# Exitedコールバック内でキャッチされない例外が稀に発生する

Exitedイベントが、完全にプロセスが終了し、各種オブジェクトが破棄された後に起こる場合があり、その時に当然ほとんどのオブジェクトは破棄されているので、`ObjectDisposedException`、あるいは`NullReferenceException`が発生して、キャッチされない例外になる場合がある。

これに関しては、`BeginOutputReadLine`等と組み合わせると発生するようだが、詳しい条件は不明。全く起きない環境もあったりする。
.NET Frameworkでも、.NET Coreでも起きたりするので、もしかしたらOS側のAPIの使い方に原因があるのかもしれない。

回避策は、コールバック内でキャッチするか、あるいは`BeginOutputReadLine`等を使用しないようにすれば、ほぼ起きなくなる。
その場合の標準出力の受け取りは、別スレッドで受け取り処理を行えば良い。

```csharp
void AsyncProcTest2()
{
    var si = new ProcessStartInfo("dotnet", "--info");
    // ウィンドウ表示を完全に消したい場合
    // si.CreateNoWindow = true;
    si.RedirectStandardError = true;
    si.RedirectStandardOutput = true;
    si.UseShellExecute = false;
    using(var proc = new Process())
    using(var ctoken = new CancellationTokenSource())
    {
        proc.EnableRaisingEvents = true;
        proc.StartInfo = si;
        // コールバックの設定
        proc.Exited += (sender, ev) =>
        {
            Console.WriteLine($"exited");
            // プロセスが終了すると呼ばれる
            ctoken.Cancel();
        };
        // プロセスの開始
        proc.Start();
        Task.WaitAll(
            Task.Run(() =>
            {
                while(true)
                {
                    var l = proc.StandardOutput.ReadLine();
                    if(l == null)
                    {
                        break;
                    }
                    Console.WriteLine($"stdout = {l}");
                }
            }),
            Task.Run(() =>
            {
                ctoken.Token.WaitHandle.WaitOne();
                proc.WaitForExit();
            })
        );
    }
}
```

## 注意点

**プロセスが終わらないと、StandardOutputからのRead(Async含む)が戻ってこないため、かならず子プロセス含めてkillする必要がある。**

# 終りに

Processクラス自体は昔からあるクラスなので、イベントベースのAPIが用意されているが、終了イベントはともかく、標準出力に関するイベントはもう使わない方が良いのかもしれない。
ただし、長期に渡るプロセスの実行時は終わるまで制御が戻らない可能性があるため、この辺りは難しいところだ。