跳到主要内容

Perfect-Negotiation模式

Perfect Negotiation 模式

Perfect Negotiation 是 W3C 给出的一种"在任何时候、任何一端都能主动 renegotiate"的写法。它解决两个真问题:

  1. glare(双方在同一时刻发起 offer)导致状态机互锁。
  2. 业务侧不想区分"我是发起者还是接收者",希望两端代码完全对称。

如果你的应用永远只有一端能发起协商(比如客户端推流到服务器),不需要这个模式,按经典 offer-answer 写更简单。

1. glare 是怎么发生的

任何"协商不只是首次建链"的场景都会遇到 glare:

  • 切摄像头 → replaceTrack 不需要协商,但切到屏幕共享会改 codec / SVC 配置 → negotiationneeded
  • 加入第二条数据通道
  • ICE Restart
  • transceiver.direction

只要两端都监听 negotiationneeded 并主动 setLocalDescription(offer),就有可能在同一时刻:

A: setLocalDescription(offerA) → signalingState = have-local-offer
B: setLocalDescription(offerB) → signalingState = have-local-offer
A 收到 offerB: setRemoteDescription → 报错 InvalidStateError(已经在 have-local-offer)
B 收到 offerA: setRemoteDescription → 报错 InvalidStateError

两端都卡在 have-local-offer,谁也不肯让步,直到信令超时。这就是 glare。

2. 解决思路:polite vs impolite

W3C 的方案不是"避免 glare",而是"glare 出现时让一方退让"。约定:

角色收到对端 offer 但自己也在发 offer收到对端 offer 时自己已 stable
polite(礼貌端)回滚自己的 offer,接受对端的正常处理
impolite(强势端)忽略对端的 offer正常处理

只要两端约定一个 polite: boolean 不一样,就永远只有一方退让,状态机收敛。

3. 怎么决定谁 polite

约定方式很多,关键是两端结论一定相反

决定方式适用场景
谁先 join 房间谁 impolite信令服务可控时最简单
peerId 字典序大者 polite完全去中心化,无信令也行
服务端下发 polite: true/false服务端转发架构(SFU)首选
客户端 → 服务端 polite=true客户端永远让服务端,服务端不会自己发 offer

反例:用 Math.random() 决定,两端结果可能相同 → 还是 glare。

4. 完整模板(一份就够,所有场景通用)

const polite = /* 由信令决定,true 或 false */;

const pc = new RTCPeerConnection(config);
let makingOffer = false;
let ignoreOffer = false;
let isSettingRemoteAnswerPending = false;

// 1. 主动协商
pc.onnegotiationneeded = async () => {
try {
makingOffer = true;
await pc.setLocalDescription(); // 不传参,浏览器自己 createOffer
signaling.send({ description: pc.localDescription });
} catch (err) {
console.error('negotiationneeded failed', err);
} finally {
makingOffer = false;
}
};

// 2. ICE 候选
pc.onicecandidate = ({ candidate }) => {
signaling.send({ candidate });
};

// 3. 网络故障自愈
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'failed') pc.restartIce();
};

// 4. 收信令
signaling.onmessage = async ({ description, candidate }) => {
try {
if (description) {
const readyForOffer =
!makingOffer &&
(pc.signalingState === 'stable' || isSettingRemoteAnswerPending);
const offerCollision = description.type === 'offer' && !readyForOffer;

ignoreOffer = !polite && offerCollision;
if (ignoreOffer) return;

isSettingRemoteAnswerPending = description.type === 'answer';
await pc.setRemoteDescription(description);
isSettingRemoteAnswerPending = false;

if (description.type === 'offer') {
await pc.setLocalDescription(); // 自动 createAnswer
signaling.send({ description: pc.localDescription });
}
} else if (candidate) {
try {
await pc.addIceCandidate(candidate);
} catch (err) {
if (!ignoreOffer) throw err; // 协商被忽略,候选也忽略
}
}
} catch (err) {
console.error(err);
}
};

这份模板已经覆盖了 W3C 规范的全部边界条件。两端代码完全一样,只有 polite 不同

5. 为什么需要 isSettingRemoteAnswerPending

新版规范引入这个标志位是为了修补一个隐蔽的竞态:

T0: pc.signalingState = have-local-offer,刚把 offer 发出去
T1: 收到 answer,setRemoteDescription(answer) 开始执行(异步)
T2: 在 setRemoteDescription 完成前,又收到对端的新 offer
T3: 新 offer 检查时 signalingState 还不是 stable(还没等到 setRemoteDescription resolve)
→ 被误判为 glare → impolite 端忽略,导致永久卡住

isSettingRemoteAnswerPending = true 期间,把 "正在 setRemoteDescription(answer)" 也视作可以接受 offer 的状态,避免误判。这个细节不少老代码模板里没有。

6. setLocalDescription() 不传参

// 老写法
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);

// 新写法(推荐)
await pc.setLocalDescription();

不传参时,浏览器根据当前 signalingState 自动createOffercreateAnswer。少一次错配机会,更符合"对称代码"原则。Chrome 80+、Firefox 75+、Safari 15+ 都支持。

7. 状态边界一览

把模板里每个判断对应到状态机:

