開発ブログへ戻る エンジニアリング更新

Mac アプリを固まらせる 3 つの原因 —— そして Desk 0.9.0 がその全部をどう直したか

SharkTTY Desk 0.9.0 が本日リリースされました。ユーザー向けのまとめは 新着情報にあり、 ここからダウンロード できます。そちらで目玉として掲げているのはゲスト共有リンクです——リンクか QR コード ひとつで、アカウントなしに誰かを自分の Mac に招待できます。この記事が語るのは別の話です。 新機能ほどデモ映えしない、舞台裏の追跡劇——アプリ全体が完全に固まり、ビーチボールが ぐるぐる回り続け、その原因を突き止めるのに本当に手間がかかった話です。互いに独立した 3 つの根本原因、3 つの個別の修正、すべてがこのビルドに収まっています。

原因その1:UI スレッド上でのブロッキング送信

Desk で別の Mac を閲覧しているとき、映像と入力は WebRTC のデータチャネルを通ります。 そのチャネルへの送信コードは同期的に実行され、しかも UI スレッド上で動いていました。 通常の条件下ではこれは見えません——送信はほぼ瞬時に返ります。しかし WebRTC の SCTP トランスポートは固定サイズの送信バッファ(128 KiB)を持っていて、相手側が遅れて このバッファが埋まると——バックプレッシャーです——送信がブロックすることがあります。 無期限に。そしてこのブロックが UI スレッド上で起きると、単なる映像ストリームの停滞では 済みません。アプリ全体が止まります:メニューは開かず、ウィンドウは再描画されず、 上流側が譲歩するまでビーチボールが回り続けます。WebSocket と QUIC のトランスポート 経路にはこの問題はありませんでした——同じブロッキング呼び出しを通らないからです。

修正はシンプルです。送信は非ブロッキングになりました。持ち帰った教訓は素朴なものです: 「常に十分速く返る」は仮定であって保証ではなく、UI スレッドはその仮定を検証する役で あってはいけません。最悪の場合「リモートのピアがバッファを空にするまでブロックする」 呼び出しは、その最悪ケースが実際にはどれだけ稀に感じられようと、レンダーループの中で 同期的に実行される資格がありません。

原因その2:誰も読み出していなかったパイプ

2 つ目の原因は、古くからよく知られていながら、気づかないうちに再導入してしまいやすい Unix の罠です。サブプロセスの出力パイプにはカーネルの小さなバッファ(約 64 KB)があり、 誰もそこから読み出さなければバッファが満杯になり、子プロセスはそれ以上書き込もうとして ブロックし、その子プロセスの終了を待っている側もまたブロックします。 waitUntilExit は永遠に戻ってきません。待っているプロセスが、誰も聞いていないパイプへの書き込みで 詰まっているからです。

この形にぴったり当てはまる呼び出しが、ロック画面キャプチャのヘルパーが有効な間、 1.5 秒おきにメインスレッド上で走っていました——周期的なチェックで、対象がパイプを 満杯にした瞬間、アプリ全体を時計仕掛けで、しかも一度きりではなく繰り返し固めることが できました。これが特に厄介だった理由はそこにあります。稀なエッジケースではなく、 周期タイマーがそのままデッドロックに直撃していたのです。

修正は 2 つの部分から成ります。読むつもりのない出力は、今はパイプではなくヌル デバイスに送られます。実際に消費する出力は、プロセスを待つ前に排出されます。教訓: 自分が作るパイプは「読み出す」という契約です——読むつもりがないなら作らない。読むなら、 書き込み側を待ってブロックする前に排出する。

原因その3:使うほど遅くなる履歴

3 つ目はハングというより緩やかな出血でした。長時間の mosh ターミナルセッションは 最初こそ問題なく同期していましたが、時間が経つほど目に見えて遅くなっていきました。 原因はアルゴリズム的なものでした。ターミナル履歴を同期し続けるコストが、履歴量に 対して二乗、O(H²) でスケールしていたのです。ロックでもリークでもなく、5 分の セッションには何の問題もなく、5 時間のセッションでは徐々に高くつくようになる やり方だっただけです——外から見れば、ほかの 2 つの原因が引き起こすのと同じ種類の 固まり方に見えるようになるまで。

修正は履歴を構造共有を使う形に作り直し、長時間実行されるセッションが年月とともに 遅くなるのではなく、速いままでいられるようにしました。教訓:二乗コストはどんな 短いテストやデモでも見えません。セッションが十分長く走って H が意味を持つように なって初めて姿を現すからです——つまりそれを見つけるのは常に、あなたの本物の ユーザーが、本物の最長セッションでたどり着く場所であって、自分から探しに行かない 限りそうなるということです。

共有リンクが https 形式に

0.9.0 が目玉として掲げるのはゲスト共有リンクです。Mac 側の「共有」パネルから、 リンクか QR コードで誰かを招待できます。ホスト側は有効期限、同時に閲覧できる人数、 閲覧専用にするかどうか、任意のアカウント許可リストをコントロールでき——どの共有も いつでも取り消せます。リンク自体が Mac の恒久的な接続キーを運ぶことは決してありません。 ゲストはクラウドが発行する短命なチケットを引き換え、その有効期限・同時接続数の上限・ 閲覧専用モード・取り消しを検証し実施するのはクラウドではなく Mac 上のエージェントです。 まだ初期段階の、無料の選択肢として、共有を 1 時間単位の独占予約として提供することも できます。ゲストは UTC に合わせた空き時間枠を選び、その時間帯だけ Mac を独占します。

0.9.0 が変えるのはリンクそのものの形です。今では生の ttyshare:// アドレスではなく、https の URL—— https://safafish.com/s/#s=<code>&n=<name> ——になりました。https リンクはどのデバイスのどのブラウザでも開き、 受け渡しページに着地します。 そこでは「SharkTTY で開く」を提示し、未インストールなら App Store を案内し、iPhone や iPad でスキャンできる QR コードを表示します。すでにアプリの入ったデバイスにこの https リンクを貼り戻しても、同じように往復します。まだできないことも正確に言っておきます。 リンクをタップしただけで自動的に iOS アプリが瞬時に開くわけではありません——その 受け渡しは、私たちがまだ出荷していないアプリビルドに依存しています。今日手に入るのは、 どのブラウザでも開いて SharkTTY にリンクを引き渡すページです。それでも、ゲストの 半分のデバイスが認識しないであろう URI スキームより、チャットに貼るにはずっと親切です。

0.9.0 のもう 2 つの小さな改善

触れておくべき小さな改善が 2 つあります。Desk のターミナル——SSH、ローカルシェル、 mosh のどれでも——で Option を押しながらクリックすると、その位置へシェルのカーソルが 移動するようになりました。Terminal.app や iTerm2 と同じ芸当で、合成した矢印キーで 実現しています。プライマリバッファでも、フルスクリーンアプリの代替スクリーンでも 動作します。そしてシャープな拡大鏡(ルーペ)には小さな精度のバグがありました。 スロットリングのウィンドウがドラッグの最後の移動を捨ててしまい、ルーペがカーソルの 実際の停止位置より少し手前に留まることがあったのです。今は静止位置が必ず最後の 更新を受け取るので、拡大された内容は正確に十字線の下に来ます。

上記について質問やバグ報告がありますか?フィードバックボードが私たちに届く一番早い方法です。