第4章:最初の一文字が化ける

全体像の中でこの章が扱う部分: アプリごとの適応層(詳細は付録「全体像」を参照)

第I部では、キーを受け取り、NICOLAの規則で判定し、仮名を出力するところまでの経路を組み立てました。三層構成、newtype、RAII(値の生存期間の終わりに合わせて資源の解放を自動的に行う仕組み)、Effectという仕組みは、すべてこの経路を正しく保つための工夫でした。

ここから先で問題になるのは、この経路の内側のロジックではありません。経路の外側にある存在、つまりWindowsやIME、フォーカスを奪い合う他のプロセスが返してくる情報を、どこまで信じてよいかという問題です。自分の側の判定がどれほど正しくても、判定の材料そのものが揺らいでいれば、結果は揺らぎます。

自分の書いたコードに誤りがあるなら、読み直せば見つかります。デバッガで追えば、どの行がどの値を作ったかを特定できます。しかし相手が返してくる状態そのものが、常に正しいとは限らないとしたら、読み直す先がありません。相手のソースコードは読めず、相手の内部状態を覗く公式な手段もないからです。第II部では、この前提から章を積み重ねていきます。

最初に出会った症状は、単純でした。ただし単純に見える症状ほど、原因が一つだとは限りません。同じ化け方が二度現れたとき、それを同じ原因だと決めつけてよいのか。この章は、その問いから始まります。

この章の症状は、WindowsやIMEが嘘の値を返したために起きたわけではありません。相手が、こちらの期待する速さで初期化を終えてくれなかっただけです。ただし結果から見れば、期待通りに動かない相手を、期待通りに動くものとして信じ切っていたという点で、根は同じです。

最初の一文字だけが、日本語にならない

「こ」と打ったつもりが、画面には「kお」という文字列が出ました。ローマ字入力の最初のキーだけが、IMEに渡る前に生の文字として出力されていました。二文字目以降は普通に変換されるため、ぱっと見には「たまたま一文字だけ壊れた」ようにしか見えません。しかし何度再現しても、壊れるのはいつも先頭の一文字でした。

この現象は一度だけではありませんでした。フォーカスを移した直後、変換を確定した直後、あるいは長時間放置した後に、繰り返し姿を見せました。この三つの瞬間には共通点があります。いずれもIME側が内部状態を新しく作り直す必要がある瞬間だという点です。

日付コミット打ったつもり実際に出た文字
2026-04-27c2a6052kお
2026-05-044409409これでkoれで
2026-05-1883d5707このぎょkおのぎょ
2026-06-1884e6942こちらkoちら

四つの事例は半年近くにわたって現れています。しかも見た目はほぼ同じです。最初の一文字だけがローマ字のまま残り、残りは正しく変換されるという形が、毎回繰り返されていました。

表に挙げた以外にも、同じ形の化け方は繰り返し記録されています。「む」が「mう」になる例、三つのキーの分岐処理が重なって「この」が「こnおあたり」になる例もありました。原因を一つに絞り込む前に、まず現れ方の共通性だけが先に積み上がっていきました。

この種の症状は、狙って再現するのが難しいという性質も持っていました。フォーカスを移した瞬間、確定操作の直後、あるいは十分な時間放置した後といった、特定のタイミングでしか起こらないためです。何度打っても化けない試行が続いた後、忘れた頃に同じ化け方が現れる。そのたびに、前回の修正が本当に効いていたのかを、あらためて疑う必要がありました。

見た目が同じであることは、原因が同じであることを意味しません。しかし当時の私は、この二つを区別せずに調査を始めました。「前回直したはずの箇所を、また壊したのだろう」というのが、最初に浮かんだ説明でした。

この思い込みには理由がありました。バグ報告は、コードの内部構造ではなく、画面に映った結果として届きます。同じ結果が届けば、同じ原因を疑うのは自然な反応です。しかし原因の側から見ると、一つの結果に至る経路は一つとは限りません。この章で追う二つの事件は、まさにその一例です。

調査の手がかりは、当時のコミット履歴とADRに残された記録でした。どちらも後から読み返して初めて、二つの事件が別の系統に属することが分かります。渦中にいる間は、どちらも「最初の一文字が化ける」という同じ一行の症状としてしか見えていませんでした。

送信順序を逆にしたら、直った

