第12章:それでも壊れる条件

全体像の中でこの章が扱う部分: 世代照合(詳細は付録「全体像」を参照)

送信されたコマンドは、もう存在しない場所に向かっていた

次の手順を、コードの上でたどってみます。たとえば、ALT+TABで別のウィンドウへ切り替える 場面を考えます。切替の直前、あるウィンドウにフォーカスがある状態で、IMEをオフにするという 決定が下ります。決定はOSへの非同期コマンドとして、キューに置かれます。コマンドが実際に 送信される前に、ユーザーが別のウィンドウへフォーカスを移します。そして、最初の決定に基づく コマンドが、いま存在しないはずの古いフォーカス先へ向けて送信されます。

利用者から見れば、これは次のキー入力が、狙った通りに変換されない、という一瞬の不具合と して現れるはずです。ただし、この手順を実際に踏んだという記録は、開発ログのどこにも 残っていません。ここまでの追跡で分かったのは、コードを読む限り、これを止める仕組みが 存在しない、という事実だけです。送信されてしまえば、そのコマンドは取り消せません。 OSへ渡った命令は、戻ってきません。

信念(belief)であれば、後から間違いに気づいて書き直せます。しかし、一度OSへ送信された 命令は、awaseの内部にあるどんな値を書き直しても、なかったことにはできません。書き直せる 対象と、書き直せない対象を、同じ強さで守る必要はないはずです。ところが、書き直せない 対象のほうを守る仕組みは、まだありません。

前章で見たFocusEpochは、まさにこの種の事故を防ぐために作られました。それなら、なぜこの 経路だけが素通りするのでしょうか。答えは単純です。FocusEpochが守っている場所と、この コマンドが通る場所が、別の場所だからです。

前章を書き終えた時点では、観測にまつわる問題は解決したかのように見えました。しかし、 観測は目的そのものではありません。観測は、最終的にOSへ渡す命令を正しく組み立てるための 手段です。手段が正しくなったからといって、その先の手続きまで正しくなったとは限りません。

実は、この点には前章の時点でも一つだけ、答えの出ないまま残されていた問いがありました。 「admit()でエポックを照合した後、AcceptedObservationをストアへ保存してから実際に 使うまでの間に、新しいエポックへ遷移するケースを、いまの実装は実際にどう防いでいるのか。」 前章ではこの問いに触れず、コードを読んで確かめる作業を本章へ持ち越していました。

観測は使うたびに洗い直される。命令はそうではない

確認は、二つの経路に分けて行いました。

一つ目は、観測が受理されてからストアへ書き込まれるまでの経路です。ImmLikeTicket::admit() の呼び出しと、ストアへの書き込みは、with_app(|app| { ... })という一つのクロージャの中で 連続して実行されています(focus_tracking.rs:349key_pipeline.rs:214,910)。awaseは シングルスレッド・協調的なスケジューリング(win32_async::spawn_local)で動いており、 この区間に割り込みは入りません。受理してから保存するまでの間に、隙間はありませんでした。

そして前章で見た通り、derive_open()は呼び出しの都度、現在のエポックと記録済みの エポックを再比較します。一度受理されて終わりではなく、使われるたびに、もう一度 洗い直されます。ここまでは、前章の設計が意図した通りに動いています。

二つの経路を並べると、再検証がどこで途切れているかが一目で分かります。

観測経路は使うたびに再照合されるが、効果経路はdispatch直前の再検証を持たない

二つ目は、決定がOSへの実際の作用として送信されるまでの経路です。この経路は、そもそも 同期処理では済みません。IMEの切替をプロセスをまたいで反映させる呼び出しは、即座には 終わらず、完了までに時間を要します。だからこそ非同期のキューを経由する構造になって いるのですが、非同期であるということは、送信を待っている間に世界が変わりうる、という ことでもあります。runtime/executor.rsdispatch_ime_set_openが担う、ImmCross向けの 非同期処理を確認しました。次のようになっています。

// 決定(open, origin)はここより前、effective_open() を使った同期処理で確定済み
win32_async::spawn_local(async move {
    let ok = crate::ime::set_ime_open_cross_process_async(open).await;
    // この await の間にフォーカスが変わっても、送信はキャンセルされない
    ...
});

