第7章:敵プロセスを外側から観測する

型で状態を表現しても、それだけでは足りないことがあります。観測する経路そのものが、外部から汚染されている場合です。第II部までは、Windowsが返す情報をどう解釈するかを問題にしてきました。第III部では、判定条件を増やす、いわゆる「パッチ」を重ねるだけでは解けなかった問題を三つ扱います。
最初にぶつかったのは、状態を一切外に出さない相手でした。相手は自分のコードではなく、競合する別の日本語入力エンジン、GJI(Google Japanese Input、Google 日本語入力)です。公式なAPIで内部状態を尋ねる手段は、最初から存在しませんでした。
awaseは、GJIが実際に活性化したかどうかを知る必要がありました。両者が同じ物理キーの入力を奪い合う場面があったからです。知る手段がないまま送信を続けると、文字が正しく合成されない事故が起きました。
第7章から第9章までが、この第III部にあたります。局所的な対策を積み重ねる代わりに、問題の見方そのものを変える場面を、三つの章に分けて描きます。
GJIは、活性化したかどうかを教えてくれなかった
待ち時間をあと200ミリ秒延ばすだけなら、設定を1行書き換えれば済むはずでした。実際には、そこから三つの方式を試し、四つの案を退けるまでに数週間を要しました。最後に残った答えは、待ち時間の調整ではありませんでした。
事の発端は、ChromeやBrave、Edgeのようなブラウザで起きた症状です。IMEをOFFからONへ切り替えた直後にローマ字を入力すると、「kおのなかで」のような部分的なリテラル化が発生しました。日本語入力を始めるたびに起きうる、ありふれた操作の直後の事故でした。
原因の構造は、こうでした。これらのブラウザはVKモードで動作し、物理キーコードをそのまま受け取ります。GJIのcomposition contextは、活性化前の状態(cold状態)ではVKキーを素通りさせてしまいます。GJIをVK_DBE_HIRAGANA(16進数で0xF2にあたる、IME専用の仮想キーコード。コミット履歴では略して「F2」と呼ばれるが、キーボード最上段のファンクションキーF2とは別物)で活性化させるまでには、数百ミリ秒の遅延がありました。この遅延の間に最初のVKキーが届くと、ひらがなに変換されないままリテラル文字として確定してしまいました。awase自身のキー処理はほぼ即座に終わるため、遅いのはこちらではなく相手のGJIでした。
この事故を防ぐには、GJIが「いつ活性化を終えたか」を知る必要がありました。しかし、GJIは競合する別会社の製品であり、内部状態を教えてくれる公式なインターフェースはありません。当初検討したのは、三つの方向でした。DLLフックは、対象のプロセスに自分の書いたコードを注入し、内部の関数呼び出しを横から書き換えて横取りする技術です。COM経由の問い合わせは、TSF(Text Services Framework、IMM32の後継としてWindowsが提供するIME連携の仕組み)を通じて、外部のプロセスに直接いまの状態を尋ねる技術です。名前付きパイプによるIPCの盗聴は、別々のプロセス同士がやり取りする内部通信(プロセス間通信、IPC)を、外側から覗き見る技術です。
実際に手を動かしたのは、この三つの方向だけではありませんでした。単純な待ち時間の調整も含めて、四つの具体案を実装し、実機で確認しました。結果は、侵襲性・安定性・バージョン依存・実装コストという四つの軸で見ると、それぞれ異なる理由ですべて却下でした。
| 案 | 決定的だった弱点 |
|---|---|
| 待機時間の延長(300ミリ秒→500ミリ秒) | 安定性。Windows Terminalの初期化遅延(344ミリ秒)に追随できず、時定数の調整自体が競合条件でした |
| DLLフックによるメッセージ傍受(WM_IME_STARTCOMPOSITION) | 侵襲性。DLLインジェクションが前提になり、アンチウイルスの誤検知リスクが大きすぎました |
| COM経由のクロスプロセス問い合わせ(ImmGetCompositionString) | バージョン依存。TSFネイティブなアプリでは常に0が返り、実験では952ミリ秒遅れて反映されることもありました |
| I/Oカウンタを時刻で見る方法(GetProcessIoCounters、時間ベース) | 実装コスト。ChromeのTSFコンテキスト更新はCOMイベント経由で届くため、観測タイミングとの競合が消えませんでした |
四つの案は、それぞれ実機で次のように試され、次の理由で退けられました。
待機時間の延長では、GJIの活性化にかかる数百ミリ秒の遅延を、待ち時間そのものを300ミリ秒から500ミリ秒に延ばすことで吸収しようとしました。ところがWindows Terminalというターミナルアプリ自体の初期化にも344ミリ秒かかっており、GJI側の遅延とアプリ側の遅延が重なる場面がありました。待ち時間という一つの定数で両方の遅延を吸収しようとすると、必要な長さが環境ごとに変わってしまいます。定数を延ばす作業自体が、「どこまで延ばせば足りるか分からない」という競合条件になりました。
DLLフックでは、GJIのメッセージ処理関数そのものに割り込み、WM_IME_STARTCOMPOSITIONというメッセージが処理される瞬間を直接つかもうとしました。狙いどおりに動けば、活性化の瞬間を正確に検出できたはずです。しかしこの方式は、別プロセスへコードを注入するという、アンチウイルスやEDR製品がマルウェアの典型的な挙動として検知する操作そのものでした。ユーザーが安心してインストールする日本語入力エンジンが、誤検知の標的になるリスクは受け入れられませんでした。
COM経由の問い合わせでは、ImmGetCompositionStringというAPIを使い、GJIに直接「いまの合成文字列」を尋ねようとしました。このAPIは、IMM32(Input Method Manager、Windowsが長く提供してきたIME連携の旧来API)が提供するものです。ところがTSFネイティブなアプリでは、この問い合わせは常に0を返し、何も分かりませんでした。値が返る場合でも、実験では実際の状態変化から952ミリ秒遅れて反映されることがありました。問い合わせても正しい値が返るとは限らず、返ったとしても遅すぎるという二重の不確実性を抱えていました。
I/Oカウンタを時刻ベースで見る方法では、GetProcessIoCounters()というAPI自体は、後に採用する方式と同じものをすでに使っていました。ただしこのときは、I/Oが最後に発生した時刻を見て、そこから一定時間だけ待機を延長するという使い方でした。ところがChromeは、自分自身のTSFコンテキスト更新をCOMの非同期イベントとして別経路で届けてきます。I/Oカウンタの観測とCOMイベントの到着は、どちらも非同期に起きるため、どちらが先に届くかを保証できませんでした。
四つの案に共通していたのは、GJI自身に何かを尋ねるか、相手の出力タイミングを予測しようとする点でした。相手に依存する限り、侵襲性か不安定さのどちらかを引き受けることになりました。この時点で、すでに数週間が経過していました。
四つ目の案は、最終的に採用する仕組みとよく似た材料を使っていました。ただし、見ていた指標が違いました。
見ていたのは時刻だった、必要だったのはバイト数だった
GetProcessIoCounters()というAPIは、対象プロセスのI/O統計を外部から読み取れます。これ自体は、失敗した四つ目の案ですでに使っていた材料でした。失敗の原因は、この値を時刻の経過として扱っていたことにありました。
この転換は、Chromeの内部実装を新たに解析した結果ではありませんでした。すでに手元にあった同じAPIの返り値を、これまでとは違う軸で読み直しただけでした。視点を変えて、時刻ではなくバイト数の差分を見ると、結果が変わりました。WriteTransferCount(書き込み転送バイト数)の差分が、GJIが「モード切替キー」を処理したのか、「実際の文字合成」を処理したのかを、明確に区別していたのです。
実際の差分がどれくらいの大きさになるのか、実機で5サンプルを取って確かめた記録が残っています。
| 種別 | VK_A送信後のGJI write差分 | 判定 |
|---|---|---|
| cold状態のChrome(リテラル'a'が出る) | +300バイト(w_KB=+0.3) | timeout→SacrificialResend |
| warm状態のChrome(合成'あ'になる) | +400バイト(w_KB=+0.4) | composition-confirmed |
中間の350バイトを、活性化判定のしきい値としました。しきい値を低く取りすぎれば、「F2」だけの操作をwarmと誤判定します。逆に高く取りすぎれば、本物の合成が起きているのにcoldのまま見逃します。350バイトという値は、この両側の失敗を避けるために選ばれた境界でした。「F2」単体の送信では、write_bytesは+0.0バイトのまま変化しませんでした。もし時刻ベースのgji_last_io_msを使っていたら、「F2」の処理だけで誤検知していたはずです。バイト数ベースに転換したことで、この誤検知を避けられました。
350バイトという数字は、GJIのこのバージョン・この環境で観測された値であり、普遍的な定数ではありません。GJIの実装が変われば、しきい値も引き直す必要があります。採用したコードでは、この値を固定の判定基準としてではなく、warm状態とcold状態を分ける経験的な境界として扱っています。
この差分も、第5章・第6章で見た意味でのObservation(観測)の一種にすぎません。GJIが活性化したという事実を直接確定させるのではなく、beliefを更新するための一つの手がかりとして扱いました。観測は、それだけでは真実になりません。
本物の入力を、観測のために使う
しきい値が決まっても、それだけでは活性化を確認する手段になりません。GJIに向けて、判定用の何かを送る必要がありました。ここで採った方法は、専用のプローブを新たに作らないというものでした。
採用したのは、捨て打ち機能と呼ぶ技法です。必要な操作、つまり本物のキー送信そのものを、観測のためのプローブとして転用します。捨て打ちという名前は、実際には使わない捨て文字を、本当は打つ必要のない一手として送ることに由来します。後述するADR-062は、この捨て打ちすら行わない、さらに徹底した形へ進みます。
流れはこうです。cold状態を検知すると、まずVK_AとBSをひとまとめに送信します。GJIがこれを合成しようとすればwrite_bytesが増えるため、その差分を観測します。350バイトを超えていればwarm状態への遷移とみなし、いったん捨てた本物のローマ字を送り直します。届かなければタイムアウトとみなし、GJIをIME OFF/ONで強制的にリセットしてから再試行します。

