見えないクラッシュ:無効なサロゲートペアがいかにしてモダンエディタを破壊するか

ソフトウェアエンジニアリングの世界では、些細なバグもあれば、伝説となるバグもあります。最も恐ろしいのは、サイレントな失敗です。システムは完璧に動作しているように見えながら、データが虚空へと消えていく現象です。TipTap、ProseMirror、Yjsを使用して共同編集エディタを構築していたチームにとって、ユーザーから「編集内容が保存されなくなった」という報告があったとき、この悪夢は現実となりました。

コンソールエラーも、スタックトレースも、クラッシュもありませんでした。エディタは応答し続けましたが、Yjsドキュメントへの同期が停止していました。これは、人間が文字をどのように認識するかと、JavaScriptがそれらをどのように保存するかとの間のギャップに潜むバグの物語です。

サイレントな失敗の解剖学

このバグは稀であり、プロダクトマネージャーがある特定のパターンを発見するまで、再現がほぼ不可能でした。そのパターンとは、2つのマルチバイト絵文字(具体的には 🟢 と 🔴)の間に文字を挿入することでした。この特定の操作が、基盤となるCRDT (Conflict-free Replicated Data Type)ライブラリ内で「スプライス(splice)」を引き起こし、サロゲートペアを真っ二つに分割してしまったのです。

サロゲートペアが分割されると、結果として得られる断片はもはや有効な文字ではありません。ブラウザ上では置換文字()としてレンダリングされることもありますが、特定のJavaScript関数に渡されると致命的な失敗を引き起こします。今回のケースでは、孤立したサロゲートが encodeURIComponent に渡され、URIError: URI malformed がスローされました。

このエラーがYjsやTipTapの同期ループ内でキャッチされなかったため、同期プロセス全体が停止してしまいました。ユーザーはローカルで入力を続けていましたが、自分の変更がサーバーに送信されていないことに気づいていませんでした。

Unicodeの階層構造を理解する

なぜ単純な .slice() がアプリケーションをクラッシュさせるのかを理解するには、現代のコンピューティングにおける文字列表現の3つのレイヤーを理解する必要があります。

1. コードユニット (Code Units)

JavaScriptは内部的にUTF-16を使用しています。コードユニットは、生の16ビット値です。.length がカウントし、.slice() が操作対象とするのはこれです。標準的なASCII文字の場合、1つのコードユニットは1文字に相当します。絵文字の場合、これは稀なケースです。

2. コードポイント (Code Points)

コードポイントは、Unicode標準によって定義された実際の原子的な単位です。U+FFFF を超える文字(ほとんどの絵文字を含む)は、単一の16ビットコードユニットには収まりません。UTF-16は、これをサロゲートペアを使用して処理します。つまり、1つの上位サロゲートと1つの下位サロゲートを使用します。もし文字列をこれら2つのちょうど中間でスライスしてしまうと、「無効なサロゲート」が作成されます。これは、相棒となるペアがいなければ存在できないコードユニットです。

3. 書記素クラスタ (Grapheme Clusters)

書記素クラスタとは、人間が1つの文字として認識する単位です。一部の絵文字は、実際には複数のコードポイントの組み合わせで構成されています。例えば、女性の宇宙飛行士(👩‍🚀)は、女性の絵文字、ゼロ幅接合子、そしてロケットの絵文字で構成されています。これは1つの書記素、3つのコードポイント、そして5つのコードユニットです。

文字 コードユニット コードポイント 書記素
A 1 1 1
🤠 2 1 1
👩‍🚀 5 3 1
👨‍👨‍👧‍👧 11 7 1

解決への道のり

アップストリームの依存関係(この場合は lib0)に存在するバグを修正するには、多層的な戦略が必要です。チームはいくつかの防御層を実装しました。

即時的な回避策: チームはオフラインサポートを有効にしました。これは主要な修正ではありませんが、同期が失敗した場合でもCRDTがローカルで更新を続けられるようにし、接続が復旧した際やページをリロードした際にマージが可能になるようにしました。

究極の手段: window.addEventListener("error", ...) によるグローバルリスナーを追加し、URIError: URI malformed をキャッチするようにしました。エラーが検出された場合、アプリはユーザーにリロードを促すモーダルを表示し、データのサイレントな消失を防ぎました。

構造的な修正: ProseMirrorにおいて、チームは絵文字を「アトミックなノードタイプ」として定義しました。絵文字を分割不可能な単位として扱うことで、エディタはカーソルがサロゲートペアの内部に位置することを防ぎ、スプライスによるバグのトリガーを排除しました。

アップストリームへのパッチ: 最終的に、lib0 は孤立したサロゲートを検出し、それをUnicode置換文字(U+FFFD)に置き換えるようにパッチが当てられました。これにより、URIError がスローされるのを防ぎます。

モダンな開発における教訓

このバグは、根本的な真実を浮き彫りにしています。str[0]str.slice(0, 1) が完全な文字を返すと想定しているコードは、すべて潜在的に壊れています。これは、ユーザーの名前からイニシャルを生成するツールなどでよく見られます。もしユーザーの名前が絵文字で始まる場合、結果として得られる「イニシャル」はサロゲートペアの半分になり、レンダリングの不具合やクラッシュを引き起こします。

モダンな解決策: Intl.Segmenter

JavaScriptで文字列操作を行う開発者にとって、標準の .slice() はしばしば不適切なツールです。現代的な答えは Intl.Segmenter であり、これを使用することで書記素クラスタに基づいて文字列を分割することが可能です。

const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" });
const segments = [...seg.segment("👩‍🚀A👍")].map((s) => s.segment);
// 結果: ['👩‍🚀', 'A', '👍']

コミュニティの視点

このバグに関する議論は、言語がテキストをどのように扱うかという、より深い緊張関係を明らかにしています。一部の開発者は、UTF-16は「不必要なミス」であり、、文字列は固定サイズの64ビット値であるべきだったと主張しています。他の開発者は、書記素クラスタの複雑さは、エンコーディングに関わらず避けられないものだと指摘しています。

さらに、環境によって独自の危険がもたらされることもあります。あるコントリビューターが指摘したように、LANG のようなサーバーサイドの環境変数によってUnicodeの扱い方が変わり、本番環境でのみ発生し、開発者が調査のためにサーバーにSSHでログインした瞬間に消えてしまうようなバグが発生することがあります。

最終的に、サロゲートペアのバグは、「文字列」という抽象化が、見た目よりもはるかに脆いものであることを思い出させてくれます。テキストを扱う際、特に共同編集や国際化された環境においては、文字列を単なる文字の配列ではなく、書記素のシーケンスとして扱うことだけが安全な道です。

Sources