Pixel Pedals of Tomakomai

北海道苫小牧市出身の初老の日常

謹賀新年

明けましておめでとうございます。本年もよろしくお願い致します。

新型コロナで在宅勤務が始まってからは、自然と育児にコミットする時間が激増していて、まあ、間近で子供の成長を見られるのは他では体験できないとても幸せなことではあるのですが、一方で、本当にこんな人生を歩んでいいんだっけと日々自問自答しております。

一言で言えば、数学やHaskellやRustなどきちんと興味を持てるものにどっぷりと浸かりたいので、新年を機に時間を作っていこうと思います。

cargo test --jobs N -- --test-threads=M

redis-rs の cluster のテストが何度やっても通らなくてハマった。ただし、テスト名を指定して一個ずつであれば成功する。該当するテストは以下。

redis-rs/test_cluster.rs at bd8dc731ded564329d74717dfacc5cce748d891f · mitsuhiko/redis-rs · GitHub

このテストは redis server を立ち上げてクラスタを組んでテストを実行している。このとき、各サーバには 7000 番ポートから順番に割り当てている。

redis-rs/cluster.rs at bd8dc731ded564329d74717dfacc5cce748d891f · mitsuhiko/redis-rs · GitHub

そして、デフォルトでは cargo test 1 は複数のテストを並列に実行する。直列な世界に生きているので、ここに気がつくまでに時間がかかってしまった。

テストの実行ファイルでヘルプを表示させると、デフォルトでは並列実装すると書いてある。

$ target/debug/deps/test_cluster-cdbee22b93a5c3e7 --help
Usage: --help [OPTIONS] [FILTER]                                                                                                                                                                                                                        
Options:
..snip..
        --test-threads n_threads
                        Number of threads used for running tests in parallel
..snip..
By default, all tests are run in parallel. This can be altered with the
--test-threads flag or the RUST_TEST_THREADS environment variable when running
tests (set it to 1).
..snip..

実際、 rustc 側で実装を見ると、デフォルトは CPU コア数であることがわかる。

rust/concurrency.rs at 4ae328bef47dffcbf363e5ae873f419c06a5511d · rust-lang/rust · GitHub

並列で実行すると test_cluster.rs が落ちるのは明確だ。 7000 番 port が競合しているので、それぞれのテストで使う redis のサーバが建てられなくなってしまう。直列で実行するしかない。

redis-rs のリポジトリには、 Makefile があり、そこで --test-threads が指定されている。これはテストの実行ファイルが持つオプションなので cargo に直接渡すことはできず、 -- --test-threads=1 というように -- の後ろに指定しなければならない。

redis-rs/Makefile at bd8dc731ded564329d74717dfacc5cce748d891f · mitsuhiko/redis-rs · GitHub

ところで、 --test-threads=1 とは別に、 cargo には --jobs というオプションがある。

$ cargo test --help
..snip..
    -j, --jobs <N>                   Number of parallel jobs, defaults to # of CPUs
..snip..

ドキュメントは以下にある。紛らわしいが、実行ファイルのビルドに使う CPU 数を指定するオプションらしい。

doc.rust-lang.org

The --jobs argument affects the building of the test executable but does not affect how many threads are used when running the tests.

今回のように「単体テストが逐次実行しかできない」という場合には --jobs ではなく --test-threads を使わねばならない。指定先も cargo に指定するのか単体テストの実行ファイル側に指定するのかも別れていてわかりにくい。

そもそも、「単体テストが逐次実行しかできない」という状況がイケてないなあとは思う。


  1. 正確には、テストの実行ファイルである。

Haskellの古いmkSocketの引数のfamily

Linuxネットワークプログラミングの勉強をきちんとしましょうということなんだけど、とりあえず調べたことをメモっておく。

HaskellmkSocket という関数がある。

https://hackage.haskell.org/package/network-3.1.2.0/docs/Network-Socket.html#v:mkSocket

今は引数がファイルディスクリプタ CInt だけとなっているが、 2.x 系の頃の引数はもっと複雑だった。

https://hackage.haskell.org/package/network-2.2.3/docs/Network-Socket.html#v:mkSocket

この中に Family という引数があって、これは AF_INET とか AF_INET6AF_UNIX と言ったようなソケットの種類を表す値なんだけど、 Server::Starter のようにファイルディスクリプタが渡されている場合にどうやってこれらの値を決めるかが今回の疑問点。

実際、拙作の hs-server-starter では、 Server::Starter から渡された環境変数の値 0.0.0.0:80=3 というような値を雑にパースしてこれらの値を推測していたのだけど、 IPv6 アドレスの判定が雑過ぎて完全にバグっていた。

