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

@@ -102,7 +102,7 @@ continuous rebalance or general load score is introduced.
A Peer opportunity is fenced by its exact parent and endpoint sessions plus
endpoint-transition strength. With NAT traversal disabled, one failed attempt
consumes it. With traversal enabled, availability acquisition and direct
continuation may create at most three candidate connections for that opportunity.
continuation may create at most four candidate connections for that opportunity.
Only an admitted candidate creation spends an attempt; duplicate failure and
timeout messages cannot spend it again. A preparation rejection that cannot
create a connection remains terminal. A new session or strictly stronger
@@ -168,7 +168,10 @@ remaining Peer budget into the existing round-robin direct continuations, not
another retry ledger. No usable SFU route is interrupted by these attempts.
This permits longer total acquisition while keeping every operation bounded and
requiring no polling or independent retry timer. The prepare message reports
the actual candidate's connection attempt, not inferred UI progress.
the actual candidate's connection attempt, not inferred UI progress. Four-attempt
progress is sent only to recipients opting in after HTTP capability discovery;
older pages receive the same candidate without the optional progress field.
This reader support does not change routing authority or its attempt budget.
The five-second no-transport-progress window advances a silent Peer only while
another bounded candidate or SFU fallback remains. It is a route scheduling
+2 -2
View File
@@ -50,7 +50,7 @@ connection acquisition reuses the route controller's existing opportunity ledger
whether a signaled candidate was ordinary, predicted, or end-of-candidates,
and whether the selected remote foundation was ordinary, predicted, or
unknown.
5. The same per-share gate also enables the three-attempt Peer acquisition
5. The same per-share gate also enables the four-attempt Peer acquisition
budget in [ADR-0005](0005-automatic-hybrid-media-routing.md). Browser and
Native candidates use the ordinary server-owned prepare/rollback lifecycle;
no Viewer-owned ICE-restart loop is added. Local public-link and Hosted
@@ -86,7 +86,7 @@ Negative:
may add connectivity checks for up to eight adjacent UDP ports;
- two endpoint-dependent NATs remain sensitive to candidate scheduling and
intervening mappings, so room-wide coverage is not a success guarantee; and
- enabled acquisition can occupy up to three ordinary operation windows when
- enabled acquisition can occupy up to four ordinary operation windows when
one parent remains, yielding between operations to other waiting Viewers.
Predicted connectivity checks originate from participant ICE agents and target
+19 -6
View File
@@ -15,9 +15,9 @@ its content and design boundary.
| Documentation source | [Reader guides](../guide/README.md), with a separate [developer map](../README.md) |
GitHub Pages serves `piik.tv` behind
Cloudflare; both the GitHub origin and Cloudflare edge use HTTPS. Website DNS is
proxied with strict origin TLS verification and origin cache headers respected.
Demo DNS remains unproxied so its UDP listeners stay reachable.
Cloudflare; both the GitHub origin and Cloudflare edge use HTTPS. Website and
Demo HTTP traffic use proxied DNS with origin cache headers respected.
The separate `stun.piik.tv` hostname stays DNS-only for direct UDP connectivity.
GitHub Pages can serve the complete website: it hosts
[static HTML, CSS and JavaScript](https://docs.github.com/en/pages/getting-started-with-github-pages/what-is-github-pages).
@@ -38,19 +38,32 @@ ALLOWED_ORIGINS=https://demo.piik.tv
SITE_ACCESS_PASSWORD=
ROOM_DATABASE_PATH=/var/lib/piik/rooms.sqlite
MAX_VIEWERS_PER_ROOM=20
STUN_URLS=stun:demo.piik.tv:3478
STUN_URLS=stun:stun.piik.tv:3478
NAT_PREDICTION_ENABLED=true
SFU_UDP_PORT=
```
Point `demo.piik.tv` directly at the US server so UDP reaches its STUN listeners;
allow UDP 3478/3479/3480 and terminate HTTPS/WSS at Caddy. The demo uses the
Point both hostnames at the US server: proxy `demo.piik.tv` through Cloudflare,
but leave `stun.piik.tv` DNS-only. Allow UDP 3478/3479/3480 directly to the server;
Cloudflare's ordinary HTTP proxy does not carry STUN. Caddy terminates origin
HTTPS/WSS. The demo uses the
standard [systemd service](./service-management.md#systemd), with its release
under `/opt/piik/current` and configuration at `/etc/piik/piik.env`. The service
unit provides the SQLite state directory. Room ownership, invitations and private-room
admission still apply. The [configuration reference](../standards/configuration.md)
owns each setting and its bounds.
The Demo's Caddy proxy compresses `/assets/*` with zstd/gzip. Successful
content-hashed JavaScript and CSS responses use
`Cache-Control: public, max-age=31536000, immutable`; other responses default to
`no-store`. Match both the hashed filename and a successful response status so
a missing bundle cannot acquire the long cache lifetime. Keep HTML, room APIs,
health responses and signaling outside static caching. New bundles have new
filenames; do not publish different bytes under an existing hashed URL.
Cloudflare caches those static bundles; HTML and API responses remain uncached,
and WebSocket signaling passes through to the same room authority. Changing the
HTTP delivery path does not enable SFU or proxy peer media.
For updates, follow the [application release boundary](../deployment.md#release-boundary)
and its artifact verification, health checks and recovery rules. Routine application
rollback restores the previous executable while keeping current room data. This
+12 -1
View File
@@ -166,6 +166,15 @@ its HTTP/2 fallback. Piik requests `--protocol auto` and
the existing 30-second startup budget. This affects HTTP/WebSocket control only;
media routes, tunnel identity and ordered shutdown keep their existing owners.
DNS failures, API rejection and networks blocking both transports can still fail.
The preceding Quick Tunnel allocation is a single HTTPS request with a 15-second
dependency timeout; failure exits before transport fallback applies. The App's
pre-readiness exit message and exit code do not identify that cause. Match the
same run's `public-tunnel` dependency diagnostics before classifying a report.
Piik retries a child that exits before readiness, up to four starts with a
one-second pause between starts, all within the same 30-second acquisition
deadline. The previous child, output readers and temporary config retire before
the next start. Cancellation and local setup errors stop acquisition; a ready
tunnel retains its original identity and explicit ordered-close owner.
A 2026-09-21 Windows check used the packaged binary with an isolated UDP sink and
TCP forwarder. The previous arguments timed out at 30 seconds without trying TCP.
@@ -177,7 +186,9 @@ establish regional reachability, uninterrupted connector availability or the
cause of reports without matching diagnostics. Upstream
[Quick Tunnel setup](https://github.com/cloudflare/cloudflared/blob/2026.8.3/cmd/cloudflared/tunnel/quick_tunnel.go)
and [protocol fallback](https://github.com/cloudflare/cloudflared/blob/2026.8.3/supervisor/tunnel.go)
own the dependency behavior; Piik adds no separate retry loop.
own the dependency behavior. An early-exit fixture confirms fourth-start recovery
and bounded exhaustion; these checks do not identify a reporter's API or network
failure or guarantee four starts when earlier attempts consume the deadline.
[HTTP 1033](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-1xxx-errors/error-1033/)
means Cloudflare cannot find a healthy tunnel connector. App readiness requires
+1 -1
View File
@@ -43,7 +43,7 @@ production behavior and remaining acceptance.
unsupported, pending, denied or failed access must leave ordinary capture and
stop controls usable. Other capture sessions can keep the border visible.
Browser indicators remain browser-owned.
- Share and source-switch requests ask the Browser for available audio by
- Display/window/tab capture requests ask the Browser for available audio by
default. Missing audio is reported clearly but does not block video-only
sharing. Native screen capture can include system playback audio and native
window capture can include selected-process audio when the platform exposes it.
+16 -1
View File
@@ -52,7 +52,9 @@ over a reused comic's default; text and pure-visual tooltips retain that context
show a Browser and server, not a failed video path. A media-route failure can
coexist with a healthy server connection, and vice versa.
- Connection notice: signaling recovery may coexist with healthy media. It
cannot convert proved playback into waiting.
cannot convert proved playback into waiting or hide media recovery. The
presentation stage owns the primary media status; a coexisting notice never
selects that status or asserts another connection's health.
- Notices: necessary coexisting issues and operation feedback use the existing
pills near the stage; they are not another primary media status. Raw
PeerConnection errors remain diagnostic observations, not persistent red
@@ -61,6 +63,19 @@ over a reused comic's default; text and pure-visual tooltips retain that context
The operation supplies its outcome tone and matching scene together. Neither
the presence of an illustration nor a default checkmark proves success;
partial application retains a limitation instead of claiming completion.
- Source sound: every capture source with confirmed absent source audio keeps
the same neutral notice and detail, including camera capture. This describes
the captured input, not a failure or the mixed output's sound. The microphone
control owns voice state separately; do not hide facts by source category or
infer missing audio while capture details are unknown.
- Host absence: without a proved current picture, a stopped/unknown Host means
waiting for sharing, and an offline Host means waiting for return. A failed
route does not override those facts. Retain route evidence so reconnection
alone cannot claim media recovery; independent playback/server errors retain
their meaning. An authenticated snapshot with an absent Host does not identify
why the Host is absent: use neutral waiting. Only the explicit sharing-stop
event establishes an intentional stop; do not reconstruct it from a retained
share generation or remembered messages.
- Topology: unready edges use the blue pending treatment. Readiness alone cannot
distinguish initial connection from recovery, so this view does not invent a
warning. Confirmed interruption remains owned by the status projection.
+3 -1
View File
@@ -41,7 +41,9 @@ share generation:
Native uses one media socket for IPv4 and, where available, IPv6
direct connections; its STUN survey and best-effort gateway mapping use IPv4.
Availability and background direct acquisition share a bounded budget of
three actual connection attempts per eligible parent/session opportunity.
four actual connection attempts per eligible parent/session opportunity:
the initial attempt plus three retries. The first has no retry label; subsequent
attempts show retry 1/3, 2/3 and 3/3 on pages supporting that progress format.
Retries use the same serial controller and normal operation deadlines;
waiting Viewers and untried parents receive their opportunities first.
It is not a participant capability, route score, or SFU preference. Candidate
+4 -1
View File
@@ -144,7 +144,10 @@ The microphone comic depicts Host commentary gain;
the Viewer's listening-volume comic remains a different action.
Visual source choices (windows, screens and cameras) share named thumbnail cards,
including their focus, hover and refresh treatment. Microphone selection uses
including their focus, hover and refresh treatment. Thumbnails fit the whole frame
at its original aspect ratio within the card; constrain the media box itself so
portrait and wide sources cannot enlarge its layout or be cropped. Keep both
orientations represented in the source-picker preview. Microphone selection uses
device names in a compact selector. Shared enumeration does not make these two
selection tasks visually interchangeable.
+5 -3
View File
@@ -1,6 +1,6 @@
# Current Status
Last updated: 2026-09-21
Last updated: 2026-09-22
This is the compact execution/deployment index. Product modules own behavior,
[verification status](./verification-status.md) owns unresolved physical limits,
@@ -47,8 +47,10 @@ HTTPS; external LiveKit/coturn services remain disabled. Participants, routes an
media remain process-only. This private service is not a public demonstration.
The separate US [public demo](https://demo.piik.tv) is deployed with open site
entry, persistent room authority and P2P-only media. Caddy owns HTTPS, and the
standard systemd service runs the published Server package. Public DNS,
entry, persistent room authority and P2P-only media. Cloudflare fronts HTTP/WSS
and caches static bundles; STUN uses a separate DNS-only hostname. Caddy owns
origin HTTPS, and the standard systemd service runs the published Server package.
Public DNS,
HTTPS, runtime assets, all three STUN listeners and a bounded browser P2P
sharing/stop check passed; the browser check used synthetic video on one network.
The operator record owns exact identity and postflight evidence.
+12 -8
View File
@@ -1,6 +1,6 @@
# Current TODO Ledger
Last reviewed: 2026-09-22
Last reviewed: 2026-09-23
Only **Now** is executable. Product modules own behavior; Git/PRs own completed
history. A parked idea is not implementation authority.
@@ -20,9 +20,9 @@ history. A parked idea is not implementation authority.
- [ ] **Refine the isolated room-interaction prototype.** Review chat, optional
danmaku and participant-targeted reactions for usability, placement and intended
cross-view synchronization. Preserve room authorization and media-route owners.
Accept its combined layout: sharing settings with the picture, wide-screen chat
beside participants, stacked chat on narrow screens and room actions before
diagnostics. Check its theater chat entry and participant menu on real devices;
Accept its combined layout: sharing settings with the picture, floating chat
without page reflow and room actions before diagnostics. Check narrow-screen
keyboard avoidance, its theater entry and participant menu on real devices;
keep chat available independently of sharing.
Retain current room creation: the first share creates a room, an existing room
can resume, and stopping media keeps the opted-in interaction session. Do not
@@ -31,7 +31,9 @@ history. A parked idea is not implementation authority.
## Awaiting Device Or Reporter Evidence
- [ ] **No available media route.** Obtain matched Host/Viewer diagnostics from
- [ ] **No available media route.** A decline is reported since around v1.4;
establish comparable attempts on the same endpoints/network before attributing
a version regression. Obtain matched Host/Viewer diagnostics from
a failed attempt, with version and mode. Inspect candidate exchange, selected
paths, first-frame admission and route rejection separately. The Browser
candidate-queue rejection defect is repaired and regression-tested; current
@@ -49,7 +51,8 @@ history. A parked idea is not implementation authority.
[#396 timeout](https://github.com/TNTcraftHIM/Piik/issues/396#issuecomment-5691465700)
with the updated App. A controlled UDP-blocked/TCP-available reproduction confirms
a dependency configuration defect; the existing tunnel now uses cloudflared's
bounded protocol fallback. [Runtime evidence](./research/cross-platform-client-runtime.md#public-invitation-startup)
bounded protocol fallback and pre-readiness retries within one startup deadline.
[Runtime evidence](./research/cross-platform-client-runtime.md#public-invitation-startup)
separates this fix from DNS, provider and remote access failures. The reporting
environments still need version and diagnostic evidence before assigning a cause.
Demo access failures and public-link HTTP 1033 also remain unconfirmed: current
@@ -164,8 +167,9 @@ not establish better connection success or speed; this note adds no retry policy
and retain Gitee; do not add a self-hosted mirror. Chrome still blocks the
Gitee attachment when Referer is removed. Reopen for new evidence or a
provider review; the warning remains unresolved.
10. **4K in advanced sharing settings.** Deferred until after the current capture
maintenance phase. Add 3840x2160 without changing the default or recommended
10. **4K in advanced sharing settings.** Hold the isolated candidate for joint
review with the next room-interaction release; its compatibility and release
boundary are not yet accepted. Add 3840x2160 without changing the default or recommended
presets. Extend the existing capture, decode and relay bounds together; verify
H.264 level negotiation for 4K at 60 fps. Current strict quality messages reject
`2160p`, so preserve published Browser/App/Server compatibility through explicit
+61 -8
View File
@@ -24,15 +24,19 @@ import (
)
const (
startupTimeout = 30 * time.Second
shutdownTimeout = 5 * time.Second
maxLogLineBytes = 64 * 1024
startupTimeout = 30 * time.Second
startupAttempts = 4
startupRetryDelay = time.Second
shutdownTimeout = 5 * time.Second
maxLogLineBytes = 64 * 1024
)
var quickOriginPattern = regexp.MustCompile(
`https://[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.trycloudflare\.com`,
)
var errStartupExited = errors.New("public invitation service exited before connecting; reopen Piik to try again")
type Process struct {
origin string
cancel context.CancelFunc
@@ -72,6 +76,48 @@ func Start(
if err := validateLocalOrigin(localOrigin); err != nil {
return nil, err
}
startup, cancel := context.WithTimeout(parent, startupTimeout)
defer cancel()
var err error
for attempt := 1; attempt <= startupAttempts; attempt++ {
var process *Process
process, err = startAttempt(startup, executable, localOrigin)
if err == nil {
return process, nil
}
if startup.Err() != nil || !errors.Is(err, errStartupExited) || attempt == startupAttempts {
break
}
// Only a retired, pre-ready child is retried. Ready tunnel recovery
// remains cloudflared's job; all startup attempts share one deadline.
slog.Debug("public-tunnel", "event", "startup-retry", "attempt", attempt+1, "total", startupAttempts)
timer := time.NewTimer(startupRetryDelay)
select {
case <-startup.Done():
timer.Stop()
case <-timer.C:
}
if startup.Err() != nil {
// No child is live during the retry pause; stopping here is clean.
err = startup.Err()
break
}
}
if startup.Err() != nil {
if err != startup.Err() {
err = errors.Join(startup.Err(), err)
}
if parent.Err() == nil {
err = errors.Join(errors.New("public invitation service did not connect within 30 seconds; check your Internet connection and reopen Piik to try again"), err)
}
}
return nil, err
}
func startAttempt(parent context.Context, executable, localOrigin string) (*Process, error) {
if err := parent.Err(); err != nil {
return nil, err
}
configPath, err := writeTemporaryConfig()
if err != nil {
return nil, err
@@ -167,8 +213,6 @@ func Start(
close(process.done)
}()
timer := time.NewTimer(startupTimeout)
defer timer.Stop()
registered := false
for process.origin == "" || !registered {
select {
@@ -177,9 +221,7 @@ func Start(
registered = true
case <-process.done:
cancel()
return nil, errors.Join(errors.New("public invitation service exited before connecting; reopen Piik to try again"), process.Err())
case <-timer.C:
return nil, errors.Join(errors.New("public invitation service did not connect within 30 seconds; check your Internet connection and reopen Piik to try again"), process.Close())
return nil, errors.Join(errStartupExited, process.Err())
case <-parent.Done():
if err := process.Close(); err != nil {
return nil, errors.Join(parent.Err(), err)
@@ -187,6 +229,17 @@ func Start(
return nil, parent.Err()
}
}
select {
case <-parent.Done():
if err := process.Close(); err != nil {
return nil, errors.Join(parent.Err(), err)
}
return nil, parent.Err()
case <-process.done:
cancel()
return nil, errors.Join(errStartupExited, process.Err())
default:
}
return process, nil
}
+136
View File
@@ -10,6 +10,7 @@ import (
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"testing"
@@ -18,6 +19,38 @@ import (
func TestMain(tests *testing.M) {
if mode := os.Getenv("PIIK_TUNNEL_FIXTURE"); mode != "" {
if record := os.Getenv("PIIK_TUNNEL_FIXTURE_ATTEMPTS"); record != "" {
previous, _ := os.ReadFile(record)
var paths []string
if len(previous) > 0 {
paths = strings.Split(strings.TrimSpace(string(previous)), "\n")
}
for _, path := range paths {
if _, err := os.Stat(strings.TrimSpace(path)); !os.IsNotExist(err) {
fmt.Fprintln(os.Stderr, "previous attempt was not retired")
os.Exit(9)
}
}
configIndex := slices.Index(os.Args, "--config")
if configIndex < 0 || configIndex+1 >= len(os.Args) {
os.Exit(9)
}
file, err := os.OpenFile(record, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
os.Exit(9)
}
_, writeErr := fmt.Fprintln(file, os.Args[configIndex+1])
closeErr := file.Close()
if writeErr != nil || closeErr != nil {
os.Exit(9)
}
failures, _ := strconv.Atoi(os.Getenv("PIIK_TUNNEL_FIXTURE_FAILURES"))
if len(paths) < failures {
fmt.Fprintln(os.Stderr, "fixture allocation unavailable")
code, _ := strconv.Atoi(os.Getenv("PIIK_TUNNEL_FIXTURE_EXIT_CODE"))
os.Exit(code)
}
}
if mode == "tcp-only" {
// Model the pinned Quick Tunnel's opt-in protocol fallback. Without
// both flags, an unavailable UDP edge consumes Piik's startup budget.
@@ -49,6 +82,109 @@ func TestMain(tests *testing.M) {
os.Exit(tests.Run())
}
func TestStartupRetriesOnlyRetiredPreReadyChildren(t *testing.T) {
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
for _, sample := range []struct {
name string
failures, exitCode int
ready bool
}{
{"fourth attempt succeeds", 3, 7, true},
{"four failures stop", 9, 7, false},
{"clean exit is not readiness", 9, 0, false},
} {
t.Run(sample.name, func(t *testing.T) {
record := filepath.Join(t.TempDir(), "attempts")
t.Setenv("PIIK_TUNNEL_FIXTURE", "ready")
t.Setenv("PIIK_TUNNEL_FIXTURE_ATTEMPTS", record)
t.Setenv("PIIK_TUNNEL_FIXTURE_FAILURES", strconv.Itoa(sample.failures))
t.Setenv("PIIK_TUNNEL_FIXTURE_EXIT_CODE", strconv.Itoa(sample.exitCode))
process, err := Start(t.Context(), executable, "http://127.0.0.1:8787")
if sample.ready {
if err != nil || process == nil {
t.Fatalf("fourth attempt: %v, %v", process, err)
}
if err := process.Close(); err != nil {
t.Fatal(err)
}
} else {
if process != nil || !errors.Is(err, errStartupExited) {
t.Fatalf("exhausted startup: %v, %v", process, err)
}
if sample.exitCode != 0 {
var failure *exec.ExitError
if !errors.As(err, &failure) || failure.ExitCode() != sample.exitCode {
t.Fatalf("latest dependency exit was lost: %v", err)
}
}
}
assertRetiredAttempts(t, record, 4)
})
}
}
func TestStartupRetriesShareCancellationAndDeadline(t *testing.T) {
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
for _, sample := range []struct {
name string
timeout time.Duration
attempts int
cancelOnly bool
}{
{"during retry delay", 500 * time.Millisecond, 1, false},
{"during second child", 2 * time.Second, 2, false},
{"clean cancellation during retry delay", 500 * time.Millisecond, 1, true},
} {
t.Run(sample.name, func(t *testing.T) {
record := filepath.Join(t.TempDir(), "attempts")
t.Setenv("PIIK_TUNNEL_FIXTURE", "starting")
t.Setenv("PIIK_TUNNEL_FIXTURE_ATTEMPTS", record)
t.Setenv("PIIK_TUNNEL_FIXTURE_FAILURES", "1")
t.Setenv("PIIK_TUNNEL_FIXTURE_EXIT_CODE", "7")
ctx, cancel := context.WithTimeout(t.Context(), sample.timeout)
defer cancel()
if sample.cancelOnly {
ctx, cancel = context.WithCancel(t.Context())
defer cancel()
defer time.AfterFunc(sample.timeout, cancel).Stop()
}
started := time.Now()
process, err := Start(ctx, executable, "http://127.0.0.1:8787")
if process != nil || (!sample.cancelOnly && !errors.Is(err, context.DeadlineExceeded)) ||
(sample.cancelOnly && err != context.Canceled) {
t.Fatalf("startup deadline: %v, %v", process, err)
}
if time.Since(started) > sample.timeout+2*time.Second {
t.Fatal("retry renewed the startup deadline")
}
assertRetiredAttempts(t, record, sample.attempts)
})
}
}
func assertRetiredAttempts(t *testing.T, record string, count int) {
t.Helper()
payload, err := os.ReadFile(record)
if err != nil {
t.Fatal(err)
}
paths := strings.Split(strings.TrimSpace(string(payload)), "\n")
if len(paths) != count {
t.Fatalf("started %d children, want %d", len(paths), count)
}
for _, path := range paths {
if _, err := os.Stat(strings.TrimSpace(path)); !os.IsNotExist(err) {
t.Fatalf("attempt config was not removed: %v", err)
}
}
}
func TestStartupEnablesCloudflaredProtocolFallback(t *testing.T) {
executable, err := os.Executable()
if err != nil {
+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.
+23 -7
View File
@@ -11,6 +11,7 @@ import {
ApiError,
authenticateSiteAccess,
getRuntimeCapabilities,
getOptionalRuntimeCapabilities,
getSiteAccess,
type SiteAccessStatus,
} from "./lib/api";
@@ -163,10 +164,9 @@ function AppRoute() {
}
if (appRoute.kind === "viewer" && viewerRoute) {
return viewerRoute.viewerGrant ? (
<ViewerPage
<ViewerEntry
key={viewerRoute.viewerGrant}
{...viewerRoute}
launchedByClient={clientLaunchBootstrap?.launchedByClient}
route={viewerRoute}
/>
) : (
<SiteAccessGate key={viewerRoute.invalidGrant ? "invalid" : "code"}
@@ -202,6 +202,24 @@ function RouteLoader() {
);
}
// Invitation and room-code entry share discovery without making an invitation
// pass through site authentication. Resolve before mounting media: changing the
// negotiated flag later would restart an already healthy Viewer session.
function ViewerEntry({ route }: { route: ViewerRoute }) {
const [progressSupported, setProgressSupported] = useState<boolean | null>(null);
useEffect(() => {
let active = true;
void getOptionalRuntimeCapabilities().then(capabilities => {
if (active) setProgressSupported(capabilities?.connectionAttemptProgress4 === true);
});
return () => { active = false; };
}, []);
return progressSupported === null ? <RouteLoader /> : (
<ViewerPage {...route} connectionAttemptProgress4={progressSupported}
launchedByClient={clientLaunchBootstrap?.launchedByClient} />
);
}
function StaticRoute({
comic,
titleKey,
@@ -371,10 +389,7 @@ function SiteAccessGate({
if (access.kind === "ready") {
if (surface === "viewer" && viewerRoute) {
return (
<ViewerPage
{...viewerRoute}
launchedByClient={clientLaunchBootstrap?.launchedByClient}
/>
<ViewerEntry route={viewerRoute} />
);
}
if (surface === "join") {
@@ -385,6 +400,7 @@ function SiteAccessGate({
launchedByClient={clientLaunchBootstrap?.launchedByClient}
sfuAvailable={capabilities?.sfu === true}
natPredictionAvailable={capabilities?.natPrediction === true}
connectionAttemptProgress4={capabilities?.connectionAttemptProgress4 === true}
onAuthorizationRequired={() =>
setAccess({ kind: "required", error: t("gate.expired") })
}
+16 -1
View File
@@ -68,9 +68,10 @@ export async function getSiteAccess(): Promise<SiteAccessStatus> {
return parseSiteAccessStatus(body);
}
export async function getRuntimeCapabilities(): Promise<RuntimeCapabilities> {
export async function getRuntimeCapabilities(signal?: AbortSignal): Promise<RuntimeCapabilities> {
const response = await fetch("/api/capabilities", {
headers: { Accept: "application/json" },
signal,
});
const body = await responseBody(response);
if (!response.ok) {
@@ -86,6 +87,20 @@ export async function getRuntimeCapabilities(): Promise<RuntimeCapabilities> {
return parsed.data;
}
// Viewer progress is optional feedback: unavailable capability discovery must
// not prevent viewing. Host policy discovery remains required.
export async function getOptionalRuntimeCapabilities(): Promise<RuntimeCapabilities | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2_000);
try {
return await getRuntimeCapabilities(controller.signal);
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
export async function authenticateSiteAccess(
password: string,
): Promise<SiteAccessStatus> {
+1 -2
View File
@@ -367,8 +367,7 @@ export const en: Record<CopyKey, string> = {
"viewer.msg.stale": "Page updated; refresh to continue",
"viewer.msg.sessionReplaced": "This page's session was taken over by another tab",
"viewer.msg.signalTerminated": "Connection terminated; refresh to continue",
"viewer.notice.signalRecovering": "Server reconnecting; video keeps playing",
"viewer.notice.mediaRecovering": "Media connection recovering",
"viewer.notice.signalRecovering": "Server reconnecting",
"viewer.notice.hostOffline": "Host connection lost; picture may freeze",
"viewer.hint.denied": "Use the host's invite link, or try the room password.",
"viewer.hint.notFound": "Check that the room code belongs to {site}, or open the host’s invite link.",
+1 -2
View File
@@ -367,8 +367,7 @@ export const zh = {
"viewer.msg.stale": "页面版本已更新,请刷新后重试",
"viewer.msg.sessionReplaced": "此页面的会话已被另一个标签页接管",
"viewer.msg.signalTerminated": "连接已终止,请刷新后重试",
"viewer.notice.signalRecovering": "服务器连接正在恢复,画面仍在播放",
"viewer.notice.mediaRecovering": "媒体连接正在恢复",
"viewer.notice.signalRecovering": "服务器连接正在恢复",
"viewer.notice.hostOffline": "房主连接已中断,画面可能停住",
"viewer.hint.denied": "请使用房主发来的邀请链接,或输入房间密码。",
"viewer.hint.notFound": "请确认这是 {site} 的房间号,或打开房主的邀请链接。",
+5 -5
View File
@@ -481,8 +481,8 @@ export function deriveViewerPresentation(
state.connection === "reconnecting" || state.connection === "failed";
return {
...presentation(
signalRecovering || mediaRecovering ? "recovering" : "playing",
signalRecovering || mediaRecovering ? "viewer.msg.recovering" : "viewer.msg.playing",
mediaRecovering ? "recovering" : "playing",
mediaRecovering ? "viewer.msg.recovering" : "viewer.msg.playing",
"none",
state,
),
@@ -490,14 +490,14 @@ export function deriveViewerPresentation(
? "viewer.notice.hostOffline"
: signalRecovering
? "viewer.notice.signalRecovering"
: mediaRecovering
? "viewer.notice.mediaRecovering"
: null,
failureCode: state.host === "offline" ? "HOST_OFFLINE" : null,
};
}
if (state.routeStatus?.state === "failed") {
// A failed route explains missing media only while the Host can publish.
// Keep the fact: Host reconnection alone does not repair that route.
if (state.host === "online" && state.routeStatus?.state === "failed") {
return presentation(
"route-failed",
"viewer.msg.routeFailed",
+1 -1
View File
@@ -28,7 +28,7 @@ const SOURCES = { kind: "ready", processAudio: true, systemAudio: true, captureB
] } satisfies NativeSourceList;
const previewSource = async () => POSTER;
const previewCameras: typeof loadCameraPreviews = async (_signal, publish) => publish([
{ id: "built-in", label: "Built-in camera", preview: `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180"><rect width="320" height="180" fill="#dce9e5"/><rect x="216" y="16" width="82" height="102" rx="7" fill="#fbfcf3"/><path d="M257 16v102M216 66h82" stroke="#b2cbc2" stroke-width="4"/><path d="M106 147q0-62 54-62t54 62" fill="#449986"/><circle cx="160" cy="57" r="29" fill="#449986"/><path d="M151 55v4m18-4v4" stroke="#203c3a" stroke-width="5" stroke-linecap="round"/><path d="M0 151h320v29H0" fill="#d8b898"/><rect x="232" y="131" width="21" height="23" rx="5" fill="#fff7e9"/></svg>')}` },
{ id: "built-in", label: "Built-in camera", preview: `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 320"><rect width="180" height="320" fill="#dce9e5"/><rect x="106" y="26" width="58" height="102" rx="7" fill="#fbfcf3"/><path d="M135 26v102M106 76h58" stroke="#b2cbc2" stroke-width="4"/><path d="M36 255q0-100 54-100t54 100" fill="#449986"/><circle cx="90" cy="124" r="29" fill="#449986"/><path d="M81 122v4m18-4v4" stroke="#203c3a" stroke-width="5" stroke-linecap="round"/><path d="M0 259h180v61H0" fill="#d8b898"/><rect x="142" y="241" width="21" height="23" rx="5" fill="#fff7e9"/></svg>')}` },
{ id: "usb", label: "USB Camera", preview: `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180"><rect width="320" height="180" fill="#e2c5a8"/><path d="M0 25h320M0 157h320" stroke="#cda987" stroke-width="2"/><rect x="53" y="27" width="163" height="124" rx="5" fill="#fffaf0" transform="rotate(-8 135 89)"/><path d="m97 112 30-35 23 25 18-12 18 19" fill="none" stroke="#689b8a" stroke-width="5" stroke-linejoin="round"/><circle cx="166" cy="57" r="11" fill="#ebbd63"/><path d="m245 64 8 77" stroke="#477b73" stroke-width="7" stroke-linecap="round"/><circle cx="266" cy="35" r="19" fill="#f9f5df"/><circle cx="266" cy="35" r="12" fill="#8c6651"/></svg>')}` },
]);
const previewMicrophones = async () => [{ id: "headset", label: "USB Headset" }, { id: "desk", label: "Desk Microphone" }];
+4 -1
View File
@@ -332,6 +332,7 @@ function hostTerminationKey(reason: SignalingTerminationReason): CopyKey {
interface HostPageProps {
sfuAvailable?: boolean;
natPredictionAvailable?: boolean;
connectionAttemptProgress4?: boolean;
launchedByClient?: boolean;
onAuthorizationRequired?: () => void;
}
@@ -350,6 +351,7 @@ type ShareSourceSelection =
export function HostPage({
sfuAvailable = false,
natPredictionAvailable = false,
connectionAttemptProgress4 = false,
launchedByClient = false,
onAuthorizationRequired,
}: HostPageProps = {}) {
@@ -2552,6 +2554,7 @@ export function HostPage({
qualitySettings: qualitySettingsRef.current,
routePolicy: routePolicyRef.current,
viewerPresence: true,
...(connectionAttemptProgress4 ? { connectionAttemptProgress4: true } : {}),
displayName: initialDisplayName,
},
{
@@ -3608,7 +3611,7 @@ export function HostPage({
) : null}
</div>
<div className="lr-stage-notices" role="status" aria-live="polite">
{!details?.hasSourceAudio && stream ? (
{details?.hasSourceAudio === false && stream ? (
<Pill icon="speakerOff" label={t("host.noAudio")} comic="no-audio" tone="off" />
) : null}
{hostSfuWarningText ? (
+5 -2
View File
@@ -128,6 +128,7 @@ interface ViewerPageProps {
viewerGrant?: string;
invalidGrant?: boolean;
launchedByClient?: boolean;
connectionAttemptProgress4?: boolean;
}
type ViewerQualityEvidence = Extract<
@@ -167,6 +168,7 @@ export function ViewerPage({
viewerGrant,
invalidGrant = false,
launchedByClient = false,
connectionAttemptProgress4 = false,
}: ViewerPageProps) {
const { lang, t, vis, titleFrames } = useCopy();
const viewerClientId = useMemo(
@@ -594,6 +596,7 @@ export function ViewerPage({
: {}),
displayName: displayNameRef.current,
viewerPresence: true,
...(connectionAttemptProgress4 ? { connectionAttemptProgress4: true } : {}),
},
{
onStatus: (status) => {
@@ -1583,7 +1586,7 @@ export function ViewerPage({
? "paused"
: message.hostOnline
? "online"
: "stopped",
: "unknown",
});
currentQualitySettings = message.qualitySettings;
currentShareGeneration = message.shareGeneration;
@@ -1936,7 +1939,7 @@ export function ViewerPage({
viewerRelaySourceKey = null;
void nativeClientPromise?.then((client) => client?.close());
};
}, [roomId, viewerGrant, viewerPasswordAttempt, launchedByClient]);
}, [roomId, viewerGrant, viewerPasswordAttempt, launchedByClient, connectionAttemptProgress4]);
useEffect(() => {
const video = videoRef.current;
@@ -62,6 +62,16 @@ export const STATUS_SCENARIOS: readonly StatusScenario[] = [
scenario("signal-recovering", "信令断开,画面仍在播放", "控制连接正在恢复;媒体仍有当前画面,标题保留观看状态。", [
...playing, { type: "signal", signal: "reconnecting" },
]),
scenario("signal-media-recovering", "信令与媒体同时恢复", "服务器提示与媒体状态各自描述自己的事实;服务器提醒不把恢复中的媒体显示成正常播放。", [
...playing,
{ type: "signal", signal: "reconnecting" },
{ type: "connection", revision: 1, connection: "reconnecting" },
]),
scenario("host-offline-media-recovering", "房主离线,媒体正在恢复", "保留已证明的画面;电视说明媒体正在恢复,旁边的提醒说明房主连接中断。", [
...playing,
{ type: "host", host: "offline" },
{ type: "connection", revision: 1, connection: "reconnecting" },
]),
scenario("host-offline-playing", "房主离线,仍有当前画面", "房主在线状态和正在接收的媒体不是同一件事;先给提示,不直接覆盖画面。", [
...playing, { type: "host", host: "offline" },
]),
@@ -95,10 +105,28 @@ export const STATUS_SCENARIOS: readonly StatusScenario[] = [
...receiving, { type: "playback-failed", generation: 1, revision: 1 },
]),
scenario("waiting-host", "等待房主开始分享", "房间存在,当前没有共享源。", [{ type: "access", access: "ready" }]),
scenario("host-absence-snapshot", "重连后只确认房主不在线", "快照没有说明房主为何不在线;用中性等待,不推断主动停播或连接中断。", [
...ready, { type: "host", host: "unknown" },
]),
scenario("host-offline", "房主离线,没有画面", "没有当前或留存画面,说明房主离线。", [
...ready, { type: "host", host: "offline" },
]),
scenario("host-offline-route-failed", "房主离线,线路也已失败", "没有可播放画面时先说明房主离线;线路失败证据仍保留,房主回来不代表线路已经恢复。", [
...receiving,
{ type: "host", host: "offline" },
{ type: "route-status", revision: 1, state: "failed" },
]),
scenario("host-offline-retained", "房主离线,只剩最后画面", "线路失败使当前播放证明失效;保留最后画面,以轻量覆盖层提示房主离线。", [
...playing,
{ type: "route-status", revision: 1, state: "failed" },
{ type: "host", host: "offline" },
]),
scenario("stopped", "分享已经停止", "正常结束,不伪装为正在恢复或播放失败。", [...playing, { type: "sharing-stopped" }]),
scenario("stopped-route-failed", "停止分享后收到线路失败", "停止分享清理画面;随后到达的线路失败不把正常停播变成媒体故障。", [
...playing,
{ type: "sharing-stopped" },
{ type: "route-status", revision: 1, state: "failed" },
]),
...denied.map(([failure, name]) => scenario(failure.toLowerCase(), name,
"服务端准入或会话终止事实优先;旧画面和旧质量样本不能重新覆盖这个状态。",
[{ type: "access", access: "denied", failure }])),
+2 -1
View File
@@ -308,7 +308,7 @@ body.lr-theater-open .lr-top { visibility: hidden; }
background: var(--stage);
}
.lr-tv-screen video { display: block; width: 100%; height: 100%; object-fit: contain; background: var(--stage); }
.lr-tv-screen.has-entry { aspect-ratio: 16 / 9; }
.lr-tv-screen.has-entry { display: grid; aspect-ratio: 16 / 9; }
.lr-tv-screen.is-on { animation: lr-crt .5s cubic-bezier(.2, .8, .3, 1) both; }
.lr-tv-chin {
display: flex;
@@ -593,6 +593,7 @@ body.lr-theater-open .lr-top { visibility: hidden; }
background: rgba(255, 255, 255, .1);
}
.lr-source-option-preview {
grid-template: minmax(0, 1fr) / minmax(0, 1fr);
width: 100%;
height: 144px;
overflow: hidden;
+2 -6
View File
@@ -127,14 +127,10 @@ export function deriveViewerStatus(
signal: SignalConnectionState,
route: "p2p" | "sfu" | null = null,
) {
// Control recovery does not interrupt a picture that remains proved.
const mediaRecovering = presentation.noticeKey === "viewer.notice.mediaRecovering";
const playable = presentation.overlay === "none" && !mediaRecovering;
const stage = playable ? "playing" : presentation.stage;
const stage = presentation.stage;
const activity: StatusDescriptor = {
...STAGE_VISUALS[stage],
labelKey: mediaRecovering ? "viewer.notice.mediaRecovering"
: playable ? "viewer.msg.playing" : presentation.messageKey,
labelKey: presentation.messageKey,
...(stage === "receiving" ? { comic: route === "sfu" ? "connecting-sfu" as const : "connecting-p2p" as const } : {}),
};
let notice: (StatusDescriptor & { comic: ComicKind }) | null = null;
+6 -3
View File
@@ -218,6 +218,7 @@ export const DEFAULT_ROUTE_POLICY = {
} as const satisfies RoutePolicy;
export const runtimeCapabilitiesSchema = z.object({
connectionAttemptProgress4: z.boolean().optional(),
sfu: z.boolean().default(false),
natPrediction: z.boolean().default(false),
});
@@ -372,9 +373,9 @@ export const preparedRouteCandidateSchema = z
transport: z.enum(["direct", "sfu"]),
qualityProbe: z.boolean(),
connectionAttempt: z.object({
current: z.number().int().min(1).max(3),
total: z.literal(3),
}).strict().optional(),
current: z.number().int().min(1).max(4),
total: z.union([z.literal(3), z.literal(4)]),
}).strict().refine(({ current, total }) => current <= total).optional(),
})
.strict();
export type PreparedRouteCandidate = z.infer<
@@ -816,6 +817,7 @@ const authenticateMessageSchema = z.discriminatedUnion("role", [
qualitySettings: qualitySettingsSchema.optional(),
routePolicy: routePolicySchema.default(DEFAULT_ROUTE_POLICY),
viewerPresence: z.literal(true).optional(),
connectionAttemptProgress4: z.literal(true).optional(),
displayName: displayNameSchema.optional(),
})
.strict(),
@@ -830,6 +832,7 @@ const authenticateMessageSchema = z.discriminatedUnion("role", [
viewerPassword: viewerPasswordSchema.optional(),
displayName: displayNameSchema.optional(),
viewerPresence: z.literal(true).optional(),
connectionAttemptProgress4: z.literal(true).optional(),
})
.strict(),
]);
+29
View File
@@ -12,6 +12,7 @@ import {
authenticateSiteAccess,
createRoom,
getSiteAccess,
getOptionalRuntimeCapabilities,
replaceOwnedRoom,
} from "../src/client/lib/api.ts";
import {
@@ -63,6 +64,34 @@ afterEach(() => {
setCopy({ lang: "zh", vis: false });
});
describe("optional Viewer capability discovery", () => {
it("returns supported progress and tolerates an unavailable or malformed descriptor", async () => {
for (const [body, status, expected] of [
[{ connectionAttemptProgress4: true }, 200, true],
[{}, 200, undefined],
[{}, 503, undefined],
[{ connectionAttemptProgress4: 4 }, 200, undefined],
] as const) {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status })));
expect((await getOptionalRuntimeCapabilities())?.connectionAttemptProgress4).toBe(expected);
}
});
it("abandons stalled discovery without preventing viewing", async () => {
vi.useFakeTimers();
let observed: AbortSignal | undefined;
vi.stubGlobal("fetch", vi.fn((_url: string, options: RequestInit) => new Promise((_resolve, reject) => {
observed = options.signal!;
observed.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
})));
const discovery = getOptionalRuntimeCapabilities();
await vi.advanceTimersByTimeAsync(2_000);
await expect(discovery).resolves.toBeNull();
expect(observed?.aborted).toBe(true);
expect(vi.getTimerCount()).toBe(0);
});
});
function routeAuthenticated(
shareGeneration: string | null = null,
qualitySettings: QualitySettings = DEFAULT_QUALITY_SETTINGS,
+54 -1
View File
@@ -51,7 +51,7 @@ describe("media status projection", () => {
it.each(["reconnecting", "failed"] as const)("shows media recovery only on the television: %s", (connection) => {
const result = deriveViewerStatus(presentation({ type: "connection", revision: 1, connection }), "connected");
expect(result.television).toMatchObject({
tone: "warn", icon: "refresh", labelKey: "viewer.notice.mediaRecovering",
tone: "warn", icon: "refresh", labelKey: "viewer.msg.recovering",
});
expect(result.notice).toBeNull();
expect(result.overlay).toBeNull();
@@ -68,6 +68,59 @@ describe("media status projection", () => {
expect(result.overlay).toBeNull();
});
it.each(["reconnecting", "failed"] as const)("does not let coexisting notices hide media %s", connection => {
for (const host of ["online", "offline"] as const) {
for (const signal of ["connected", "reconnecting"] as const) {
const state = [
...playingActions,
{ type: "host", host } as const,
{ type: "signal", signal } as const,
{ type: "connection", revision: 1, connection } as const,
].reduce(reduceViewerPresentation, INITIAL_VIEWER_PRESENTATION_STATE);
const status = deriveViewerStatus(deriveViewerPresentation(state), signal);
expect(status.television).toMatchObject({ tone: "warn", comic: "recovering" });
expect(status.overlay).toBeNull();
expect(status.notice?.labelKey ?? null).toBe(host === "offline"
? "viewer.notice.hostOffline" : signal === "reconnecting" ? "viewer.notice.signalRecovering" : null);
const recovered = reduceViewerPresentation(state, { type: "frame-presented", generation: 1, revision: 1, proofEpoch: 0 });
const healthy = deriveViewerStatus(deriveViewerPresentation(recovered), signal);
expect(healthy.television).toMatchObject({ tone: "live", labelKey: "viewer.msg.playing" });
expect(healthy.notice).toEqual(status.notice);
}
}
});
it.each([
["unknown", "viewer.msg.waitingHost", "waiting-for-host", "viewerWaiting"],
["stopped", "viewer.msg.waitingHost", "waiting-for-host", "viewerWaiting"],
["offline", "viewer.msg.hostOffline", "host-offline", "viewerUnavailable"],
["paused", "viewer.msg.hostPaused", "host-paused", "paused"],
] as const)("explains absent Host %s instead of exhausted routes in either event order", (host, labelKey, comic, titleFrameKey) => {
const absence: ViewerPresentationAction = { type: "host", host };
const failure: ViewerPresentationAction = { type: "route-status", revision: 1, state: "failed" };
for (const hadPicture of [false, true]) {
for (const kind of ["p2p", "sfu"] as const) {
for (const order of [[absence, failure], [failure, absence]]) {
const state = [
...(hadPicture ? playingActions : playingActions.slice(0, 4)),
{ type: "route", revision: 1, phase: "active", kind } as const,
...order,
].reduce(reduceViewerPresentation, INITIAL_VIEWER_PRESENTATION_STATE);
const status = deriveViewerStatus(deriveViewerPresentation(state), "connected", kind);
expect(status).toMatchObject({
television: { labelKey, comic },
overlay: { mode: hadPicture ? "status" : "blocking", status: { labelKey, comic } },
titleFrameKey,
});
// Host return cannot by itself turn an exhausted route into a healthy one.
expect(state.routeStatus).toEqual({ revision: 1, state: "failed" });
const returned = reduceViewerPresentation(state, { type: "host", host: "online" });
expect(deriveViewerPresentation(returned).stage).toBe("route-failed");
}
}
}
});
it("keeps Host source activity separate from signaling and ignores a previous share's pause", () => {
const sharing = deriveHostStatus({ phase: "live", paused: false, signal: "reconnecting", roomReady: true });
expect(sharing.television.tone).toBe("live");
+22 -6
View File
@@ -35,6 +35,17 @@ const qualitySettings = {
} as const;
describe("runtime capabilities", () => {
it("keeps progress opt-in optional and strict for either signaling role", () => {
for (const role of ["host", "viewer"] as const) {
const identity = { type: "authenticate", protocol: SIGNALING_PROTOCOL, roomId,
clientId: "client_12345678", role, ...(role === "host" ? { token } : {}) };
expect(clientMessageSchema.safeParse(identity).success).toBe(true);
for (const connectionAttemptProgress4 of [true, false, null, 4, "true"]) {
expect(clientMessageSchema.safeParse({ ...identity, connectionAttemptProgress4 }).success)
.toBe(connectionAttemptProgress4 === true);
}
}
});
it("defaults missing capabilities off and ignores unknown descriptors", () => {
expect(runtimeCapabilitiesSchema.parse({})).toEqual({ sfu: false, natPrediction: false });
expect(runtimeCapabilitiesSchema.parse({ sfu: true, extra: { enabled: true } }))
@@ -43,11 +54,14 @@ describe("runtime capabilities", () => {
.toEqual({ sfu: false, natPrediction: true });
expect(runtimeCapabilitiesSchema.parse({ sfu: true, natPrediction: true }))
.toEqual({ sfu: true, natPrediction: true });
expect(runtimeCapabilitiesSchema.parse({ connectionAttemptProgress4: true }))
.toEqual({ sfu: false, natPrediction: false, connectionAttemptProgress4: true });
});
it("rejects malformed known capabilities and non-object responses", () => {
for (const value of [null, [], true, { sfu: null }, { sfu: "true" },
{ natPrediction: null }, { natPrediction: 1 }]) {
{ natPrediction: null }, { natPrediction: 1 },
{ connectionAttemptProgress4: null }, { connectionAttemptProgress4: 4 }]) {
expect(runtimeCapabilitiesSchema.safeParse(value).success).toBe(false);
}
});
@@ -1492,14 +1506,16 @@ describe("server signaling protocol", () => {
childPeerId: "child_12345678", connectionId: "connection_12345678",
transport: "direct", qualityProbe: false,
};
for (const current of [1, 2, 3]) {
expect(preparedRouteCandidateSchema.safeParse({
...candidate, connectionAttempt: { current, total: 3 },
}).success).toBe(true);
for (const total of [3, 4]) {
for (let current = 1; current <= total; current++) {
expect(preparedRouteCandidateSchema.safeParse({
...candidate, connectionAttempt: { current, total },
}).success).toBe(true);
}
}
for (const connectionAttempt of [
{ current: 0, total: 3 }, { current: 4, total: 3 },
{ current: 1, total: 4 }, { current: 1.5, total: 3 },
{ current: 5, total: 4 }, { current: 1, total: 5 }, { current: 1.5, total: 3 },
]) {
expect(preparedRouteCandidateSchema.safeParse({
...candidate, connectionAttempt,
+3 -3
View File
@@ -336,7 +336,7 @@ describe("Viewer presentation reducer", () => {
{ type: "signal", signal: "reconnecting" },
);
expect(deriveViewerPresentation(state)).toMatchObject({
stage: "recovering",
stage: "playing",
overlay: "none",
noticeKey: "viewer.notice.signalRecovering",
});
@@ -599,8 +599,8 @@ describe("Viewer presentation reducer", () => {
);
expect(deriveViewerPresentation(reconnecting)).toMatchObject({
stage: "recovering",
messageKey: "viewer.msg.recovering",
stage: "playing",
messageKey: "viewer.msg.playing",
overlay: "none",
noticeKey: "viewer.notice.signalRecovering",
});
+3 -2
View File
@@ -48,8 +48,9 @@ it.each(["host-paused", "preview-paused", "route-failed", "source-failed", "room
it.each(STATUS_SCENARIOS)("composes feedback from the actual $id state", scenario => {
const presentation = deriveViewerPresentation(scenario.state);
const { overlay } = deriveViewerStatus(presentation, scenario.state.signal);
const waiting = ["joining", "preparing-p2p", "preparing-sfu", "waiting-sfu", "allocating",
"receiving", "recovering", "waiting-host", "stopped"].includes(scenario.id);
const waiting = presentation.overlay !== "none" &&
["joining", "preparing-p2p", "preparing-sfu", "waiting-sfu", "allocating",
"receiving", "recovering", "waiting-host"].includes(presentation.stage);
expect(overlay?.waiting ?? false).toBe(waiting);
if (!overlay) return;
const html = renderToStaticMarkup(createElement(StageOverlay, {