最初に疑ったのは、キーイベントを送る順序でした。Windows Terminalは独自のTSF(Text Services Framework、Windowsの入力方式を仲介する仕組み)テキストストアを持つアプリケーションで、キーの上げ下げをどう束ねて送るかによって、IME側の合成バッファが化けることがありました。K↑のタイミングで合成バッファがコミットされてしまうなら、キーを束ねる順序を変えれば防げるはずだ、というのが当初の仮説でした。

一般的なWin32アプリでは、送るキーの順序を気にする必要はほとんどありませんでした。IMEとの仲介をOSが単純な形で行ってくれるためです。Windows Terminalのように独自のテキストストアを実装しているアプリだけが、送信順序に敏感に反応しました。この違いが、アプリの種類ごとに出力方式を切り替えるという設計の出発点でした。

2026年4月11日のd35dc09で、アプリの種類ごとに出力方式を切り替える設計を導入しました。当初Windows Terminalは一般的なWin32アプリと同じ扱いでしたが、翌日には独自TSFを持つアプリとして再分類し、数日後にはSendInputをまとめて送るBatched方式も試しています。それでも化ける現象は消えず、4月21日には設定ファイルで手動上書きできるforce_vkまで追加しました。自動分類のロジックを何度組み替えても直らないという事実は、原因が分類の精度ではなく、別の場所にあることを示す手がかりでした。ただし当時は、この手がかりにまだ気づいていませんでした。

4月27日、キーの送信順序を「押した順」から「重ねて押した順」に変更したところ、症状は消えたように見えました。ところがこの判断は一日と持ちませんでした。同じ日のうちに、別のバグ修正のついでに順序を元へ戻し、IME種別の自動検出を無効化し、また重ねる順序へ変え、逐次順に戻し、最後にもう一度重ねる順序へ確定する――という反転が、たった一日のうちに5回起きています。一つの変更が別の症状を連れてくるたびに条件が上書きされ、「直った」という判断の賞味期限が数時間しか持たない状態でした。

この日のうちに何度も設定が反転すること自体が、実は重要な兆候でした。修正のたびに手応えは感じるのに、次の症状が出るまでの間隔だけがどんどん短くなっていくなら、直しているのは症状の表面であって、原因そのものではないと疑うべきタイミングです。当たりを外したまま調整を続けると、変更のたびに何かが良くなったように見えても、全体としては同じ場所を行ったり来たりするだけになります。

4月29日、最終的な送信順序の確定をもって、「最初の一文字が化ける」問題は解決したと判断しました。何日も再現を試し、症状が出ないことを確認した上での判断でした。

同じ化け方が、別の場所で起きた

解決したはずの症状が、5日後の5月4日に再び現れました。「これで」が「koれで」になる、まったく同じ見た目の化け方です。何日も再現しないことを確認した後だっただけに、この再発は解決の判断そのものへの疑いにつながりました。

私は最初、送信順序の確定が不十分だったのだろうと考えました。4月29日に確定した重ねて押す順序を、あらためて見直しました。順序を変えて試しましたが、症状は変わりませんでした。この時点で、送信順序という説明は反証されました。

原因はまったく別の場所、Windows Terminalの内部初期化にかかる時間にありました。Windows TerminalはIMEをONにした直後、内部のコンポジション機構(変換中の文字列を組み立てて保持しておく仕組み)を再初期化します。この初期化が終わる前にローマ字キーが届くと、IMEを素通りして生の文字として出力されます。これが4月29日までの送信順序の問題とは別種の、TSFのコールドスタート問題でした。

送信順序の問題は「どちらを先に送るか」という、二択に近い形をしていました。しかしコールドスタートの問題は、「どれだけ待てば十分か」という、際限のない調整を要求する形をしていました。同じ症状の裏に、まったく異なる形の問題が隠れていたことになります。

この違いに気づいてからは、調査の進め方も変わりました。症状が同じだからといって前回の修正を疑うのではなく、まず前回の修正が今回も効いているかを確かめる。効いていなければ、原因は別の場所にあると考える。当たり前のようですが、渦中では見失いやすい順序でした。

