📝 この記事は forge.workstyle.tech に掲載した記事の転載です。
Web サイトに埋め込む音声対話アバターを運用している。ページを開くと繋がり、話しかけると
返事をする。裏では WebRTC でブラウザと音声パイプラインが繋がっている。
サーバを再起動したら、アバターが黙ったままになった。
👤 (話しかける)
🤖 (無言)
👤 (もう一度話しかける)
🤖 (無言)
見た目は何も壊れていない。アバターは立っているし、マイクのボタンも ON のままだ。
ページをリロードするまで、訪問者は「壊れている」ことに気づけない。
そもそも再接続の仕組みが無かった
最初に確かめたのはここだった。切れたあとに繋ぎ直すコードが、どこにも無い。
繋ぐ処理は「ページを開いたとき」に一度呼ばれるだけで、切れたときの経路が存在しない。
サーバの再起動どころか、地下鉄に入った・Wi-Fi が切り替わった・スリープから復帰した、
そのどれでも同じことが起きる。デプロイのたびに、たまたま繋いでいた訪問者が無言の
アバターに向かって話しかけていたことになる。
指数バックオフで繋ぎ直す処理を足した。
const waits = [1000, 2000, 4000, 8000, 15000, 30000];
const wait = waits[Math.min(retryRef.current, waits.length - 1)];
retryRef.current += 1;
setTimeout(() => reconnectRef.current(), wait);
最後の 30 秒で打ち切らずに繰り返す。ここは意図的にそうした。訪問者は「あとで戻ってくる」
のではなく「そのページを開いたまま」なので、諦めた瞬間にそのセッションは終わる。
繋ぎ直すコストはほぼゼロだから、諦める理由がない。
デプロイして、サーバを再起動してもらった。
[widget] 接続が切れた — 1秒後に再接続 (1回目)
……そこで止まった。1 回目のログは出るのに、2 回目が出ない。繋がってもいない。
冪等でない関数を、冪等のつもりで呼んでいた
繋ぐ処理の先頭はこうなっていた。
const connect = useCallback(async () => {
if (pcRef.current) return; // ← ここ
...
古い接続オブジェクトが残っていると、何もせず戻る。 ページを開いたときに一度だけ呼ぶ
関数としては正しい。二重に繋がないためのガードだ。
だが再接続では、まさにその「古い接続」が残っている状態から呼ぶことになる。
connect() は毎回、静かに何もせずに戻っていた。エラーも出ない。ログも出ない。
先に disconnect() を呼ぶようにした。今度は繋がった。
15:09:43 サーバ再起動
15:10:16 音声パイプラインが切断を検知
15:10:28 再接続(12秒後)
会話の履歴も残っていた。会話 ID をブラウザの sessionStorage に持たせてあるので、
繋ぎ直しても「続きから」になる。ここまでは狙いどおりだった。
マイクだけが戻らない
繋がったのに、話しかけても返事が来ない。マイクのボタンは ON のままだ。
サーバ側のログで確認できた。音声パイプラインに、音の大きさと「人の声らしさ」を
30 秒ごとに出す診断を仕込んである。
[vad] 直近30秒 判定1500回 発話0回 惜しい0回(平均 声0.00 音量0.00)
音量 0.00 が続いている。 音が 1 サンプルも届いていない。ボタンは ON なのに、
実際のマイクは切れている。
マイクのトラックは、繋ぐときに取得して既定では無効にしてある。ボタンを押した
タイミングで有効にする作りだ。だから再接続したあとも、元が ON だったなら
有効に戻してやる必要がある。そのコードは書いてあった。書いてあるのに効かない。
ソースを読んで、3回外した
ここから恥ずかしい時間が続く。コードを読んで仮説を立て、直し、外す、を 3 回やった。
| 疑ったもの | 理由 | 結果 |
|---|---|---|
| 利用量の上限による機能制限 | 制限がかかるとマイクを切る処理がある | 制限はかかっていなかった |
| 8 秒後の状態確認が誤判定している | 再接続の成否を後から確かめる処理を足していた | 無関係だった |
| 繋ぐ処理の内部で順序が入れ替わる | 非同期の処理が並んでいる | ここも違った |
3 回とも「ソースを読んで、ありそうな筋を選んだ」だけだ。どれも読めば筋が通って見える。
実際に起きていることは 1 つも確認していない。
4 回目に、諦めて診断ログを入れた。誰がいつマイクを切っているかを、そのまま出す。
ログ1行で終わった
返ってきたのはこれだった。
[widget] マイク復帰の後: {呼んだ: true, 有効: [true]}
[voicepipe] 新しいマイクトラックを取得(既定は無効) ← ★これが後
[widget] マイク復帰の2秒後: {有効: [true], 戻された: true}
有効にしたあとに、新しいマイクを取得している。
つまり私が有効にしていたのは、これから捨てられる古いトラックだった。そのあと
connect() が新しいトラックを作り、既定どおり無効で始めていた。
「後から誰かに無効へ戻された」のではない。順序が逆だっただけだ。3 回の仮説は
全部「誰が切ったのか」を探していたが、誰も切っていなかった。
早すぎた理由は、嘘をつく状態変数だった
では、なぜ有効化が新しいトラックより先に走ったのか。条件はこう書いてあった。
if (voicepipe.status === 'connected') { ... } // 繋がったら有効化する
接続状態の更新はこうなっていた。
pc.oniceconnectionstatechange = () => {
const s = pc.iceConnectionState;
if (s === 'connected' || s === 'completed') {
setStatus('connected');
} else if (s === 'failed' || s === 'disconnected' || s === 'closed') {
if (s === 'failed') setStatus('failed'); // ← failed のときだけ
...
disconnected でも closed でも、状態は 'connected' のままだった。
更新するのは failed のときだけ。
だから「繋がったら有効化する」という条件が、切断を検知した直後にすでに成立していた。
待っているつもりで、待っていなかった。
この状態変数は間違ってはいない。disconnected は一時的な断で復帰することもあるから、
すぐ failed にしないのは妥当な判断だ。問題は、そういう含みを持った変数を
「いま繋がっているか」の判定に流用したことにある。
直し方は単純だった。状態変数を見るのをやめて、繋ぐ処理の完了そのものを待つ。
voicepipe.disconnect();
await voicepipe.connect(); // 完了を待つ
if (micOnRef.current) voicepipe.setMicEnabled(true); // その後で有効化
connect() は新しいトラックの取得と offer/answer を終えてから返るので、この時点なら
確実に新しいトラックを掴む。「状態を見て判断する」から「順番に実行する」に変えただけだ。
動いた
サーバを再起動して確認した。
15:10:28 再接続(サーバ再起動の45秒後 / 切断検知の12秒後)
15:17:42 [vad] 音量 0.51 発話0回 ← マイクは生きたまま待機
15:18:12 [vad] 発話31回 声0.03 ← 話しかけた
15:17:49 [Talk TTS] 'こんにちは。本日はどのようなご用件でしょうか?'
副産物がひとつあった。マイクの生死は、サーバ側のログだけで判定できる。
| 音量 | |
|---|---|
| マイク OFF | 0.00 |
| マイク ON | 0.5 前後 |
これに気づいてから、訪問者にブラウザのコンソールを開いてもらう必要がなくなった。
「マイクが入っているはずなのに反応しない」という報告に、こちらだけで裏が取れる。
持ち帰ったこと
冪等でない関数を、冪等のつもりで呼んでいないか。 if (すでにある) return; は
初期化のガードとして正しいが、「やり直す」経路から呼ばれた瞬間に静かに何もしない関数
になる。エラーが出ないぶん、見つけるのに時間がかかる。
状態を表す変数が、一部の失敗しか見ていないことがある。 今回の status は
「failed になったか」を表す変数であって、「いま繋がっているか」を表す変数ではなかった。
名前は後者に見える。その変数を条件にした処理は、全部おかしくなる。
待ちたいものがあるなら、状態を覗くより完了を await するほうが確実だ。
ソースを読んで立てた仮説は、3回外した。 診断ログは 1 回で当てた。
コードは「何が起こりうるか」しか教えてくれない。「何が起きたか」は測るしかない。
筋の通った仮説を 3 つ思いつけた時点で、それは読んでも分からないという証拠だったはずだ。
補足: 切れる原因はサーバの再起動だけではない
再接続を入れる前に、そもそもどれくらい切れるのかを整理した。
- デプロイ・Pod の再起動・ノードの入れ替え
- Wi-Fi ↔ モバイル回線の切り替え、地下・エレベータ
- スマートフォンのスリープと復帰(バックグラウンドで接続が畳まれる)
- 途中の NAT や TURN のタイムアウト(無音が続くと落ちることがある)
サーバ側の都合は運用でいくらか減らせるが、下の 3 つは減らせない。
「切れない」ではなく「切れても戻る」に投資するのが正しいという判断になった。