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

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

第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う」になる例もありました。原因を一つに絞り込む前に、まず現れ方の共通性だけが先に積み上がっていきました。

この種の症状は、狙って再現するのが難しいという性質も持っていました。何度打っても化けない試行が続いた後、忘れた頃に同じ化け方が現れます。そのたびに、前回の修正が本当に効いていたのかを、あらためて疑う必要がありました。

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

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

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

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

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

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

2026年4月11日のd35dc09で、アプリの種類ごとに出力方式を切り替える設計を導入しました。その後も分類ロジックを何度か組み替え、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のコールドスタート問題でした。

送信順序の問題は「どちらを先に送るか」という、二択に近い形をしていました。しかしコールドスタートの問題は、「どれだけ待てば十分か」という、際限のない調整を要求する形をしていました。二つの事件を並べると、この違いがはっきりします。

「どちらが先か」は選べば終わるが、「どれだけ待つか」には終わりがない、という二つの事件の違い

二択の問題は、どちらかを選び切った時点で終わります。実際、送信順序は4月29日の確定以降、再発していません。一方、時間の見積もりの問題には、選び切るという終着点がありません。どれだけ調整しても、次の環境でまた足りなくなる可能性が残ります。

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

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

このあとも細かな調整が続きました。特殊なスキャンコード(キーボードが物理的なキーごとに割り当てる識別番号)の付加や、ウォームアップに使う値を「F2」からVK_IME_ONへ差し替える変更などです。一つひとつは小さくても、合わせると迷走と呼べる量になっていました。

この試行錯誤は、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が提供する仕組み)への切り替えと差し戻しを経て、dbda95fでは要因ごとに異なる待機値(NativeF2Consumedなら1000ミリ秒、それ以外なら500ミリ秒)を割り当てるColdReason型が導入されました。単一の固定値から、状況に応じて値を変える発想への最初の一歩です。

待つのをやめて相手の内部状態を直接覗こうとする試みも二つありました。5月18日のsession.ipc(IME側のプロセス間通信ファイル)へのアクセス時刻監視と、メッセージキューへの空メッセージ送信です。後者は同じ日のうちに「効果がなく、むしろ悪化した」として取り消されています。ここまでの四つの試行――スキャンコード付加、値の差し替え、session.ipc監視、空メッセージ送信――は、いずれも相手の内部を外側から覗こうとする試みだったという点で共通していました。

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ミリ秒は環境によって変わるため、どれだけ精密に測っても次に同じ数字が出る保証はありませんでした。

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へ、待たずに正しく届きます。

この設計にも限界は残っています。文字列全体がリテラル化した場合は検出できますが、一部の文字だけがリテラル化し、かつIMEの候補表示自体は正常に発火した場合は、成功したと誤って判定されたままでした。候補表示という一つの合図だけでは、内部の文字列がどこまで正しく渡ったかを区別できなかったのです。利用者から見れば、全体がローマ字のまま残るよりも一部だけ混ざる方がかえって気づきにくく、この部分リテラルの検出は、今も未解決の課題として残っています。

この往復から、後にひとつの規約が生まれています。rust-nicolaの.claude/rules/tuning-constants.mdは、タイミング定数を変更するコミットに、実測値を本文へ書くことを義務づけています。「効かないので増やした」という理由だけの変更は禁止し、値を動かす前に、その定数が支配する待機が実際に何ミリ秒必要かを実機で測ることを求める規約です。500ミリ秒から600、1500、そして500へという往復のうち、実測値を添えていたのは83d5707だけでした。その実測でさえ、1時間29分後には別の理由で覆っています。このルールが生まれたのは、この章の出来事よりも後のことです。しかし当時AIへの指示に欠けていたのは、まさにこの一行、実測を書けという一文でした。

他分野への転用

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

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

ただしKubernetesのreadinessプローブも、確認のための通信を追加で送るという代償を払っています。「完了を検出できる合図に置き換える」という原則は共通していても、その合図をどう安く手に入れるかは、対象ごとに異なる問題として残ります。


設計原則

原則: 対象の処理が完了したかどうかを、経過時間の長さで言い当てようとすると、いずれ実際の所要時間との差が顕在化します。値をどれだけ調整しても、待機時間という近似の形そのものは変わりません。

適用条件: 相手側の処理時間が環境やタイミングによって変動する場合に働きます。相手が自分の管理下にない外部プロセスで、内部実装を知る手段がない場合、処理時間が一定である保証はまず得られません。処理時間が完全に一定であることが保証されているなら、待機時間による近似でも実用上問題は起きません。

実装の形: 待機時間を調整するのではなく、完了そのものを検出できる合図に置き換えます。事前に賭けるのではなく、事後に確認して、必要なら取り消して直すという順序に変えることが要点です。ただし、完了の合図が来ない場合に無限に待ち続けるわけにはいかないため、タイムアウトは状態の代用品としてではなく、異常を打ち切るための安全弁として残します。

限界: この原則は「完了したかどうか」を確認する手段があることを前提にしています。確認の手段そのものが得られない相手に対しては、この原則だけでは足りません。確認する方法自体をどう見つけるかは、別の問題として残ります。