
接続が切れただけでアプリ全体が落ちてはいけない — でも私たちのは落ちた
バックエンドが会議の途中でオフラインになったとき、それは文字起こしを止めただけではありませんでした — アプリ全体をクラッシュさせたのです。原因はたった一つの処理されないイベント、修正は十行でした。これは、GeekByeがかつて自分を殺していた事態を通り抜けても、サインインを保ち、接続を保つようにしたリリース群の物語です。
いくつかのバグは一つの機能を中断させます。最悪のものは、その周りのすべてを道連れにします。GeekByeの初期のビルドでは、あなたが会議の真っ最中にバックエンドがオフラインになると、文字起こしは止まるだけでなく — アプリ全体がクラッシュしたのです。接続が一つ切れただけで、ウィンドウ全体が消えました。
これを直したリリース群 — v1.6.8からv1.6.11まで — は、「サインインを保ち、接続を保つ」という地味な仕事のお手本です。目玉の修正は十行でした。その十行が何だったのか、そしてそれと並んで出荷された他のすべてを、ここに記します。
切断とクラッシュのあいだの十行
クラッシュは音声コードに潜んでいました。文字起こしセッションがWebSocketを取り壊すとき、それらに対して removeAllListeners() を呼び出していました — もっともなクリーンアップです。しかしここに、クリーンアップを大惨事に変えるNode.jsの細部があります: リスナーのない error イベントは無視されません。未捕捉例外として再スローされます。
だからバックエンドがオフラインになるときの順序を思い描いてください: ソケットには発火を待つ error イベントが キューに積まれて います。クリーンアップが removeAllListeners() を走らせ、それを捕まえるはずだったハンドラを剥がします。ほんの一瞬後、キューに積まれたエラーが発火します — 虚空へと。Nodeは誰も聞いていない error イベントを見て、その契約が許す唯一のことをします: それを未捕捉例外としてスローし、プロセスをクラッシュさせるのです。ユーザーのバックエンドが二秒しゃっくりしただけで、そのアプリは消えました。
修正(v1.6.8)は、拍子抜けするほどです。リスナーを剥がした直後に、意図的な何もしない error ハンドラを付け直します:
this.micWebSocket.removeAllListeners()
this.micWebSocket.on('error', () => {}) // absorb late error events
その空の関数こそが肝心な点のすべてです。保留中のエラーに着地する場所 — シンク — を与えるので、Nodeにはリスナーがあり、決して再スローしません。同じコミットはまた、接続の試行が途中で失敗したときに半開きのソケットも片付けたので、しくじった接続が error の発火を待つ生きたソケットをぶら下げたままにできなくなりました。コミットのコメントははっきりこう言っています: 「これがないと、保留中のエラーは未捕捉例外になり、アプリをクラッシュさせる。」 十行が、完全なクラッシュを静かで回復可能な切断へと変えました。
教訓は修正そのものより値打ちがあります: まだ取り壊している最中かもしれないソケットやストリームに対して removeAllListeners() を呼び出すときはいつでも、まず何もしない error のシンクを付け直しなさい。未処理の error イベントはログの一行ではなく、クラッシュです。
サインインを保つ: 先回りして更新し、跳ね返りでも更新する
「接続を保つ」の半分には、「サインインを保つ」という双子がいました。この群より前、GeekByeの認証トークンは単に期限切れになっていました — しばらくすると、ワークフローの途中で、あなたに見える理由もなく、無造作にログイン画面へ突き戻されたのです。
v1.6.8は、これを両方向から直しました:
- プロアクティブ: タイマーがトークンを期限切れの 前 に更新します。期限からスマートなバッファ(トークン寿命の一部で、下限と上限が設けられている)を引いた時刻にスケジュールされます。あなたが気づく前に更新済みです。
- リアクティブ: それでもリクエストが
401で返ってきたら — クロックのずれ、逃したタイマー、冷えたノートPC — クライアントは一度だけ更新し、元のリクエストをきっかり一度だけ再試行します。単一のガードフラグがその再試行に上限をかけるので、しつこい 401 が無限ループに回転することは決してありません。
微妙だが決定的な部分: 両方の経路がミューテックスの背後にあります。もし十のリクエストが同じ瞬間に 401 に当たっても、十回の更新を発火させはしません — それらはすべて進行中の 一つの 更新を待ってから再試行します。そして更新がどうしても救えないときには、アプリはセッションを 優雅に 期限切れにします — 明快な十秒の「セッションの有効期限が切れました。もう一度ログインしてください」というトースト — ログイン画面への無言の蹴り出しではなく。
再接続: クライアントに追加し、それからバックエンドへ移す
v1.6.9は、切れた文字起こし接続を致命的なものではなく 回復可能 にしました。バックエンドが上流の音声接続が切れたと報告したとき、クライアントは致命的なエラーを表に出すのをやめ、代わりに再接続しました — 意図的に穏やかなUXとともに: 一つの琥珀色の「再接続中…」トースト(試行ごとに一つではなく)、戻ってきたときの緑の「再接続しました」トースト、そして終端的な「再起動してください」エラーは すべての 試行を使い果たした 後にだけ。
そしてこの群について私が気に入っていること: 一リリース後、v1.6.10で、私たちはそのクライアント側の再接続ロジックを削除しました — 約113行 — そして再接続をバックエンドへ移しました。バックエンドは今、静かな「再接続中」ステータスメッセージを発し、クライアントはそれをただ反映するだけです。私たちはクライアントに回復を作り、バックエンドこそ正しい所有者だと判断し、それを移しました。これは優柔不断ではありません。難しい問題の正直なライフサイクルです。信頼性のロジックが接続の両端に分かれているのは嫌な兆候です — 所有者を選びなさい。(バックエンドは最終的にこれの決定的な所有者になりました。それがAIノートテイカーはなぜ悪いWi-Fiで止まるのかで語られる再接続の物語です。)
同じリリースは、録音時間の制限を人間味あるものにしました: 生のエラーの代わりに、録音を続けさせる警告トースト、そして実際に止まるときの明快な「録音制限に達しました」メッセージ — 親しみやすい一文を表に出し、内部のプレフィックスを剥がして。
すべてを統べる、ただ一つのリトライ
v1.6.11までに、同じバックオフ付きリトライのロジックが三つの別々の場所にコピー&ペーストされていました — 認証、更新、セッションの取り壊しです。そこで、そもそも何が再試行に値するのか についての共有された一つのルールを持つ単一のモジュールへと統合されました: 一時的なネットワーク障害(接続リセット、タイムアウト、DNSのしゃっくり、ソケットのハングアップ)は指数バックオフで再試行され、本物のサーバーエラーは即座に失敗します。なぜなら、正真正銘の 4xx を再試行してもユーザーの時間を無駄にするだけだからです。一つの分類器、いくつかの名前付きプリセット、それに移された三つの呼び出し元。
この群が教えたこと
- 未処理の
errorイベントはログの一行ではなく、クラッシュだ。 Nodeはリスナーのない error イベントを再スローする。まだ取り壊している最中かもしれないソケットのリスナーを剥がすなら、何もしない error のシンクを付け直さなければならない — さもないと、切れた接続一つがアプリ全体を道連れにする。 - 期限前 と 401 に対するリアクティブの両方で資格情報を更新せよ — 両方にミューテックスを添えて。 プロアクティブがサインインを保ち、リアクティブがエッジケースを捕まえ、ミューテックスがN個の同時呼び出し元にちょうど一度だけ更新を起こさせ、再試行の上限が悪いトークンを永遠にループさせない。
- 一時的なエラーのときだけ再試行し、分類器を集約せよ。 「これは再試行に値するか?」は一つの決定であり、一つの場所に属する。本物のエラーの再試行は、ただの遅い失敗にすぎない。
- 再接続の所有者を決めよ。 私たちはそれをクライアントに追加し、それからバックエンドへ移した。回復のロジックが両端に住むのはバグの生成器だ。単一の住処を与えよ。
これは、GeekBye v2になる信頼性物語の第三章です。前の章は、音声コードを5,000行削除したら — 文字起こしが二重に出始めた(v1.6.0)を。再接続が最終的に落ち着いた先は、AIノートテイカーはなぜ悪いWi-Fiで止まるのかを。そして全体の弧については、ソフトウェアを完璧に出荷することの解剖学をご覧ください。