VK_AとBS(バックスペース)を、あえて同じSendInputのバッチで送っています。理由はADR-048にこう記録されています。別のSendInput呼び出しに分けると、Chromeがフレームを描画するタイミングで捨て文字の'a'が一瞬画面に表示されてしまいます。同一バッチであればOSがイベントキューに連続して積むため、Chromeは描画の前にVK_AとBSの両方を処理し、画面には何も現れません。
観測の基準値(baseline)を取得するタイミングにも、見落としがありました。commit 26bc0feの記録によれば、VK_A送信後にbaselineを取得すると、cold状態のChromeがすでに書き込んだ+300バイトごとbaselineに吸収されてしまい、差分が0に見えてしまいます。baselineはVK_A送信前に取得しなければなりませんでした。単純な前後関係の取り違えが、しきい値判定そのものを無効化してしまう例です。
この仕組みは、当初すべてのcold-startに適用していました。ところがcommit d02ec44の記録によれば、TSFモード以外のアプリでは、VK_Aを送ってもHIDEイベントが来ない、composition_was_seenフラグが立たないといった不具合が起き、かえって性能が落ちました。最終的には、長時間cold状態が続き、かつTSFモードで動作しているアプリに限って適用する形に絞り込んでいます。この技法は、対象の状態モデルが一致する範囲でしか有効ではありません。
絞り込みはここで終わりませんでした。6月30日、6分の間に方針が二度反転しています。06時28分のcommit 6c1732dは、捨て打ち(VK_A+BSを送る方式)をやめ、IME制御キーであるVK_IME_OFF→ONだけを送る方式へ、対象アプリすべてを統一しました。理由はコミットメッセージにこう記されています。
vim等のターミナルアプリではVK_Aがcold時にアプリへ届き誤動作する。VK_IME_OFF→VK_IME_ONはIME制御キーのためvimのキーバインドに干渉しない。
VK_Aという文字キーは、vimのノーマルモードではappendコマンドに割り当てられています。Windows Terminalではvimが動くこともあるため、捨て文字のつもりで送ったVK_Aが、cold状態のためにIMEを素通りしてターミナルへ直接届き、vim側の編集コマンドとして誤って実行されてしまう可能性がありました。IME制御キーであるVK_IME_OFF/ONにはこの種の衝突がありません。
ところがその6分後、06時34分のcommit 22c3905は、Chromeだけを元のVK_A+BS方式へ戻しています。
ChromeでVK_IME_OFFがTSF contextを壊す問題を修正(過去検証済み知見)。Chrome: VK_A+BS(元の方式)。Chrome内でvimが動くケースは稀でありVK_Aのvim問題は許容。
普段のIME ON/OFF切り替えではVK_IME_ON/OFFはChromeでも問題なく動きます。ところがcold-start直後、コンポジションがまだ確立していない一瞬にVK_IME_OFF→ONを送ると、ChromeのTSFコンテキストそのものを壊してしまうという、より限定的な問題がありました。vimとの衝突というリスクと、TSFコンテキスト崩壊というリスクを比べ、Chromeに関してはvim問題を許容する側を選んだことになります。
こうして捨て打ち機能は、Chrome+GJI(GJIが活性化している場合にのみ観測が必要になるため)の組み合わせにだけ残る技法になりました。ADR-063によれば、競合するIMEがMS-IMEの場合はTSFコンテキストが常にwarm状態であるため、そもそもこの種のプローブ自体が不要です。Windows Terminalのような他のTSFネイティブアプリは、IME制御キーだけを使うVK_IME_OFF→ON方式に置き換わっています。同じ問題に見えた症状が、対象アプリの内部実装次第で、最後まで同じ解法を共有できなかったことになります。
観測を担う部分の実装は、次のようになっています。
fn sample(&mut self) -> Option<GjiIoDelta> {
let counters = get_process_io_counters(self.handle)?;
let write_bytes = counters.WriteTransferCount
.saturating_sub(self.last_write_bytes);
// 他4指標(読み込み回数等)も同様に差分化するが、判定に使うのは write_bytes だけ
Some(GjiIoDelta::from_write_bytes(write_bytes))
}
GjiMonitor::sampleが返すGjiIoDeltaは、読み込み回数・書き込み回数・その他の回数、読み込みバイト数・書き込みバイト数という五つの差分をまとめて持っています。活性化の判定に使っているのは、このうちwrite_bytes一つだけです。時刻も併せて記録していますが、判定そのものには使っていません。呼び出し側は、この差分が350バイトを超えたかどうかだけを見ればよく、GJI内部の実装詳細を知る必要はありません。三つの方式を試し、四つの案を退けるのに数週間を費やした末に見つかった答えは、10行に満たない差分計算の関数でした。
この関数を二つに分けたことにも意味があります。実際のOS呼び出し(get_process_io_counters)はsampleの中に閉じ込められ、しきい値との比較は、その外側の純粋な関数として書けます。本物のGJIプロセスを起動しなくても、GjiIoDeltaに任意のバイト数を詰めて渡すだけで、「349バイトならcold、350バイトならwarm」という境界条件をテストコードの中で確認できます。実機を用意しなければ確かめられない部分(OS呼び出し)と、値さえあれば確かめられる部分(しきい値判定)を切り分けておいたことが、後から350という値を調整し直す作業を軽くしていました。
同じ観測方法に、二度たどり着いた
この技法は、6月14日から24日にかけてのADR-048で最初に記録されました。Chromeのcold-start問題を解くための、SacrificialWarmup機構としてです。
同じ着想が、6月25日のADR-062でも独立に現れました。今度の対象は、まだ学習していない種類のTSFモードを自動判定するUnicodeLiteralObserverFsmでした。ローマ字を送信してから100ミリ秒待ち、GJIのwriteがあるかどうかで判定する形に、同じ発想を再利用していました。
ADR-062は、ADR-048よりもう一歩先に進んでいました。ADR-048は捨て文字のVK_Aを新たに送っていましたが、ADR-062はそれすら送っていません。どのみち送るはずだった本物のローマ字自体を、プローブとして転用していました。専用の観測用入力を足すこと自体が、新しい非決定性の発生源になり得るという判断です。
ADR-062には、その理由がこう記録されています。
gji_last_io_ms(時刻ベース)ではなくgji_write_bytes(バイト数ベース)を使う理由: F2(モード切り替え)もI/O時刻を更新するため誤検知するが、WriteTransferCountはF2で増加しない(w_KB=+0.0)ため分離できる。
10日あまり離れた二つの問題が、同じ観測方法にたどり着きました。偶然の一致ではなく、GJIの内部が見えないこと、時刻では区別できないことという同じ制約が、別の問題でも繰り返し現れたためだと考えられます。その場しのぎの奇策のように見えましたが、別の問題でも同じ観測方法へ戻ってきました。
この技法をawase固有の思いつきだと考えるのは、正確ではありません。電気やガス、水道の使用量を外から見るだけで、部屋の中で誰かが何をしているかを言い当てる、という考え方に近いものです。実際、プロセスのI/Oカウンタを操作の前後で比較し、相手の挙動を推測するという手法自体は、2013年ごろに広まったアンチデバッグ手法の解説記事(セキュリティ研究者Peter Ferrieによる技法集にも収録されています)に、ほぼ同じ仕組みで登場します。ただしそちらの用途は、デバッガ(プログラムの動作を一行ずつ追跡するツール)に自分が見張られていないかを、プログラム自身が確かめる、いわば「見張られていないかを自分で確認する」内向きの自己防衛でした。awaseが行ったのは、この技法を外向きに——敵対する相手ではなく、同じ画面を取り合っている隣人のようなプロセスの様子を、相手に気づかれず外から窺うために——転用したことです。
「これから行う必要な操作に、観測を便乗させる」という発想にも先例があります。郵便配達員が、わざわざ玄関のチャイムを鳴らして在宅かどうかを尋ねる代わりに、前日の郵便物がポストから無くなっているかどうかを見るだけで在宅を推測するようなものです。ネットワークの世界にも、専用の確認要求を送らず、すでに発生している通常の通信に相乗りして相手の状態を推測する、受動的ヘルスチェックという手法があります(F5やNGINXのようなロードバランサ製品が採用しています)。捨て打ち機能、そしてADR-062がたどり着いた「専用のプローブすら送らない」という徹底も、この発想の系譜に連なるものだと言えます。
対象プロセスが何をしたかを、外から言い当てる話でした。まだ手つかずだったのは、逆方向の問題です。こちらが送ったキーが、相手のアプリケーションにとって本物として解釈されるか、そうでないかという問題でした。この問題は、相手の状態を観測することではなく、送信する経路そのものの設計を必要としました。
設計原則
原則: 相手が状態を公開しないなら、相手に必要な操作をさせ、その副作用を数量として観測する。
適用条件: 対象プロセスに公式な状態取得手段がなく、代わりに何らかのシステムリソース(I/O・CPU・メモリ等)の変化を外部から読み取れる場合に使う。判定に使う指標は、目的の操作とそれ以外の操作を区別できるものを選ぶ(本章では時刻でなくバイト数)。対象の状態が短時間で頻繁に切り替わる場合ほど、時刻ではなく量で判定できる指標を優先する。
実装の形: 本物の操作にプローブを便乗させ、専用の観測用入力を新たに作らない。しきい値は実測した複数サンプルから中間値として決め、コード上は経験的な境界として扱う。判定に使う値の取得順序(いつ基準値を取るか)も、しきい値そのものと同じくらい注意して決める。
限界: しきい値は対象プロセスの具体的なバージョン・環境に依存し、普遍的な定数として扱えない。対象の実装が変われば、しきい値の再計測が必要になる。対象が想定していない状態にまで一律に適用すると、判定の空振りによってかえって性能が落ちることがある。