本文へ移動
ブログに戻る

MailVault 2.4.0: 安定性のためのリリース

7分で読めます

MailVault 2.4.0に注ぎ込まれた作業の大半は、目に見えません。UIはほとんど同じに見えます。機能の一覧も控えめです — 設定できる送信の遅延、送信済みフォルダへのコピー、設定画面の整理。しかし内側では、このリリースはほとんどが、メールアプリへの信頼を静かに削っていく種類の不具合を直すことに費やされています。クリックしたのと違うメールが表示される、古いメッセージがアカウントをまたいで混ざる、バックグラウンドヘルパーが自分自身と通信できない、そして壊れたバイナリを出荷しかねないリリースの仕組み。

これは安定性のためのリリースです。何が壊れていて、それにどう対処したかを説明します。

この記事を書かせた不具合

OutlookとMicrosoft 365のアカウントで、あるメールをクリックすると、まったく別のメールの内容が表示されることがありました。一覧の件名と、表示画面の上部の件名は一致しているのに、本文、送信者、ヘッダーはまるで別のメッセージのものでした。報告はまれで、狙って再現することはできず、初めて見たときはぞっとします。

根本の原因は、2つの異なるメールのプロトコルを橋渡しした際の行き違いでした。従来のIMAPは、どのメッセージにも、メールボックスが存在するかぎり変わらない安定したUIDを与えます。Microsoft Graph APIはそうではありません — 代わりに不透明な文字列のメッセージIDを持ちます。MailVaultのコードベースのほかの部分を揃えておくために、私たちのGraphの通信の層は位置に基づくUIDをその場で作り出していました。フォルダに索引を付け、1、2、3と順に配っていたのです。

これは順序が変わるまではうまくいきます。新しいメッセージが届く、スレッドが別のフォルダへ移る、サーバー側の並べ替えがミリ秒の差でずれる — それだけで、メールAを指していた位置ベースのUIDが、いまはメールBを指すようになります。ヘッダーの一覧は毎回取り直していたので問題ありませんでした。しかし、同じずれていくUIDを鍵にしていた本文のキャッシュが、誤った内容を返すようになっていたのです。

対処はアーキテクチャの変更でした。各メールのヘッダーにGraphの本物のメッセージIDを直接埋め込み、本文のキャッシュ、コンテンツの読み込み、各種の操作の経路すべてで、それを正式な識別子として使います。合成のUIDは、アプリ内のIMAP風のインターフェースとの互換性のために残っていますが、「これは本当はどのメッセージなのか」の拠りどころではなくなりました。

アカウントをまたぐデータの混入

「違うメールが出る」不具合の親戚がもう一つありました。アカウントAからアカウントBに切り替え、Bの中でたまたま空だったフォルダを開くと、アカウントAのメールが一覧に少しの間残って見える、というものです。

これはキャッシュの無効化の問題でした。アカウントの切り替えは、メールの状態の一部を消していましたが、すべてではありませんでした。並べ替え済みのメール一覧、メモリ上の本文キャッシュ、スレッドのキャッシュが、それぞれ微妙に違うタイミングで更新されていたのです。切り替え先のフォルダに新しいメールがあれば、新しいデータが十分に速く届いて古い項目を覆い隠しました。切り替え先のフォルダが空だと、「空である」という結果がようやく伝わるまで、古い一覧が見えていたのです。

修正は地味なものでした。アカウントやメールボックスの切り替えでメールの状態を消すすべての場所が、新しいアカウントのデータが読み込まれ始める前に、そのすべてを不可分に消すようになりました。中途半端に消えたキャッシュも、「空のフォルダが届いた」と「前のアカウントのメールが追い出された」の競争も、もうありません。

自分のソケットを見つけられなかったサンドボックス内のデーモン

MailVaultにはバックグラウンドヘルパーがあります — メインのウィンドウを閉じていてもメールの同期を続ける、小さなサイドカーのデーモンです。macOSでは、アプリもデーモンもApp Sandboxの中で動くため、それぞれが違うファイルシステムの見え方をします。Unixドメインソケットで通信するには、共有の待ち合わせ場所が必要です。

不具合の内容: アプリはある方法でソケットのパスを解決し、デーモンは別の方法で解決していて、サンドボックス版のビルドではその2つのパスが別のコンテナのディレクトリを指していました。デーモンは起動してソケットをbindし、そこで待ち続けます。アプリは接続を試みて外し、デーモンは動いていないと結論づけていたのです。

今はどちらの側でもdirs::home_dir()を使って~/.mailvault/mv.sockを解決します。サンドボックスのもとでは、これは両方のプロセスにとって同じコンテナ内の相対のパスに展開されるので、クライアントは自分を起動したサーバーを必ず見つけられます。

ヘルパーのエンタイトルメントも同じように整理しました。デーモンのバイナリは、アプリの署名の設定からapp-sandboxを受け継いでいましたが、これは自前のInfo.plistを持たない単独のバイナリをクラッシュさせます。application-groupsも設定されたままでした。App GroupのIPCを使う以前の計画の名残です — 両方のプロセスが同じコンテナのホームを共有する今では不要です。どちらも取り除きました。ヘルパーは、新しく入れたサンドボックス付きのビルドを含め、私たちが試すすべてのmacOSのバージョンできれいに起動します。

正しく見えて、正しくなかったentitlements

これはいちばん厄介な種類の不具合です。何もかもが正しく見えて、静かに動かないのです。

