fix(connection): retry startup and clarify sharing status

Public invitations could stop after an early tunnel-process exit, and connection progress and Viewer status did not consistently describe the current attempt or Host state. This maintenance release adds bounded startup retries, aligns enhanced NAT retry feedback across entry paths, and fixes camera-preview and room-entry layout.

The existing route owner and published contracts remain in place. Four-attempt progress is advertised and opted into; older readers retain supported messages. Interaction/chat experiments are outside this PR.

Validation: npm run check (74 files, 1072 tests and production build), npm run check:go (including Windows capture compile), documentation/repository hygiene, 10 production Viewer-entry discovery cases, and a real public-link gate with remote HTTP/WebSocket access and shutdown cleanup all passed. Existing device/network evidence limits remain documented.

## Release notes

### 中文

这是一次维护更新,重点改善公网邀请启动、连接重试反馈和分享状态提示,并修正两处界面布局问题。

- 公网邀请服务在连接就绪前意外退出时,会自动重试,最多重试 3 次;所有尝试仍共用 30 秒启动预算。
- 增强穿透连接在首次尝试失败后最多重试 3 次,重试进度显示为 1/3、2/3、3/3。邀请链接和房间号加入都支持新版进度提示。
- 修正观众状态提示:房主离线时不再误报“没有可用的媒体线路”,服务器连接恢复与媒体恢复也会分别呈现。
- 修正电视内房间号输入界面的居中位置,以及摄像头选择预览被裁切的问题。
- 尚未确认来源声音状态时,不再提前提示“未分享来源声音”。

本次保持现有版本兼容;新版重试进度需要页面与服务端均支持。额外重试不能保证所有网络都能直连。

### English

This maintenance update improves public invitation startup, connection retry feedback, and sharing status messages, with two layout fixes.

- If the public invitation service exits before connecting, Piik retries up to three times within the existing shared 30-second startup budget.
- Enhanced NAT traversal now allows up to three retries after the initial attempt, shown as 1/3, 2/3, and 3/3. Both invitation links and room-code entry support the updated progress display.
- Viewer status now distinguishes an offline host from an unavailable media route, and reports server reconnection separately from media recovery.
- Fixed the alignment of room-code entry inside the TV and cropping in camera-selection previews.
- The missing-source-audio notice now waits until the source's audio availability is known.

Existing version compatibility is preserved. Updated retry progress requires support from both the page and server. Additional retries cannot guarantee a direct connection on every network.
This commit is contained in:
AAA authored and GitHub committed 2026-09-23 22:27:56 +08:00
1 parent 6cc6863294
commit 01821ae7ac
41 files changed
+671 -175

No files matched your search

