🦀

Rustの`std::io::Write::write`が`Ok(0)`を返すことについて

2024/12/04に公開

はじめに

先日、hyperのストリーム[1]tokio-rustlsでラップした際に、tokio::io::AsyncWrite::poll_writeが返すOk(0)の解釈の違いによって問題が出たので、そのときに調べたことをまとめます
こちらは厳密にはtokioAsyncWriteの話ですが、実質同じなのでstd::io::Writeの話として記事を続けます

そのときのissue

起きたこと

こちらのコードはtokio-rustlsにあるTLSストリームの実装の一部です

https://github.com/rustls/tokio-rustls/blob/66fb0ae98fbc9e71d5aa855d45e88ca8d53f95f3/src/common/mod.rs#L330-L334

ここではshutdownの際にwhileループでなにやら今まで書ききれてなかったデータを書き込もうとしています
しかし、tokio-rustlshyperのストリームをラップしていた場合、この段階でこのwriteに対して常にOk(0)を返すのでself.session.wants_write()が一生trueのままになり無限ループに陥ってしまいます

結局、https://doc.rust-lang.org/std/io/trait.Write.html#tymethod.write には

A return value of Ok(0) typically means that the underlying object is no longer able to accept bytes and will likely not be able to in the future as well, or that the buffer provided is empty.

と書かれているので、hyper側がOk(0)を返すのは正しい挙動で、rust-tls側が受け取ったOk(0)を特別扱いする必要があるということでまとまりました

std::io::Write::writeOk(0)を返すことについての第一印象

しかし https://github.com/rustls/tokio-rustls/issues/92#issuecomment-2507878251 のコメントの通り、ストリームがもうこれ以上書き込めないよということを表明するためにOk(0)を返すのはあんまりなデザインだと思わざるを得ません
POSIXのwrite(2)にもそのような話はないっぽい
普通に考えて、仮にstd::io::Write::write_allを自分で実装するとしたら、自分も上記のtokio-rustlsのコードみたいに書く自信があります

できればもうこれ以上書き込めないよというときには https://doc.rust-lang.org/std/io/enum.ErrorKind.html からWriteZeroとかそれっぽいやつを選んで返してほしいものです

ちなみにstd::io::Write::write_allOk(0)を特別に扱っているのでそれが原因で無限ループすることはありません
https://doc.rust-lang.org/src/std/io/mod.rs.html#1703-1715

rustc内の議論

上記のデザイン上の疑問について調べていたところ、目を引くissueが見つかりました
https://github.com/rust-lang/rust/issues/56889

主題はstd::io::Write::write_allOk(0)をどう扱うかについてですが

https://github.com/rust-lang/rust/issues/56889#issuecomment-740110530

IMHO a sane writer should return an error when it is already at the end. This would be in line with eg. a block device (a typical size-limited object to write to...). Ok(0) doesn't really make any sense to me. If you can't write anything yet, return EWOULDBLOCK (or, well, block), if you can't write anything because of some error, return the error. But don't just do nothing (or purely some unrelated internal stuff) and return 0, that makes no sense.
So in that sense, treating Ok(0) as an error condition is acceptable, because it's something that shouldn't happen anyway. (And yes, IMO the slice impl could just as well be changed)

というコメントがあり、個人的に納得しました。かなりまとめるとwriteOk(0)を返すのはそもそも異常なので、受け取った側がそれをエラーとして扱うのは妥当だということです

まとめ

個人的には以下のような結論に至りました。

  • std::io::Write::writeに対してOk(0)が帰ってきた場合、受け取った側はそれをエラーとして扱うべき
  • だからといってstd::io::Write::write実装側がもうこれ以上書き込めないよというときにOk(0)を返すのは良くない。普通にstd::io::Errorを返すべき
  • そもそもResult<std::num::NonZeroUsize>返せよ。Breaking Changeなのを差し引いてもさすがにこれはオタクすぎるか
  • std::io::Write::write_allOk(0)をエラーとして扱う。無限ループするわけにはいかないので[2]これは正しい
  • std::io::Write::write_allがそういう挙動をしている以上 https://doc.rust-lang.org/std/io/trait.Write.html#tymethod.writeA return value of Ok(0) typically means that the underlying object is no longer able to accept bytes and will likely not be able to in the future as well, or that the buffer provided is empty. と書くのは妥当
脚注
  1. 具体的にはhyperのHTTP/2サーバーでUpgradeされたストリーム ↩︎

  2. 関連するLibs-API Meeting https://hackmd.io/@rust-libs/SJUBKd-lK ↩︎

GitHubで編集を提案

Discussion