Perfect-Negotiation模式
Perfect Negotiation 模式
Perfect Negotiation 是 W3C 给出的一种"在任何时候、任何一端都能主动 renegotiate"的写法。它解决两个真问题:
- glare(双方在同一时刻发起 offer)导致状态机互锁。
- 业务侧不想区分"我是发起者还是接收者",希望两端代码完全对称。
如果你的应用永远只有一端能发起协商(比如客户端推流到服务器),不需要这个模式,按经典 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 自动调 createOffer 或 createAnswer。少一次错配机会,更符合"对称代码"原则。Chrome 80+、Firefox 75+、Safari 15+ 都支持。
7. 状态边界一览
把模板里每个判断对应到状态机:
signalingState | makingOffer | 收到 offer 该怎么办 |
|---|---|---|
stable | false | 直接 setRemoteDescription + 回复 answer |
stable | true | 自己刚开始发,glare → polite 让,impolite 不让 |
have-local-offer | true | 自己 offer 发出去了,glare → polite 让 |
have-local-offer | false | 自己已 setLocalDescription 但还没收到 answer,glare → polite 让 |
have-remote-offer | - | 异常状态,正常流不会到这里 |
isSettingRemoteAnswerPending 这一类瞬时态被 readyForOffer 单独考虑。
8. ICE Restart 和 Perfect Negotiation 的协作
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'failed') {
pc.restartIce();
}
};
restartIce() 会触发 negotiationneeded → setLocalDescription 生成带新 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)。但有两种情况仍需要:
- SFU 在重启 / 升级时触发 ICE Restart,可能在客户端也触发
negotiationneeded。 - 客户端动态切层(订阅 / 取消订阅其它人)会改变 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-internals 的 signalingState,正确实现下应能看到一端短暂出现 have-local-offer 然后回到 stable,最终两端都是 stable。
12. 反模式
| 反模式 | 后果 | 替代 |
|---|---|---|
两端都 polite=true 或都 polite=false | glare 时两端都让或都不让,仍卡死 | 用确定性方法决定(peerId 比较) |
在 negotiationneeded 里 await 信令 ack | 阻塞协商,慢半秒以上 | 直接 send,靠协议层 seq 处理时序 |
用 signalingState 判断 collision | 不考虑 makingOffer、isSettingRemoteAnswerPending,漏掉边界 | 严格用 W3C 模板的判断式 |
| 自己实现 rollback | setLocalDescription({type:'rollback'}) 已经够用 | 不需要手动管 transceiver |
| polite 端忽略 offer | polite 应该是"接受",impolite 才是"忽略" | 角色搞反了 |
13. 权威资料
- W3C WebRTC 1.0 §4.7.1 Perfect Negotiation Example: https://www.w3.org/TR/webrtc/#perfect-negotiation-example
- WebRTC samples - perfect negotiation: https://webrtc.github.io/samples/src/content/peerconnection/negotiation/
- IETF RFC 8829 JSEP §5.3 Rollback: https://www.rfc-editor.org/rfc/rfc8829#section-5.3
- Jan-Ivar Bruaroey 原始提案与解释:https://blog.mozilla.org/webrtc/perfect-negotiation-in-webrtc/
- 核对日期:2026-06-22