Elixir スクリプト で `/dev/stdin` を `File.read` で直接読むには？

elixir.jp slack で以下の質問があり、面白かったので共有します。

> File.read("/dev/stdin") なのですが、いまいち動作が納得できないので、こちらで質問させてください。
> `"/dev/stdin" |> File.read!() |> IO.inspect()`
> という elixir script である  test.exs を作成しました。
> Linux 上で `elixir test.exs < test.exs` の実行結果は、`"\"/dev/stdin\" |> File.read!() |> IO.inspect()\n"` で期待通りでした。
> しかし、Linux 上で `cat test.exs | elixir test.exs` 、および mac 上での `elixir test.exs < test.exs` および `cat test.exs | elixir test.exs` の実行結果は `""` と予想外のものになりました。

※質問者様には公開の許可をいただきました。

### 現象について

※私は手元に Mac がないので、以降は Linux 環境下での実行結果です。

実行環境は以下です。

```bash
elixir -v
Erlang/OTP 27 [erts-15.0.1] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit:ns]

Elixir 1.17.2 (compiled with Erlang/OTP 27)
```

スクリプトを２つ用意します

```pipe_test_with_file_read.exs
File.read!("/dev/stdin") |> IO.inspect()
```

```pipe_test_with_io_read.exs
IO.read(:stdio, :eof) |> IO.inspect()
```

どちらも、標準入力を読んでそれを表示させるという内容です。
これらは実行すると興味深いことに以下のようになります。

```bash
echo hello | elixir pipe_test_with_file_read.exs 
""
```

``` bash
echo hello | elixir pipe_test_with_io_read.exs 
"hello\n"
```

前者の `/dev/stdin` をファイルとして直接読むスクリプトは実行結果が期待する結果になりません。
なぜだか不思議で興味深いです。ただ、空文字列の出力は得ているので、エラーを起こしているわけではないようです。

elixir の issue を掘っていて、 `race` という単語を見つけたからだったと思いますが、以下を試すことを思いつきます。

```bash
LC_ALL=c ping -c 2 example.com | elixir pipe_test_with_file_read.exs
"64 bytes from 93.184.215.14 (93.184.215.14): icmp_seq=2 ttl=54 time=130 ms\n\n--- example.com ping statistics ---\n2 packets transmitted, 2 received, 0% packet loss, time 1002ms\nrtt min/avg/max/mdev = 129.863/130.133/130.403/0.270 ms\n"
```

``` bash
LC_ALL=c ping -c 2 example.com | elixir pipe_test_with_io_read.exs
"PING example.com (93.184.215.14) 56(84) bytes of data.\n64 bytes from 93.184.215.14 (93.184.215.14): icmp_seq=1 ttl=54 time=126 ms\n64 bytes from 93.184.215.14 (93.184.215.14): icmp_seq=2 ttl=54 time=128 ms\n\n--- example.com ping statistics ---\n2 packets transmitted, 2 received, 0% packet loss, time 1001ms\nrtt min/avg/max/mdev = 126.211/127.262/128.313/1.051 ms\n"
```

この場合、 `pipe_test_with_file_read.exs` でも出力を得ることができました。ただ、 `icmp_seq=1` は出力されていません。つまり、 `ping` の一回目の出力が読めて無さそうです。

また、何度か `pipe_test_with_file_read.exs` を実行すると、`elixir pipe_test_with_io_read.exs` と同様の結果を得られることがありました。なんとなく読む `タイミング` で結果が変わっていそうな感じがします。

### issue なのか確かめる

elixir のリポジトリで issue を立てました。

https://github.com/elixir-lang/elixir/issues/13780

即日に Andrea さんと Jose さんが返信をしてくださいました。要約すると

- Erlang のふるまい
- Erlang が入力をバッファリングしてるからだと思うよ

とのことでした。なので、 erlang のリポジトリでさらに issue を立てました。

https://github.com/erlang/otp/issues/8725

こちらも即日 Lukas さんが返信をしてくださいました。

- `-noinput` オプションを使えば動くよ

とのことでした。以下のスクリプトを作って


```pipe_test_with_file_read.erl
#!/usr/bin/env escript
%%! -noinput
main(_) ->
  {ok, Binary} = file:read_file("/dev/stdin"),
  io:format("~s", [binary_to_list(Binary)]).
```

