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:
AAA authored and GitHub committed 2026-09-17 15:18:08 +08:00
1 parent 08da234b8f
commit 083c9273a7
10 files changed
+202 -30

No files matched your search

+20 -17
View File
@@ -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 {
+68
View File
@@ -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()