🧊

WebRTCのPerfect negotiationについて

なんやそれ・・また新しいプロトコルか?って思いますよね。
安心してください!ただの造語です。

Perfect negotiation in WebRTC - Advancing WebRTC

出自はこの記事で、書かれたのは4月とかなり前。
ただちょいちょい更新されてる + `webrtc-pc`のIssueでも度々引用されてくる + 最近それが顕著だったので、今さら解説してみた。

この記事の概要と、思うところについて書きます。

Perfect negotiationの概要

まえがき

  • WebRTCのネゴシエーションは思った以上に複雑
    • SDPやICE candidateの交換
    • ステートフルな`signalingState`
    • TrickleICEのタイミング問題
    • etc...
  • ただしなんとかする方法もある
    • 何も気にせず各エンドポイントが`addTrack()`すればいいだけにできる
  • そのためには2つのピースが必要
    • `negotiationneeded`イベント
    • SDPのtype: `rollback`

しかし現状ではFirefoxでしか実装されておらず、実現できない・・。

negotiationneededを使おう

ブラウザがネゴシエーションが必要だと思われるAPIコールに応じて、自動的に発火してくれるイベント。
ICEのcandidateが見つかったときに、`icecandidate`イベントが発火するのと同じ。

送信側のコードのイメージ。

pc.onnegotiationneeded = async () => {
  await pc.setLocalDescription(await pc.createOffer());
  io.send({ description: pc.localDescription });
};

受信側はこう。

// candidateとSDPが飛んでくる
io.onmessage = async ({ data: { description, candidate }}) => {
  // SDP
  if (description) {
    await pc.setRemoteDescription(description);
    // オファーの場合は、アンサーを送り返す必要がある
    if (description.type == "offer") {
      await pc.setLocalDescription(await pc.createAnswer());
      io.send({ description: pc.localDescription });
    }
  }
  // candidate
  else if (candidate) await pc.addIceCandidate(candidate);
};

これだけ仕込んでおけば、後は任意のタイミングで`addTrack()`するだけで、自動的にネゴシエーションが行われる。

基本的にはこれだけでPerfectなんだが、いわゆるグレア(両側で同時に操作した場合)の問題が残る。

(ちなみに、Chromeでこのイベントがまともに発火するようになったのは割と最近・・)

そこでrollback

そもそもグレアは、アプリケーションレイヤーで解消できるものでもある。
ただし、WebRTCのレイヤーでも`rollback`を使えばなんとかできる。

端的にいうと、グレアに陥ったときに「お先にどうぞ」する紳士的な実装にすればよい。

// さっきのハンドラーをアップデート
io.onmessage = async ({ data: { description, candidate }}) => {
  // SDP
  if (description) {
    // オファーを受け取ったが、自分もオファーを出している状態だった
    if (description.type == "offer" && pc.signalingState != "stable") {
      // 礼儀正しいピアと、そうでないピアのP2Pになるようにしておく
      if (!polite) return;
      // 礼儀正しいならばロールバックして相手のオファーを優先
      await Promise.all([
        pc.setLocalDescription({ type: "rollback" }),
        pc.setRemoteDescription(description)
      ]);
    }
    // アンサーを受け取っただけ or 自分は何もしてないときにオファーを受けた
    else {
      await pc.setRemoteDescription(description);
    }

    // それがオファーならアンサーを送り返す
    if (description.type == "offer") {
      await pc.setLocalDescription(await pc.createAnswer());
      io.send({ description: pc.localDescription });
    }
  }
  // candidateはそのまま
  else if (candidate) await pc.addIceCandidate(candidate);
};

`polite`というフラグ変数だけ事前に用意して決めておく必要がある。

`rollback`のところで`Promise.all()`してる理由は、その隙間にICE candidateが飛んてくる可能性があるから。
ちゃんと`await`すれば、このPromiseが解消されたあとで`addIceCandidate()`が解消される。

これでまた一歩Perfectに近づいたけど、あと一歩足りない。

同じような処理を、`negotiationneeded`時にも考慮する必要がある。

pc.onnegotiationneeded = async () => {
  const offer = await pc.createOffer();
  // 相手のオファーを処理中
  if (pc.signalingState != "stable") return;
  await pc.setLocalDescription(offer);
  io.send({ description: pc.localDescription });
};

`return`しちゃったら呼ばれないまま消えちゃうのでは?と思うけど、`signalingState`が`stable`にもどったら、再度`negotiationneeded`が発火するので問題ない。

つらくない?

Issue: 2165

`setLocalDescription()`を引数なしでできるように。

pc.onnegotiationneeded = async () => {
  // これが非同期なので
  const offer = await pc.createOffer();
  // ここで相手からオファーが届いてしまう可能性がある

  // そうなるとコレが困る
  await pc.setLocalDescription(offer);
  io.send({ desc: pc.localDescription });
};

なので、`signalingState`が`stable`かどうかのチェックをしようって話だった。

でもそんなことするくらいならもうこうしたいって話。

pc.onnegotiationneeded = async () => {
  io.send({ desc: await pc.setLocalDescription() });
};

これは、

  • 自分の`signalingState`によって、これがアンサーかオファーかは判断する
  • まだO/Aしてないなら、`createOffer()`か`createAnswer()`する
  • それを返す

というように動く。

というか実は、

await pc.createOffer();
await pc.setLocalDescription({ type: "offer" });

これが動作する・・・。

Issue: 2166
await Promise.all([
  pc.setLocalDescription({type: "rollback"}),
  pc.setRemoteDescription(description)
]);

これをなんとかしたいという話。

`setRemoteDescription(desc)`が、暗黙的に`rollback`的な効力を持つようになる。

Issue: 2167

現状だとICEリスタートは、`createOffer({ iceRestart: true })`ではじめるしかない。

しかしそれだと、`negotiationneeded`が動かない!

なので、`pc.restartIce()`で動くようにしたいという話。

なぜこれらが必要か

  • ネゴシエーションの必要性を決めるアルゴリズムは、思ってる以上に複雑である
  • たとえばアンサー側の`addTrack()`は`signalingState`によって挙動が変わる
    • `stable`に戻ったとき、利用可能な`Transceiver`の数に応じて変わる
    • 改めてネゴシエーションが必要な場合と不要な場合がある
  • 手動でそういうのも管理できる?

というわけで

叩くべきAPIは決まってるけど、ネゴシエーションの落とし穴は思ってるより多いよという話と、こうすればPerfectですっていう提案。

まぁアプリケーションレイヤーでコントロールすることもできるのはできるけど、そこまでやる?っていうトレードオフはあるはずで・・。

ここにあるコードを使えば、そういうしがらみからは逃れられるけど、Firefoxでしか使えないので実用的ではないんよね。
気になるChromeの実装状況はこちらから。

980872 - chromium - An open-source project to help move the web forward. - Monorail

まぁWebRTCやってる人の中でも、SDK作りに心血を注いでる人くらいしか気にしてないトピックやと思うけど・・。

個人的には、いまさら感 + Firefoxでしか動かんので、送信用と受信用で2コネクション張るほうが考えることなくなって楽では?という気持ち。