fix: cap peer-selected TURN attempts per room (#121)

This commit is contained in:
AAA authored and GitHub committed 2026-08-21 10:16:59 +08:00
1 parent b78e25b4fb
commit 8dc929fc87
7 files changed
+324 -6

No files matched your search

@@ -87,7 +87,9 @@ the initial pending Host publication or an active Host/SFU route; production
configures the tuple, but real TURN/media evidence remains open.
The wire has two explicit edge kinds: `peer-selected` for the last-mile failed
peer edge and `host-sfu-ingress` for a restricted Host-to-SFU retry. Ordinary
Peer ICE remains STUN-only.
Peer ICE remains STUN-only. Each room admits at most one pending or answered
`peer-selected` attempt; `host-sfu-ingress` is an independent source transport
attempt and does not consume that last-mile admission slot.
The current runtime removes the old all-room coturn contract. Production
requires STUN and authenticated ICE snapshots contain only STUN servers in
+1 -1
View File
@@ -25,7 +25,7 @@ Last updated: 2026-08-21
- Keep experiments bounded: the standard representation sequence is below; native RTP relay, encoded-object striping, and FEC remain separate.
- ADR-0006's historical browser-bridge canary remains no-go; VP8 stays default. Browser H.264 rendered 298/299; Native WGC/MF rendered 203 with PID/LUID-correlated `VideoEncode`. Downloaded use, multi-viewer/endurance/public proof remain open.
- Mobile endpoints are Web Viewer-only. Current Android/iOS browsers do not expose Web Host capture; AirPlay/system mirroring is Viewer-local output.
- ADR-0005 accepts direct/peer UDP, bounded SFU roots, then selected-edge TURN. Production grants one revision-bound, one-use retry for initial/active Host ingress. The deployed tuple/control path have no production SFU/TURN media canary; participant-wide TURN stays rejected and ordinary peers stay STUN-only.
- ADR-0005 accepts direct/peer UDP, bounded SFU roots, then selected-edge TURN. Source admits one pending or answered `peer-selected` attempt per room while `host-sfu-ingress` remains independent. Production grants one revision-bound, one-use retry for initial/active Host ingress. The deployed tuple/control path have no production SFU/TURN media canary; participant-wide TURN stays rejected and ordinary peers stay STUN-only.
- The controller is process-enabled, not room-allowlisted: `PEER_ASSISTED_MEDIA=true` gives every normal room the same direct/peer -> SFU -> selected-edge path with per-room state. Room `1` is historical smoke. Preserve sticky P2P, mobile leaves and break-before-make; recovery gets one attempt per layer (ICE restart, same-parent rebuild, alternate peer, then SFU), never three identical retries.
- C+B quality reparenting is edge-local and cooldown-bound; no score, timer, or global parent penalty.
- Flagship media is UDP; HTTPS/WSS stays TLS/TCP; the old release remains rollback-only.
+7 -2
View File
@@ -1,6 +1,6 @@
# Built-In Peer ICE TURN Candidate
- Research date: 2026-08-20
- Research date: 2026-08-20; quota boundary rechecked 2026-08-21
- Scope: authenticated TURN on every ordinary peer `RTCPeerConnection` in an
exact-room canary
- Status: rejected after bounded production canary; historical evidence only
@@ -86,6 +86,8 @@ coherent controller-owned change with a current consumer:
- one in-memory attempt bound to room/share generation, viewer and parent
sessions, route revision, the replaced connection, and a server-generated new
connection identity;
- at most one pending or answered `peer-selected` attempt per room, with the
separate Host-to-SFU ingress attempt excluded from that last-mile cap;
- coordinated parent and child rebuild for exactly that edge, using relay-only
ICE only on the new connection;
- revalidation at grant, rebuild, ready, failure, timeout, route change,
@@ -97,7 +99,10 @@ coherent controller-owned change with a current consumer:
Coturn still cannot enforce the application edge identity. The application
guards provide selected-edge authorization; short expiry and coturn quotas bound
bearer reuse and resource cost. Do not claim cryptographic edge binding.
bearer reuse and resource cost. Coturn's current `user-quota` and `total-quota`
bound allocations by credential user and server/realm, not by Screener room, so
they do not replace this application admission guard. Do not claim cryptographic
edge binding.
The source candidate uses the complete `SELECTED_EDGE_TURN_*` tuple only with
exact-room peer assistance and SFU configuration. `HostPeer`, relay parents, the
+1 -1
View File
@@ -36,7 +36,7 @@ Flagship; parallel; min run/smoke/rollback; benchmark later. Active UI/config/lo
- Native Win11 audio is source-only/default-off: target isolation was 4018x and one Viewer got 495 Opus packets. Download, game sync, Win10, and other routes remain open.
- Access protocol/config/HTTP/storage/SQLite/signaling focused tests pass, including commit-first teardown and v1 rollback.
- Native wire/session tests enforce at most two authoritative children, no presence-created edge, stale-revision ignore, and one bounded unsupported-route failure per revision.
- Production includes initial/active selected Host ingress and peer last-mile; focused tests cover exactly-once retry, cleanup, rollback, and ordinary STUN-only. No relay media ran.
- Production includes initial/active selected Host ingress and peer last-mile; source caps pending plus answered `peer-selected` attempts at one per room without counting Host ingress. Focused tests cover admission, release, independent ingress, exactly-once retry, cleanup, rollback, and ordinary STUN-only. No relay media ran.
- Host names/window audio/all-room routing/Host selected ingress are active. New AV stats pass 42 focused tests/typecheck; this is not audible proof.
- Exact `16f6eab` passed typecheck, both builds and 30 files/445 tests. Deploy preserved all recorded config/network/SQLite hashes; DB, routes, services, endpoints, site access, and old-endpoint 404 passed. It triggered no TURN/SFU session. Rollback is exact `22119b907d3cf03ce8b06d6fb4596ce1a26fedd7`; `fd76277b05d491af8840b28f3132b7ff445d3cbe` remains secondary.
+1 -1
View File
@@ -26,7 +26,7 @@
- [ ] 一对多观看:当前安全默认上限为 8、可配置范围为 1 至 16;自动分布式拓扑验收后,同一 release 将默认值和上限改为 20。当前 PoC 的逐观看者连接是测量基线,不再是超过两名观看者时的目标拓扑;未通过 20 人门禁时继续明确拒绝超额加入。
- [ ] 端点 fanout 硬上限:任一非服务器端点任何时刻最多维护两条 active downstream media edges,包括加入、断线、换父和恢复过程;上游接收不计入这项上传预算。当前 browser relay 因逐跳重编码仍保守限制为一个 child,只有资源矩阵通过后才可升到二。第三名及后续观看者由系统自动分配给通过验收的 peer-assisted 拓扑,不能隐藏地回退为 Host 第三条连接。没有可行 peer path 时,SFU 作为主要中央兜底 virtual parent 正常向一至两个必要 roots 送流;媒体到达 root 后其余可行观看者仍优先沿受限 peer descendants 扩散。多个无法挂到健康 root 的极端 viewer 可成为额外 server-fed 例外,但必须受显式中央 egress/admission 上限约束,不能无界整房 fanout。TURN 只作为可选的极端网络 transport 兼容层,不定义房间拓扑。
- [ ] P2P 优先:每条逻辑媒体边的 ICE 必须先尝试直接 UDP 路径,服务器不得默认中转可直连的媒体。一个或两个观看者优先直接连接分享端;更多观看者的 peer-assisted 行为须先通过 ADR-0004 的实验门槛。
- [ ] NAT 与传输兜底:STUN 必须可用,所有 ordinary peer `RTCPeerConnection` 只接收 STUN ICE servers。PC 失败后按一次 ICE restart、一次同 parent rebuild、一个 alternate peer、再 SFU/UDP virtual parent 的顺序恢复,任一步成功即停止;只有该 edge 的 SFU/UDP 也失败后,controller 才可授权一次短期 authenticated selected-edge TURN/UDP rebuild。授权必须绑定当前 room/share generation、viewer/parent session、route revision 与 old/new connection identity,成功、失败、超时或代次变化即消费;不得按 participant、房间或普通 peer PC 下发,Native v2 保持 STUN-only。凭据不进入 URL、日志、浏览器持久化或 SQLite。Coturn 只验证 HMAC 与 expiry,应用必须重验 edge 代次并用短 TTL、one-use state 与 quota 限界。媒体 ICE/TCP、TURN/TCP 或 TURN/TLS 另行验收;HTTPS/WSS 仍使用 TLS/TCP。
- [ ] NAT 与传输兜底:STUN 必须可用,所有 ordinary peer `RTCPeerConnection` 只接收 STUN ICE servers。PC 失败后按一次 ICE restart、一次同 parent rebuild、一个 alternate peer、再 SFU/UDP virtual parent 的顺序恢复,任一步成功即停止;只有该 edge 的 SFU/UDP 也失败后,controller 才可授权一次短期 authenticated selected-edge TURN/UDP rebuild。授权必须绑定当前 room/share generation、viewer/parent session、route revision 与 old/new connection identity,成功、失败、超时或代次变化即消费;同一房间同时 pending 或 active 的 `peer-selected` attempt 总数最多为一,`host-sfu-ingress` 使用独立状态且不占该 last-mile 名额。不得按 participant、房间或普通 peer PC 下发,Native v2 保持 STUN-only。凭据不进入 URL、日志、浏览器持久化或 SQLite。Coturn 只验证 HMAC 与 expiry,应用必须重验 edge 代次并用短 TTL、one-use state 与 quota 限界。媒体 ICE/TCP、TURN/TCP 或 TURN/TLS 另行验收;HTTPS/WSS 仍使用 TLS/TCP。
- [ ] 质量控制:保留 1080p60(8 Mbps)、1080p30(5 Mbps)、720p30(3 Mbps)三档推荐上限,默认采用清晰优先;分享期间可直接换档或应用有界高级设置,不重新选择来源或重建健康连接。Peer-assisted relay 与已配置的 SFU 必须接收同一个严格校验的房间级权威设置,覆盖当前和以后建立的 sender;普通 P2P 信令结构保持不变。任何设置都只是浏览器上限和偏好,必须读回实际 sender 参数并显示参数拒绝、改写和持续受限原因。不做自定义动态 FPS、综合评分或周期性全房间调档。
- [ ] 路径隔离双表示:direct/peer 继续使用每条 PeerConnection 独立的 stock WebRTC GCC,不把任何 viewer 反馈合成全房目标。每条 SFU path 从 `HIGH` ceiling 开始;优先用 pinned LiveKit 2.22.0/1.13.5 的标准两层 screen-share simulcast,在 Dynacast off 下常驻 exactly `HIGH+LOW`,让内建 per-subscriber BWE 独立降层和恢复。若该 gate 通过,Screener 不实现媒体层 selector。首 gate 使用 zero-descendant SFU leaf;root-with-children 的自动 downshift 与有界迁移是 default-on 前独立硬门。只有内建选择不满足门槛时才用标准 subscriber quality 显式限制,常驻成本不合格时再测标准 sender 手动启停。`LOW` 停编只是资源优化;active representation/layer 硬上限为二,禁止按 viewer 增长。必须实测实际接收层、硬件编码证据、游戏 FPS/p1 low、CPU/GPU、区间 encode cost、上传和分层 bytes。当前 Web P2P simulcast 缺少 receiver 选 RID 和跨 PeerConnection shared-encode 契约;当前 Web/LiveKit SVC 也缺少 direct/peer 独立选层与可移植硬件保证,且 pinned screen share 强制三层 temporal-only `L1T3`,二者按现契约静态 no-go。全部标准路径失败后才考虑 custom/native dual encode。没有合格 hardware/power-efficient 路径或额外表示破坏游戏/上传预算时 fail closed:保护健康路径并明确显示弱路径降级或不可用。
- [ ] 路径级质量状态:应用的 `HIGH`/`FALLBACK` 非对称窗口只决定拓扑资格和诊断,不命令正常的媒体层切换,也不计算综合分数。SFU BWE downshift 先进入 `suspect`;多个连续窗口的 sender/viewer 关联证据确认 `FALLBACK` 后,应用不得稳定保留该 parent,并把 downstream capacity 降为 0。计划或显式质量 fallback 仅是内建路径失败后的候选:已有 children 时先在 connection/assignment generation guard 下全部迁走,失败或代次过期则保持原 assignment/quality。Autonomous downshift 无法 packet-level 预先迁移,default-on 前必须验收 descendants 的短暂影响。只有更长的稳定恢复窗口和 cooldown 后才能重新声明 relay capacity。Viewer 请求必须鉴权、限频、去重,并由 sender 的 GCC/Transport-CC、实际码率、RTT、丢包、重传、编码限制、协商 codec/profile/parameters 与 viewer 解码、丢帧、冻结证据交叉验证;不能仅凭自报生效,也不得上传 raw SDP、candidate 地址或原始设备/网络标识。
+7
View File
@@ -1009,6 +1009,13 @@ export class HybridMediaRouter {
if (this.selectedEdgeTurnAttempts.has(key)) {
return true;
}
if (
[...this.selectedEdgeTurnAttempts.values()].some(
(attempt) => attempt.roomId === roomId,
)
) {
return false;
}
if (
!config ||
!active ||
+304
View File
@@ -721,6 +721,139 @@ async function activateSingleViewerSfu(
return { ...direct, revision: viewerPrepare.revision };
}
async function activateTwoFailedViewerSfuRoots(
webSocketUrl: string,
room: CreatedRoom,
prefix: string,
) {
const first = await activateSingleViewerSfu(
webSocketUrl,
room,
`${prefix}-first`,
);
const secondViewer = await openClient(webSocketUrl);
const secondAuth = peerAssisted(
await authenticate(
secondViewer,
room,
"viewer",
`${prefix}-second-viewer`,
0,
),
);
expect(secondAuth.routeAssignment.upstream).toEqual({
kind: "peer",
peerId: first.hostAuth.peerId,
});
const hostConnectionId = `${prefix}-host-edge`;
first.host.socket.send(JSON.stringify({
type: "signal",
targetPeerId: secondAuth.peerId,
payload: {
kind: "description",
connectionId: hostConnectionId,
description: { type: "offer", sdp: "v=0\r\n" },
},
}));
await secondViewer.inbox.next("signal");
secondViewer.socket.send(JSON.stringify({
type: "route-failed",
revision: secondAuth.routeRevision,
phase: "active",
connectionId: hostConnectionId,
}));
const reparented = await nextActiveRouteAfter(
secondViewer,
secondAuth.routeRevision,
);
expect(reparented.assignment.upstream).toEqual({
kind: "peer",
peerId: first.viewerAuth.peerId,
});
const peerConnectionId = `${prefix}-peer-edge`;
first.viewer.socket.send(JSON.stringify({
type: "signal",
targetPeerId: secondAuth.peerId,
payload: {
kind: "description",
connectionId: peerConnectionId,
description: { type: "offer", sdp: "v=0\r\n" },
},
}));
await secondViewer.inbox.next("signal");
secondViewer.socket.send(JSON.stringify({
type: "route-failed",
revision: reparented.revision,
phase: "active",
connectionId: peerConnectionId,
}));
const hostPrepare = await nextPreparedRoute(first.host);
const firstRootPrepare = await nextPreparedRoute(first.viewer);
const secondRootPrepare = await nextPreparedRoute(secondViewer);
await Promise.all([
first.host.inbox.next("sfu-config"),
first.viewer.inbox.next("sfu-config"),
secondViewer.inbox.next("sfu-config"),
]);
for (const client of [first.host, first.viewer, secondViewer]) {
client.socket.send(JSON.stringify({
type: "route-ready",
revision: hostPrepare.revision,
phase: "prepare",
}));
}
const [, firstRootActive, secondRootActive] = await Promise.all([
nextActiveRouteRevision(first.host, hostPrepare.revision),
nextActiveRouteRevision(first.viewer, firstRootPrepare.revision),
nextActiveRouteRevision(secondViewer, secondRootPrepare.revision),
]);
expect(firstRootActive.assignment.upstream).toEqual({ kind: "sfu" });
expect(secondRootActive.assignment.upstream).toEqual({ kind: "sfu" });
return {
host: first.host,
hostAuth: first.hostAuth,
failedViewer: secondViewer,
failedAuth: secondAuth,
secondRoot: first.viewer,
secondRootAuth: first.viewerAuth,
revision: hostPrepare.revision,
};
}
async function failActiveSfuRoot(client: TestClient, revision: number) {
client.socket.send(JSON.stringify({
type: "refresh-sfu",
revision,
}));
await client.inbox.next("sfu-config");
client.socket.send(JSON.stringify({
type: "route-failed",
revision,
phase: "active",
connectionId: null,
}));
}
async function startPeerSelectedTurn(
viewer: TestClient,
parent: TestClient,
revision: number,
) {
await failActiveSfuRoot(viewer, revision);
const [viewerGrant, parentGrant] = await Promise.all([
viewer.inbox.next("selected-edge-turn"),
parent.inbox.next("selected-edge-turn"),
]);
expect(parentGrant).toEqual(viewerGrant);
if (viewerGrant.edgeKind !== "peer-selected") {
throw new Error("expected a peer-selected grant");
}
return viewerGrant;
}
async function exhaustDeepViewerPeerRoutes(
webSocketUrl: string,
room: CreatedRoom,
@@ -4031,6 +4164,149 @@ describe("WebSocket signaling", () => {
).rejects.toThrow("Timed out");
});
it("admits only one pending peer-selected TURN attempt per room", async () => {
const harness = await startSfuHarness({
tokenIssuer: { async issueToken({ peerId }) { return `token-${peerId}`; } },
selectedEdgeTurn: true,
});
const peers = await activateTwoFailedViewerSfuRoots(
harness.webSocketUrl,
harness.room,
"selected-room-pending-cap",
);
await startPeerSelectedTurn(
peers.failedViewer,
peers.secondRoot,
peers.revision,
);
await failActiveSfuRoot(peers.secondRoot, peers.revision);
await expect(
nextActiveRouteAfter(peers.secondRoot, peers.revision),
).resolves.toMatchObject({
assignment: { upstream: { kind: "peer" } },
});
await expect(
peers.secondRoot.inbox.next("selected-edge-turn", 40),
).rejects.toThrow("Timed out");
});
it("keeps an answered peer-selected TURN edge inside the room cap", async () => {
const harness = await startSfuHarness({
tokenIssuer: { async issueToken({ peerId }) { return `token-${peerId}`; } },
selectedEdgeTurn: true,
});
const peers = await activateTwoFailedViewerSfuRoots(
harness.webSocketUrl,
harness.room,
"selected-room-answered-cap",
);
const grant = await startPeerSelectedTurn(
peers.failedViewer,
peers.secondRoot,
peers.revision,
);
peers.secondRoot.socket.send(JSON.stringify({
type: "signal",
targetPeerId: peers.failedAuth.peerId,
payload: {
kind: "description",
connectionId: grant.newConnectionId,
description: { type: "offer", sdp: "v=0\r\n" },
},
}));
await peers.failedViewer.inbox.next("signal");
peers.failedViewer.socket.send(JSON.stringify({
type: "signal",
targetPeerId: peers.secondRootAuth.peerId,
payload: {
kind: "description",
connectionId: grant.newConnectionId,
description: { type: "answer", sdp: "v=0\r\n" },
},
}));
await peers.secondRoot.inbox.next("signal");
await failActiveSfuRoot(peers.secondRoot, peers.revision);
await expect(
nextActiveRouteAfter(peers.secondRoot, peers.revision),
).resolves.toMatchObject({
assignment: { upstream: { kind: "peer" } },
});
await expect(
peers.secondRoot.inbox.next("selected-edge-turn", 40),
).rejects.toThrow("Timed out");
});
it("releases the room cap after the selected edge fails", async () => {
const harness = await startSfuHarness({
tokenIssuer: { async issueToken({ peerId }) { return `token-${peerId}`; } },
selectedEdgeTurn: true,
});
const peers = await activateTwoFailedViewerSfuRoots(
harness.webSocketUrl,
harness.room,
"selected-room-failure-release",
);
const firstGrant = await startPeerSelectedTurn(
peers.failedViewer,
peers.secondRoot,
peers.revision,
);
peers.failedViewer.socket.send(JSON.stringify({
type: "route-failed",
revision: peers.revision,
phase: "active",
connectionId: firstGrant.newConnectionId,
}));
await peers.failedViewer.inbox.next("error");
await failActiveSfuRoot(peers.secondRoot, peers.revision);
const [secondGrant, hostGrant] = await Promise.all([
peers.secondRoot.inbox.next("selected-edge-turn"),
peers.host.inbox.next("selected-edge-turn"),
]);
expect(hostGrant).toEqual(secondGrant);
expect(secondGrant).toMatchObject({
edgeKind: "peer-selected",
viewerPeerId: peers.secondRootAuth.peerId,
});
});
it("releases the room cap when the selected Viewer disconnects", async () => {
const harness = await startSfuHarness({
tokenIssuer: { async issueToken({ peerId }) { return `token-${peerId}`; } },
selectedEdgeTurn: true,
viewerDisconnectGraceMs: 500,
});
const peers = await activateTwoFailedViewerSfuRoots(
harness.webSocketUrl,
harness.room,
"selected-room-disconnect-release",
);
await startPeerSelectedTurn(
peers.failedViewer,
peers.secondRoot,
peers.revision,
);
await closeClient(peers.failedViewer);
await failActiveSfuRoot(peers.secondRoot, peers.revision);
const [secondGrant, hostGrant] = await Promise.all([
peers.secondRoot.inbox.next("selected-edge-turn"),
peers.host.inbox.next("selected-edge-turn"),
]);
expect(hostGrant).toEqual(secondGrant);
expect(secondGrant).toMatchObject({
edgeKind: "peer-selected",
viewerPeerId: peers.secondRootAuth.peerId,
});
});
it("allows selected TURN for Host SFU ingress outside historical room 1", async () => {
const harness = await startSfuHarness({
persistent: true,
@@ -4059,6 +4335,34 @@ describe("WebSocket signaling", () => {
});
});
it("does not count Host SFU ingress against the peer-selected room cap", async () => {
const harness = await startSfuHarness({
tokenIssuer: { async issueToken({ peerId }) { return `token-${peerId}`; } },
selectedEdgeTurn: true,
});
const direct = await activateSingleViewerSfu(
harness.webSocketUrl,
harness.room,
"selected-independent-ingress",
);
direct.host.socket.send(JSON.stringify({
type: "route-failed",
revision: direct.revision,
phase: "active",
connectionId: null,
}));
await expect(
direct.host.inbox.next("selected-edge-turn"),
).resolves.toMatchObject({ edgeKind: "host-sfu-ingress" });
await startPeerSelectedTurn(
direct.viewer,
direct.host,
direct.revision,
);
});
it("retries an initial Host SFU prepare through one selected ingress", async () => {
const harness = await startSfuHarness({
tokenIssuer: { async issueToken({ peerId }) { return `token-${peerId}`; } },