LoginSignup
11
0

Rustで Broken Pipeが起きる原因と対策

Last updated at Posted at 2023-12-04

Rustで以下のようなプログラムを書き、head -1 など出力を途中で切るプログラムと組み合わせると Broken Pipe エラーによってパニックしてしまいます。

simple-yes.rs
fn main() {
    loop {
        println!("y");
    }
}
$ ./simple-yes | head -1
y
thread 'main' panicked at library/std/src/io/stdio.rs:1019:9:
failed printing to stdout: Broken pipe (os error 32)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

どうしてこれが起きるのかとその対策を調べました。

なぜ起きるのか

ここで発生しているエラーは、 write(2) システムコール時の EPIPE エラーです。
同様のプログラムをCで実装した場合には以下のように1行のみ出力されます。

y.c
#include <unistd.h>

int main() {
    while(1) {
        write(STDOUT_FILENO, "y\n", 2);
    }
    return 0;
}
$ cc y.c -o y
$ ./y | head -1
y
$ echo "${PIPESTATUS[@]}"

ここで無限ループにしているにもかかわらず、プログラムが終了しているのは、SIGPIPE シグナルが発生したときにデフォルトでプロセスが停止するようになっているためです。

signal(7) を確認すると以下の記述があることから確認できます(これは環境によって異なる可能性があります)

       Signal      Standard   Action   Comment
       ────────────────────────────────────────────────────────────────────────
...       
       SIGKILL      P1990      Term    Kill signal
       SIGLOST        -        Term    File lock lost (unused)
       SIGPIPE      P1990      Term    Broken pipe: write to pipe with no
                                       readers; see pipe(7)
       SIGPOLL      P2001      Term    Pollable event (Sys V);
                                       synonym for SIGIO
       SIGPROF      P2001      Term    Profiling timer expired
...

また $PIPESTATUS を確認すると、 y のプロセスは 141 となっています。
ステータスが 128 以上の場合は、 128を引くことで原因となったシグナルが取得できます。
パイプしたときの終了ステータスの取得方法 が参考になりました。

141-128=13 となります。
シグナル番号を signal(7) で確認するとやはり SIGPIPE で終了していることが確認できます。

       Signal        x86/ARM     Alpha/   MIPS   PARISC   Notes
                   most others   SPARC
       ─────────────────────────────────────────────────────────────────
...
       SIGKILL          9           9       9       9
       SIGUSR1         10          30      16      16
       SIGSEGV         11          11      11      11
       SIGUSR2         12          31      17      17
       SIGPIPE         13          13      13      13
       SIGALRM         14          14      14      14
       SIGTERM         15          15      15      15
...

また、 write(2)EPIPE の記述を確認すると以下のように記されています。

       EPIPE  fd is connected to a pipe or socket whose reading end is closed.  When this
              happens the writing process will also receive a SIGPIPE signal.  (Thus, the
              write return value is seen only if the program catches, blocks  or  ignores
              this signal.)

つまり、指定された fd からデータを読んでいるパイプやソケットがすでにcloseされている場合に EPIPEは発生します。 これが起きるときプロセスは SIGPIPEシグナルを受け取ります。 そのため、write の戻り値を確認するためには、対象のシグナルを無視するかブロックする必要がある、と書いてあります。

試しにSIGPIPEを無視するプログラムを書いて確かめてみます。

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
#include <string.h>
#include <errno.h>

int main() {
    signal(SIGPIPE, SIG_IGN);
    while(1) {
        ssize_t nwrites = write(STDOUT_FILENO, "y\n", 2);
        if (nwrites < 0) {
            fprintf(stderr, "%d: %s\n", errno, strerror(errno));
            return 141;
        }
    }
    return 0;
}
$ cc y.c -o y
$ ./y | head -1
y
32: Broken pipe
$ errno -l | grep EPIPE
EPIPE 32 Broken pipe

SIGPIPE シグナルを無視するようにしたことで、 write(2) の返り値を確認できるようになり、errnoEPIPE が設定されていることが確認できます。

Rustでは

ここでRustのケースに戻ると、Broken pipe によってパニックしていることから SIGPIPEを無視するようになっていることが想像できます。

そこで strace を使って Rustで書いたプログラムでのシステムコールをトレースすると、以下のようになります。

$ strace -o /tmp/simple-yes.trace ./simple-yes | head -1 > /dev/null
$ grep SIGPIPE /tmp/simple-yes.trace
rt_sigaction(SIGPIPE, {sa_handler=SIG_IGN, sa_mask=[], sa_flags=SA_RESTORER|SA_RESTART, sa_restorer=0x25d991}, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=0}, 8) = 0
--- SIGPIPE {si_signo=SIGPIPE, si_code=SI_USER, si_pid=175199, si_uid=1000} ---

やはり sa_handlerSIG_IGN が指定されています。

つまり、Rustでは SIGPIPE に対して、明示的に SIG_IGN が指定されることによって EPIPE エラーが発生し println! マクロでunwrap()しているためにパニックしていることがわかります。

GitHub を探してみると、 Should Rust still ignore SIGPIPE by default というissueが建てられていました。

対策

対策としては大まかに3つの方法があります。

1つめ

println! マクロを使わずに 書き込み時のエラーハンドリングを自分でやる

match writeln!(&mut stdout, "y") {
    Ok(_) => {}
    Err(err) if err.kind() == io::ErrorKind::BrokenPipe => std::process:exit(141),
    Err(err) => {
        eprintln!("{:?}", err);
    }
}

ここで注意が必要なのが、"${PIPESTATUS}" が同じ挙動になるようにするためには、 ステータスコードを 141 で終了する必要があります。 ただ、Rustで作られた CLIアプリケーションで普通にステータスコードを 0 で終了しているものもあるため、どの程度こだわるかは場合によると思います。

2つ目

#[unix_sigpipe = "..."] を使用する(ただしnightly使用)

https://github.com/rust-lang/rust/issues/97889
この issue にあるように、 main()unix_sigpipe アトリビュートを使用して、 sig_dfl を指定することで signalを無視するのを抑制できるようです。

コード例
#![feature(unix_sigpipe)]

#[unix_sigpipe = "sig_dfl"]
fn main() {
    loop {
        println!("y");
    }
}

3つ目

libcクレートと unsafe を使って自分で signal を設定する。

コード例
unsafe {
    libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}

signal(2) は本来非推奨で sigaction(2) を使うべきですが簡単のために signal(2) を使用しています。

まとめ

Rustでは SIGPIPE を無視していることで、書き込み時に BrokenPipe エラーが発生して println! マクロで unwrap() されていることで パニックしていることを確認しました。

SIGPIPE をデフォルトの挙動に戻すか、 BrokenPipe エラーをハンドリングすることでパニックを起こさないようにできることを確認しました。

最近 勉強のために 毎日(?) システムコール でシステムコールについて勉強し始めたことでRustでのBrokenPipeのエラーがなぜ起きているのか説明できたのでこれからも続けていきたい。

11
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
0