対策として採用されたのが、IMEをONにした直後に一定時間だけ待ってからローマ字を送る、待機時間の追加でした。ADR-0002はこの待機を「TSFコールドスタート・ウォームアップ」と呼び、その調整の記録を残しています。5月4日の1703fcfで、ウォームアップ用にVK_DBE_HIRAGANAを先行送信する仕組みを導入しました。この値は16進数で0xF2にあたります。当時のコミットメッセージはこれを略して「F2」と呼んでいますが、キーボード最上段にあるファンクションキーのF2とは無関係の、IME専用の仮想キーコードです。以下、この本でも当時の呼び方に倣い「F2」と表記します。翌5月5日のbabce4cでは、この「F2」とローマ字キーを別々のSendInputに分けました。同じバッチで送ると、IME初期化が終わる前にローマ字側が処理されてしまうと分かったためです。

このあとも試行は続きました。特殊なスキャンコード(キーボードが物理的なキーごとに割り当てる識別番号)を付加する案を試し、効果がないと判断して除去する変更もありました。ウォームアップに使う値自体を、「F2」から別の値(VK_IME_ON)に差し替える変更も一度試み、翌日には元の「F2」に戻しています。一つひとつの変更は小さくても、合わせると迷走と呼べる量になっていました。

この試行錯誤は、5月4日から6月19日過ぎまで、一ヶ月半にわたって断続的に続きました。送信順序という一つの変数を確定させるのに要した日数と比べると、桁違いの長さです。相手が「いつ終わるか」という時間の見積もりは、相手が「どちらを先に受け取るか」という順序の見積もりより、はるかに御しがたい対象でした。

600ミリ秒から1500へ、そして500へ

待機時間の値は、症状が出るたびに調整されました。次の三つのコミットは、その調整の激しさを示しています。

順番変更コミットメッセージの要旨
5月18日以前500ms→600msaea5a25 セッション期限切れ時の強制待機、「kおれ」バグ対策
5月18日 01:00600ms→1500ms83d5707 「このぎょ→kおのぎょ」バグ対策
5月18日 02:291500ms→500ms4249846 「adaptive warmup実装前の基準値」に戻す

83d5707のコミットメッセージは、変更の根拠を数字で示していました。「Alt+Tab後のメッセージキュー洪水が約200ミリ秒続き、GJI(Google Japanese Input、Google 日本語入力。競合する別会社の日本語入力エンジン)の初期化に560ミリ秒かかる。合計760ミリ秒必要なのに対し600ミリ秒では足りない」という実測値が添えられ、余裕を持って1500ミリ秒に設定する、と書かれていました。ところがその1時間29分後、4249846は「adaptive warmup実装前の基準値」という理由で、1500ミリ秒を500ミリ秒に戻しました。実測に基づいて延ばした値が、実装前の暫定値だからという理由で三分の一へ戻されたことになります。一週間で少なくとも五回の変更があり、実測根拠を示した変更でさえ二時間も経たずに覆っています。この往復は、待機時間という考え方、ひいては固定値そのものへの信頼が、この時点で既に薄れていたことを示していました。

待ち方の仕組み自体も、この間ずっと変わり続けました。固定sleepからWaitForInputIdle(相手のプロセスが入力の処理を終えて待受状態に入るまで待つ、Windowsが提供する仕組み)へ切り替え、また固定sleepへ戻すという往復もありました。その後dbda95fでは、コールドスタートの要因をColdReasonという型で分類し、要因ごとに異なる待機値(NativeF2Consumedなら1000ミリ秒、それ以外なら500ミリ秒)を割り当てる変更が入りました。単一の固定値を探す発想から、状況に応じて値を変える発想への、最初の一歩です。

待つのをやめて相手の内部状態を直接覗こうとする試みも二つありました。5月18日、IME側が使うプロセス間通信ファイル(session.ipc)へのアクセス時刻を監視する仕組みと、Windowsのメッセージキューへ空のメッセージを送り込み応答の有無を見る仕組みです。後者は同じ日のうちに「効果がなく、むしろ悪化した」として取り消されています。相手の内部実装が見えない以上、外側から観測できる結果を頼りに、値や覗き方を変えて反応を見るという試行が、この一ヶ月半のほとんどを占めていました。

44ミリ秒の差が、化けるかどうかを決めていた

待機の仕組みは、固定sleepから一歩進み、WindowsのUIオートメーションが発するイベント(WinEventのOBJ_NAMECHANGE)を監視する方式に置き換わっていました。相手側の状態変化を、時間ではなくイベントで捉えようとする試みです。ただしこの監視にも、待てる上限としてNameChangeWait 300ミリ秒という値が残っていました。上限そのものは、依然として時間で決められていたのです。

