fix(routing): retain connected candidates during delayed viewer feedback
When Viewer progress and first-frame confirmations are delayed, an already connected P2P candidate can be discarded at the five-second scheduling boundary if another candidate is available. This can turn a working initial connection into another connection attempt. Host and relay parents now report transport progress through the existing room router. The router checks the exact participant/session, revision and connection; only the candidate Viewer can confirm first-frame readiness. The total operation deadline and the routing algorithm are unchanged. Browser and Native senders use their existing prepared-child owners, and discarded preparations cannot report progress. The loopback benchmark now identifies Peer and SFU connections from room signaling, excluding local codec probes and encoding-pool transports from media-edge counts. Its optional old Viewer-only quality canary remains unsuitable for the current sender-owned quality contract and is recorded in TODO. Validation: - `npm run check`: type checks, 1,000 tests and production Web build passed. - `npm run check:go-core`: formatting, Go tests, vet and supported Windows/Linux builds passed; Darwin capture remains a native-runner check. - Documentation and project-state checks passed. - Released v1.5.0 versus this candidate, delaying Viewer transport/ready feedback by six seconds: the old candidate connection closed after the scheduling boundary despite 79 decoded frames; the fixed connection stayed intact. Browser Host, Native Host and relay-parent paths were exercised, with another Viewer continuing to play. - Three-Viewer loopback topology and relay-departure recovery passed; Host and relay copy limits remained valid. - Actual App public-link signaling, with isolated 1/8/20-second packet loss on one Browser Viewer: playback recovered in all three cases and the control Viewer continued. Every test process, port and isolated profile was cleaned up. The original reporter's browser/network and paired diagnostics are unavailable. These bounded tests establish the repaired mechanism, not the cause of every brief connection or a representative NAT success rate. ## Release notes ### 中文 这是一次连接稳定性修复,改善进入房间时的 P2P 连接过程。 - 修复观众的连接确认反馈较慢时,已接通的线路可能被提前拆除,造成重复连接或延迟出画的问题。修复覆盖 App 分享、浏览器分享和观众中继。 - 使用 App 公网邀请或本地模式时,房主更新 App 后重新分享即可。使用已部署站点时,请管理员更新 Server,并刷新页面。 ### English This patch improves P2P connection stability when joining a room. - Fixed an established connection being discarded prematurely when Viewer confirmations arrive late, causing repeated connection attempts or delayed playback. The fix covers App sharing, browser sharing and Viewer relays. - For App public invitations or Local mode, the Host should update the App and start sharing again. For a deployed site, update the Server and refresh the page.
This commit is contained in:
10 files changed
+202
-30
No files matched your search
@@ -732,10 +732,21 @@ func (r *router) handleRouteReady(participant authenticatedRouteParticipant, mes
|
||||
r.requestPump(participant.roomID)
|
||||
}
|
||||
|
||||
func (r *router) handleRouteTransportConnected(participant authenticatedRouteParticipant, message protocol.RouteTransportConnectedMessage) {
|
||||
if participant.role != protocol.RoleViewer {
|
||||
return
|
||||
// Either endpoint may report transport progress or local failure. First-frame
|
||||
// readiness remains owned by the candidate Viewer in handleRouteReady.
|
||||
func (r *router) ownsCandidateEndpoint(participant authenticatedRouteParticipant, operation *route.OperationSnapshot) bool {
|
||||
if participant.role == protocol.RoleViewer && participant.peerID == operation.ChildPeerID &&
|
||||
participant.sessionID == operation.ChildSessionID {
|
||||
return true
|
||||
}
|
||||
if operation.Current.Tuple.Kind == route.UpstreamPeer && participant.peerID == operation.Current.Tuple.ParentPeerID {
|
||||
peer, ok := r.connectedPeer(participant.roomID, participant.peerID)
|
||||
return ok && peer.SessionID == participant.sessionID
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *router) handleRouteTransportConnected(participant authenticatedRouteParticipant, message protocol.RouteTransportConnectedMessage) {
|
||||
rm, _ := r.rooms.Get(participant.roomID)
|
||||
if rm == nil || rm.controller == nil {
|
||||
return
|
||||
@@ -744,17 +755,16 @@ func (r *router) handleRouteTransportConnected(participant authenticatedRoutePar
|
||||
operation := controller.Operation()
|
||||
revision := int64(message.Revision)
|
||||
if operation == nil || operation.Current == nil ||
|
||||
operation.ChildPeerID != participant.peerID ||
|
||||
operation.ChildSessionID != participant.sessionID ||
|
||||
operation.Current.Revision != revision ||
|
||||
operation.Current.ConnectionID != message.ConnectionID ||
|
||||
operation.Current.Tuple.Kind != route.UpstreamPeer {
|
||||
operation.Current.Tuple.Kind != route.UpstreamPeer ||
|
||||
!r.ownsCandidateEndpoint(participant, operation) {
|
||||
return
|
||||
}
|
||||
before := controller.Revision()
|
||||
progressed := controller.CandidateTransportConnected(route.CandidateGuard{
|
||||
ChildPeerID: participant.peerID,
|
||||
ChildSessionID: participant.sessionID,
|
||||
ChildPeerID: operation.ChildPeerID,
|
||||
ChildSessionID: operation.ChildSessionID,
|
||||
Revision: revision,
|
||||
ConnectionID: message.ConnectionID,
|
||||
}, r.now())
|
||||
@@ -821,21 +831,14 @@ func (r *router) handleRouteFailed(participant authenticatedRouteParticipant, me
|
||||
return
|
||||
}
|
||||
current := operation.Current
|
||||
ownsChild := participant.role == protocol.RoleViewer &&
|
||||
participant.peerID == operation.ChildPeerID &&
|
||||
participant.sessionID == operation.ChildSessionID
|
||||
ownsParent := false
|
||||
if current.Tuple.Kind == route.UpstreamPeer && participant.peerID == current.Tuple.ParentPeerID {
|
||||
peer, ok := r.connectedPeer(participant.roomID, participant.peerID)
|
||||
ownsParent = ok && peer.SessionID == participant.sessionID
|
||||
}
|
||||
ownsEndpoint := r.ownsCandidateEndpoint(participant, operation)
|
||||
ownsPublication := false
|
||||
if current.Tuple.Kind == route.UpstreamSfu && participant.role == protocol.RoleHost &&
|
||||
participant.peerID == rm.hostPeerID {
|
||||
host, ok := r.store.GetConnectedHost(participant.roomID)
|
||||
ownsPublication = ok && host.SessionID == participant.sessionID
|
||||
}
|
||||
if !ownsChild && !ownsParent && !ownsPublication {
|
||||
if !ownsEndpoint && !ownsPublication {
|
||||
return
|
||||
}
|
||||
if connectionID != "" && connectionID != current.ConnectionID {
|
||||
|
||||
@@ -21,6 +21,7 @@ package signal
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
@@ -1515,6 +1516,73 @@ func TestRouterKeepsExactTransportConnectedDirectCandidatePastBoundary(t *testin
|
||||
})
|
||||
}
|
||||
|
||||
func TestRouterAcceptsExactParentProgressWithoutGrantingReadiness(t *testing.T) {
|
||||
for _, relayParent := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("relay=%v", relayParent), func(t *testing.T) {
|
||||
h := newRouterHarness(t, routerHarnessOptions{capacity: 2, withSfu: true, prepareTimeoutMs: 300, fakeTimers: true})
|
||||
created := h.createRoom()
|
||||
host := h.connectHost(created, nil, "", "")
|
||||
h.locked(func() { h.doComplete(host) })
|
||||
parents := map[string]authenticatedRouteParticipant{host.peerID: host}
|
||||
if relayParent {
|
||||
for _, name := range []string{"root-a", "root-b"} {
|
||||
root := h.connectViewer(created, name)
|
||||
h.locked(func() { h.doComplete(root) })
|
||||
prepared := h.waitPreparedTransport(root.sessionID, "direct")
|
||||
h.routeReady(root, int64(prepared.Revision))
|
||||
h.locked(func() { h.doRelay(root, 2) })
|
||||
parents[root.peerID] = root
|
||||
}
|
||||
}
|
||||
viewer := h.connectViewer(created, "delayed-feedback")
|
||||
h.locked(func() { h.doComplete(viewer) })
|
||||
direct := h.waitPreparedTransport(viewer.sessionID, "direct")
|
||||
h.locked(func() {
|
||||
rm, _ := h.router.rooms.Get(created.RoomID)
|
||||
before := rm.controller.Operation()
|
||||
parent := parents[before.Current.Tuple.ParentPeerID]
|
||||
if (parent.role == protocol.RoleViewer) != relayParent {
|
||||
t.Fatal("unexpected preparing parent")
|
||||
}
|
||||
message := protocol.RouteTransportConnectedMessage{Type: "route-transport-connected", Revision: direct.Revision, ConnectionID: direct.Candidate.ConnectionID}
|
||||
stale := parent
|
||||
stale.sessionID = "retired-session"
|
||||
h.router.handleRouteTransportConnected(stale, message)
|
||||
foreign := parent
|
||||
foreign.peerID = "unrelated-peer"
|
||||
h.router.handleRouteTransportConnected(foreign, message)
|
||||
wrong := message
|
||||
wrong.ConnectionID = "retired-connection"
|
||||
h.router.handleRouteTransportConnected(parent, wrong)
|
||||
wrong = message
|
||||
wrong.Revision++
|
||||
h.router.handleRouteTransportConnected(parent, wrong)
|
||||
if rm.controller.Operation().WakeAtMs != before.WakeAtMs {
|
||||
t.Fatal("unrelated progress changed candidate timing")
|
||||
}
|
||||
h.router.handleRouteTransportConnected(parent, message)
|
||||
after := rm.controller.Operation()
|
||||
if after == nil || after.DeadlineAtMs != before.DeadlineAtMs || after.WakeAtMs != before.DeadlineAtMs {
|
||||
t.Fatal("parent progress did not retain the original total deadline")
|
||||
}
|
||||
h.doReady(parent, int64(direct.Revision))
|
||||
if rm.controller.Operation() == nil {
|
||||
t.Fatal("parent committed a route without Viewer readiness")
|
||||
}
|
||||
})
|
||||
h.clock.advance(150)
|
||||
if prepared, _ := h.preparedFor(viewer.sessionID); !reflect.DeepEqual(prepared, direct) {
|
||||
t.Fatal("connected candidate was replaced before delayed Viewer readiness")
|
||||
}
|
||||
h.routeReady(viewer, int64(direct.Revision))
|
||||
h.waitFor("the active peer edge", func() bool {
|
||||
active, ok := h.activeAfter(viewer.sessionID, int64(direct.Revision)-1)
|
||||
return ok && active.Assignment.Upstream.Kind == "peer"
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterUsesDirectBoundaryToBootstrapSfuWhenEveryHostSlotIsFull(t *testing.T) {
|
||||
h := newRouterHarness(t, routerHarnessOptions{capacity: 2, withSfu: true, prepareTimeoutMs: 300, fakeTimers: true})
|
||||
created := h.createRoom()
|
||||
|
||||
Reference in new issue
Block a user