https://github.com/hiratara/hs-server-starter/blob/f47d198aab92135e6546191f82f44bec60ccaf5f/src/Network/ServerStarter/Socket.hs#L83-L101

じゃあ、どうやって直せばいいかってところなんだけど、 Starlet のコードを見ると、渡ってきたファイルディスクリプタはすべて IPv4 であると見なしてコードが組まれているように見えた。これに従えば、 AF_INET 決め打ちにしても差し支えないように思える。

https://metacpan.org/source/KAZUHO/Starlet-0.31/lib/Plack/Handler/Starlet.pm#L26-30

現に、以下のように改修をしても、テストやサンプルアプリは問題なく動いた(もちろん、 network-2.x 系を使って試している)。

https://github.com/hiratara/hs-server-starter/pull/10/files

じゃあ、この family は何に使っているのかということで、例えば以下にある getSocketName を見てみる。

https://hackage.haskell.org/package/network-2.2.3/docs/src/Network-Socket.html#getSocketName

withNewSockAddr を見ると、 sizeOfSockAddrByFamily でサイズを求めるのに使っている。直前の定義を見ると AF_INET = 16 AF_INET6 = 28 AF_UNIX = 110 などと family ごとにサイズが違っている。

https://hackage.haskell.org/package/network-2.2.3/docs/src/Network-Socket-Internal.html#withNewSockAddr

そして、このサイズを元に struct sockaddr を確保して c_getsockname を呼ぶことになる。この getsockname を調べると、

https://man7.org/linux/man-pages//man2/getsockname.2.html

The returned address is truncated if the buffer provided is too small;

つまり、渡した struct sockaddr のサイズが足りなくても、渡した文のサイズで切られて返されるだけということである。そういう意味では AF_INET は一番サイズが短いので、 AF_UNIX などを採用しておいたほうが害が少ないのかもしれない。

family はこのようにシステムコールを呼ぶのに必要な struct sockaddr のサイズを決めるためにしか使われておらず、実際はシステムコールによって返された struct sockaddr を以下の関数 peekSockAddr で解釈して使うことになる。 struct sockaddrfamily によって形式が違うが、先頭は sa_family_t となっているので、これを見ることで family を判別できる。

https://hackage.haskell.org/package/network-2.2.3/docs/src/Network-Socket-Internal.html#peekSockAddr

ちなみに、 network-3.x 系では family などを渡さなくて良くなっているが、それは十分に大きい struct sockaddr を確保しているからである。

https://hackage.haskell.org/package/network-3.1.2.0/docs/src/Network.Socket.Types.html

-- sizeof(struct sockaddr_storage) which has enough space to contain
-- sockaddr_in, sockaddr_in6 and sockaddr_un.
sockaddrStorageLen :: Int
sockaddrStorageLen = 128

Rustでdocker build時に依存ライブラリの再ビルドを避ける

Rust で書かれたアプリをビルドするための docker image は以下で公開されている。

hub.docker.com

しかし、ここに出ているように COPY . . なんてやっていると、 src/main.rs を触るだけで毎回 cargo install 時に依存するライブラリをすべてダウンロードしてきてコンパイルしてしまい、恐ろしく効率が悪い。

これを避けるには、アプリをビルドする前に依存だけをビルドするレイヤを作っておき、その上に自分のアプリをビルドするレイヤを載せられるといい。しかし、残念なことに、現在の cargo には依存クレートだけをビルドする仕組みがない。じゃあどうするかというのが、以下のエントリに書かれている。

whitfin.io

