Rust で HTTP サーバ書いてたらハマった

ある日突然 TcpStream を直接操作して HTTP リクエストを処理するミニマルなサーバを書こうという気になりました。そうしたらよくわかってなくて色々ハマったので自分のためにメモします。

BufReader ってなに

いろいろ調べながら書いていると BufReader というのがおまじないのようにでてくるんですが、公式ドキュメントの説明がよくまとまっていました。

It can be excessively inefficient to work directly with a Read instance. For example, every call to read on TcpStream results in a system call. A BufReader<R> performs large, infrequent reads on the underlying Read and maintains an in-memory buffer of the results.

書いてあるとおりなのですが TcpStream に対して read を呼びつけると毎回システムコールが発生するらしいので、 BufReader をつかって read をまとめて回数を減らすのがよいと書いてあります。

毎回システムコール発生ってマジですか?と思って呼び出し元を掘り下げて調べてみたところ以下のような関数にあたったので、確かに libc の recv を呼んでいそうです。

    fn recv_with_flags(&self, mut buf: BorrowedCursor<'_>, flags: c_int) -> io::Result<()> {
        let ret = cvt(unsafe {
            libc::recv(
                self.as_raw_fd(),
                buf.as_mut().as_mut_ptr() as *mut c_void,
                buf.capacity(),
                flags,
            )
        })?;
        unsafe {
            buf.advance_unchecked(ret as usize);
        }
        Ok(())
    }

Read trait のドキュメントにも、 BufRead trait を実装しているのであればそれを使えというただし書きが書いてありました。

Please note that each call to read() may involve a system call, and therefore, using something that implements BufRead, such as BufReader, will be more efficient.

TcpStream の読み出し中に動作が止まった

実装過程で私は以下のようなコードを書いていました。request_linesLines<BufReader<&TcpStream>> 型がつきます。

fn handle_connection(mut st: TcpStream) {
    let reader = BufReader::new(&st);
    let mut request_lines = reader.lines();
    while let Some(Ok(line)) = request_lines.next() {
        println!("{:?}", line);
    }
    println!("-----");
}

さてこれで意気揚々と以下のように POST リクエストを投げてみたところ、curl コマンドがハングしました。

$ curl -v --data "12345" -H "Content-Type: application/octet-stream" http://localhost:4221/files/file_123
* Host localhost:4221 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:4221...
* connect to ::1 port 4221 from ::1 port 51762 failed: Connection refused
*   Trying 127.0.0.1:4221...
* Connected to localhost (127.0.0.1) port 4221
> POST /files/file_123 HTTP/1.1
> Host: localhost:4221
> User-Agent: curl/8.5.0
> Accept: */*
> Content-Type: application/octet-stream
> Content-Length: 5
>
// ここで止まり curl コマンドが終了しない

ここでサーバ側のログを見てみると Content-Length: 5 までは表示されていました。ということは println!("-----"); に到達しておらずその手前でとまっていることになります。 ハングしているのは request_lines.next() の部分と考えられます。request_lines.next() を lazy 評価しようとして TcpStream を読もうとしているが、今回の設定では POST リクエストの最後(body の末尾)に行終端記号 \n も来ないし、FIN パケットを飛ばすものもないので、 I/O 待ちになっている、と考えると辻褄が合いそうな気はします。あまりまじめに実装を追っていないので違うかも。lines で返って来る iterator がどんな挙動をするかは公式ドキュメントには明確には記載がないように見受けられるのですが、 read_line は改行がやってくるまで読むと書いてあるし、lines もたぶん改行まで読むんじゃないかなあという気がする。

で、ここから入れる保険がないかを考えてみたのですが、reader.lines() で Iterator にしている時点でどうも期待薄な感じがしています。Iterator になってから呼べるメソッドが next() くらいしかないような気がしており……うまくやれるのかな。

ということで仕方がないので lines に変換するのはやめて、愚直に TcpStream を読む方法でやり直すことにします。

fn handle_connection(mut st: TcpStream) {
    let mut reader = BufReader::new(&st);

    let mut start_line = String::new(); 
    let _ = reader.read_line(&mut start_line);
    let mut start_line_parts = start_line.split_whitespace();

    let method = start_line_parts.next();
    let path = start_line_parts.next();
    // ... 以下続く...

リクエストボディには改行コードがない可能性がありますが(というか curl で送ると上掲したようにないようなのですが)、ヘッダー行は CRLF で改行されていることが期待され、また受信側は LF を改行として認知して良いので (CR)LF が2回連続で出現するまでは(つまりただの空行を認知するまでは)愚直に read_line すればよいです。

残りは POST リクエストの body みたいに改行や EOF が来ないものをどうハンドルするかが問題になります。

一つの方法としては take を使って、コンテンツの長さだけ読んだら EOF を返すような Read のインスタンスを生成してしまうのがよさそうです。コンテンツの長さは Content-Length ヘッダで取得できるので、これでとりあえず動きます。

let mut b = reader.take(content_length);
let mut body = Vec::<u8>::new();
let _ = b.read_to_end(&mut body);

read_line は先程言ったような事情で改行が来ない場合に止まるのでダメです。read_to_end とか read_to_string してしまうと、これらは TcpStream に EOF が来るのを待機してしまいスレッドがブロックされます。

感想

TCP パケットを直接触ることは今までほとんどなかったので慣れなくて大変でしたが色々勉強になりました。フレームワークのありがたみを実感しました。もうしばらく勉強してからもう一度振り返ってみるとまた学びがありそうです。