+3 -3
View File
@@ -352,7 +352,7 @@ func TestCapabilitiesReportServicesDisabledByDefault(t *testing.T) {
server := start(t, Options{Config: testConfig(t)})
server.do(http.MethodGet, "/api/capabilities").
expect(http.StatusOK, `{"sfu":false,"natPrediction":false}`)
expect(http.StatusOK, `{"connectionAttemptProgress4":true,"sfu":false,"natPrediction":false}`)
}
func TestCapabilitiesReportOptionalNATPredictionWithoutExposingConfiguration(t *testing.T) {
@@ -363,7 +363,7 @@ func TestCapabilitiesReportOptionalNATPredictionWithoutExposingConfiguration(t *
server := start(t, Options{Config: configuration})
server.do(http.MethodGet, "/api/capabilities").
expect(http.StatusOK, `{"sfu":false,"natPrediction":true}`).
expect(http.StatusOK, `{"connectionAttemptProgress4":true,"sfu":false,"natPrediction":true}`).
expectHeader("Cache-Control", "no-store").
expectHeader("X-Content-Type-Options", "nosniff")
@@ -406,7 +406,7 @@ func TestSFUListenerFollowsApplicationCloseAndEnd(t *testing.T) {
t.Fatal("missing runtime owner")
}
server.do(http.MethodGet, "/api/capabilities").
expect(http.StatusOK, `{"sfu":true,"natPrediction":false}`)
expect(http.StatusOK, `{"connectionAttemptProgress4":true,"sfu":true,"natPrediction":false}`)
var err error
if ending {
err = server.End(context.Background())
+3 -2
View File
@@ -76,8 +76,9 @@ func (s *Server) route(
return
}
sendJSON(writer, http.StatusOK, protocol.RuntimeCapabilities{
Sfu: s.config.SFU != nil,
NatPrediction: s.config.NATPredictionEnabled,
ConnectionAttemptProgress4: true,
Sfu: s.config.SFU != nil,
NatPrediction: s.config.NATPredictionEnabled,
})
return
}
+31 -23
View File
@@ -13,31 +13,33 @@ type ClientMessage interface {
// AuthenticateHostMessage is the host member of authenticateMessageSchema.
type AuthenticateHostMessage struct {
Type string `json:"type"`
Protocol string `json:"protocol"`
RoomID string `json:"roomId"`
Role Role `json:"role"`
Token string `json:"token"`
ClientID string `json:"clientId"`
ShareGeneration string `json:"shareGeneration,omitempty"`
SharingPaused *bool `json:"sharingPaused,omitempty"`
QualitySettings *QualitySettings `json:"qualitySettings,omitempty"`
RoutePolicy RoutePolicy `json:"routePolicy"`
ViewerPresence bool `json:"viewerPresence,omitempty"`
DisplayName *DisplayName `json:"displayName,omitempty"`
Type string `json:"type"`
Protocol string `json:"protocol"`
RoomID string `json:"roomId"`
Role Role `json:"role"`
Token string `json:"token"`
ClientID string `json:"clientId"`
ShareGeneration string `json:"shareGeneration,omitempty"`
SharingPaused *bool `json:"sharingPaused,omitempty"`
QualitySettings *QualitySettings `json:"qualitySettings,omitempty"`
RoutePolicy RoutePolicy `json:"routePolicy"`
ViewerPresence bool `json:"viewerPresence,omitempty"`
ConnectionAttemptProgress4 bool `json:"connectionAttemptProgress4,omitempty"`
DisplayName *DisplayName `json:"displayName,omitempty"`
}
// AuthenticateViewerMessage is the viewer member of authenticateMessageSchema.
type AuthenticateViewerMessage struct {
Type string `json:"type"`
Protocol string `json:"protocol"`
RoomID string `json:"roomId"`
Role Role `json:"role"`
ClientID string `json:"clientId"`
ViewerGrant string `json:"viewerGrant,omitempty"`
ViewerPassword string `json:"viewerPassword,omitempty"`
DisplayName *DisplayName `json:"displayName,omitempty"`
ViewerPresence bool `json:"viewerPresence,omitempty"`
Type string `json:"type"`
Protocol string `json:"protocol"`
RoomID string `json:"roomId"`
Role Role `json:"role"`
ClientID string `json:"clientId"`
ViewerGrant string `json:"viewerGrant,omitempty"`
ViewerPassword string `json:"viewerPassword,omitempty"`
DisplayName *DisplayName `json:"displayName,omitempty"`
ViewerPresence bool `json:"viewerPresence,omitempty"`
ConnectionAttemptProgress4 bool `json:"connectionAttemptProgress4,omitempty"`
}
// SignalingChallengeMessage is { type: "signaling-challenge", sequence }.
@@ -281,7 +283,7 @@ func decodeAuthenticate(data []byte) (ClientMessage, error) {
return nil, err
}
if err := present.optional("shareGeneration", "sharingPaused", "qualitySettings",
"routePolicy", "viewerPresence", "displayName"); err != nil {
"routePolicy", "viewerPresence", "displayName", "connectionAttemptProgress4"); err != nil {
return nil, err
}
if message.Protocol != SignalingProtocol {
@@ -302,6 +304,9 @@ func decodeAuthenticate(data []byte) (ClientMessage, error) {
if present.has("viewerPresence") && !message.ViewerPresence {
return nil, errors.New("viewerPresence must be true when present")
}
if present.has("connectionAttemptProgress4") && !message.ConnectionAttemptProgress4 {
return nil, errors.New("connectionAttemptProgress4 must be true when present")
}
if !present.has("routePolicy") {
message.RoutePolicy = DefaultRoutePolicy
}
@@ -316,7 +321,7 @@ func decodeAuthenticate(data []byte) (ClientMessage, error) {
return nil, err
}
if err := present.optional(
"viewerGrant", "viewerPassword", "displayName", "viewerPresence"); err != nil {
"viewerGrant", "viewerPassword", "displayName", "viewerPresence", "connectionAttemptProgress4"); err != nil {
return nil, err
}
if message.Protocol != SignalingProtocol {
@@ -337,6 +342,9 @@ func decodeAuthenticate(data []byte) (ClientMessage, error) {
if present.has("viewerPresence") && !message.ViewerPresence {
return nil, errors.New("viewerPresence must be true when present")
}
if present.has("connectionAttemptProgress4") && !message.ConnectionAttemptProgress4 {
return nil, errors.New("connectionAttemptProgress4 must be true when present")
}
return message, nil
}
return nil, fmt.Errorf("unknown authenticate role %q", role)
+19 -1
View File
@@ -14,6 +14,7 @@ func TestRuntimeCapabilities(t *testing.T) {
want RuntimeCapabilities
}{
{`{}`, RuntimeCapabilities{}},
{`{"connectionAttemptProgress4":true}`, RuntimeCapabilities{ConnectionAttemptProgress4: true}},
{`{"sfu":true,"extra":{"enabled":true}}`, RuntimeCapabilities{Sfu: true}},
{`{"natPrediction":true,"SFU":true}`, RuntimeCapabilities{NatPrediction: true}},
{`{"sfu":true,"natPrediction":true}`, RuntimeCapabilities{Sfu: true, NatPrediction: true}},
@@ -27,6 +28,7 @@ func TestRuntimeCapabilities(t *testing.T) {
for _, data := range []string{
`null`, `[]`, `true`, `{"sfu":null}`, `{"sfu":"true"}`,
`{"natPrediction":null}`, `{"natPrediction":1}`,
`{"connectionAttemptProgress4":null}`, `{"connectionAttemptProgress4":4}`,
} {
var got RuntimeCapabilities
if err := json.Unmarshal([]byte(data), &got); err == nil {
@@ -473,7 +475,10 @@ func TestPreparedRouteCandidate(t *testing.T) {
{`{"current":3,"total":3}`, true},
{`{"current":0,"total":3}`, false},
{`{"current":4,"total":3}`, false},
{`{"current":1,"total":4}`, false},
{`{"current":1,"total":4}`, true},
{`{"current":4,"total":4}`, true},
{`{"current":5,"total":4}`, false},
{`{"current":1,"total":5}`, false},
{`{"current":1.5,"total":3}`, false},
} {
assertServerMessage(t, "connectionAttempt "+sample.attempt,
@@ -664,6 +669,19 @@ func TestOptionalAndNullableAreExact(t *testing.T) {
}
// A host authenticate without routePolicy takes DEFAULT_ROUTE_POLICY.
func TestAuthenticateConnectionProgressOptIn(t *testing.T) {
for _, role := range []string{"host", "viewer"} {
base := `"type":"authenticate","protocol":"` + SignalingProtocol + `","roomId":"1234","clientId":"client_12345678","role":"` + role + `"`
if role == "host" {
base += `,"token":"` + repeat("a", 43) + `"`
}
for _, value := range []string{"true", "false", "null", "4", `"true"`} {
assertClientMessage(t, role+value, []byte(`{`+base+`,"connectionAttemptProgress4":`+value+`}`), value == "true")
}
assertClientMessage(t, role+" baseline", []byte(`{`+base+`}`), true)
}
}
func TestAuthenticateAppliesTheRoutePolicyDefault(t *testing.T) {
message, err := DecodeClientMessage([]byte(
`{"type":"authenticate","protocol":"piik-v23","roomId":"1234","role":"host",` +
+8 -6
View File
@@ -168,8 +168,9 @@ func (v *RoutePolicy) UnmarshalJSON(data []byte) error {
// RuntimeCapabilities mirrors runtimeCapabilitiesSchema.
type RuntimeCapabilities struct {
Sfu bool `json:"sfu"`
NatPrediction bool `json:"natPrediction"`
ConnectionAttemptProgress4 bool `json:"connectionAttemptProgress4,omitempty"`
Sfu bool `json:"sfu"`
NatPrediction bool `json:"natPrediction"`
}
// UnmarshalJSON implements json.Unmarshaler.
@@ -182,11 +183,12 @@ func (v *RuntimeCapabilities) UnmarshalJSON(data []byte) error {
if present == nil {
return errors.New("runtime capabilities must be an object")
}
if err := present.optional("sfu", "natPrediction"); err != nil {
if err := present.optional("sfu", "natPrediction", "connectionAttemptProgress4"); err != nil {
return err
}
for key, target := range map[string]*bool{
"sfu": &v.Sfu, "natPrediction": &v.NatPrediction,
"connectionAttemptProgress4": &v.ConnectionAttemptProgress4,
} {
if value, ok := present[key]; ok {
if err := json.Unmarshal(value, target); err != nil {
@@ -482,11 +484,11 @@ func (v *ConnectionAttempt) UnmarshalJSON(data []byte) error {
if err := present.require("current", "total"); err != nil {
return err
}
if !inRangeInt(v.Current, 1, 3) {
if !inRangeInt(v.Current, 1, int64(v.Total)) {
return errors.New("connectionAttempt.current is out of range")
}
if v.Total != 3 {
return errors.New("connectionAttempt.total must be 3")
if v.Total != 3 && v.Total != 4 {
return errors.New("connectionAttempt.total must be 3 or 4")
}
return nil
}
+1 -1
View File
@@ -240,7 +240,7 @@ type HostPublication struct {
}
// ConnectionAttemptProgress is the NAT-prediction attempt counter
// (Total is always 3). TS: ConnectionAttemptProgress.
// (initial attempt plus retries; the controller owns Total).
type ConnectionAttemptProgress struct {
Current int
Total int
+1 -1
View File
@@ -13,7 +13,7 @@ import (
const (
maxDirectHeadStartMs int64 = 5_000
maxNatConnectionAttempts = 3
maxNatConnectionAttempts = 4
sfuOpportunityPrefix = "sfu\x00"
maxSafeInteger int64 = protocol.MaxSafeInteger
qualityEvidenceExpiryMs int64 = protocol.ViewerQualityEvidenceExpiryMs
+7 -8
View File
@@ -2802,17 +2802,16 @@ func TestDoesNotPromoteWhenSynchronousAdmissionCommitFails(t *testing.T) {
// --- describe("bounded NAT candidate acquisition") -------------------------
// TS 5033: runs three real generations on %s, then exhausts until a new session
func TestRunsThreeRealGenerationsThenExhaustsUntilNewSession(t *testing.T) {
func TestRunsFourRealGenerationsThenExhaustsUntilNewSession(t *testing.T) {
for _, failure := range []string{"failed", "timeout"} {
t.Run(failure, func(t *testing.T) {
routes := newController(2, Options{NatPredictionEnabled: true, OperationTimeoutMs: 20_000})
addViewer(routes, A, 0, nil)
nowMs := int64(0)
var previous *CandidateGuard
for attempt := 1; attempt <= 3; attempt++ {
for attempt := 1; attempt <= 4; attempt++ {
operation, prepared, guard := preparePeer(t, routes, nowMs, fmt.Sprintf("nat_%d", attempt))
eqDeep(t, prepared.Current.ConnectionAttempt, &ConnectionAttemptProgress{Current: attempt, Total: 3})
eqDeep(t, prepared.Current.ConnectionAttempt, &ConnectionAttemptProgress{Current: attempt, Total: 4})
eq(t, operation.DeadlineAtMs-nowMs, 20_000)
eq(t, prepared.WakeAtMs, operation.DeadlineAtMs)
if previous != nil {
@@ -2827,7 +2826,7 @@ func TestRunsThreeRealGenerationsThenExhaustsUntilNewSession(t *testing.T) {
nowMs++
result = routes.CandidateFailed(guard, nowMs)
}
if attempt == 3 {
if attempt == 4 {
eqSlice(t, result.FailedPeerIDs, []string{A})
} else {
eqSlice(t, result.FailedPeerIDs, []string{})
@@ -2850,7 +2849,7 @@ func TestGivesWaitingViewersFirstOpportunityBeforeAnotherViewerRetries(t *testin
routes := newController(2, Options{NatPredictionEnabled: true})
addViewer(routes, A, 0, nil)
addViewer(routes, B, 0, nil)
for index, childPeerID := range []string{A, B, A, B, A, B} {
for index, childPeerID := range []string{A, B, A, B, A, B, A, B} {
_, prepared, guard := preparePeer(t, routes, int64(index*2), fmt.Sprintf("fair_%d", index))
eq(t, prepared.ChildPeerID, childPeerID)
eq(t, attemptCurrent(t, prepared), index/2+1)
@@ -2911,11 +2910,11 @@ func TestKeepsSfuForegroundTimingAndSharesAttemptBudgetWithDirectContinuations(t
}, nowMs, nil, CandidateProof{})
nowMs++
}
for index, childPeerID := range []string{A, B, A, B} {
for index, childPeerID := range []string{A, B, A, B, A, B} {
_, retry, guard := preparePeer(t, routes, nowMs, fmt.Sprintf("background_%d", index))
eq(t, retry.ChildPeerID, childPeerID)
eq(t, retry.Reason, DemandDirectConvergence)
eqDeep(t, retry.Current.ConnectionAttempt, &ConnectionAttemptProgress{Current: index/2 + 2, Total: 3})
eqDeep(t, retry.Current.ConnectionAttempt, &ConnectionAttemptProgress{Current: index/2 + 2, Total: 4})
eq(t, retry.WakeAtMs-nowMs, 20_000)
nowMs++
eqSlice(t, routes.CandidateFailed(guard, nowMs).FailedPeerIDs, []string{})
+5 -5
View File
@@ -1305,10 +1305,10 @@ func TestRouterCreatesNatRetriesThroughRealPrepareMessages(t *testing.T) {
})
connectionIDs := map[string]struct{}{}
var staleFailure *protocol.RouteFailedMessage
for current := 1; current <= 3; current++ {
for current := 1; current <= 4; current++ {
prepared := h.waitPreparedMatching(viewer.sessionID, "connection attempt", func(prepared protocol.RouteUpdatePrepareMessage) bool {
return prepared.Candidate.ConnectionAttempt != nil &&
*prepared.Candidate.ConnectionAttempt == protocol.ConnectionAttempt{Current: protocol.Int(current), Total: 3}
*prepared.Candidate.ConnectionAttempt == protocol.ConnectionAttempt{Current: protocol.Int(current), Total: 4}
})
connectionIDs[prepared.Candidate.ConnectionID] = struct{}{}
h.locked(func() {
@@ -1321,7 +1321,7 @@ func TestRouterCreatesNatRetriesThroughRealPrepareMessages(t *testing.T) {
if again, _ := lastPrepare(h.sent[viewer.sessionID], ""); again.Candidate.ConnectionID != prepared.Candidate.ConnectionID {
t.Fatal("a stale failure must not disturb the current candidate")
}
if current == 3 {
if current == 4 {
h.doReady(viewer, int64(prepared.Revision))
if active := h.doMustEdge(created.RoomID, viewer.peerID); active.connectionID != prepared.Candidate.ConnectionID {
t.Fatalf("active edge = %+v", active)
@@ -1334,8 +1334,8 @@ func TestRouterCreatesNatRetriesThroughRealPrepareMessages(t *testing.T) {
}
})
}
if len(connectionIDs) != 3 {
t.Fatalf("distinct connection ids = %d, want 3", len(connectionIDs))
if len(connectionIDs) != 4 {
t.Fatalf("distinct connection ids = %d, want 4", len(connectionIDs))
}
for _, message := range h.allMessages() {
switch typed := message.(type) {
+39 -35
View File
@@ -698,44 +698,47 @@ func (s *Server) handleMessage(sess *session, encoded []byte) {
// fields authenticate() and authenticationErrorCode() read. Empty strings
// and nil pointers are absent keys.
type authRequest struct {
role protocol.Role
roomID string
clientID string
token string
viewerGrant string
viewerPassword string
shareGeneration string
sharingPaused bool
qualitySettings *protocol.QualitySettings
routePolicy protocol.RoutePolicy
viewerPresence bool
displayName *string
role protocol.Role
roomID string
clientID string
token string
viewerGrant string
viewerPassword string
shareGeneration string
sharingPaused bool
qualitySettings *protocol.QualitySettings
routePolicy protocol.RoutePolicy
viewerPresence bool
connectionAttemptProgress4 bool
displayName *string
}
func authRequestOf(message protocol.ClientMessage) (authRequest, bool) {
switch m := message.(type) {
case protocol.AuthenticateHostMessage:
return authRequest{
role: protocol.RoleHost,
roomID: m.RoomID,
clientID: m.ClientID,
token: m.Token,
shareGeneration: m.ShareGeneration,
sharingPaused: m.SharingPaused != nil && *m.SharingPaused,
qualitySettings: m.QualitySettings,
routePolicy: m.RoutePolicy,
viewerPresence: m.ViewerPresence,
displayName: displayNameString(m.DisplayName),
role: protocol.RoleHost,
roomID: m.RoomID,
clientID: m.ClientID,
token: m.Token,
shareGeneration: m.ShareGeneration,
sharingPaused: m.SharingPaused != nil && *m.SharingPaused,
qualitySettings: m.QualitySettings,
routePolicy: m.RoutePolicy,
viewerPresence: m.ViewerPresence,
connectionAttemptProgress4: m.ConnectionAttemptProgress4,
displayName: displayNameString(m.DisplayName),
}, true
case protocol.AuthenticateViewerMessage:
return authRequest{
role: protocol.RoleViewer,
roomID: m.RoomID,
clientID: m.ClientID,
viewerGrant: m.ViewerGrant,
viewerPassword: m.ViewerPassword,
viewerPresence: m.ViewerPresence,
displayName: displayNameString(m.DisplayName),
role: protocol.RoleViewer,
roomID: m.RoomID,
clientID: m.ClientID,
viewerGrant: m.ViewerGrant,
viewerPassword: m.ViewerPassword,
viewerPresence: m.ViewerPresence,
connectionAttemptProgress4: m.ConnectionAttemptProgress4,
displayName: displayNameString(m.DisplayName),
}, true
}
return authRequest{}, false
@@ -877,12 +880,13 @@ func (s *Server) completeAuthentication(sess *session, request authRequest, part
}
displayName := authenticatedDisplayName(request)
authenticated := &authenticatedSession{
roomID: roomID,
role: participant.Role,
peerID: participant.PeerID,
shareGeneration: shareGeneration,
displayName: displayName,
viewerPresence: request.viewerPresence,
roomID: roomID,
role: participant.Role,
peerID: participant.PeerID,
shareGeneration: shareGeneration,
displayName: displayName,
viewerPresence: request.viewerPresence,
connectionAttemptProgress4: request.connectionAttemptProgress4,
}
sess.authenticated = authenticated
if participant.Role == protocol.RoleViewer {
+59 -7
View File
@@ -795,13 +795,14 @@ func isMediaReady(entry presenceEntry) bool {
// ---------------------------------------------------------------------------
type presenceOptions struct {
displayName string
viewerPresence bool
viewerPassword string
codeOnly bool
sharingPaused *bool
qualitySettings map[string]any
routePolicy *protocol.RoutePolicy
connectionAttemptProgress4 bool
displayName string
viewerPresence bool
viewerPassword string
codeOnly bool
sharingPaused *bool
qualitySettings map[string]any
routePolicy *protocol.RoutePolicy
}
// authenticate ports the authenticate() helper. relayCapacity -1 is the TS
@@ -824,6 +825,9 @@ func authenticate(
"role": role,
"clientId": clientID,
}
if presence.connectionAttemptProgress4 {
message["connectionAttemptProgress4"] = true
}
if role == protocol.RoleHost {
message["token"] = rm.HostToken
if shareGeneration != "" {
@@ -1148,6 +1152,54 @@ func TestSignalDeliversOnlySelfHostedAuxiliaryNatStunEndpoints(t *testing.T) {
// lifecycle and restart
// ---------------------------------------------------------------------------
func TestConnectionAttemptProgressIsNegotiatedForEachRecipient(t *testing.T) {
for _, hostSupport := range []bool{false, true} {
for _, viewerSupport := range []bool{false, true} {
t.Run(fmt.Sprintf("host=%t/viewer=%t", hostSupport, viewerSupport), func(t *testing.T) {
h := startHarness(t, harnessOptions{
natPredictionEnabled: true,
stunURLs: []string{"stun:share.example.test:3478"},
})
host, viewer := openClient(t, h), openClient(t, h)
policy := protocol.RoutePolicy{PeerOnly: true, NatPrediction: true}
authenticate(t, host, h.room, protocol.RoleHost, "progress-host", 1, "progress_share_generation_12345678",
presenceOptions{routePolicy: &policy, connectionAttemptProgress4: hostSupport})
authenticate(t, viewer, h.room, protocol.RoleViewer, "progress-viewer", 0, "",
presenceOptions{connectionAttemptProgress4: viewerSupport})
for current := 1; current <= 4; current++ {
hostPrepare, viewerPrepare := nextPreparedRoute(t, host), nextPreparedRoute(t, viewer)
for _, recipient := range []struct {
update routeUpdate
support bool
}{
{hostPrepare, hostSupport}, {viewerPrepare, viewerSupport},
} {
decoded, err := protocol.DecodeServerMessage(recipient.update.raw)
if err != nil {
t.Fatal(err)
}
progress := decoded.(protocol.RouteUpdatePrepareMessage).Candidate.ConnectionAttempt
if recipient.support {
if progress == nil || *progress != (protocol.ConnectionAttempt{Current: protocol.Int(current), Total: 4}) {
t.Fatalf("new reader progress = %+v", progress)
}
} else if progress != nil {
t.Fatalf("old reader received unsupported progress: %+v", progress)
}
}
if hostPrepare.Candidate.ConnectionID != viewerPrepare.Candidate.ConnectionID {
t.Fatal("reader capability changed the actual candidate")
}
viewer.sendJSON(map[string]any{
"type": "route-failed", "phase": "prepare",
"revision": viewerPrepare.Revision, "connectionId": viewerPrepare.Candidate.ConnectionID,
})
}
})
}
}
}
func TestSignalRebuildsRouteWhenViewerReconnectsBeforeHostAfterRestart(t *testing.T) {
path := filepath.Join(t.TempDir(), "rooms.sqlite")
firstDatabase, err := room.NewDatabase(path)
+14 -6
View File
@@ -16,12 +16,13 @@ import (
// authentication replaced between dispatch and use. Empty shareGeneration
// means no share; nil displayName means the participant has no display name.
type authenticatedSession struct {
roomID string
role protocol.Role
peerID string
shareGeneration string
displayName *string
viewerPresence bool
roomID string
role protocol.Role
peerID string
shareGeneration string
displayName *string
viewerPresence bool
connectionAttemptProgress4 bool
}
// outbound is one queue item: a text frame or the close frame that ends the
@@ -257,6 +258,13 @@ func (s *Server) heartbeat() []*session {
// send drops unless open, terminates above the buffer bound,
// and otherwise queues the encoded message.
func (s *Server) send(sess *session, message protocol.ServerMessage) {
// Progress is optional feedback, not route authority. Older pages reject
// totals above three; omit it for that recipient without changing the route.
if prepare, ok := message.(protocol.RouteUpdatePrepareMessage); ok &&
(sess.authenticated == nil || !sess.authenticated.connectionAttemptProgress4) {
prepare.Candidate.ConnectionAttempt = nil
message = prepare
}
encoded, err := protocol.EncodeServerMessage(message)
if err != nil {
// JSON.stringify of a server literal cannot fail; neither can this.