macOSのエンタイトルメントのファイルでは、開発チームの接頭辞を$(AppIdentifierPrefix)として参照できます。Xcodeは、ファイルをcodesignへ渡す前にこの変数を展開します。私たちのリリースのパイプラインはXcodeではなく — codesignを直接呼びます — そしてcodesignはその変数を展開しないことが分かりました。$(AppIdentifierPrefix)という文字列がそのまま、署名済みのバイナリに埋め込まれていたのです。

症状はこうでした。アプリとデーモンの間のキーチェーンの共有が、黙って壊れていたのです。アプリはキーチェーンから認証情報を読めるのに、デーモンには見えませんでした — 要求していたアクセスグループの識別子が、本物のチームの接頭辞の付いたものではなく、$(AppIdentifierPrefix).com.mailvault...という文字列そのものだったからです。デーモンは持っていない認証情報を求めにいき、あきらめていました。

修正は、codesignを呼ぶ前に、本物のチームの接頭辞を入れてエンタイトルメントのファイルを書き換えるsedの展開の手順です。既存のバージョンの整合性の確認と並べて追加したので、「ぱっと見は正しく見える設定」という同じ種類の不具合をCIで捕まえられます。

snapの公開のゲート

CIの話ついでに。2.4.0までは、リリースのタグをpushすると、できあがったsnapが自動でstableチャンネルに公開されていました。最初の簡単な動作確認のあとで何かが壊れると — そしてそれは実際に起きました。Snapの隔離は開発ビルドとは違う振る舞いをし、それはインストールしたあとにしか現れないからです — 気づいたときには壊れた版がすでに公開されていました。

リリースのパイプラインは今、「snapをアップロードした」段階で止まります。ビルドをedgeのチャンネルからstableへ移すのは別のPromote Snapsのワークフローで、しかもそれは、きれいなマシンにedgeのチャンネルから入れて基本の動きを確かめたあとだけです。アカウントの追加、メールの読み込み、バックアップです。うまくいったリリースではボタンを1回多く押すだけで、うまくいかなかったリリースでは、ずっと落ち着いて戻せます。

もっと静かな修正

いくつかの小さな修正も、「アプリが自分自身と矛盾しないようにする」という同じ主題に収まります。

モジュールのキャッシュの無効化。遅延して初期化されるモジュールの水準のキャッシュがいくつか — メモ化されたフォルダの参照や記述子の参照など — 下のデータが変わっても消されていませんでした。今は消されますし、統合フォルダのキャッシュには上限が付いたので、際限なく育つことはありません。

記述子の重複した保存。アカウントの有効化が、1回の有効化につき記述子をディスクへ複数回書いており、素早く切り替えると自分自身と競合することがありました。重複した書き込みはなくなりました。

アカウントの有効化での無限再帰。activateAccountのまれな経路 — キャッシュに見つからなかったあとの記述子の復元の代替 — では、復元した記述子がさらに別の復元を引き起こすと、自分自身を呼び戻すことがありました。再入の防止で頭は押さえていましたが、より整った修正で再帰そのものを取り除きました。

ストアの構成。コンポーネントと主となるZustandのストアの間に「ドメインストア」の包みが並んでおり、状態をローカルのコピーへ複製していました。整ったAPIを提供するつもりでしたが、実際には状態ごとに真実の出どころが2つになり、更新の順序の不具合がときどき出ていました。今はセレクターを再輸出するだけの薄いものです — 使い勝手はそのままで、真実の出どころは1つです。

背後のヘルパーの構成。背後のヘルパーを管理するRustのコードは、1つのモジュールで2つの仕事をしていました。デーモンをどう起動するか(どのバイナリを、どの引数で、どのサンドボックスのもとで)を決めることと、動き始めたあとのRPCの通信を扱うことです。これを、起動の方針を担うhelper.rsと、別の通信のモジュールに分けました。起動の失敗を調べるのに要求と応答のコードを掻き分ける必要はなくなりましたし、その逆も同じです。あわせて利用者向けの文言も書き直しました — 「system service」は「background helper」になり、実際の姿により正直な名前になりました。

このリリースに時間がかかった理由

安定性のためのリリースは、妙に計画しにくいものです。個々の修正は小さいのに、それが及ぶ範囲は非常に広い — Graphのトランスポート、アカウントの切り替え、サンドボックス内のIPC、コード署名、リリースの基盤のすべてに手が入りました。これらの不具合の多くは、狙って再現できないほどまれでした。トレース、ユーザーからの報告、そしてコードを読むことから推論を重ね、悪い状態がどう生まれるのかが見えるところまで進めてから修正を書き、報告が止まるのを待って確かめるしかありませんでした。

その見返りは、週に一度は噛みついてきた不具合を、あるとき急に見なくなって初めて気づく種類の変化です。上に挙げた挙動のどれか — Outlookで違うメールの内容が出る、アカウントの切り替えで古いメールが残る、バックグラウンドヘルパーが接続を保てない — に当たったことがあるなら、2.4.0はそれらが消えるリリースです。

次に控えているのは、5段階のリファクタリングを終えること、v2.4の積み残しの一覧(HostingerでのIMAPの単一メールボックスの検出、フォルダの切り替えの端の場合、作成中の下書きのバブル)を片づけること、そして分類器の書き直しを形にしていくことです。機能の開発は続きます — ただし、信頼できる土台の上でだけです。