
十二分で生まれ、そして直された
GeekBye v1.8.10のchangelogは、キーボードショートカットの編集中のクラッシュを直したと言います。直したのは本当です — けれどそのクラッシュは同じプルリクエストの中で、十二分の間を置いて、導入され、そして直され、ただの一人のユーザーにも届きませんでした。本当の物語は、それを生んだ信頼性の連鎖です: ショートカットをいつ一時停止するかへの、小さく正しい変更と、エディターをより安全にするための一行から転がり落ちたReactのティアダウンのバグ。
GeekBye v1.8.10について私が書ける最も正直な一文はこれです: それが直したことで名を馳せたクラッシュは、誰の身にも決して起きませんでした。changelogは "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit," と言い、それは本当で、出荷されてユーザーに噛みつき、追って修正されたバグのように読めます。そうではありませんでした。そのクラッシュは、同じ金曜の朝、_同じプルリクエスト_の中で、十二分の間を置いて、一つのコミットによって導入され、別のコミットによって削除されました。それはまるごと一つのブランチの中で生きていました。リリースノートは、リポジトリの外の誰かが見られるより前に形づくられ癒えた傷痕を、記述しているのです。
それはchangelogの失敗ではありません; それは反復的な仕事の正直な形であり、語る価値があるのは、その十二分が本当に教訓に富んだReactのバグを含んでいるからです。しかしそのクラッシュを理解するには、このリリースが実際何の_ため_だったのかを理解しなければなりません — なぜならそのクラッシュは、別の何かを直したことの巻き添え被害だったからです。
このリリースが実際に扱っていたこと
GeekByeは設定でキーボードショートカットを付け替えさせてくれます。新しい組み合わせを記録している間、アプリはその_グローバル_ショートカットが発火するのを止めなければなりません — さもなければCmd+Bを割り当てようと押しても、それはキャプチャされる代わりにただウィンドウをトグルするだけになってしまいます。だからエディターはIPC越しにグローバルショートカットを一時停止します: レンダラーがwindow.electronAPI.setShortcutsSuspended(true)を呼び、メインプロセスのShortcutsHelperがunregisterAll()を呼んで応じ、あらゆるglobalShortcutの登録を落とすので、キー押下はアクションを引き起こす代わりにキャプチャ入力へ届きます。再開時にはそれらを元に戻すためにregisterGlobalShortcuts()を呼びます。
このすべてを始めたバグ(issue #233)は、その一時停止の_範囲_についてでした。古いコードは、あなたが実際にキーを記録している間だけでなく — ショートカット設定ページが開いている間ずっと、ショートカットを一時停止していました。たいていの場合それは目に見えません。しかしページを開いて、別のことをし始めて、編集を一度も終えないまま離れてしまうと、あなたが戻るまで、アプリはグローバルショートカットがすべて登録解除されたまま — 静かに死んだまま — 残されかねませんでした。それを直すコミットは症状について率直です: 広く一時停止しすぎることは "making the app appear broken if the user forgot they had the page open." の危険を冒していた、と。
修正は一行の絞り込みです: 訪問まるごとの間ではなく、行が実際に編集されているときだけ一時停止する — あなたが記録している行のidを保持するフックの状態、!!editingShortcutをキーにして。良い変更です。しかし「編集中だけ一時停止する」は即座に問いを立てます: 何をもって編集の_完了_とするのか? キーを押せばそれを完了します。Escapeを押せばそれをキャンセルします。しかし、もしあなたがただ…どこかへクリックして離れたり、キャプチャの最中にまるごと別のアプリへ切り替えたりしたら? 何も編集をキャンセルしなければ、フォーカスされていないウィンドウへショートカットを記録し続けることになり、ショートカットは一時停止されたままです。だから同じPRは安全弁を加えました: フォーカスが去ったら編集をキャンセルする。そしてそれがクラッシュした一行なのです。
十二分のバグ
安全弁はShortcutsSettings.tsxの中のuseEffectで、行が編集されている間、blurを待ち受けていました:
const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine
二つのリスナー。一つはwindowに — 別のアプリへ切り替えたときに発火します。一つは_直接、生のDOMリスナーとして、_キャプチャ用の<input>へ取り付けられ — 別の要素へクリックして離れたときに発火するはずのもの。それらは冗長で無害に見えます。そうではありません。
まさにchangelogの言い回しそのものである "switching windows mid-edit," のシーケンスをたどってみましょう:
- あなたはショートカットを記録している;
<input>はフォーカスされている。あなたはCmd+Tabで別のアプリへ移る。 - ウィンドウのblurが発火する。
handleBlurがcancelEditing()を走らせ、それがeditingShortcutをnullへ戻す。 - その状態を設定すると**
<input>がアンマウントされる** — 行が編集モードを抜けるので、入力はDOMから取り除かれる。 - フォーカスされた要素をDOMから取り除くと、同期的にその要素で
blurイベントがディスパッチされる。 生のinputRefリスナーがそれを捕まえてcancelEditing()をもう一度呼ぶ — 今度はReactのcommitフェーズの最中、コンポーネントツリーが取り壊されている最中に。 - ティアダウンの最中のfiberで状態セッターを呼ぶと、Reactの内部不変条件の一つに引っかかり、投げます:
"Should have a queue"。設定ビューがクラッシュします。
バグはウィンドウのリスナーではないし、本当のところ「blurでキャンセルする」という概念でもありません。二つのリスナーの一つが**Reactが管理する要素への生のaddEventListener**だったこと、それがバグです。Reactはそのリスナーを知らず、アンマウント時にそれを片づけず、そして — 決定的なことに — ブラウザはReactがまさに行っているDOMの除去の最中にblurを発火させるので、ハンドラーは最悪の瞬間にあなたの状態ロジックへ再突入します。修正コミットのメッセージは、私が言えるより正確にそれを述べています: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."
修正
bf28a50は、クラッシュが生まれてから十二分後、二つの小さなことをします。
まず、それは生の入力リスナーを削除し、ウィンドウのものだけを残します。それは、そもそも手動のDOMリスナーである必要があった唯一のものです(「ウィンドウ全体がフォーカスを失った」に対応するReactのonBlurは存在しません):
const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('blur', handleWindowBlur)
}
次に、それはクリックで離れる場合 — 「ユーザーが別の要素をクリックした」 — を、入力に対するReact自身のonBlurpropへ移し、そしてそれを一tick遅延させます:
onBlur={() => {
// Deferred to avoid state updates during React's render cycle.
setTimeout(() => cancelEditing(), 0)
}}
どちらの部分も大切です。生のリスナーの代わりにReactのonBlurpropを使うことは、Reactがハンドラーのライフサイクルを所有し、それをぶら下がったまま放置しないことを意味します。そしてsetTimeout(…, 0)は、このblurがアンマウントによって_引き起こされた_場合ですら、cancelEditing()の呼び出しが次のtick — 現在のレンダーとcommitが終わった後 — に着地することを保証するので、それはまだ取り壊し中のコンポーネントで状態を更新することは決してできません。もしその時までにコンポーネントがすでに消えていれば、遅延された呼び出しは無害な何もしない操作(no-op)です。
それが修正のすべてです。Reactが見られなかったリスナーを削除する; 残った方はReactにスケジュールさせる。
なぜこれは良い種類の退屈なのか
これを「些細」の下に — 一つのsetTimeout、一行の削除 — 分類して先へ進みたい誘惑があります。しかしその形は、Reactのデスクトップアプリがクラッシュする最もありふれた仕方の一つであり、身に染み込ませる価値があります:
- クラッシュはライフサイクルの境界(アンマウント)でのみ現れ、フレームワークがスケジュールしないイベント(DOM除去の最中のネイティブなblur)によって引き起こされます。それはクリックスルーのテストには現れません; それは「編集中にフォーカスが去る」という特定の経路を必要とします。
- 根本原因は、同じ要素の上で二つの所有モデルを混ぜたこと — 生のDOMイベントと、Reactの合成された、スケジュールされたイベント — です。あなたのハンドラーがその要素をアンマウントしうる状態を変更する瞬間、生の経路は再入可能(re-entrant)になります。
- 修正は「ガードフラグを足す」ことではありません(
isMountedのrefがそれを覆い隠しはしたでしょうが); それは生のリスナーをまるごと取り除くことであり、それが_より小さな_変更であり、正しいものです。またしても引き算です。
そしてメタな教訓、このシリーズ全体が周回し続けているもの: リリースノートではなく、diffを読め。"Fixed a crash while editing keyboard shortcuts" は正確ですが、それはそのクラッシュが自ら招いたもので、同じ朝のことで、決して出荷されなかったこと — そして本当のリリースは、グローバルショートカットを_いつ_一時停止するかについての、静かな正しさの勝利であること — を隠しています。
ほかに何が出荷されたか
もう二つのことがv1.8.10に相乗りしました、どちらも一行の価値があります。ショートカットのシステムはまたスリープ/ウェイクをまたいだ自己修復を得ました: macOSはマシンがスリープするとアプリのグローバルショートカットの登録を静かに落とすことがあるので、いまや再開ハンドラーがそれらを再検証し再登録するので、ラップトップが目覚めた後も、再起動なしにあなたのショートカットは依然として動きます。そしてAIの側では、クライアントがレート制限について賢くなりました — 429にぶつかったストリーミングリクエストは、いまや固定の遅延を待ち切る代わりに、指数関数的にバックオフし、録音を止めるとカウンターをリセットします。異なるサブシステムへの三つの信頼性の修正、そのうちの一つが、決してクラッシュではなかったクラッシュです。
v1の物語の前の章については、PDFライブラリなしで会議をPDFに印刷する(v1.8.9)を; そして弧の全体については、ソフトウェアを完璧に出荷することの解剖学をご覧ください。