openという値は、awaitに入る前にすでに確定しています。win32_async::spawn_localは 協調的なスケジューラ(複数の処理が、自分から一時停止しては順番に進行を譲り合う方式)の 上で動きます。await(結果が返るまでいったん待つ、という意味の構文)に入った瞬間、制御は いったんイベントループへ戻ります。フォーカス変更を処理するイベントは、その間に先に 実行されます。送信を待っているopenという値は、別のエポックへ遷移したことを何も知らない まま、待ち続けます。送信は無条件に実行されます。

完了後の処理はgenerationという値(apply命令を発行するたびに1つ増える番号。第6章で 導入した仕組みです)を照合しますが、これが守るのは「この完了通知を信じてよいか」という 一点だけです。「コマンドがすでに取り消せない形で送信済みである」という 事実そのものは、この照合の対象になっていません。この設計自体は、意図的に絞り込まれて いました。ここで守ろうとしていたのは状態の一貫性であって、物理的な作用の発火可否 ではなかったのです。

この状態は、二つの独立したフェンシング機構(古い指示や古い通知を、無効なものとして 退ける仕組み)が並んでいる、と整理できます。

機構対象方向何を守るか何を守らないか
FocusEpoch / AcceptedObservation入力(観測の受理)staleな観測がbeliefを汚染すること
ImeTransition.generation出力(効果の完了記録)staleな完了通知がbeliefを上書きすることOSへの送信自体が、staleな決定に基づいて既に発火済みであること

観測を信じる前にエポックを見る。完了通知を信じる前にgenerationを見る。この二つは対称的な 防御に見えます。しかし、まだ実行していない効果を、実行する直前にもう一度照合するという 三つ目の防御は、どちらの機構にも含まれていません。

観測の経路と、効果の経路とでは、保証の中身が違います。

対象現在の保証
古い観測によるbelief更新epoch照合によってほぼ防げる
利用時の古い観測derive_open()が呼び出しの都度、再照合する
古い文脈で生成されたEffect保証されていない
dispatch直前の鮮度保証されていない
送信済みOSコマンドの取消不可能

この表の上二行と、下三行の間には、明確な境界線があります。観測が信念を汚す経路は、 二重に守られています。信念が作用に変わり、OSへ送り出される経路は、まだ一度も 検査されていません。

なお、観測の経路にも隙間は残っています。GJI(Google Japanese Input、Google 日本語入力)・ ObserverPoll・TSF(Text Services Framework、Windowsの入力方式を仲介する仕組み)のような イベント駆動の観測は、エポック照合の対象外です。これらは 3000ミリ秒の鮮度ウィンドウだけに守られています。ただしこれは観測経路に残る別の隙間であり、 本章の主題である効果経路の欠落とは区別しておくべきです。

「型で防げる」と言うとき、実際に防げている範囲を、こうして書き出しておく必要があります。 おおよそ塞がっているという手触りと、実際に塞がっている範囲は、別のものです。

まだ存在しない設計

先の表の下三行を埋めるとすれば、という以上の意味を持たない候補です。ここから先は、 実装済みの仕組みの説明ではありません。awaseのコードには、まだ存在しない設計です。

Effectにも観測と同じように、生成時点のエポックを持たせるという案があります。ただし、 エポックを値として持たせるだけでは十分ではありません。それを実際に照合する場所を、 どこかに用意する必要があります。

dispatchの手前に、通過しなければ送信できない関所を置くという案もあります。観測の受理で 使った形を、そのまま効果の送信にも当てはめるということです。AcceptedObservationに 対応する形で、AcceptedEffectのような型を用意し、その関所を通った値しか送信関数に渡せない ようにする案も考えられます。関所を型で強制しておかなければ、送信経路が新しく増えるたびに、 同じ抜け道がまた生まれます。

二つの案は、同じ目的に対する強さが違います。値を持たせるだけの案は、照合を呼び出す側が 書き忘れれば、そのまま素通りしてしまいます。関所を型で強制する案は、関所を通っていない 値を型として受け付けないため、送信経路が増えても同じ抜け道が生まれにくくなります。