試すと、

```bash
echo hello | escript pipe_test_with_file_read.erl 
hello
```

たしかに動きます。(オプション無しだと、出力を得ません。
このオプションを `elixir` コマンドで使えれば良さそうです。

### elixir コマンドで `-noinput` を使う

`-noinput` を調べると `erl` コマンドのオプションであることが分かります。
ref. https://www.erlang.org/doc/apps/erts/erl_cmd.html 

また、 `elixir -h` によると

```bash
  --erl "SWITCHES"             Switches to be passed down to Erlang
```

とのことなので、以下のようにしてやれば良さそうです。

```bash
echo hello | elixir --erl -noinput pipe_test_with_file_read.exs 
"hello\n"
```

お、動きました🎉

### `race`, `タイミング` とは結局なんだったのか？

Elixir や Erlang で処理を実行するとき、その処理を実行するのは VM 上のプロセスです。
スクリプトの実行ではそのスクリプトを実行するプロセスが存在します。
これは　`self() |> IO.inspect()` と書いたスクリプトを実行することで確認できます。

つまり、 `pipe_test_with_file_read.exs` の実行では、スクリプトを実行するプロセスが `File.read` で直接 `/dev/stdin` を読んでいます。

一方で、 `pipe_test_with_file_read.exs` の実行では、スクリプトを実行するプロセスが`IO.read(:stdio, :eof)` で何をしているかというと https://hexdocs.pm/elixir/IO.html#module-io-devices を読むと

> :stdio - a shortcut for :standard_io, which maps to the current Process.group_leader/0 in Erlang

という標準入出力を司るプロセスに問い合わせをしているようです。

これらを踏まえると、 VM をオプションを付けずに使う場合、
`:stdio(:standard_io)` と名付けられたプロセスが `/dev/stdin`, `/dev/stdout` をハンドリングしているので、`File.read("/dev/stdin")`をすることはそのハンドリングと `競合` しそうです。

たとえば、どちらが早く読むのか。プロセスの処理の実行順序はスケジューリングされているので、それ依存になるはずです。よって、VM をオプションを付けずに使う場合、標準入出力は `IO` モジュールの関数を介して `stdio` プロセス経由で使うのが良さそうです。

#### `-noinput` オプション と `-noshell` オプションの意味するところ

ref. https://www.erlang.org/doc/apps/erts/erl_cmd.html

> - -noinput - Ensures that the Erlang runtime system never tries to read any input. Implies -noshell.

`:standard_io` プロセスに入力を読まなくさせるオプションととらえることができそうです。
なので、このオプションを使うと `File.read("/dev/stdin")` を使っても競合が起きなくなります。（もちろん、 `IO.read(:stdio, :eof)` は動作しなくなります。）
`Implies -noshell` は、 VM(ERTS) が `shell` の入力取得を `:standard_io` プロセスに頼っていると想像するので、必然的にそうなると考えて良さそうです。 

> - -noshell - Starts an Erlang runtime system with no shell. This flag makes it possible to have the Erlang runtime system as a component in a series of Unix pipes.

`shell` が存在するとそこからの入力が `:standard_io` に入るので、それを避けるためにあると考えられます。これは `elixir` コマンド実行時にはすでに設定されていることを以下で確認することができます。

```bash
echo hello | ELIXIR_CLI_DRY_RUN=1 elixir --erl -noinput pipe_test_with_file_read.exs 
erl -noshell -elixir_root /home/pojiro/.asdf/installs/elixir/1.17.2-otp-27/bin/../lib -pa /home/pojiro/.asdf/installs/elixir/1.17.2-otp-27/bin/../lib/elixir/ebin -elixir ansi_enabled true -s elixir start_cli -noinput -extra pipe_test_with_file_read.exs
```

### そもそもなんで `/dev/stdin` を直接読みたくなったのか？

質問者は `IO.read` が遅いために `/dev/stdin` を使いたくなったようでした。
遅いことについては issue がすでにあるので(`IO.write`ですが)、

https://github.com/erlang/otp/issues/8305

このパフォーマンスをあげることにコントリビュートできるとみんながハッピーになれるかもしれません！

---

以上です。