signalingStatemakingOffer收到 offer 该怎么办
stablefalse直接 setRemoteDescription + 回复 answer
stabletrue自己刚开始发,glare → polite 让,impolite 不让
have-local-offertrue自己 offer 发出去了,glare → polite 让
have-local-offerfalse自己已 setLocalDescription 但还没收到 answer,glare → polite 让
have-remote-offer-异常状态,正常流不会到这里

isSettingRemoteAnswerPending 这一类瞬时态被 readyForOffer 单独考虑。

8. ICE Restart 和 Perfect Negotiation 的协作

pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'failed') {
pc.restartIce();
}
};

restartIce() 会触发 negotiationneededsetLocalDescription 生成带新 ice-ufrag/ice-pwd 的 offer。整个流程完全走 Perfect Negotiation 模板,不需要特判。

注意:网络切换时双方可能同时检测到 failed 都调 restartIce(),又是一次 glare → polite 让步处理。Perfect Negotiation 在这里是必需的。

9. 用 TypeScript 做完整封装

type SignalingPayload =
| { description: RTCSessionDescriptionInit }
| { candidate: RTCIceCandidateInit };

interface SignalingChannel {
send(payload: SignalingPayload): void;
on(handler: (payload: SignalingPayload) => void): void;
}

class PolitePeerConnection {
private pc: RTCPeerConnection;
private makingOffer = false;
private ignoreOffer = false;
private settingRemoteAnswerPending = false;

constructor(
config: RTCConfiguration,
private polite: boolean,
private signaling: SignalingChannel,
) {
this.pc = new RTCPeerConnection(config);
this.pc.onnegotiationneeded = this.onNegotiationNeeded;
this.pc.onicecandidate = this.onIceCandidate;
this.pc.oniceconnectionstatechange = this.onIceStateChange;
this.signaling.on(this.onSignal);
}

get raw() { return this.pc; }

private onNegotiationNeeded = async () => {
try {
this.makingOffer = true;
await this.pc.setLocalDescription();
this.signaling.send({ description: this.pc.localDescription! });
} finally {
this.makingOffer = false;
}
};

private onIceCandidate = (e: RTCPeerConnectionIceEvent) => {
if (e.candidate) this.signaling.send({ candidate: e.candidate.toJSON() });
};

private onIceStateChange = () => {
if (this.pc.iceConnectionState === 'failed') this.pc.restartIce();
};

private onSignal = async (msg: SignalingPayload) => {
try {
if ('description' in msg) await this.handleDescription(msg.description);
else await this.handleCandidate(msg.candidate);
} catch (err) { console.error('[signal]', err); }
};

private async handleDescription(desc: RTCSessionDescriptionInit) {
const ready =
!this.makingOffer &&
(this.pc.signalingState === 'stable' || this.settingRemoteAnswerPending);
const collision = desc.type === 'offer' && !ready;

this.ignoreOffer = !this.polite && collision;
if (this.ignoreOffer) return;

this.settingRemoteAnswerPending = desc.type === 'answer';
await this.pc.setRemoteDescription(desc);
this.settingRemoteAnswerPending = false;

if (desc.type === 'offer') {
await this.pc.setLocalDescription();
this.signaling.send({ description: this.pc.localDescription! });
}
}

private async handleCandidate(candidate: RTCIceCandidateInit) {
try { await this.pc.addIceCandidate(candidate); }
catch (err) { if (!this.ignoreOffer) throw err; }
}
}

业务侧只需要:

const peer = new PolitePeerConnection(config, /* polite */ true, signalingChannel);
peer.raw.addTrack(localTrack, localStream);
peer.raw.ontrack = ({ track }) => attachToVideo(track);

10. 多人会议中 Perfect Negotiation 还有用吗

在 SFU 架构里,每个客户端只跟服务器建一条 PeerConnection,glare 在客户端 ↔ SFU 之间几乎不会发生(SFU 通常不主动发 offer)。但有两种情况仍需要:

  1. SFU 在重启 / 升级时触发 ICE Restart,可能在客户端也触发 negotiationneeded
  2. 客户端动态切层(订阅 / 取消订阅其它人)会改变 transceivers → negotiationneeded

约定客户端 polite=true、SFU polite=false,省去状态推理。LiveKit、mediasoup 的客户端 SDK 内部都是这种模式。

11. 测试 glare 的方法

工程上很难自然触发 glare。手动测试方法:

// 同时在两端代码加:
async function forceRenegotiate() {
// 加一个新 transceiver 必然触发 negotiationneeded
pc.addTransceiver('audio', { direction: 'recvonly' });
}

// 用 setTimeout 让两端在 ±5ms 内同时执行
setTimeout(forceRenegotiate, 100);

观察 chrome://webrtc-internalssignalingState,正确实现下应能看到一端短暂出现 have-local-offer 然后回到 stable,最终两端都是 stable

12. 反模式

反模式后果替代
两端都 polite=true 或都 polite=falseglare 时两端都让或都不让,仍卡死用确定性方法决定(peerId 比较)
negotiationneededawait 信令 ack阻塞协商,慢半秒以上直接 send,靠协议层 seq 处理时序
signalingState 判断 collision不考虑 makingOfferisSettingRemoteAnswerPending,漏掉边界严格用 W3C 模板的判断式
自己实现 rollbacksetLocalDescription({type:'rollback'}) 已经够用不需要手动管 transceiver
polite 端忽略 offerpolite 应该是"接受",impolite 才是"忽略"角色搞反了

13. 权威资料