いずれの案も、狙いは一つです。決定からOSへの送信までの間に生じる間隙で、もう一度いまの エポックやgenerationと照合し、一致しなければ送信そのものを取りやめる。取り消せなくなる 直前の地点を、最後の検査点にする、ということです。

もっとも、この検査点にも代償はあります。送信の直前にもう一度照合するということは、 その分だけ送信のタイミングが遅れる、ということでもあります。フォーカスが変わる頻度に 比べて、この遅れがどこまで許容できるかは、実装してみるまで分かりません。取り消せない 作用を防ぐことと、反応を遅らせないことは、同じ場所で綱引きになります。

本章を書いている時点で、これらはどの案も実装されていません。前章の84分とは違い、ここには まだ答えがありません。エポックという発想そのものが、観測の側で一度、三か月かけてようやく 形になったことを思えば、効果の側にも同じだけの時間がかかっても、不思議ではないでしょう。

他分野への転用

空いた駐車スペースを見つけてから、車を取りに戻るまでの間

空いている駐車スペースを見つけて、少し離れた場所に停めた自分の車を取りに戻ったとします。 戻ってくる間に、別の車が先にそのスペースへ入ってしまうことがあります。確認した瞬間には 確かに空いていたのに、実際に使おうとする瞬間には、状況が変わっているということです。

この種の間隙は、ソフトウェアの世界では長らく知られてきました。「確認した時点」と「使う 時点」がずれることで起きる不具合は、TOCTOU(Time-Of-Check to Time-Of-Use、確認時点から 使用時点への時間差)と呼ばれ、CWE(Common Weakness Enumeration、既知のソフトウェアの 弱点を分類する体系)にも独立した項目(CWE-367)として登録されています。古典的な例は、 Unixのファイル操作です。あるファイルにアクセスしてよいかを確認してから、問題なければ 実際に開くという、一見自然な二段階の手順があります。この二回の呼び出しの間に、ファイルが 別のもの(たとえば別のファイルへのシンボリックリンク)へすり替えられると、確認したはずの ファイルとは別のファイルが開かれてしまいます。

awaseのExecutor直前の空白は、この古典的なパターンの一変種です。admit()という確認と、 実際にOSへコマンドを送信するという使用が、非同期のawaitを挟んで離れているために、 確認時点で正しかった前提が、送信時点でも正しいとは限らなくなっていました。ファイルが すり替えられるか、フォーカスが自然に切り替わるかという違いはあっても、「確認と使用の間に 間隙があれば、その間に前提が壊れうる」という形そのものは同じです。

この類似には、テストのしやすさという観点から見て、重要な非対称性もあります。admit()derive_open()のような純粋関数は、値を入れ替えて呼び出すだけでテストコードの中に再現 できました。しかしTOCTOUの間隙そのものは、狙って再現するのが極めて困難です。実際に フォーカスが変わるタイミングと、awaitから処理が再開されるタイミングという、二つの 非同期な出来事が特定の順序で重ならなければ発現しないからです。純粋な関数へ切り出すという 工夫は、その関数の内部ロジックが正しいことは保証しますが、その関数を呼ぶタイミングそのもの に潜む間隙までは保証しません。テスト容易性と、間隙の不在は、別の性質です。

そして、この「Executor直前の空白」は、後に調べたロボットランタイムにも存在していました。


設計原則

鮮度の再照合は、値を使う場所に置くだけでは終わらない。作用が取り消せなくなる直前の 場所にも、同じ再照合を置く必要がある。

適用条件: 決定から実行までの間に非同期の間隙があり、かつ実行後に取り消せない作用を 伴う経路に当てはまる。間隙のない同期処理や、実行後に取り消せる作用には当てはまらない。

実装の形: 送信を担う関数に、決定時点のエポックやgenerationを引数として持たせる。 送信の直前に、その値と現在の値をもう一度照合し、一致しない場合は送信そのものを取りやめる。

保証しない範囲: この再照合を置いても、判定から送信までの間にさらに間隙が生まれれば、 同じ問題は一段深い場所に移るだけである。境界をどこまで遡って検査するかは、有限の選択で あり続ける。