要点だけ説明すると、まず cargo new --bin my_project とこれからビルドするプロジェクトと同名のダミーの空のプロジェクトを用意し、そこに Cargo.tomlCargo.lock のみを配置して cargo build --release する。その後 src/*.rs を本物に差し替えて、もう一度 cargo build --release すれば良い。ただし、自分で試してみたところなぜか 2 度目の cargo build が動いてくれなかったので、 touch src/main.rs をするなどの処理をおまけで入れておいた。

さて、手元での実行はこれで良いが、 Cloud Build: serverless CI/CD platform  |  Google Cloud を使うと、前回ビルド済のレイヤを参照できるわけではないのでキャッシュが効いてくれない。 --cache-from を使う方法もあるが、マルチステージビルドとだいぶ相性が悪い。というのも、ビルドに使ったイメージも破棄せず docker push しなければならないので。じゃあどうするかというのは、以下のエントリに書かれている。

qiita.com

kaniko を使えば、 cloudbuild.yaml は本当に大丈夫かいなってくらい恐ろしくシンプルになる。それでも動かしてみるときちんとキャッシュをする動きになっていて、初回実行が 18M52S かかる cargo build が2度目の実行が 1M25S で済んだ。すばらしい。

&Tがそのまま&dyn Traitとして使えるわけではない

トレイトオブジェクトを返す次の関数 fコンパイルできる。

trait X {}

fn f(x: &dyn X) -> &dyn X {
    x
}

ところが、次のように書くとコンパイルできない。

fn f<T: X + ?Sized>(x: &T) -> &dyn X {
    x
}
   Compiling playground v0.0.1 (/playground)
error[E0277]: the size for values of type `T` cannot be known at compilation time
 --> src/lib.rs:4:5
  |
3 | fn f<T: X + ?Sized>(x: &T) -> &dyn X {
  |      - this type parameter needs to be `std::marker::Sized`
4 |     x
  |     ^ doesn't have a size known at compile-time
  |
  = help: the trait `std::marker::Sized` is not implemented for `T`
  = note: to learn more, visit <https://doc.rust-lang.org/book/ch19-04-advanced-types.html#dynamically-sized-types-and-the-sized-trait>
  = note: required for the cast to the object type `dyn X`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0277`.
error: could not compile `playground`.

To learn more, run the command again with --verbose.

T はトレイト X を実装しているのだから、何もしなくても &dyn X なのではと思われるかもしれないが、この書き方をした f では x が直接返されるわけではなく、トレイトオブジェクトに変換される。トレイトオブジェクトについては Trait object types - The Rust Reference に記述がある。 Exploring Rust fat pointers でもトレイトオブジェクトの構造 (fat pointers) についてわかりやすく説明されている。

Each instance of a pointer to a trait object includes: a pointer to an instance of a type T that implements SomeTrait a virtual method table, often just called a vtable, which contains, for each method of SomeTrait and its supertraits that T implements, a pointer to T's implementation (i.e. a function pointer).

この変換時になぜ Sized が必要なのかは、以下の issue で説明されている。 [u8] のサイズの格納位置が &&dyn の場合で違うため、単純にポインタを流用してトレイトオブジェクトを作ることができないようだ。なるほど。

github.com

A legitimate example of why the Sized bound is needed is for e.g. Self = [u8]. You cannot cast &[u8] to &dyn Trait because there would be nowhere to store the length of the slice (&[u8] stores it in the same place where &dyn Trait puts its vtable).

ただ、この issue がなぜクローズされていないかはよくわからず。将来挙動が変わる見込みがあるんだろうか?

Rustの&mutのmoveとreborrow

&mutCopy trait を 実装していない 。よって、こちらは実行できない。

fn main() {
    let x: &mut i32 = &mut 0;
    {
        let y = x;
        println!("y: {}", y);
    }
    println!("x: {}", x);
}
   Compiling playground v0.0.1 (/playground)
error[E0382]: borrow of moved value: `x`
 --> src/main.rs:7:23
  |
2 |     let x: &mut i32 = &mut 0;
  |         - move occurs because `x` has type `&mut i32`, which does not implement the `Copy` trait
3 |     {
4 |         let y = x;
  |                 - value moved here
...
7 |     println!("x: {}", x);
  |                       ^ value borrowed here after move

error: aborting due to previous error

For more information about this error, try `rustc --explain E0382`.
error: could not compile `playground`.

To learn more, run the command again with --verbose.

しかし、なぜかこっちは実行できる。

fn main() {
    let x: &mut i32 = &mut 0;
    {
        let y: &mut i32 = x;
        println!("y: {}", y);
    }
    println!("x: {}", x);
}

ブロックを無くして、 xy を共存させると、コンパイルできなくなる。

fn main() {
    let x: &mut i32 = &mut 0;
    let y: &mut i32 = x;
    println!("x + y: {}", *x + *y);
}
   Compiling playground v0.0.1 (/playground)
error[E0503]: cannot use `*x` because it was mutably borrowed
 --> src/main.rs:4:27
  |
3 |     let y: &mut i32 = x;
  |                       - borrow of `*x` occurs here
4 |     println!("x + y: {}", *x + *y);
  |                           ^^   -- borrow later used here
  |                           |
  |                           use of borrowed `*x`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0503`.
error: could not compile `playground`.

To learn more, run the command again with --verbose.

cannot use *x because it was mutably borrowed という不穏なメッセージが出ている。これは、 y に代入しているものが &mut *x であることを暗に示している。実際、最初の例を以下のようにすると実行可能になる。

fn main() {
    let x: &mut i32 = &mut 0;
    {
        let y = &mut *x;
        println!("y: {}", y);
    }
    println!("x: {}", x);
}

この振る舞いを調べると、どうやら reborrow と言われている振る舞いであることがわかった。しかし、現時点で reborrow について明示的にドキュメントされてないらしく、以下の issue が上がっている。

github.com

もっとも、この挙動はレシーバが &mut self であるようなメソッドから、同様にレシーバが &mut self であるメソッドを複数回呼ぶだけで自然に発生する(ただ、メソッドのレシーバは auto dereference が効くので、特殊な気もするが)もので、特筆しなくても常識だろうということなのかもしれない。とはいえ、 Copy じゃない割に move しているようにも見えなくて腹落ちしてなかったので、そこがはっきりしたのは調べた甲斐はあった。

Surface Book 3を手にするまでの2ヶ月の苦闘の記録

Surface Book 2 を買ってから 2年半が経過し、今回 Surface Book 3 を購入した。日本でも販売されることを知らずに US のマイクロソフトストアで購入したのだが、今回実機を手にするまでにめちゃめちゃ苦労したので、記録を残しておく。 US キーボードが欲しかったので、まあ、苦労した意味は0ではないかなと思っている。

5/7 予約

Surface Book 2 がとても気に入っていたので、 Surface Book 3 の発表はずっと待っていた。 発表された ことを知り、即予約をした。クレジットカードの認証がうまくいかなかったり、2重で注文してしまったり(!)と言ったトラブルで2回サポートとチャットしたが、サポートがうまく対処してくれ、この時点では特に大きな問題は起きなかった。

5/23 問題その1. Mailout canceled

予定通りマイクロソフトから商品が発送され、 5/22 には転送業者である Planet Express の倉庫に到着した。本当は昔から愛用している 1worldshopping.com を使いたかったのだが、 事務所の契約期限が切れて閉鎖 されてしまったので、急遽見つけたのが Planet Express だった。結論から言えば、 Planet Express はきちんとサポートしてくれるし、よいサービスである。

倉庫に荷物が届いたということで、早速日本への配送手続きを済ませてワクワクしながら寝たのだが、翌日、発送をキャンセルするというメールを受け取って青ざめた。全く知らなかったのだが、 US から日本へ輸入をする際、金額が $2,500 を超えると Electronic Export Information という物が必要になるようだ。 1worldshopping は勝手にやってくれていたのだが、 Planet Express は別途手続きが必要らしい。手続きが必要と言ってもなんのことやらさっぱりだし、品物の金額が金額なだけに、US国内で破棄するという最悪の事態まで考えて胃が痛くなった。

しかし、蓋を開けてみれば話は簡単で、追加料金 $40 を支払うことで、特に追加の書類などは用意せずとも、 Planet Express 側で勝手にやってもらえた。まあ、さすがに $40 は高いと思うので、今度からは高額なものを扱うときは別の業者を探すかもしれない。ともあれ、三日後の 5/26 には無事に日本から発送された。

5/30 問題その2. 初期不良

コロナの影響で遅れることを覚悟したのだが、そこはさすが FedEx 。めちゃくちゃ高いだけあって、消費税が高過ぎて一瞬だけ税関で止まったが、電話で FedEx にクレジット番号を教えたら決済してくれて、 4 日後の 5/30 には日本郵政経由で Surface Book 3 を受け取ることができた。ここですべてが無事終わったと思っていたが、実はこの日が苦闘の始まりに過ぎなかったのだ。

開封してセットアップするものの、残念ながら全く感動はない。そりゃあそうだ、 Surface Book 2 と中身以外は全く同じなのだから。まあ、もともと気に入っているもののリプレースなので、そんなもんだろうと思いながら淡々とセットアップをしていたのだが、途中でおかしなことに気がつく。・・・画面に頻繁に横線上のノイズが入っている?? 初めて気がついたのは Windows Update を適用して再起動をしたとき。その時は気のせいだと思っていたが、時間が経てば経つほどどんどんノイズの頻度は上がっていく。これはヤバい、と思い始めた。

なんとか解決できないかといろいろなことをした。ドライバのアップデートをしたり、オンボードIntel Iris Pro Graphics を無効にして使ってみたり。一時期、改善したような気分になったこともあった。しかし、忘れた頃に高負荷になるとノイズが現れる。高負荷にならないようにだましだまし使えば、この個体とも上手に付き合えるのではないかとも考えてみた。しかし、これだけ高額なものを買って妥協するのは、流石に辛すぎる。セーフモードですら発生していたので、ドライバのアップデートで将来治ることも期待できない。

セーフモードでのノイズの様子

4日後の 6/2 に、諦めてサポートに連絡した。この時点で 海外購入の Surface Book が故障したときの涙の物語 のエントリは読んでいたので、日本から直接 Surface Book 3 を送りつけて交換してもらうことは無理だろうなと把握していた。最終手段として、アメリカに移住した元同僚に連絡を取り、助けを求めることにする。幸い、米国でコロナやデモが大変な中、快く手伝ってもらえた。彼がいなければ今回は完全に詰んでいたので、どれだけ感謝してもしきれない。

6/3 に FedEx で US に発送し 6/5 には元同僚の家に到着した。Surface Book 3 のリプレースは、マイクロソフトに発送ラベルを発行してもらい、それを使ってマイクロソフトのサポートセンターへ送付することで交換してもらえる仕組みになっている。この発送ラベルの住所が US のものじゃないと受け取ってもらえないそうだ。戻りの通関手続きなどを考えれば、そりゃあそうであろう。ここでラベルの発行処理に手間取ってサポートへ3~4度メールとチャットで連絡する羽目になったが、翌日の 6/5 には UPS のラベルを発行してもらい、 6/7 に元同僚がマイクロソフトサポートに向けて発送してくれた。

6/13 問題その3. 初期不良のある Surface Book 3が行方不明

UPSの追跡番号によれば、 6/10 にはマイクロソフトサポートセンターに到着したことになっているのだが、マイクロソフトの管理画面で見ることができる受注ステータスが一向に変わらない。 6/13 にサポートへ連絡したところ、コロナの影響で遅れているので4営業日待ってくれと言われた。しかし、一向に状況が変わらず 6/19 にサポートへ連絡したところ、状況がわからないので調べるから待ってくれという返答。とても嫌な予感がする。そして、残念ながらその後も連絡が来なかったので 6/22 に連絡をしたところ、Surface Book 3 はどうなっているかわからないという返答。おいおい、マジかよ・・・。そこは流石に高額な品だったこともあり、責任者らしき人が出てきて 24 時間以内に連絡するので待てと言われた。その後、 23 時間程度経ったところで返品の注文がキャンセルされてなかったことにされたので、このままバックレられるかとビビったのだけど、その 50 分後( 24 時間経過の 10 分前) に新しいオーダーが作成されて、今送ったから5日以内に代替機が届くよと連絡が来た。元同僚宅に代替機が届いたのは、 6/25 のこと。ここでも FedEx は速かった。

6/28 問題その4. 謎の小包

さて、あとは日本に発送してもらうだけだと思っていたら、マイクロソフトからまたメールが来た。どうやら UPS 経由でもうひとつ何かを元同僚宅へ輸送中らしい。おいおい、まさか重複して配送してないだろな・・・と思ってサポートに連絡すると、不明とのこと。2台到着したらやばいなあと思っていたのだけど、元同僚宅に到着したのは最初に送った初期不良Surface Book 3 の電源コードだけが送られてきた。どうやら、修理のときには電源ケーブルは送らなくて良かったらしい。それならそうとサポートに問い合わせた時点で言って欲しい・・・。

代替機の Surface Book 3 は元同僚に念の為動作チェックまでしてもらい、6/28にFedExに日本宛に郵送してもらった。そして、 7/1 、本日、無事に受け取ることができた。最初の注文から2ヶ月弱、長い戦いだった・・・。日本に到着後、セイノースーパーエクスプレスが配達日を守らないなんてことを最後の最後にやってくれたのだけど、ここまでの工程を考えれば誤差の範囲だろう。

感想

わかっているつもりではあったけど、やはり個人輸入のリスクはとてもでかいなあと思った。今回手伝ってくれるUS在住の人が居なかったら完全に終わっていたので。 US在住の知り合いがもっと欲しい。

後、USマイクロソフトのサポートは、毎回思うのだけどサポートを受けにくいなと思う。大きい会社なので仕方がないのだろうけど。チャットでサポートを捕まえるのは比較的簡単なのだけど、その後メールに移ってしまうと数日に一回しかレスがもらえなくなる。そこでチャットで別のサポートを捕まえるのだけど、引き継ぎの仕組みが貧弱なようで、情報の齟齬が発生してトラブルが大きくなる。今回も合計で10回以上サポートに連絡をしており、人によってサポートの質も大きく違うのもあり、本当に大変だった。

冒頭に書いたとおり、 Surface Book 3 は基本的に Surface Book 2 と同じなので、特に感想は書かない。 Surface Book 2 の時点ですでに完成された機体で、今でもめちゃめちゃ気に入っているので、後はこれ以上故障が発生しないことを祈るばかりである。