Deno は Node.js みたいなやつの新しいやつです.
この記事で signal と言っているのは, Ctrl-C を押した時にプログラムに送られる SIGINT や, kill コマンドを実行した時に送られる SIGTERM などの事です.
Deno は汎用の JavaScript 処理系を目指したプロジェクトですが, 最近までこのような signal をハンドリングする API がありませんでした. この記事はその API を自分が実装した話です.
発端
deno勉強会4 で @hashrock さんが, delectron という Deno で electron をやるというプロジェクトをやっていました. そのデモの中で, Deno には Ctrl-C や終了時の hook を取る手段がないので, プログラム終了時の後処理が出来なくて不便という話が上がりました.
その話を受けて自分が issue #2339 を立てました(2019年5月). #2339 では, まず自分が, 主な言語での signal handling のための API を調査して, description に書きました. 既存の (Deno が特に参考にしている) 主な言語の signal handler の API は以下のようになっていました.
node.js
process.on('SIGTERM', () => {/* do something */}));
python
def handler(signum, frame):
# do something
signal.signal(signal.SIGTERM, handler)
ruby
Signal.trap("TERM") {
# do something
exit
}
golang
sigc := make(chan os.Signal, 1)
signal.Notify(sigc, syscall.SIGTERM)
go func() {
s := <-sigc
// do somethig
}()
rust (using tokio signal)
let ctrl_c = tokio_signal::ctrl_c().flatten_stream();
let prog = ctrl_c.for_each(|()| {
// do something
Ok(())
});
tokio::run(prog.map_err(|err| panic!("{}", err)));
この issue は, とても良いリアクションが沢山ついて, 全員一致でやろうという話の流れになりましたが, API のデザインについては色々な意見が出てきて, なかなか結論が出ない状態が続いていました.
Deno では API デザインの議論に結論が出ることが必ずしも必須ではありません. ある程度意見がまとまったら, どれかを選んで実装者が実装し, それに対して ry (作者) が強く反対しなければ, それが API としてマージされます. ただ, この signal handler については, 意見が全くまとまっている様子がなく, ry もどれが可能性が高そうという意見を出さないため, どこに向かっているのか分からないため, 個人的にはこの issue はあえてずっとスルーしていました.
Kevin's attempt
そんな中 Kevin が 8月に PR #2735 を投げました. この PR は基本的には機能としてはもう十分動いていて, マージされてもおかしく無い内容だったようですが, レビューで出たいくつかの論点を解決出来ず, 2ヶ月後ぐらいに close されました.
主な論点として上がっていたのは, 以下です.
- sigaction という API 名が気にいらない by kitson
- Op::AsyncOptional という Op の新しい型 (Op::Sync, Op::Async に続く3番目の型になる) を導入しているのが気に入らない by ry & bert
- signal の binding を消す API が無い by ry
特に 2, 3 の点が解決されないことが決定的となり close されました. Deno の PR でここまでのクオリティでクローズされるケースはかなり稀です. というのもこの辺りのコードを, TypeScript と Rust 双方に渡って, きちんとデザインし, かつテストもきちんと書けるコントリビュータは 5, 6 人しかいないため, このクオリティの PR を閉じてしまうことはかなりのリソースのロスになります.
それほどに, Op::AsyncOptional という enum を追加することの Core へのダメージが大きいと判断されたということだと思います (ただし, のちにこの Op は自分の PR で入ることになりました).
1.0 blocker
Deno は #2473 という issue で, 1.0 のブロッカーになる issue を管理しています. 自分もこの中の issue のいくつかを解決してコントリビュートしてきました.
9月に signal handler の issue が 1.0 ブロッカーに追加されました. つまりこの機能が入らないと 1.0 が出せないということです. 上の通り, この signal handler は API 結局どれがいいの? が分からない状態が続いていた (結局最後まで結論めいたものは出なかった) ため, 自分もかなり敬遠し続けていたのですが, 12月末ごろになって, もう残りの 1.0 blocker issue がほとんどなくなってきた中で, 残っている中で一番着手しうるのが signal handler という状況になってしまいました.
そこでついに自分が重い腰をあげて(どっこいしょ), 1月頭ぐらいから signal handler に真剣に着手し始めました. Kevin のほぼ動いていた PR が見れるため, 当然かなり参考にしながら API を作成していきましたが, すでに8月と比べると Op 周りのアーキテクチャがファイルレベルで見ると全く異なる構造に作り変わっていたため, コピペで再利用できる箇所はほぼありませんでした.
TypeScript 側の API については, 余り人気がなさそうだった Kevin の sigaction(signo, callback)
という形は採用せず, sholladay さんがコメントで書いていた:
for await (const _ of Deno.signals.sigterm()) {
// handling
}
という AsyncIterable を返す関数を作る形を採用しました. これを選んだ理由は, このコメントに唯一 ry が 👍 をつけていたという, あるかなきかの薄い理由でしかありません. 個人的に好みの API というのは特に無いので, 正直 API はどれでも良く, 賛成が得られれば何でも良かったというのが正直なところです.
作業
2020/01/06 に PR #3610 という PR を WIP で立てましたが, まともに動くようになるまで1週間強かかりました. Op は以前に permission を作り変える PR を出した時に作った事があったので, Op の追加自体はなんとなく勘が働きましたが, 今回の PR の場合, Op の結果として, 副作用として Tokio の signal の stream を Deno 内にデータとして残すという事が必要で, 作った stream を rust の作法にのっとった形で Deno の中に正しく保持するコードを書くのがとても困難でした.
また, rust の stream や future は JS の async iterator や promise の概念にほぼ等しいものですが, その使い方はかなり違っていて, fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>
のような signature のメソッドを正しく扱う方法を理解するのにとても時間がかかりました. また, tokio の signal の実装が Kevin が実装した頃は tokio-signal という独立した crate で実装されていたらしいのですが, その後 tokio-net という crate に移され, その後さらに tokio 本体の tokio::signal という namespace に移されていた事がわかったのですが, 古い方のドキュメントには新しくなっている旨の記述が無く, この多重に deprecated されている状態に気づくまでに相当な時間がかかってしまいました. 最終的に, ドキュメントだけでは tokio-signal や tokio-net の signal が deprecated になっていることは分からず, tokio 自体のソースコードの様子や, issue 漁りなどをして, やっと本物の signal 実装を見つける事が出来ました.
また #3610 の過程で, やはり, Deno の終了をブロックしない Async な Op がどうしても必要という事が分かってきて, 一旦 Async でブロックしない Op を入れるだけの PR #3715 を別で出しました. これを出したところから, やっとレビューが始まりましたが, 一番最初についたコメントは Bartek の「この機能って何で要るの?」というコメントで, 明らかに前途多難な予感がしました. その後 Kevin も議論に参加してくれて, この機能自体は要るという方向にまとまったものの, どうやって実装するのか, という点については意見が別れた状態で, とりあえず双方のパターンを見比べられるようにもう一つの PR #3721 を作成しました. 結局この #3721 がマージされて, Async で Deno の終了をブロックしない Op という概念 (AsyncUnref という名称になった) が Deno のコアに導入されることになりました.
AsyncUnref が出来れば, Signal Handler まではあともう一歩というところで, 2日後に諸々整理した PR #3757 を出して, Bartek と ry の各種レビューに対応したのち, やっとマージされました. マージ直後に 0.31.0 がリリースされて, 公式 twitter からも 0.31.0 のメイン機能としてメンションしてもらえました. めでたし. めでたし.
所感
作業の中で Rust 側の実装が本当に困難で, 特にレビューの中で FuturesUnordered<PendingOpFuture>
という, まだ終了してない Op の List を 2つ (終了判定時に無視する方と無視しない方) にするべきだというコメントがついたときは, 自分には実装不可能だと思いそれは無理というコメントを返しましたが, そこで Bartek が futures::stream::select を使えば, 2つの stream から正しく poll 出来るという助け舟を出してくれたのが本当に助かりました. このヒントがなければ多分この作業は完了出来なかったので Bartek に感謝.
最終的にはマージされましたが, JS/TS 側の API デザインについての議論がいまいち尽くされなかったのが, 少し引っかかりました (これから作り直しがあるかもしれませんが). 今の API の形には反対意見もあったはずで, その人たちの論点が address されないまま, なし崩し的に API が決まっている点が少し気になりました.