6月18日、55797ミリ秒(約15分)アイドルした後に「こちら」が「koちら」になる症状(84e6942)を調べたとき、初めて内訳が数字として明らかになりました。長時間アイドル後という条件は、5月4日以来何度も見てきたコールドスタートの、もっとも厳しい形です。この調査で、ようやく「なぜ44ミリ秒だけ足りないのか」という粒度の答えが得られました。

  1. 実物の「F2」(#0)とウォームアップ用の「F2」(#1)を送信し、GJIの初期化完了を312ミリ秒後に検知する。
  2. その完了を受けて、確認用の「F2」(#3)を送信する(この時点でt=312ミリ秒)。
  3. Windows Terminal側は、「F2」を受け取ってからコンポジション再初期化に実測344ミリ秒を要する。
  4. 待機の締切(NameChangeWait 300ミリ秒)に従い、t=612ミリ秒でローマ字キー(K・O)を送信する。
  5. Windows Terminalの準備が整うのはt=656ミリ秒であり、送信はその44ミリ秒前だった。
  6. K・OはGJIを素通りし、ターミナルに直接「ko」というASCII文字列として出力される。

時系列に並べると、締切と準備完了がすれ違う様子が見えます。

Windows Terminalの初期化が終わる44ミリ秒前に、待機の締切がローマ字キーを送信してしまう時系列

44ミリ秒の差で、キーはIMEを素通りしてターミナルへ直接出力されていました。待機時間をいくら調整しても、この差はいつか必ず起こり得ます。相手の初期化が終わる時刻を、時間の見積もりだけで言い当て続けることはできないからです。しかもこの44ミリ秒は、Alt+Tabの有無、システムの負荷、アイドル時間の長さによって変わります。どれだけ精密に測っても、次に同じ数字が出る保証はありませんでした。

600ミリ秒から1500ミリ秒への変更も、NameChangeWaitの300ミリ秒という上限も、根は同じでした。どちらも「相手はこのくらいの時間で終わるはずだ」という見積もりに過ぎず、見積もりである以上、外れる場合が必ず存在します。5月18日の1時間29分での往復は、この見積もりの不安定さを、期間を縮めて見せていただけでした。

ここで問題の見方が変わりました。「正しい待機時間を探す」問題ではなく、「相手が終わったかどうかを知る」問題だったのです。それまでの設計は、送る前に「もう終わっているはずだ」と賭けていました。採用された設計は、その賭けをやめ、送信した後に実際どうなったかを確認する方式でした。賭けて外れれば取り返しがつきませんが、確認してから直すなら、外れても取り返せます。

// 旧: 待機時間を信じて送る
sleep(eager_settle_ms);
send_romaji_batch(&keys);

// 新: 送ってから、化けたかどうかを確認する
send_romaji_as_tsf_warm(&keys);
if literal_detected(&keys) {
    backspace(keys.len());
    send_romaji_as_tsf_warm(&keys); // warmのまま再送(coldにはしない)
}

LiteralDetectFsmと名付けられたこの仕組みは、送信結果を監視し、リテラル化を検出した場合にのみバックスペースで取り消し、warm状態を保ったまま再送します。待機時間の調整はここで終わりました。

再送を「cold」ではなく「warm」のまま行う点には理由があります。coldとして再送すると、「F2」による再ウォームアップから始まってしまい、Windows Terminal側の344ミリ秒の再初期化タイマーがまた最初から動き出します。warmのまま直接VKキーを送れば、既に準備が終わっているWindows Terminalへ、待たずに正しく届きます。

600ミリ秒から1500ミリ秒への調整も、44ミリ秒の差の分析も、結局は「相手がいつ終わるか」を外側から言い当てようとする試みでした。LiteralDetectFsmは、言い当てることをやめ、送った結果を見てから判断するという、逆向きの発想に立っています。

この設計にも限界は残っています。文字列全体がリテラル化した場合は検出できますが、一部の文字だけがリテラル化し、かつIMEの候補表示自体は正常に発火した場合は、成功したと誤って判定されたままでした。候補表示という一つの合図だけでは、内部の文字列がどこまで正しく渡ったかを区別できなかったのです。検出の仕組みを持ち込んだことで、賭けに頼らずに済むようになりましたが、検出そのものの精度には、まだ穴が残っていたことになります。

利用者から見れば、全体がローマ字のまま残るよりも、一部だけが混ざる方がかえって気づきにくい場合があります。変換された部分に紛れて、違和感が薄まってしまうためです。検出という発想そのものは正しくても、何を検出の根拠にするかによって、救える範囲は変わります。この部分リテラルの検出は、今も未解決の課題として残っています。

他分野への転用

朝、取引先に電話をかける前に「もう始業しているはずだ」と決め打ちして掛けると、まだ準備中で出てもらえないことがあります。逆に、確実を期して昼まで待てば、今度は相手を待たせすぎたことになります。「相手の準備が整うまでにかかる時間」を外から言い当てようとする限り、早すぎるか遅すぎるかのどちらかに振れ続けます。

分散システムの世界には、この問題に正面から取り組んだ仕組みがあります。Kubernetes(コンテナ化されたアプリケーションを多数のサーバーにまたがって運用するための基盤ソフトウェア)は、新しく起動したコンテナに通信を回してよいかどうかを、起動から何秒経ったかという固定時間では判断しません。readinessプローブと呼ばれる確認要求を実際に送り、応答が返ってくるまで通信を回さないという設計を採っています。同じ仕組みには、動作中のコンテナが実は固まっていないかを継続的に確かめるlivenessプローブという、別の役割の確認要求もあります。前者は「まだ始まっていないものを、早く扱わない」ため、後者は「止まってしまったものを、いつまでも動いていると誤解しない」ためのものです。

似た発想は、ネットワーク通信の再送タイマーにも古くからあります。TCP(インターネットの基本的な通信規約の一つ)は、送ったデータへの応答がどれくらいで返ってくるかを通信のたびに実測し、その実測値をもとに次の再送までの待ち時間を調整します(Karnのアルゴリズムとして知られる手法です)。固定の待ち時間を最初から決め打ちするのではなく、相手の応答という実測できる合図に基づいて待ち時間そのものを更新し続けるという点で、LiteralDetectFsmが「送ってから確認する」方式へ転換したのと、根は同じ発想です。

固定待機時間という近似が、どの分野でも同じ理由で行き詰まるわけではありません。Kubernetesのreadinessプローブは、確認のための通信を追加で送るという代償を払っています。TCPの再送タイマーは、実測にもとづく調整であっても、その調整が追いつくまでには必ず何度かの試行錯誤を要します。「完了を検出できる合図に置き換える」という原則は共通していても、その合図をどう安く手に入れるかは、対象ごとに異なる問題として残ります。

設計原則:固定待機時間は、状態遷移の代用品にはならない

対象の処理が完了したかどうかを、経過時間の長さで言い当てようとすると、いずれ実際の所要時間との差が顕在化します。600ミリ秒から1500ミリ秒、そして500ミリ秒へという一週間の調整も、44ミリ秒の差で化けるかどうかが決まったことも、この差が現実に起こり得ることを示しています。値をどれだけ調整しても、待機時間という近似の形そのものは変わりません。値を変える作業は、原則そのものの見直しを先送りにしているだけだったとも言えます。

この原則が働くのは、相手側の処理時間が環境やタイミングによって変動する場合です。処理時間が完全に一定であることが保証されているなら、待機時間による近似でも実用上問題は起きません。しかし相手が自分の管理下にない外部プロセスであり、しかもその内部実装を知る手段がない場合、処理時間が一定である保証はまず得られません。相手のバージョンが変わるだけで、必要な時間も変わり得ます。相手のCPU負荷や、直前に何が起きていたかによっても変わります。適用条件を見誤ると、待機時間はいつまでも「もう少し延ばせば直る」ものに見え続けます。

実装の形としては、待機時間を調整するのではなく、完了そのものを検出できる合図に置き換えます。今回であれば、送信結果を観測し、意図した通りに反映されたかを確認する方式が、待機時間の調整に代わるものでした。事前に賭けるのではなく、事後に確認して、必要なら取り消して直すという順序に変えたことが要点です。

ただし、完了の合図が来ない場合に無限に待ち続けるわけにはいきません。タイムアウトは、状態を表現する手段としてではなく、異常を打ち切るための安全弁として残します。時間を状態の代用品にしないことと、時間を安全装置として使うことは、両立します。両者を混同しないことが、この原則を正しく適用する上での分かれ目になります。

保証しない範囲についても述べておきます。この原則は「完了したかどうか」を確認する手段があることを前提にしています。確認の手段そのものが得られない相手に対しては、この原則だけでは足りません。今回は送信結果を事後に確認できたために解決しましたが、確認する方法自体をどう見つけるかは、別の問題として残ります。