fix: cap peer-selected TURN attempts per room (#121)
This commit is contained in:
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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 地址或原始设备/网络标识。
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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}`; } },
|
||||
|
||||
Reference in new issue
Block a user