From 01821ae7acf091908ee88bcb94afc9ddde72245e Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:27:56 +0800 Subject: [PATCH] fix(connection): retry startup and clarify sharing status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../0005-automatic-hybrid-media-routing.md | 7 +- docs/adr/0009-optional-nat-prediction.md | 4 +- docs/operations/website.md | 25 +++- .../research/cross-platform-client-runtime.md | 13 +- docs/standards/media-quality.md | 2 +- docs/standards/media-status.md | 17 ++- docs/standards/routing-transport.md | 4 +- docs/standards/visual-language.md | 5 +- docs/status.md | 8 +- docs/todo.md | 20 +-- internal/app/publictunnel/process.go | 69 +++++++-- internal/app/publictunnel/process_test.go | 136 ++++++++++++++++++ internal/server/app/app_test.go | 6 +- internal/server/app/routes.go | 5 +- internal/server/protocol/client.go | 54 ++++--- internal/server/protocol/contract_test.go | 20 ++- internal/server/protocol/types.go | 14 +- internal/server/route/api.go | 2 +- internal/server/route/controller.go | 2 +- internal/server/route/controller_test.go | 15 +- internal/server/signal/router_test.go | 10 +- internal/server/signal/server.go | 74 +++++----- internal/server/signal/server_test.go | 66 ++++++++- internal/server/signal/session.go | 20 ++- src/client/App.tsx | 30 +++- src/client/lib/api.ts | 17 ++- src/client/locales/en.ts | 3 +- src/client/locales/zh.ts | 3 +- src/client/media/viewer-presentation.ts | 10 +- src/client/pages/ControlsPreview.tsx | 2 +- src/client/pages/HostPage.tsx | 5 +- src/client/pages/ViewerPage.tsx | 7 +- src/client/pages/status-preview-scenarios.ts | 28 ++++ src/client/styles.css | 3 +- src/client/ui/media-status.ts | 8 +- src/shared/protocol.ts | 9 +- tests/client-state.test.ts | 29 ++++ tests/media-status.test.ts | 55 ++++++- tests/protocol.test.ts | 28 +++- tests/viewer-presentation.test.ts | 6 +- tests/waiting-status.test.ts | 5 +- 41 files changed, 671 insertions(+), 175 deletions(-) diff --git a/docs/adr/0005-automatic-hybrid-media-routing.md b/docs/adr/0005-automatic-hybrid-media-routing.md index 5ddd763..75648b1 100644 --- a/docs/adr/0005-automatic-hybrid-media-routing.md +++ b/docs/adr/0005-automatic-hybrid-media-routing.md @@ -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 diff --git a/docs/adr/0009-optional-nat-prediction.md b/docs/adr/0009-optional-nat-prediction.md index 72500b7..b6ee7dd 100644 --- a/docs/adr/0009-optional-nat-prediction.md +++ b/docs/adr/0009-optional-nat-prediction.md @@ -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 diff --git a/docs/operations/website.md b/docs/operations/website.md index 0a73475..c229316 100644 --- a/docs/operations/website.md +++ b/docs/operations/website.md @@ -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 diff --git a/docs/research/cross-platform-client-runtime.md b/docs/research/cross-platform-client-runtime.md index fa2c4a6..077e4cc 100644 --- a/docs/research/cross-platform-client-runtime.md +++ b/docs/research/cross-platform-client-runtime.md @@ -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 diff --git a/docs/standards/media-quality.md b/docs/standards/media-quality.md index 049ba6e..1af96b8 100644 --- a/docs/standards/media-quality.md +++ b/docs/standards/media-quality.md @@ -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. diff --git a/docs/standards/media-status.md b/docs/standards/media-status.md index 1a0ea8b..1d2cd12 100644 --- a/docs/standards/media-status.md +++ b/docs/standards/media-status.md @@ -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. diff --git a/docs/standards/routing-transport.md b/docs/standards/routing-transport.md index 8b5fd58..de1db8c 100644 --- a/docs/standards/routing-transport.md +++ b/docs/standards/routing-transport.md @@ -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 diff --git a/docs/standards/visual-language.md b/docs/standards/visual-language.md index 29d7ba8..fda7bb3 100644 --- a/docs/standards/visual-language.md +++ b/docs/standards/visual-language.md @@ -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. diff --git a/docs/status.md b/docs/status.md index fb7bdb2..f8cb29f 100644 --- a/docs/status.md +++ b/docs/status.md @@ -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. diff --git a/docs/todo.md b/docs/todo.md index 12e88e1..7e12fae 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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 diff --git a/internal/app/publictunnel/process.go b/internal/app/publictunnel/process.go index 992f53d..d1f4f6c 100644 --- a/internal/app/publictunnel/process.go +++ b/internal/app/publictunnel/process.go @@ -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 } diff --git a/internal/app/publictunnel/process_test.go b/internal/app/publictunnel/process_test.go index 394ff3b..4900c80 100644 --- a/internal/app/publictunnel/process_test.go +++ b/internal/app/publictunnel/process_test.go @@ -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 { diff --git a/internal/server/app/app_test.go b/internal/server/app/app_test.go index 250df78..596de8f 100644 --- a/internal/server/app/app_test.go +++ b/internal/server/app/app_test.go @@ -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()) diff --git a/internal/server/app/routes.go b/internal/server/app/routes.go index 750f0ac..bb6a013 100644 --- a/internal/server/app/routes.go +++ b/internal/server/app/routes.go @@ -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 } diff --git a/internal/server/protocol/client.go b/internal/server/protocol/client.go index f6b2074..8bc3e54 100644 --- a/internal/server/protocol/client.go +++ b/internal/server/protocol/client.go @@ -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) diff --git a/internal/server/protocol/contract_test.go b/internal/server/protocol/contract_test.go index dbe5e5e..4931b9e 100644 --- a/internal/server/protocol/contract_test.go +++ b/internal/server/protocol/contract_test.go @@ -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",` + diff --git a/internal/server/protocol/types.go b/internal/server/protocol/types.go index d51b850..4365aed 100644 --- a/internal/server/protocol/types.go +++ b/internal/server/protocol/types.go @@ -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 } diff --git a/internal/server/route/api.go b/internal/server/route/api.go index b8cf582..eaf052e 100644 --- a/internal/server/route/api.go +++ b/internal/server/route/api.go @@ -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 diff --git a/internal/server/route/controller.go b/internal/server/route/controller.go index 3532501..bde5567 100644 --- a/internal/server/route/controller.go +++ b/internal/server/route/controller.go @@ -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 diff --git a/internal/server/route/controller_test.go b/internal/server/route/controller_test.go index d4679b8..1afb30c 100644 --- a/internal/server/route/controller_test.go +++ b/internal/server/route/controller_test.go @@ -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{}) diff --git a/internal/server/signal/router_test.go b/internal/server/signal/router_test.go index 2fa405a..ddadb9c 100644 --- a/internal/server/signal/router_test.go +++ b/internal/server/signal/router_test.go @@ -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) { diff --git a/internal/server/signal/server.go b/internal/server/signal/server.go index 111a6bc..5155155 100644 --- a/internal/server/signal/server.go +++ b/internal/server/signal/server.go @@ -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 { diff --git a/internal/server/signal/server_test.go b/internal/server/signal/server_test.go index e04cbc4..ae9bd39 100644 --- a/internal/server/signal/server_test.go +++ b/internal/server/signal/server_test.go @@ -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) diff --git a/internal/server/signal/session.go b/internal/server/signal/session.go index b4b3990..46dd9fa 100644 --- a/internal/server/signal/session.go +++ b/internal/server/signal/session.go @@ -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. diff --git a/src/client/App.tsx b/src/client/App.tsx index 0e87a22..7ce6a31 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -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 ? ( - ) : ( (null); + useEffect(() => { + let active = true; + void getOptionalRuntimeCapabilities().then(capabilities => { + if (active) setProgressSupported(capabilities?.connectionAttemptProgress4 === true); + }); + return () => { active = false; }; + }, []); + return progressSupported === null ? : ( + + ); +} + function StaticRoute({ comic, titleKey, @@ -371,10 +389,7 @@ function SiteAccessGate({ if (access.kind === "ready") { if (surface === "viewer" && viewerRoute) { return ( - + ); } 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") }) } diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts index 78bfd56..55e6f8b 100644 --- a/src/client/lib/api.ts +++ b/src/client/lib/api.ts @@ -68,9 +68,10 @@ export async function getSiteAccess(): Promise { return parseSiteAccessStatus(body); } -export async function getRuntimeCapabilities(): Promise { +export async function getRuntimeCapabilities(signal?: AbortSignal): Promise { 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 { 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 { + 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 { diff --git a/src/client/locales/en.ts b/src/client/locales/en.ts index 1b7d94c..4c7d3b3 100644 --- a/src/client/locales/en.ts +++ b/src/client/locales/en.ts @@ -367,8 +367,7 @@ export const en: Record = { "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.", diff --git a/src/client/locales/zh.ts b/src/client/locales/zh.ts index de18fbe..f8b1efd 100644 --- a/src/client/locales/zh.ts +++ b/src/client/locales/zh.ts @@ -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} 的房间号,或打开房主的邀请链接。", diff --git a/src/client/media/viewer-presentation.ts b/src/client/media/viewer-presentation.ts index 451371c..a833369 100644 --- a/src/client/media/viewer-presentation.ts +++ b/src/client/media/viewer-presentation.ts @@ -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", diff --git a/src/client/pages/ControlsPreview.tsx b/src/client/pages/ControlsPreview.tsx index 567d45e..6ffa0cd 100644 --- a/src/client/pages/ControlsPreview.tsx +++ b/src/client/pages/ControlsPreview.tsx @@ -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('')}` }, + { id: "built-in", label: "Built-in camera", preview: `data:image/svg+xml,${encodeURIComponent('')}` }, { id: "usb", label: "USB Camera", preview: `data:image/svg+xml,${encodeURIComponent('')}` }, ]); const previewMicrophones = async () => [{ id: "headset", label: "USB Headset" }, { id: "desk", label: "Desk Microphone" }]; diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index 7417ed3..2f0721d 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -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}
- {!details?.hasSourceAudio && stream ? ( + {details?.hasSourceAudio === false && stream ? ( ) : null} {hostSfuWarningText ? ( diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx index 408f5a2..bdbe3c3 100644 --- a/src/client/pages/ViewerPage.tsx +++ b/src/client/pages/ViewerPage.tsx @@ -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; diff --git a/src/client/pages/status-preview-scenarios.ts b/src/client/pages/status-preview-scenarios.ts index 51cd5ea..73501f3 100644 --- a/src/client/pages/status-preview-scenarios.ts +++ b/src/client/pages/status-preview-scenarios.ts @@ -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 }])), diff --git a/src/client/styles.css b/src/client/styles.css index 814a013..a6a7b23 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -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; diff --git a/src/client/ui/media-status.ts b/src/client/ui/media-status.ts index 24b16a3..b31be00 100644 --- a/src/client/ui/media-status.ts +++ b/src/client/ui/media-status.ts @@ -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; diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 3bf0afd..2f94ac0 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -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(), ]); diff --git a/tests/client-state.test.ts b/tests/client-state.test.ts index 6691b3b..66b035a 100644 --- a/tests/client-state.test.ts +++ b/tests/client-state.test.ts @@ -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, diff --git a/tests/media-status.test.ts b/tests/media-status.test.ts index 3ef808b..6ad1e13 100644 --- a/tests/media-status.test.ts +++ b/tests/media-status.test.ts @@ -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"); diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index d72b862..2a081bb 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -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, diff --git a/tests/viewer-presentation.test.ts b/tests/viewer-presentation.test.ts index 7743d2b..f3c1178 100644 --- a/tests/viewer-presentation.test.ts +++ b/tests/viewer-presentation.test.ts @@ -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", }); diff --git a/tests/waiting-status.test.ts b/tests/waiting-status.test.ts index 99ff39f..d659f8b 100644 --- a/tests/waiting-status.test.ts +++ b/tests/waiting-status.test.ts @@ -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, {