Matter接続、80秒待ちの誤診と0.5秒への短縮
家のMatter機器は、自作CLI mat と常駐デーモン matd で動いています。機器と通信するには、まずmDNSでIPアドレスを見つけ(resolve)、次にCASE(機器との暗号化セッションを張る手続き)で鍵交換をします。ここまでは以前書いたとおりです。
機器によっては、このresolveで複数のIPv6アドレスが返ってくることがあります。ネットワークインターフェースを跨いだり、経路が切り替わったりする都合です。複数候補があるとき、matはどう振る舞うべきか。この設計を見直していて、まず自分たちが書き残していた記録の誤りに気づき、そのあとで本当の問題を見つけました。
「1アドレスしか試さない」というメモは間違っていた
matの設計ドキュメント(ARCHITECTURE.md)には、以前から気になる1行が残っていました。「CASEの確立は複数候補のうち1アドレスしか試さない」という、未実装として記録されていた制限です。
改めて実装を読み直すと、これは事実ではありませんでした。CASE確立の本体は、フルスクラッチでコントローラを書き直した7月の時点から、複数候補をfor peer in peersで順番に全部試す作りにすでになっていました。
では、あの「1アドレスしか試さない」という観測はどこから来たのか。たどると、当時のresolve側にあった別のバグに行き着きました。machine間の通信でマルチキャスト応答を受け取るソケットの初期化に不備があり、resolveがAAAAレコードを1本しか拾えていなかったのです。つまり、CASE確立が候補を1本しか試していなかったのではなく、resolveが候補を1本しか渡していなかった。原因が別の層にあるのに、症状だけを見て「CASEの制限」と誤って書き残していました。
resolve側のバグはその後の別の作業で直っていましたが、ARCHITECTURE.mdの記述だけが古いまま取り残されていました。実装を読み直して初めて、その行が指している問題はすでに存在しないと確認できました。
本当に残っていた問題は、死んだ先頭アドレスへの80秒待ち
誤診を正したことで、逆に見えてきたことがあります。「候補を全部試す」ループ自体には、まだ手を付けていない設計上の弱点が残っていました。
CASEは、Matter規格が定めるMRP(メッセージ再送プロトコル)の上で動きます。応答が来ないアドレスに対しては、規格どおりに再送を重ねます。デフォルトの間隔設定だと、再送は5000ms → 8000ms → 12800ms → 20480ms → 32768msと間隔を伸ばしながら4回。合計すると、1本の死んだアドレスに応答なしと結論づけるまで最大で約80秒かかります。
matの接続コマンド全体には--op-timeout-msという上限があり、デフォルトは60,000ms(60秒)です。候補が2本あって先頭が死んでいると、80秒かかるはずの先頭の再送が終わる前に60秒の全体期限が来てしまい、2本目には1パケットも送らないままtimeoutで失敗します。せっかく2本目の生きたアドレスが控えていても、それを試す機会すらないということです。
8月末のコード監査でこの経路が指摘されて、初めて「候補を全部試すループ自体は正しく実装されている。しかし1本ずつ律儀に順番を待つ設計だと、死んだ候補が先頭に来た瞬間に大きな時間を溶かす」という、本当に直すべき問題が輪郭を持ちました。
選んだ設計: RFC 8305のHappy Eyeballsを候補アドレスに適用する
直し方の方向性は、複数候補への接続で定番の手法です。IPv4/IPv6のデュアルスタック環境で使われるHappy Eyeballs(RFC 8305)の考え方を、CASEの複数候補に応用しました。全部を順番に待つのではなく、一定間隔で並行して試し始め、最初に成功した1本を勝者として採用する方式です。
設計にあたって、単純に「並行化する」だけでは済まない判断がいくつかありました。
起動間隔(stagger)は500ms。 RFC 8305が推奨する250msより長めにしています。理由は相手デバイス側の挙動です。先頭アドレスへの応答(Sigma2)がまだ届いていないタイミングで2本目のSigma1を撃つと、進行中のセッションがあるMatter機器はBUSY応答を返すことがあります。間隔を長めに取ることで、健全な先頭アドレスがある場合に無駄なBUSY応答を誘発しにくくしました。
試行1本あたりの時間上限は、あえて設けない。 MRPの再送予算は規格どおり使い切らせます。全体を縛るのは、これまでどおり呼び出し側の--op-timeout-msだけにしました。部分的な上限を追加で挟むと、正常but応答が遅いだけのケースを誤って切り捨てるリスクがあると判断したためです。
resolveの後着AAAA取りこぼしには、今回は手を付けない。 resolveは複数のAAAAレコードが返る場合、最初にSRVとAAAAが揃った時点で早期returnする作りで、遅れて届く候補を取りこぼす余地が理論上残っています。ここも直せなくはないのですが、直すとすべてのresolve待ちが少しずつ伸びます。実害が観測されていない箇所を、体感速度を犠牲にしてまで先回りで直す必要はないと判断し、見送りました。
実装: 候補ごとに専用ソケット、負けた試行はdropでキャンセル
並行実行の本体は、mat-controllerに新設した汎用モジュールrace.rsです。Rustの非同期タスクをNparallelで起動し、stagger間隔を空けながら次を起動していく、time以外に副作用を持たない純粋なレース関数にしました。
pub async fn race_staggered<I, T, E, Fut>(
items: Vec<I>,
stagger: Duration,
attempt: impl FnMut(I) -> Fut,
) -> Result<(usize, T), Vec<E>>
where
Fut: Future<Output = Result<T, E>>;
勝者が決まった時点で、まだ動いている残りの試行はRustのFutureをdropするだけでキャンセルされます。この関数自体はネットワークを一切知らず、時間の経過だけに依存しているので、tokio::time::pause()を使ったテストで実時間を待たずに決定的に検証できます。
この上に、CASE確立向けのcase::establish_anyを実装しました。候補ごとに専用のUDPソケットをbindしてから並行試行を始めます。1つのソケットを使い回すと、自分が送った試行以外の応答パケットを黙って捨ててしまう仕組み(UnsecuredExchange::screen)があるため、候補の数だけソケットを分ける必要がありました。
mat / matd (warm session) / 常駐Subscribe
│
▼
case::establish_any(候補アドレス列, stagger=500ms)
│
├─ 候補1: 専用ソケット ─┐
├─ 候補2: 専用ソケット ─┤ race_staggered
└─ 候補3: 専用ソケット ─┘
│
▼
最初に成功した1本を採用、残りはdrop
mat直接実行・matd経由のwarmセッション・常駐Subscribeのすべてがこの1つの実装を呼ぶ構成にしたので、変更は1箇所で全経路に効きます。呼び出し側のJSONスキーマやエラー種別は変えていません。全候補が失敗した場合のエラーメッセージだけ、以前は最後の1本の失敗理由しか出していなかったものを、全候補の失敗理由を列挙する形に変えました。
効果: 縮むのは「死んだアドレス+生きたアドレス」の組み合わせだけ
数字で見ると、旧実装は候補N本の逐次試行で最悪N×約80秒。新しい実装は「一番時間のかかった試行+stagger×(起動本数-1)」で、最悪でも約80秒+0.5秒×(N-1)です。
ただし、ここは丸めずに正直に書いておきたいところです。短縮が効くのは「死んだアドレスと生きたアドレスが両方候補にある」構成に限られます。候補が1本しかなく、それが完全に到達不能なノードの場合は、以前と同じく--op-timeout-msの60秒でtimeoutになります。並行化しても、存在しない相手からの応答は待つしかありません。速くなるのは、あくまで「順番待ちの列に無駄に並ばされていた生きたアドレス」がある場合だけです。
同じ変更のついでに、このCASE確立まわりのユニットテストも補強しました。証明書検証(x509.rs)まわりのテストが手薄だったのを底上げし、境界値やエラー系のケースを一通り足しています。
おわりに
今回の一件は、直した内容そのものより、直す前の1ステップのほうが印象に残っています。「複数アドレスをちゃんと試していない」という記録を信じて対処していたら、すでに解決済みの問題を追いかけて空振りしていたはずです。実装を読み直して記録の誤りを認めてから、初めて本当に直すべき問題(死んだ先頭アドレスへの80秒待ち)が見えました。
matのARCHITECTURE.mdには、こうした過去の誤った記述を消さずに「訂正」として残す運用にしています。今回のように、あとから読み返して気づける余地を残しておくためです。