前回の第9回:MediaRecorder で録画する前に考えることで、ブラウザ内で完結する録画を作りました。今回は、実際の運用で必ず起きる「通話が切れた」への対応です。
「社内の検証では問題なかったのに、本番で『Wi-Fi が切り替わったら戻らない』『サーバーを更新したら全員の通話が落ちた』と言われた。何を作り込めばいいの?」
結論から言うと、切断は「サーバーとの接続」と「相手との接続(経路)」の2つに分けて、それぞれに自動復帰の仕組みを入れます。サーバーとの接続(WebSocket)は、待ち時間を倍々に延ばしながら再接続します。相手との経路は、WebRTC の ICE restart(経路の探し直し)で復旧させます。そして、今どの状態なのかを利用者が分かる言葉で表示します。
今回作るもの:自動で戻る通話と、分かりやすい状態表示
- シグナリング(WebSocket)が切れたら、1秒・2秒・4秒…と間隔を延ばしながら自動で再接続し、同じルームに入り直す
- 8回失敗したらあきらめて「再接続する」ボタンを出す。オフラインの間は待ち、オンラインに戻ったらすぐ再接続する
- 相手との経路が failed(失敗)になったら、
restartIce()で経路を探し直す - 相手が退出した・切れたことを表示する
- 状態表示を ConnectionStatus コンポーネントにまとめ、利用者向けの言葉に統一する
追加・変更するファイル
| ファイル | 役割 |
|---|---|
| code/app/composables/useSignaling.ts | 指数バックオフによる再接続、online イベント、手動の再試行 |
| code/app/composables/usePeerConnection.ts | ICE restart、再入室時のつなぎ直し |
| code/app/components/ConnectionStatus.vue | 接続状態の表示(新規) |
| code/app/pages/room/[id].vue | 上記の組み込み、再接続時の TURN 設定の取り直し |
仕組み:「何が切れたか」で対処が違う
ビデオ通話で「切れた」と言われる状態は、原因によって3つに分けられます。
| 切れたもの | 起きる例 | 今回の対処 |
|---|---|---|
| 端末のネットワーク | Wi-Fi が切れた、機内モード | オフラインと表示し、戻ったらすぐ再接続 |
| サーバーとの接続(WebSocket) | サーバーの再起動・デプロイ、プロキシのタイムアウト | 指数バックオフで自動再接続し、同じルームに入り直す |
| 相手との経路(ICE) | 回線の切り替え、TURN サーバーの再起動、NAT の変化 | ICE restart で経路を探し直す |
ここで大事なのは、映像・音声は P2P で流れているので、サーバーとの接続が切れても通話はしばらく続くという点です。実際に検証でも、サーバーを止めている間、相手の映像は届き続けていました。逆に、サーバーとつながっていても相手との経路が切れれば、映像は止まります。どちらが切れているかを分けて扱う必要があります。
実装1:WebSocket を指数バックオフで再接続する
code/app/composables/useSignaling.ts(追加・変更部分)
/**
* idle=未接続 / connecting=接続中 / open=接続済み / reconnecting=切れたので再接続待ち(第10回)
* failed=再接続をあきらめた(第10回) / closed=自分で切断した
*/
export type SignalingStatus = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'failed' | 'closed'
/** 第10回: 再接続の間隔(1秒, 2秒, 4秒…最大30秒)と、あきらめるまでの回数 */
const RETRY_BASE_MS = 1000
const RETRY_MAX_MS = 30_000
const MAX_RETRIES = 8
socket.addEventListener('open', () => {
status.value = 'open'
retryCount.value = 0
// 再接続の場合も、同じルームに入り直す(サーバーから見ると新しい参加者になる)
if (targetRoomId) send({ type: 'join', roomId: targetRoomId })
})
// 接続できなかった場合(サーバー停止・426 等)も、つながった後に切れた場合も close が来る
socket.addEventListener('close', () => {
if (ws !== socket) return // 新しい接続に切り替わった後の古い close は無視
ws = null
resetRoom()
if (targetRoomId) scheduleReconnect()
else status.value = 'closed'
})
}
/** 第10回: 待ち時間を倍々に増やしながら再接続する(指数バックオフ) */
function scheduleReconnect() {
if (retryCount.value >= MAX_RETRIES) {
status.value = 'failed'
return
}
status.value = 'reconnecting'
// オフラインの間は試しても無駄なので、online イベントを待つ
if (!navigator.onLine) return
const delay = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** retryCount.value)
// 全員が同時に再接続してサーバーに集中しないよう、待ち時間を 50〜100% の範囲でばらつかせる
const jittered = delay * (0.5 + Math.random() * 0.5)
retryCount.value++
retryTimer = setTimeout(open, jittered)
}
ポイントは次のとおりです。
- 「自分で切った」と「切れた」を区別する:
targetRoomId(入っているべきルーム)が残っていれば、意図しない切断なので再接続します。退出ボタンや定員オーバーでdisconnect()した場合は null にして、再接続しません。 - 指数バックオフ+ばらつき(ジッター):サーバーを再起動すると、全員の接続が同時に切れます。全員が同じ間隔で再接続すると、復帰直後のサーバーに接続が集中します。間隔を倍々に延ばし、さらにランダムにずらすことで、負荷を分散します。
- あきらめる条件を決める:無限に再接続し続けると、利用者は何が起きているか分かりません。8回(合計でおよそ1〜2分)失敗したら
failedにして、「再接続する」ボタンを出します。 - online イベントで即再接続:オフラインから戻ったときは、待ち時間を待たずにすぐ再接続します。
これで、第6回で「WebSocket が失敗したとき(426 など)画面にエラーが出ない」と書いた問題も解消します。つながらない場合も close イベントが来るので、再接続を試み、最後は「サーバーに接続できませんでした」と表示されます。
再接続したら、相手とはつなぎ直す
再接続後のサーバーから見ると、入り直したブラウザは新しい ID の別の参加者です(サーバーのメモリも再起動で消えています)。そこで今回は、入り直したら相手との接続も作り直すことにしました。
code/app/composables/usePeerConnection.ts(変更部分)
case 'joined':
// 先に誰かいたら、その人とつなぐ。後から入った自分は polite(譲る側)
// 第10回: 再接続で入り直した場合、自分の ID が変わり以前の接続は使えないので、つなぎ直す
if (msg.peers[0]) connect(msg.peers[0], true)
else close()
break
case 'peer-joined':
// 相手が後から入ってきた。先にいた自分は impolite
// 第10回: 相手が再接続して新しい ID で入り直した場合も、古い接続を捨ててつなぎ直す
if (msg.peerId !== remotePeerId.value) connect(msg.peerId, false)
break
この方式だと、サーバーが復帰して両者が入り直した時点で、映像が一瞬途切れてつながり直します。サーバーが止まっている間は古い接続のまま映像が流れ続けるので、利用者から見ると「数秒止まって戻る」程度です。ただし、録画(第9回)は相手の映像が切り替わるところで止まって保存され、チャット(第8回)の表示も新しい接続でリセットされます。
完全に途切れさせないには、再接続したブラウザを「前と同じ参加者」としてサーバーが認識できる仕組み(再接続用の秘密のトークンと、切断後しばらく参加者を残しておく猶予時間など)が必要です。設計とテストの手間が増えるため、今回は採用していません。
実装2:ICE restart で経路を探し直す
相手との経路が切れた場合は、restartIce() を使います。MDN によると、restartIce を呼ぶと negotiationneeded イベントが発生し、新しい認証情報で候補(経路の候補)を集め直す offer が作られます。その間も、既存の送受信は続きます。
📰 出典:MDN「RTCPeerConnection: restartIce() method」
code/app/composables/usePeerConnection.ts(connect() 内の追加部分)
// 第10回: 経路が切れて復旧できない(failed)ときは、ICE restart で経路を探し直す。
// restartIce() を呼ぶと negotiationneeded が発生し、新しい offer(ICE の認証情報を作り直したもの)が送られる。
// 両側が同時に行うと offer が衝突するため、まず後から入った側(polite)が行い、
// 先にいた側は5秒たっても failed のままなら行う。
// 検証した Chromium では iceConnectionState が disconnected のまま connectionState だけが failed になったため、両方を見る
const isFailed = () => conn.iceConnectionState === 'failed' || conn.connectionState === 'failed'
const restartIfFailed = () => {
clearTimeout(restartTimer)
if (!isFailed()) return
if (polite) conn.restartIce()
else restartTimer = setTimeout(() => {
if (pc === conn && isFailed()) conn.restartIce()
}, 5000)
}
conn.addEventListener('iceconnectionstatechange', () => {
iceState.value = conn.iceConnectionState
restartIfFailed()
})
MDN の例は iceConnectionState === 'failed' をきっかけにしています。ところが筆者の検証環境(Chromium)では、TURN サーバーを止めたとき、iceConnectionState は disconnected のまま、connectionState だけが failed になりました。そのため、connectionstatechange からも同じ判定を呼んでいます。
両側が同時に restartIce すると offer が衝突します。第3回の Perfect negotiation で衝突は処理できますが、第4回で衝突時に接続が止まる現象を見ているため、ここでも「後から入った側が先に行う」ルールにそろえました。
実装3:状態を利用者の言葉で表示する
これまでルームページに直接書いていた状態表示を、ConnectionStatus コンポーネントにまとめました。優先順位は「端末のネットワーク → サーバーとの接続 → 相手との接続」です。
| 状態 | 表示する文言 |
|---|---|
| オフライン | インターネットに接続されていません。接続が戻ると自動で再接続します。 |
| サーバー再接続中 | サーバーとの接続が切れました。再接続しています…(n回目) |
| サーバー再接続をあきらめた | サーバーに接続できませんでした。ネットワークを確認して「再接続する」を押してください。(ボタン付き) |
| 相手が退出・切断 | 相手が退出しました(または接続が切れました)。相手の再入室を待っています |
| 経路が不安定(disconnected) | 通信が不安定です。回復を待っています… |
| 経路が失敗(failed) | 相手との接続が切れました。経路を探し直しています。回復しない場合は、いったん退出して入り直してください。 |
code/app/components/ConnectionStatus.vue(script の抜粋)
// 優先順位: 端末のネットワーク → サーバーとの接続 → 相手との接続
const view = computed<View>(() => {
if (!props.online) {
return { level: 'error', text: 'インターネットに接続されていません。接続が戻ると自動で再接続します。' }
}
switch (props.signalingStatus) {
case 'connecting':
return { level: 'info', text: 'サーバーに接続しています…' }
case 'reconnecting':
return { level: 'warn', text: `サーバーとの接続が切れました。再接続しています…(${props.retryCount}回目)` }
case 'failed':
return { level: 'error', text: 'サーバーに接続できませんでした。ネットワークを確認して「再接続する」を押してください。' }
}
// …(相手との接続の状態ごとの文言。上の表のとおり)
})
ルームページでは、再接続中も通話画面のままにするため、「入室済みか」の判定を status === 'open' から「idle・closed 以外」に変えました。また、再接続したら相手とつなぎ直すので、有効期限付きの TURN 認証情報(第6回)を取り直しておきます。
code/app/pages/room/[id].vue(追加部分)
// 第10回: 再接続中も通話画面のままにする(idle / closed の時だけ入室前の画面を出す)
const joined = computed(() => !['idle', 'closed'].includes(signaling.status.value))
// 第10回: 再接続した後は相手とつなぎ直すので、TURN の認証情報(有効期限つき)を取り直しておく。
// 取得に失敗しても(サーバー停止中など)、前の設定のまま再接続を続ける
watch(signaling.status, (status, prev) => {
if (status === 'open' && (prev === 'reconnecting' || prev === 'failed')) void fetchIceConfig().catch(() => {})
})
動作確認の方法
- 2つのウィンドウで通話中に、
npm run devを止めて数秒後に起動し直す → 「再接続しています」の後、自動でつながり直すこと - 開発者ツールの Network でオフラインにする → 「インターネットに接続されていません」と表示され、戻すと表示が戻ること
- 相手のタブを閉じる → 「相手が退出しました」と表示されること
- サーバーを止めたまま待つ → 最後に「再接続する」ボタンが出て、サーバー起動後に押すとつながること
- TURN 経由の設定(第6回の
NUXT_ICE_TRANSPORT_POLICY=relay)で TURN サーバーを再起動する → ICE restart で通話が戻ること
筆者の環境では、Playwright で Chromium を操作して次の点を確認しました。TURN は第6回と同じく Docker の coturn を使っています。
| 確認したこと | 結果 |
|---|---|
| 通話中にサーバーを約3秒停止→起動 | 停止中も相手の映像は届き続けた。起動後、両者が自動で再入室し、停止からおよそ6秒で再び「接続しました」。チャットも送受信できた |
| WebSocket が常に失敗する状態 | 1〜30秒の間隔で8回再試行し、約100秒後に「サーバーに接続できませんでした」と「再接続する」。ボタンで接続できた |
| オフライン化(Playwright の setOffline) | オフラインの表示が出て、戻すと「接続しました」に戻った |
| 相手がタブを閉じる | 「相手が退出しました」と表示。チャット欄は無効 |
| TURN 経由の通話中に coturn を再起動(中継の割り当てが消える) | 約19秒後に polite 側が restartIce し、新しい経路で接続が回復 |
| 同じ条件で restartIce を無効にした場合 | 1分以上たっても回復せず(ICE restart が回復の決め手であることを確認) |
| TURN を一時停止(約30秒)→ 再開 | 停止中に両側が restartIce(先にいた側は5秒後)。再開後3秒ほどで回復 |
| 定員オーバー | 3人目は入室前の画面に理由を表示し、再接続はしない |
一方で、Playwright のオフライン化では WebSocket 自体は切れず、「実際に Wi-Fi が切れたときに WebSocket の切断に気付くまでの時間」は確認できていません。実機での Wi-Fi とモバイル回線の切り替え、スリープからの復帰、Firefox・Safari・スマホでの挙動も未確認です。また、接続できないまま始まった場合も「接続が切れました」と表示される点は、文言の改善余地として残っています。npm run build と npm run typecheck は通っています。
つまずきやすい点とセキュリティ上の注意
- 切断に気付くのが遅いことがある:回線が突然切れると、WebSocket の close イベントがすぐには来ない場合があります。本番では、一定間隔でメッセージを送り合って応答が無ければ切断とみなす仕組み(ハートビート)を入れることが多いです。今回は実装していません。
- TURN の認証情報の期限:ICE restart は最初に渡した TURN の認証情報を使い続けます。長時間の通話で期限(第6回では1時間)を過ぎた後に経路を探し直す場合は、
setConfiguration()で新しい認証情報を渡す必要があります。 - 再接続の嵐に注意:サーバー側の不具合で接続直後に切れる状態になると、全員が再接続を繰り返します。指数バックオフと上限回数は、サーバーを守る意味もあります。
- エラーの詳細は利用者に見せない:画面には「何が起きていて、利用者は何をすればよいか」だけを出し、IP アドレスや内部のエラー内容はデバッグ用のログにとどめます。
発注者向けメモ:「つながる」と「切れても戻る」は別の工数
正常系(つながる)が動いても、切断からの復帰は別の作り込みとテストが必要です。どこまで対応するかで工数が変わるため、要件として決めておくのがおすすめです。
- ☐ 想定する切断のパターン(Wi-Fi とモバイル回線の切り替え、スリープ復帰、サーバーのデプロイ)が要件に書かれているか
- ☐ サーバーの更新(デプロイ)時に、通話中の利用者をどう扱うか(数秒止まってよいか、夜間に限るか)
- ☐ 再接続中・失敗時に利用者に何を表示し、何をしてもらうかの文言が決まっているか
- ☐ テスト観点に「回線の切り替え」「サーバー再起動」「TURN の障害」が入っているか
- ☐ 録画中・チャット中に切断が起きた場合の扱い(どこまで保存されるか)を確認したか
開発会社への質問例:
- 「通話中に Wi-Fi からモバイル回線に切り替わったら、どうなりますか?実機でテストしますか?」
- 「サーバーを更新したとき、通話中の人の映像は途切れますか?何秒くらいで戻りますか?」
- 「再接続に失敗したとき、利用者の画面には何と表示されますか?」
- 「切断や再接続の回数を、運用中に把握できるようにしますか?」
まとめと次回予告
- 「切れた」は、端末のネットワーク・サーバーとの接続・相手との経路の3つに分けて対処する
- WebSocket は指数バックオフ+ジッターで再接続し、上限を超えたら利用者に再試行を促す
- 相手との経路は restartIce で探し直す。検証した Chromium では connectionState の failed も見る必要があった
- 今回は再接続時に相手とつなぎ直す簡単な方式。完全に途切れさせないには、再接続用のトークンなど追加の設計が必要
- 回線切り替えなどの異常系は、正常系とは別の工数とテスト観点として見積もる
次回(第11回)は連載の最終回、「少人数通話へ:メッシュ実装と SFU という選択肢」です。1対1から3〜4人の通話に広げ、人数が増えたときの限界と、SFU という選択肢を整理します。
この連載の記事一覧
この記事は連載「NuxtとWebRTCで作るビデオチャット」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【NuxtとWebRTCで作るビデオチャット 第0回】Nuxt 4でビデオチャットの土台を作る:なぜHTTPSが必要なのか
- 【NuxtとWebRTCで作るビデオチャット 第1回】カメラとマイクを取得してプレビューし、デバイスを選べるようにする
- 【NuxtとWebRTCで作るビデオチャット 第2回】Nitro の WebSocket でシグナリングサーバーを作る
- 【NuxtとWebRTCで作るビデオチャット 第3回】RTCPeerConnection で1対1通話をつなぐ(offer/answer/ICE)
- 【NuxtとWebRTCで作るビデオチャット 第4回】ルーム作成と招待リンク、入室前プレビュー
- 【NuxtとWebRTCで作るビデオチャット 第5回】ミュートとカメラオフ
- 【NuxtとWebRTCで作るビデオチャット 第6回】STUN/TURN と本番デプロイ:「社外とつながらない」を解消する
- 【NuxtとWebRTCで作るビデオチャット 第7回】画面共有
- 【NuxtとWebRTCで作るビデオチャット 第8回】DataChannel でテキストチャット
- 【NuxtとWebRTCで作るビデオチャット 第9回】MediaRecorder で録画する前に考えること
- 【NuxtとWebRTCで作るビデオチャット 第10回】切断・再接続とエラーハンドリング(この記事)
- 【NuxtとWebRTCで作るビデオチャット 第11回】少人数通話へ:メッシュ実装と SFU という選択肢










コメント
コメント一覧 (1件)
[…] 前回の第10回:切断・再接続とエラーハンドリングで、サーバーの再起動や経路の切断から自動で戻る仕組みを作りました。連載の最終回となる今回は、1対1の通話を 3〜4人の少人数通話 に広げます。あわせて、人数が増えたときに検討する SFU という選択肢と、この連載のサンプルを本番で使うまでに残っている作業を整理します。 […]