fix(app): improve capture selection and media recovery (#433)
## Problem and outcome / 问题与结果 This maintenance phase fixes failures around native capture startup, room reconnection and media handoffs. Capture mutations stay cancellable, relayed-content failures remain request-scoped, and failed media connections return to the existing route controller. Windows Auto codec selection measures delivered cadence instead of timer-sensitive per-frame work estimates. The phase preserves P2P-first routing, one committed graph and one route operation. It adds no signaling replay mechanism, changes no wire/schema version, and includes no interaction prototype or 4K feature work. ## Release notes ### 中文 Piik v1.6.8 是一次稳定性修复,重点改善 Windows 原生采集、断线恢复和分享过程中的控制响应。 #### 修复与改善 - **更准确地选择编码器。** Windows 的自动模式按实际出帧表现评估 H.264,并正确区分启动预热和持续性能不足,减少计时误差导致的不必要 VP8 回退。硬件编码事件改为异步等待。 - **改善断线与线路切换。** 房间信令短暂断开后保留观众成员身份 20 秒;修复线路切换、SFU 订阅恢复及声音重协商卡住的问题。失效连接及时交回现有路由控制器处理,原生 SFU 发送端不再把短暂断连直接判为终止。 - **保持 App 控制响应。** 开始分享和更换来源时仍可取消操作;合法消息突发不再直接关闭控制连接。中继信令的单条内容错误不会连带结束整场原生分享。 - **修正设备列表与配置保存。** 来源或麦克风数量达到上限时保留可用列表;配置替换失败时保留原有设置。 - **改善紧凑布局和辅助功能。** 调整手机横屏下的画面与播控布局、画质预设标签和长邀请链接显示,修正部分提示与辅助功能语义。 - **加强站点访问保护。** 站点登录 Cookie 使用独立随机签名密钥,应用自身阻止被其他页面嵌套。发布说明也会在合并前检查。 #### 升级说明 通信协议和持久化格式保持不变。设置了站点密码时,App 或 Server 重启后需要重新输入站点密码;持久化房间和邀请授权不受这项 Cookie 调整影响。Piik 操作页面不再支持嵌入 iframe。 ### English Piik v1.6.8 is a stability update focused on Windows native capture, connection recovery and responsive sharing controls. #### Fixes and improvements - **More accurate codec selection.** Windows Auto mode evaluates H.264 using delivered frames and distinguishes startup warmup from sustained performance limits, reducing unnecessary VP8 fallback caused by timing noise. Hardware encoder events now use asynchronous waiting. - **Improved reconnection and route handoffs.** Viewer membership survives brief signaling outages for 20 seconds. Fixes cover route activation, SFU subscription recovery and stalled audio renegotiation. Failed connections return to the existing route controller, and the native SFU publisher no longer treats a transient disconnect as immediate termination. - **Responsive App controls.** Starting a share and replacing its source remain cancellable. Valid message bursts no longer close the control connection, and an invalid relayed payload cannot terminate the entire native sharing session. - **Device lists and saved settings.** Source and microphone enumeration retain a usable list at their limits. Failed configuration replacement preserves the previous settings. - **Compact layouts and accessibility.** Improved picture and playback-control layout on landscape phones, quality preset labels and long invitation links, with corrected tooltip and accessibility semantics. - **Stronger site-access protection.** Login cookies use an independent random signing key, and the application prevents embedding by other pages. Release notes are also checked before merge. #### Upgrade notes Wire protocols and persistent formats are unchanged. If a site password is configured, restarting the App or Server requires entering it again; this cookie change does not revoke persisted rooms or invitation grants. Piik operation pages can no longer be embedded in an iframe. ## Verification / 验证 - `npm run check`: passed, 76 test files / 1,171 tests, TypeScript and production Web build. - `npm run check:go-core`: passed; full Go/native checks also passed during integration of the capture changes. macOS cgo builds remain native-runner checks. - Windows native Host gates: Auto, H.264 and VP8 passed during the phase; final control changes rechecked with Auto, including live/paused quality, source replacement, failure retirement and subsequent sharing. - Embedded SFU browser VP8 recovery: passed with production Host/Viewer route owners. - Embedded SFU native H.264, two rooms: passed, including recovered decoded video/audio, independent room retirement and resource cleanup. - Documentation, repository hygiene and diff checks: passed. Final follow-up changes only align operations documentation with the PR note check. - Go race checks could not run locally because no cgo/GCC toolchain is available; the existing Linux CI race job remains required. AMD/Intel hardware, broader device combinations, public-NAT field reports and long endurance are not established by these local checks. ## Impact / 影响 Compatible patch release after v1.6.7. The cookie and frame-protection effects are disclosed in the public upgrade notes. No automatic retry budget, room-code admission policy, room lifetime or media topology is replaced. Reports without matched diagnostics and unmeasured native performance costs remain in the TODO ledger.
This commit is contained in:
73 files changed
+2001
-676
No files matched your search
@@ -1,6 +1,8 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -25,7 +27,25 @@ concurrency:
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
release-notes:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
cache: npm
|
||||
- run: npm ci --ignore-scripts
|
||||
- name: Validate PR release notes
|
||||
run: node scripts/release-notes.mjs --pull-request
|
||||
|
||||
validate:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
|
||||
+2
-1
@@ -124,7 +124,8 @@ does not establish coverage.
|
||||
path. Keep browser profiles isolated and consider the active firewall when
|
||||
diagnosing connection failures rather than changing global firewall rules.
|
||||
- CI, versioning and release automation belong to complete, accepted squash
|
||||
merges into `main`; ordinary branch pushes and PRs stay quiet. The
|
||||
merges into `main`; ordinary branch pushes stay quiet. PRs run only the
|
||||
lightweight public release-note check before merge. The
|
||||
[versioning policy](./docs/standards/versioning.md) owns version selection,
|
||||
publication and activation state. Package the merged SHA without writing
|
||||
version-record commits back to `main`.
|
||||
|
||||
@@ -262,6 +262,12 @@ the committed graph, and leaves new Viewers waiting. Resume starts
|
||||
reconciliation from the current graph. Missing or suspended observations do
|
||||
not create route-failure authority.
|
||||
|
||||
Confirmed departure ends the participant's physical media authority before
|
||||
descendants finish recovery, including during Host pause. A departed relay may
|
||||
remain as an inactive graph anchor only. Publishing that retirement supersedes
|
||||
any pending candidate through the same operation owner and revision sequence;
|
||||
its late readiness cannot restore the retired route.
|
||||
|
||||
Native-edge convergence runs inside the same graph, reconcile loop, and
|
||||
room-serial child operation when its per-share gate is enabled. Availability
|
||||
first gets every Viewer usable media; quality work runs only while no join,
|
||||
|
||||
+3
-2
@@ -113,8 +113,9 @@ or hashes.
|
||||
Manual `app_checks=true` dispatch packages Server and three-platform App
|
||||
candidates after validation. Once [automatic publication](./operations/github.md)
|
||||
is explicitly enabled, accepted main merges run that same pipeline and publish
|
||||
the verified artifacts. Candidate artifacts are retained for 14 days. Branches
|
||||
and PRs do not start cloud CI; no release-record commit is written back to main.
|
||||
the verified artifacts. Candidate artifacts are retained for 14 days. Ordinary
|
||||
branch pushes stay quiet; PRs run only the read-only release-note check. No
|
||||
release-record commit is written back to main.
|
||||
The workflow does not deploy into a running application service.
|
||||
|
||||
Do not build or run the full repository check on a constrained production host.
|
||||
|
||||
@@ -13,8 +13,9 @@ cleanup step checks local worktrees and remaining references first.
|
||||
|
||||
Protect `main` with PRs required, zero mandatory peer approvals for the solo
|
||||
maintainer, administrator enforcement, linear history, and no force pushes or
|
||||
deletions. Do not require cloud PR checks while branch/PR CI is intentionally
|
||||
quiet; local validation and acceptance precede the squash merge. PR/issue
|
||||
deletions. PRs run the read-only release-note check; ordinary branch pushes stay
|
||||
quiet. Local validation and acceptance precede the squash merge, and required
|
||||
check settings remain a repository-owner decision. PR/issue
|
||||
templates live in `.github/`; [naming](../../CONTRIBUTING.md#commit-pr-and-branch-names)
|
||||
is shared with automatic version selection.
|
||||
|
||||
@@ -34,8 +35,9 @@ Subsequent accepted PRs merged to main use the existing CI workflow. Product
|
||||
changes calculate one version, validate, package Server and all App targets,
|
||||
and publish the same artifacts. Standalone website/docs changes validate without
|
||||
packaging or releasing; [versioning](../standards/versioning.md#automatic-publication)
|
||||
owns that boundary. Runs queue rather than overlap or cancel one another. Branches/PRs
|
||||
stay quiet; manual `app_checks` dispatch remains available for candidates.
|
||||
owns that boundary. Runs queue rather than overlap or cancel one another. PRs
|
||||
validate release notes without packaging or publication; manual `app_checks`
|
||||
dispatch remains available for candidates.
|
||||
Writing a new version back to main is unnecessary and prohibited.
|
||||
|
||||
For a complete publisher rehearsal, dispatch with `app_checks: true` and an
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Project Memory
|
||||
|
||||
Last updated: 2026-09-24
|
||||
Last updated: 2026-09-29
|
||||
|
||||
Piik is private, low-latency screen sharing for one Host and up to 20
|
||||
authenticated friends. The current product surface is Web Host, Web Viewer, and
|
||||
@@ -62,8 +62,8 @@ implementation and routine UI detail.
|
||||
through explicit replacement/deletion, with separate invitation rotation or
|
||||
revocation. Hosted defaults to SQLite, with explicit process-memory opt-out;
|
||||
App Local remains process-only. Site access has a separate 24-hour idle
|
||||
cookie lifetime. Production persists SQLite room authority; live
|
||||
participants, routes, and media remain process-only.
|
||||
cookie lifetime within one service run. Production persists SQLite room
|
||||
authority; live participants, routes, and media remain process-only.
|
||||
- Media is automatic and distributed. Ordinary peers are STUN-only and prefer
|
||||
direct/peer UDP. A deployment may enable same-host auxiliary STUN for bounded,
|
||||
room-wide, connection-local NAT prediction; its Host switch defaults on when
|
||||
|
||||
@@ -81,9 +81,27 @@ CPU-only 720p30 trial its mean encoding work was 6.112 ms per frame; this exclud
|
||||
GPU readback and is not game-quality evidence. Separate real Browser gates
|
||||
proved manual VP8 and Auto-selected H264, including live presets, pause/resume,
|
||||
source replacement and exact RTP codec preservation. Auto uses a bounded
|
||||
synthetic target-profile throughput check, not a score or a runtime codec switch.
|
||||
synthetic target-profile cadence check, not a score or a runtime codec switch.
|
||||
GPU-heavy selection and cross-device startup remain open acceptance work.
|
||||
|
||||
A 2026-09-28 check on Ryzen 7 9700X / RTX 4070 SUPER / Windows 11 26200
|
||||
used the animated Browser gate source at a 1080p, 8 Mbps ceiling. With asynchronous
|
||||
MFT readiness and no `timeBeginPeriod(1)` request, Auto selected NVIDIA H264 at
|
||||
30 and 60 fps, reaching active in 1.851 and 1.855 s respectively. Over roughly
|
||||
6.8-second steady windows, source and output both averaged 27.9 fps at 30 and
|
||||
54.1 fps at 60; helper CPU averaged 3.7% and 6.9% of one logical core. The source
|
||||
did not supply 60 fps, so this is not proof of 60-fps game capture or a measured
|
||||
speedup over the previous implementation. Separate full Browser gates passed
|
||||
Auto, manual H264 and VP8 with source/profile replacement and retirement.
|
||||
|
||||
Native Auto now observes the live pipeline's output, including the frame dropper
|
||||
implicated by the [Browser AMD evidence](./realtime-quality-adaptation.md#h264-root-cause-and-gate).
|
||||
Cold-start work is excluded from steady cadence, while the selection deadline
|
||||
reaches MFT input/output waits. Synchronous driver activation/configuration and
|
||||
shutdown cannot be preempted by that deadline. Slow-output rejection has fixture
|
||||
coverage; native AMD/Intel physical acceptance and game-load behavior remain
|
||||
open. This evidence does not establish the cause of reported system-wide lag.
|
||||
|
||||
The Windows Browser gate now also keeps two native PeerConnections alive while
|
||||
the source changes from 720p30 to 1440p60, then changes to 480p15 while paused
|
||||
and resumes both Viewers. It proves the same Pion source survives two hardware
|
||||
|
||||
@@ -146,6 +146,18 @@ This isolates the observed AMD failure to outer bitrate-control/frame-drop
|
||||
interaction, not a fixed MFT throughput ceiling. The page cannot enable the
|
||||
process feature, override Chromium's AMD workaround, or select a different MFT.
|
||||
|
||||
Upstream status checked on 2026-09-28: Chromium main's
|
||||
[driver bug list](https://raw.githubusercontent.com/chromium/chromium/main/gpu/config/gpu_driver_bug_list.json)
|
||||
entry 449 still disables H.264 software bitrate control on AMD Windows without
|
||||
a driver-version exception
|
||||
([crbug 417752242](https://issues.chromium.org/issues/417752242)).
|
||||
This source establishes the Browser workaround, not the state of every AMD
|
||||
driver or native device. The earlier measured low-cadence output supports keeping
|
||||
an actual-output gate rather than inferring compatibility from a device name.
|
||||
Windows native Auto likewise measures through its live pipeline, including the
|
||||
untrusted rate controller and frame dropper. Matching native AMD physical
|
||||
evidence remains pending.
|
||||
|
||||
Later exact Browser cohorts demonstrated why runtime evidence is useful:
|
||||
|
||||
| Path | Actual encoder | Source / encoded / decoded | Result |
|
||||
|
||||
@@ -72,7 +72,8 @@ sets `/var/lib/piik/rooms.sqlite` under its managed state directory; the
|
||||
container image sets `/home/nonroot/rooms.sqlite` under its writable data
|
||||
directory. Keep the existing database path and data across application updates.
|
||||
Room authority has no idle expiry; the separate site-access cookie keeps its
|
||||
24-hour idle lifetime.
|
||||
24-hour idle lifetime within one service run. Restarting the App or Server
|
||||
requires site-password entry again; saved rooms and invitation grants are unaffected.
|
||||
|
||||
Removed access, room TTL/lease, endpoint-tier, room-rollout, and TURN variables fail
|
||||
startup even when blank. A present `NODE_ENV` fails the same way, so a stale
|
||||
|
||||
@@ -168,6 +168,11 @@ above rather than searching the tree.
|
||||
Dispatch separates protocol rejection, per-request operation failure and an
|
||||
already-satisfied teardown. Late teardown is idempotent and cannot retire a
|
||||
replacement resource; retain strict validation before looking up its target.
|
||||
Relayed content uses the same units and limits at each boundary; its failure
|
||||
cannot invalidate an otherwise valid local control session. Slow capture
|
||||
mutations share one cancellable operation slot while control remains
|
||||
responsive. Bounded queues apply backpressure rather than turning a valid
|
||||
burst into session failure.
|
||||
Retained media state carries failure facts; the presentation layer resolves
|
||||
localized copy during render. Keep raw exceptions in diagnostics. Transient
|
||||
event notices may resolve once when the event occurs.
|
||||
|
||||
@@ -141,16 +141,20 @@ adapter/encoder candidates within the shared selection budget. Manual H264
|
||||
requires actual H264 output and never changes codec on failure. The selected
|
||||
device owns capture and output workers; its identity is retained as the first
|
||||
choice for quality/source replacement. Native relay encoding uses the same
|
||||
selection owner. Auto measures encoding work
|
||||
for synthetic NV12 frames at the selected dimensions and frame rate, with a
|
||||
bounded warmup and sample. If H264 sustains the target it is selected; otherwise
|
||||
a usable H264 candidate is compared with VP8 within the four-second budget.
|
||||
An unsuccessful or timed-out comparison retains the proved H264 encoder. With
|
||||
no usable H264 candidate, Auto starts the ordinary VP8 path directly, as manual
|
||||
VP8 does; a benchmark with no alternative to compare must not gate capture.
|
||||
selection owner. Auto paces synthetic NV12 frames at the selected dimensions and
|
||||
frame rate through the live encoder pipeline, including its rate control and frame
|
||||
dropper, with the Browser probe's warmup, one-second sample and 100 ms lag
|
||||
allowance. Cold-start lag is excluded from the measured window; the selection
|
||||
deadline still bounds warmup and hardware input/output waits. A candidate whose
|
||||
measured delivery falls outside that allowance is rejected and the next candidate
|
||||
is tried within the four-second budget. The probe retires before the live encoder
|
||||
is prepared and proved, so selection requires only one hardware session at a time.
|
||||
Without a sustaining candidate, Auto starts the ordinary VP8 path directly, as manual VP8 does. A
|
||||
faster software encode never replaces a sustaining H264 encoder. Encoder readiness
|
||||
uses Media Foundation events rather than timer-based polling.
|
||||
Actual encoding and failure reporting remain owned by the capture worker. This
|
||||
is a throughput comparison, not a perceptual-quality score or a promise under
|
||||
future GPU load.
|
||||
is a cadence check, not a perceptual-quality score or a promise under future
|
||||
GPU load.
|
||||
The returned actual codec owns the shared source, preview, and relay; live
|
||||
quality/source changes retain it. Other native platform encoders remain H264.
|
||||
|
||||
|
||||
@@ -48,6 +48,10 @@ Three independent authorities exist:
|
||||
destination: public HTTPS retains the Secure host-prefixed cookie, while a
|
||||
separately configured HTTP LAN origin can use its HTTP cookie. Login,
|
||||
renewal, room creation and WebSocket admission use that same selection.
|
||||
Cookie signatures use a random process key independent of the password;
|
||||
restarting the Server/App requires site-password entry again. This does not
|
||||
revoke persisted room ownership or Viewer invitations. The application
|
||||
rejects framing through its own HTTP headers, including without a proxy.
|
||||
2. **Host ownership.** The exact Host token authorizes that room's Host and
|
||||
access-management operations. It cannot authorize another room.
|
||||
3. **Viewer invitation.** Every room creates a 128-bit, 22-character base64url
|
||||
|
||||
@@ -121,6 +121,14 @@ It does not require NAT prediction or reopen the route candidate. A prepared
|
||||
candidate instead follows its current route operation's failure path, without
|
||||
an independent restart loop.
|
||||
|
||||
A Browser sender's subsequent audio-direction SDP transaction has a 15-second
|
||||
completion deadline. A lost offer/answer must not leave later microphone/source
|
||||
changes queued forever behind otherwise connected media. Timeout retires that
|
||||
exact edge through its existing preparation/recovery owner; coalesced changes
|
||||
do not renew the deadline. This also applies if audio changes during preparation.
|
||||
Initial acquisition and ICE restart retain their existing route/recovery deadlines;
|
||||
ordinary signaling loss without a pending media transaction preserves healthy media.
|
||||
|
||||
When a newly committed Host-root Viewer exposes unused downstream capacity while
|
||||
another Host root has at least two direct children, the same background operation may
|
||||
move one of those children to the new root. The candidate must prove a healthy
|
||||
@@ -131,6 +139,11 @@ without admitted overlap.
|
||||
Framework reconnect runs before route reassignment. Manual media reconnect also
|
||||
recovers only the current P2P parent or current SFU subscription; it does not
|
||||
perform quality selection or choose another route.
|
||||
A Viewer that loses room signaling keeps its membership and committed edges for
|
||||
20 seconds, covering the nominal backoff for the Browser's first five reconnect
|
||||
attempts; connection and authentication time can extend those attempts.
|
||||
Reconnecting with the same client rebinds retained edges. Signaling absence alone does not
|
||||
retire a media edge before then; its route failure or that window's expiry does.
|
||||
Current-edge P2P signaling remains valid during candidate overlap. Accepting a
|
||||
replacement from that parent cancels conflicting optional preparation so the
|
||||
replacement can complete; a retired candidate cannot take its place afterward.
|
||||
@@ -176,7 +189,13 @@ retain after those resources close. ADR-0005's graph and operation rules remain;
|
||||
|
||||
Transient room-signaling loss does not close healthy SFU media. The current
|
||||
physical connection can restart ICE; actual terminal media failure returns to
|
||||
the same bounded route recovery. Source capture remains Host-owned throughout.
|
||||
the same bounded route recovery, identifying the failed physical connection.
|
||||
An active configuration describes that connection; it cannot create a replacement
|
||||
Browser subscriber. Replacement needs the controller's prepared subscription and
|
||||
first-frame proof. Reauthentication during an unfinished restart also returns
|
||||
that subscription to its route owner; sending an answer is not connection proof.
|
||||
An exhausted opportunity remains an explicit failure until an eligible new fact
|
||||
reopens acquisition. Source capture remains Host-owned throughout.
|
||||
|
||||
## Quality And Privacy Boundaries
|
||||
|
||||
|
||||
@@ -155,7 +155,10 @@ An older draft retried after a newer release cannot take over `latest`.
|
||||
`PIIK_RELEASES_ENABLED` is a GitHub repository activation variable, not a product
|
||||
setting. Until the first release is explicitly enabled,
|
||||
main runs validation and manual `app_checks` dispatches create candidates only.
|
||||
Ordinary branches and PRs do not run cloud CI. [GitHub operations](../operations/github.md)
|
||||
Ordinary branches do not run cloud CI. PRs run only a read-only release-note
|
||||
check, including after edits to their body; product scope uses the same path
|
||||
owner as publication and the PR's net diff. Packaging and publication remain
|
||||
post-merge. [GitHub operations](../operations/github.md)
|
||||
owns activation and main protection; no workflow changes repository visibility.
|
||||
|
||||
Updates remain explicit, outside an active share, using complete matching
|
||||
@@ -190,13 +193,18 @@ work logs, audit handoffs and unverified claims outside this public section.
|
||||
first-parent commits since the previous stable tag. Standalone website and
|
||||
documentation phases need no product release notes. The first release uses
|
||||
only its launch commit's product introduction; private development history is
|
||||
not a launch changelog. Missing, empty or duplicate sections fail validation
|
||||
before packaging/publication. GitHub Actions shows the generated text in its
|
||||
not a launch changelog. Missing, empty, duplicate or visibly encoding-damaged
|
||||
sections fail validation before packaging/publication. GitHub Actions shows the generated text in its
|
||||
run summary. A manual candidate with an explicit version also previews it, so
|
||||
prepare its commit text before dispatching a release rehearsal. A manual
|
||||
candidate with no product changes may still be built; its summary reports that
|
||||
there are no product release notes, and publication remains disabled.
|
||||
|
||||
Supply multiline PR/release copy through a UTF-8 file and read back the stored
|
||||
body before integration. A terminal preview alone does not prove that Chinese
|
||||
text survived the shell/API boundary. Do not repair published history by
|
||||
rewriting main.
|
||||
|
||||
The publisher appends generated build identity and package checksums to the
|
||||
reviewed text, passes it through a temporary notes file to GitHub, then removes
|
||||
that file. Gitee copies the GitHub description. Retrying the same
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Current Status
|
||||
|
||||
Last updated: 2026-09-22
|
||||
Last updated: 2026-09-29
|
||||
|
||||
This is the compact execution/deployment index. Product modules own behavior,
|
||||
[verification status](./verification-status.md) owns unresolved physical limits,
|
||||
@@ -14,7 +14,8 @@ and Git/PRs own completed history.
|
||||
- Hosted Server defaults to SQLite schema 2. Room authority has no inactivity
|
||||
expiry; explicit replacement/deletion or grant rotation/revocation ends the
|
||||
corresponding authority. Explicit memory mode and App Local end rooms at
|
||||
process exit. Site access retains its separate 24-hour idle lifetime.
|
||||
process exit. Site access retains its separate 24-hour idle lifetime within
|
||||
one service run; service restart requires site-password entry again.
|
||||
- Browser/App entry lifecycle and current-edge reconnect ownership repairs
|
||||
are implemented. Browser node-local pooling and detailed local Debug export
|
||||
remain part of the accepted media surface. Product modules own their behavior;
|
||||
|
||||
+55
-66
@@ -1,6 +1,6 @@
|
||||
# Current TODO Ledger
|
||||
|
||||
Last reviewed: 2026-09-27
|
||||
Last reviewed: 2026-09-29
|
||||
|
||||
Only **Now** is executable. Product modules own behavior; Git/PRs own completed
|
||||
history. A parked idea is not implementation authority.
|
||||
@@ -12,11 +12,10 @@ history. A parked idea is not implementation authority.
|
||||
admission, and narrow/touch UI. Verify confirmed fixes through the existing
|
||||
owners before resuming feature polish. Keep field reports without matched
|
||||
evidence separate from locally reproduced defects.
|
||||
- [ ] **Complete manual accessible-name review.** Verify the empty video's
|
||||
screen-reader output: Chromium exposes an unavailable-media
|
||||
description despite the literal shared-picture label and no media error. Also
|
||||
review accessible naming on disabled tooltip wrappers and the UI catalogue's
|
||||
paired-character example; automated checks leave those for manual review.
|
||||
- [ ] **Windows native helper cost.** Measure GPU texture/view allocation,
|
||||
WGC update cadence, source-wide keyframe demand and frame-rate bitrate
|
||||
compensation under game load before changing them. VP8 CPU cost and these
|
||||
candidate costs do not establish the reported system-wide lag's cause.
|
||||
|
||||
## Deferred Feature Work
|
||||
|
||||
@@ -59,24 +58,17 @@ is active. Retain these decisions for resumption, not as parallel work.
|
||||
|
||||
- [ ] **Self-hosted room creation HTTP 403.** Retest affected deployments using
|
||||
the [address checks](./guide/troubleshooting.md#room-creation-returns-403).
|
||||
A stale explicit `ALLOWED_ORIGINS` overrides `PUBLIC_BASE_URL`; the sample now
|
||||
follows the public address and Piik identifies its Origin rejection. Reporter
|
||||
configuration and response details are still absent; distinguish Piik's
|
||||
rejection from a proxy/WAF 403 before assigning their cause.
|
||||
Obtain the configured public address/origin and response details; distinguish
|
||||
Piik's rejection from a proxy/WAF 403 before assigning their cause.
|
||||
- [ ] **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
|
||||
STUN reachability and route-lifecycle checks do not establish these reporters'
|
||||
causes. HTTP 1033 belongs to the public-link item below, before media routing.
|
||||
A complete v1.6.3 App report confirms four candidate timeouts without Native
|
||||
share-start or any SDP/ICE signaling. Browser diagnostics are needed to
|
||||
locate prepare delivery, peer initialization or the first offer send; these
|
||||
attempts do not establish an ICE/NAT failure. Locally reproduced Browser
|
||||
construction failures now release owned tracks and reach the existing
|
||||
preparation/recovery owner, but are not proved to be this reporter's cause.
|
||||
paths, first-frame admission and route rejection separately. A v1.6.3 App
|
||||
report has candidate timeouts without share-start or SDP/ICE; obtain Browser
|
||||
evidence before calling that an ICE/NAT failure. HTTP 1033 belongs to the
|
||||
public-link item below, before media routing. Local fixes and STUN checks do
|
||||
not establish these reporters' causes.
|
||||
- [ ] **Camera and Host microphone device coverage.** The owner accepted the
|
||||
sharing layout and authorized release with these physical limits recorded.
|
||||
Verify real audio levels/echo, multiple-device replacement and native mixing on
|
||||
@@ -87,26 +79,19 @@ is active. Retain these decisions for resumption, not as parallel work.
|
||||
- [ ] **App public-invitation startup field acceptance.** Retest the frequent
|
||||
creation-failure report and the earlier
|
||||
[#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 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
|
||||
Demo health/assets and an external-host public-link HTTP/WebSocket gate pass.
|
||||
Check connector reachability separately from WebRTC media availability.
|
||||
with version and matched diagnostics. Compare against the bounded checks in
|
||||
[runtime evidence](./research/cross-platform-client-runtime.md#public-invitation-startup).
|
||||
Demo access failures and public-link HTTP 1033 also remain unconfirmed; check
|
||||
connector, DNS/provider and remote access separately from WebRTC availability.
|
||||
- [ ] **Interruption during established viewing.** A Viewer reportedly returns
|
||||
to P2P connecting after watching for a while. Native receiver renegotiation,
|
||||
retired event delivery and SFU replacement have locally reproduced defects
|
||||
and regression checks, but paired diagnostics and device/network details are
|
||||
still needed to establish this reporter's cause. Include an upstream relay's
|
||||
report when present. Terminal Browser sender failures now notify their existing
|
||||
preparation owner instead of silently disposing; expected retirement stays
|
||||
silent. Native per-output keyframe requests no longer wake unrelated outputs.
|
||||
These are confirmed local defects, not matched causes of the field reports.
|
||||
The existing five-second Viewer membership grace during
|
||||
signaling loss is unchanged. This is distinct from the first-frame report below.
|
||||
to P2P connecting after watching for a while. Include
|
||||
[#429](https://github.com/TNTcraftHIM/Piik/issues/429)'s reported SFU-to-P2P
|
||||
dropout in field acceptance of candidate-commit recovery. Obtain paired
|
||||
diagnostics, device/network details and any upstream relay's report; confirmed
|
||||
local repairs are not matched causes of these reports. Distinguish established
|
||||
viewing from the first-frame report below and signaling membership grace.
|
||||
Locally verified per-edge signaling and committed SFU recovery repairs do not
|
||||
establish a matched cause for these reporters.
|
||||
- [ ] **Windows 11 capture border remains visible.** Identify the App/Browser
|
||||
capture path, Windows build and capture-border permission result. Local checks
|
||||
reproduced a border surviving forced process termination; parent cancellation
|
||||
@@ -124,31 +109,27 @@ is active. Retain these decisions for resumption, not as parallel work.
|
||||
report, an unreachable App despite its process running, and generic share-start
|
||||
failure. Distinguish site authorization, browser permission, control capacity,
|
||||
enumeration and capture startup. The v1.6.5 report reaches Auto-to-VP8 startup
|
||||
after a hardware MFT timeout, then receives Browser `stop-share` before any
|
||||
local-edge request. App handler timestamps are about 1.5 ms apart; this is not
|
||||
a Browser-measured ACK interval or a no-frame deadline. Deliberately exhausting
|
||||
real Chrome connection objects reproduces that sequence with physical capture;
|
||||
bounded normal restarts showed no retained connections. Startup now acquires
|
||||
the Browser connection first: the same exhaustion rejects before capture, and
|
||||
releasing fixture-held objects permits sharing again. This establishes neither
|
||||
a Piik resource leak nor the reporter's cause. The actual Browser exception,
|
||||
loaded asset identity and reporter VP8 output remain unknown. Zero dimensions/
|
||||
FPS in `starting` are valid. Diagnostics preserve preview initialization/
|
||||
negotiation causes and distinguish MFT input/output waits; obtain matched
|
||||
evidence before changing recovery or deadlines. Other discovery/manual-H264
|
||||
reports still need paired evidence.
|
||||
after an MFT timeout, then Browser `stop-share` before any local-edge request;
|
||||
it does not prove a no-frame timeout. Obtain the Browser exception, loaded
|
||||
assets and actual VP8 output. Controlled connection exhaustion reproduces the
|
||||
sequence but does not establish a Piik leak or this reporter's cause.
|
||||
[#419](https://github.com/TNTcraftHIM/Piik/issues/419)'s AMD manual-H264 failure
|
||||
also needs paired evidence; distinguish activation, codec configuration and
|
||||
actual output before changing the encoder contract. Local NVIDIA success
|
||||
does not settle AMD activation.
|
||||
- [ ] **Share ends after entering a game.** Screen sharing reportedly works
|
||||
until entering a game freezes the picture, followed seconds later by share
|
||||
termination. Version, capture path, codec and matched diagnostics are unknown.
|
||||
Locate the first capture/output, preview-bridge, control or authority failure;
|
||||
distinguish ordinary source silence/resize from target replacement, exclusive
|
||||
fullscreen, display-mode change and device loss. A reproduced downstream
|
||||
retirement race no longer stops the shared source. Actual CS2 checks found
|
||||
and removed a separate decoded-silence rule that retired a connected Viewer
|
||||
route while the Host stayed live; [capture research](./research/native-client-lifecycle.md#quiet-sources-and-viewer-recovery)
|
||||
distinguishes that repair and successful restoration from the unknown cause
|
||||
of the reported Host termination. Do not conflate source silence, receiver
|
||||
decode interruption and capture-process failure.
|
||||
fullscreen, display-mode change and device loss. Compare with
|
||||
[capture research](./research/native-client-lifecycle.md#quiet-sources-and-viewer-recovery);
|
||||
do not conflate source silence, receiver decode interruption and capture-process
|
||||
failure, or assign the reported Host termination to a repaired Viewer defect.
|
||||
An input accepted by hardware without matching output within its two-second
|
||||
deadline fails the original output and can end a native Windows share. This
|
||||
is a candidate mechanism, not evidence that ordinary source silence or this
|
||||
reporter's game caused an encoder failure.
|
||||
- [ ] **Windows 32-bit candidate acceptance.** Verify the isolated
|
||||
`spike/windows-x86-capture` candidate's launch, capture/audio, memory pressure,
|
||||
source replacement and update links on a 32-bit Windows device. WOW64
|
||||
@@ -161,6 +142,11 @@ is active. Retain these decisions for resumption, not as parallel work.
|
||||
Recheck on the reporting machine after the browser-handoff repair. The Windows
|
||||
URL-handler crash itself still needs the affected build and process/dump
|
||||
evidence; local checks cannot establish its underlying cause.
|
||||
- [ ] **System becomes very laggy after starting a share.** Obtain mode, actual
|
||||
codec, profile, display refresh rate and CPU/GPU use, distinguishing startup
|
||||
from sustained lag. Auto's local selection checks and NVIDIA success do not
|
||||
establish this reporter's cause; software encoding and the helper costs under
|
||||
Now remain candidate explanations.
|
||||
|
||||
## Next: P2P Connection And Feedback Evidence
|
||||
|
||||
@@ -199,18 +185,18 @@ not establish better connection success or speed; this note adds no retry policy
|
||||
score, all-pairs probes, periodic rebalancing, parent-wide prediction or
|
||||
room-wide minimum. Check whether a quality move merely shifts pressure to
|
||||
another parent's siblings before widening policy.
|
||||
3. **Control/resource fairness and input review.** Reassess the authenticated
|
||||
WebSocket/SFU owner, HTTP/body/resource bounds, authorization and error/log
|
||||
handling before adding a queue or limiter. No parallel security framework,
|
||||
accounts, risk score or speculative policy layer.
|
||||
Measure signaling-lock contention and synchronous diagnostic I/O before
|
||||
changing the lock or execution model; their cost remains a tradeoff.
|
||||
3. **Signaling execution model.** Measure signaling-lock contention, synchronous
|
||||
persistence, presence fan-out, diagnostic I/O and cross-room password-work
|
||||
fairness before changing the lock or execution model; their cost remains a tradeoff.
|
||||
No parallel security framework, accounts, risk score or speculative policy layer.
|
||||
4. **Reachable ownership/refactor work.** Retain Host/Viewer page media-session
|
||||
extraction as a candidate alongside related behavior changes. Evaluate clear
|
||||
resource owners, fewer shared writers and a smaller change surface under
|
||||
[engineering review](./standards/engineering.md#ablation-and-review); file
|
||||
size alone does not justify a split. The completed audits do not close this
|
||||
candidate. Reopen C=3 structural-intent retention and multi-child evidence
|
||||
candidate. Evaluate a per-share Host resource owner and a Viewer route owner
|
||||
only with related behavior changes; fewer page-local refs alone do not prove
|
||||
a simpler lifecycle. Reopen C=3 structural-intent retention and multi-child evidence
|
||||
ownership only with current-contract reproductions.
|
||||
When related behavior changes, compare the Browser/Native recovery budget;
|
||||
preserve Native bridge versus network failure distinctions when sharing code.
|
||||
@@ -247,3 +233,6 @@ not establish better connection success or speed; this note adds no retry policy
|
||||
`2160p`, so preserve published Browser/App/Server compatibility through explicit
|
||||
receiving-end support before exposing or sending the new setting. Include
|
||||
source replacement, lower outputs and resource limits in acceptance.
|
||||
11. **Release-operation policy.** Protected release environments, immutable
|
||||
draft assets and changing mirror-failure policy remain unaccepted proposals;
|
||||
evaluate their benefit before adding release machinery.
|
||||
@@ -75,11 +75,25 @@ func Save(path string, config Config) error {
|
||||
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("create App configuration directory: %w", err)
|
||||
}
|
||||
if err = os.WriteFile(path, payload, 0o600); err != nil {
|
||||
// Keep the last readable configuration until the replacement is complete.
|
||||
// CreateTemp uses 0600 and the same directory keeps the rename on one volume.
|
||||
file, err := os.CreateTemp(filepath.Dir(path), ".piik-config-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create App configuration replacement: %w", err)
|
||||
}
|
||||
defer os.Remove(file.Name())
|
||||
if _, err = file.Write(payload); err == nil {
|
||||
err = file.Sync()
|
||||
}
|
||||
closeErr := file.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("write App configuration: %w", err)
|
||||
}
|
||||
if err = os.Chmod(path, 0o600); err != nil {
|
||||
return fmt.Errorf("protect App configuration: %w", err)
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close App configuration replacement: %w", closeErr)
|
||||
}
|
||||
if err = os.Rename(file.Name(), path); err != nil {
|
||||
return fmt.Errorf("replace App configuration: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -56,6 +59,88 @@ func TestSavePersistsOneNormalizedSiteChoice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveReplacementFailurePreservesDestinationAndCleansTemporaryFile(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
// A directory cannot be replaced by a configuration file. Its contents must
|
||||
// survive the failed commit and no private temporary payload may be left.
|
||||
path := filepath.Join(directory, "client.json")
|
||||
if err := os.Mkdir(path, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
marker := filepath.Join(path, "keep")
|
||||
if err := os.WriteFile(marker, []byte("original"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Save(path, Config{Version: currentVersion}); err == nil {
|
||||
t.Fatal("replacement of a directory must fail")
|
||||
}
|
||||
payload, err := os.ReadFile(marker)
|
||||
if err != nil || string(payload) != "original" {
|
||||
t.Fatalf("failed replacement damaged the destination: %q, %v", payload, err)
|
||||
}
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil || len(entries) != 1 || entries[0].Name() != "client.json" {
|
||||
t.Fatalf("replacement left temporary files: %v, %v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavePublishesPrivateCompleteReplacement(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
path := filepath.Join(directory, "client.json")
|
||||
initial := Config{Version: currentVersion, Site: "https://old.example"}
|
||||
if err := Save(path, initial); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated := Config{Version: currentVersion, Site: "https://new.example", LocalAccessPassword: "new"}
|
||||
if err := Save(path, updated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := LoadOrCreate(path)
|
||||
if err != nil || loaded != updated {
|
||||
t.Fatalf("replacement = %+v, %v", loaded, err)
|
||||
}
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil || len(entries) != 1 {
|
||||
t.Fatalf("successful replacement left temporary files: %v, %v", entries, err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("private configuration mode = %o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveDoesNotModifyPreviousFileInPlace(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "client.json")
|
||||
initial := Config{Version: currentVersion, Site: "https://old.example"}
|
||||
if err := Save(path, initial); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer previous.Close()
|
||||
updated := Config{Version: currentVersion, Site: "https://new.example"}
|
||||
saveErr := Save(path, updated)
|
||||
// Windows readers may deny replacement. Whether publication succeeds or
|
||||
// fails, an already-open previous file must never expose a truncated rewrite.
|
||||
if saveErr != nil && runtime.GOOS != "windows" {
|
||||
t.Fatal(saveErr)
|
||||
}
|
||||
want, _ := encode(initial)
|
||||
actual, err := io.ReadAll(previous)
|
||||
if err != nil || !bytes.Equal(actual, want) {
|
||||
t.Fatalf("the previous file was modified in place: %v", err)
|
||||
}
|
||||
loaded, err := LoadOrCreate(path)
|
||||
if err != nil || (saveErr == nil && loaded != updated) || (saveErr != nil && loaded != initial) {
|
||||
t.Fatalf("published configuration = %+v, load=%v, save=%v", loaded, err, saveErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSiteAcceptsOnlyAPlainWebOrigin(t *testing.T) {
|
||||
for value, want := range map[string]string{
|
||||
"https://share.example": "https://share.example",
|
||||
|
||||
@@ -383,9 +383,6 @@ func (server *Server) handleControl(response http.ResponseWriter, request *http.
|
||||
case reads <- payload:
|
||||
case <-controlContext.Done():
|
||||
return
|
||||
default:
|
||||
cancelControl()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -363,7 +363,7 @@ func TestControlSessionProcessesARequestBurstInOrder(t *testing.T) {
|
||||
writeControl(t, connection, requestJSON("request_hello", "hello"))
|
||||
var ready controlMessage
|
||||
readControl(t, connection, &ready)
|
||||
const burst = 32
|
||||
const burst = controlQueueSize * 2
|
||||
for index := 0; index < burst; index++ {
|
||||
writeControl(t, connection, requestJSON(
|
||||
fmt.Sprintf("request_extension_%d", index), "extension",
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/TNTcraftHIM/Piik/internal/app/loopback"
|
||||
"github.com/TNTcraftHIM/Piik/internal/app/nativecapture"
|
||||
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
)
|
||||
@@ -180,7 +181,7 @@ func TestFailedEdgeAndLateStopDoNotRetireAnotherShare(t *testing.T) {
|
||||
session := New(executable, nativecapture.Capabilities{SoftwareVP8: true}, false)
|
||||
defer session.Close()
|
||||
const start = `{"version":9,"id":"request_start","type":"start-share","shareId":"share_123456","source":{"kind":"display","sourceId":"1","title":"Fixture"},"audio":false,"edgeCapacity":1,"codec":"vp8","profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced"}}`
|
||||
if value, err := session.Handle(t.Context(), []byte(start)); err != nil {
|
||||
if value, err := awaitControlResponse(t, session, []byte(start)); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if _, ok := value.(shareStartedResponse); !ok {
|
||||
t.Fatalf("start failed: %#v", value)
|
||||
@@ -197,6 +198,25 @@ func TestFailedEdgeAndLateStopDoNotRetireAnotherShare(t *testing.T) {
|
||||
}
|
||||
assertOperationFailure(t, session, edge)
|
||||
assertOperationFailure(t, session, `{"version":9,"id":"request_edge","type":"prepare-edge","shareId":"stale_share","connectionId":"edge_123456","iceServers":[]}`)
|
||||
for _, text := range []string{strings.Repeat("界", 4096), strings.Repeat("界", 4097)} {
|
||||
candidate, _ := json.Marshal(map[string]any{"candidate": text})
|
||||
var upstream protocol.IceCandidate
|
||||
if err := json.Unmarshal(candidate, &upstream); (err == nil) != (len([]rune(text)) == 4096) {
|
||||
t.Fatalf("unexpected upstream admission: %v", err)
|
||||
}
|
||||
for _, kind := range []string{"edge-candidate", "receive-candidate", "publication-candidate"} {
|
||||
fields := ""
|
||||
if kind == "publication-candidate" {
|
||||
fields = `,"publicationGeneration":"publication_123"`
|
||||
}
|
||||
payload := fmt.Sprintf(`{"version":9,"id":"request_remote","type":%q,"shareId":"share_123456","connectionId":"edge_123456","candidate":%s%s}`, kind, candidate, fields)
|
||||
if len([]rune(text)) > 4096 {
|
||||
assertOperationFailure(t, session, payload)
|
||||
} else if _, err := session.Handle(t.Context(), []byte(payload)); err != nil {
|
||||
t.Fatalf("upstream-admitted content closed native control: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if session.current("share_123456") != host {
|
||||
t.Fatal("failed edge retired the current share")
|
||||
}
|
||||
@@ -209,7 +229,7 @@ func TestFailedEdgeAndLateStopDoNotRetireAnotherShare(t *testing.T) {
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if _, err := session.Handle(t.Context(), []byte(strings.ReplaceAll(start, "share_123456", "share_654321"))); err != nil {
|
||||
if _, err := awaitControlResponse(t, session, []byte(strings.ReplaceAll(start, "share_123456", "share_654321"))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 2 {
|
||||
@@ -222,3 +242,83 @@ func TestFailedEdgeAndLateStopDoNotRetireAnotherShare(t *testing.T) {
|
||||
t.Fatal("late stop retired the replacement share")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayedTextKeepsUpstreamLimits(t *testing.T) {
|
||||
for _, character := range []string{"a", "界", "😀"} {
|
||||
units := protocol.UTF16Length(character)
|
||||
sdp := strings.Repeat(character, maxSDPUnits/units)
|
||||
if !validSDP(sdp) || validSDP(sdp+character) || validSDP("") {
|
||||
t.Fatal("native SDP limit differs from the room protocol")
|
||||
}
|
||||
payload := fmt.Sprintf(`{"candidate":%q,"sdpMid":%q,"usernameFragment":%q,"sdpMLineIndex":255}`,
|
||||
strings.Repeat(character, maxCandidateUnits/units), strings.Repeat(character, 128/units), strings.Repeat(character, 256/units))
|
||||
var request edgeCandidateRequest
|
||||
if err := json.Unmarshal([]byte(`{"candidate":`+payload+`}`), &request); err != nil || !validCandidate(request.Candidate) {
|
||||
t.Fatalf("native candidate limit differs from the room protocol: %v", err)
|
||||
}
|
||||
request.Candidate.Candidate += character
|
||||
if validCandidate(request.Candidate) {
|
||||
t.Fatal("oversized candidate admitted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureStartupAndReplacementRemainCancellable(t *testing.T) {
|
||||
for _, kind := range []string{"start-share", "replace-share-source"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
t.Setenv("PIIK_QUIET_CAPTURE_FIXTURE", directory)
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := New(executable, nativecapture.Capabilities{SoftwareVP8: true}, false)
|
||||
defer session.Close()
|
||||
start := `{"version":9,"id":"request_start","type":"start-share","shareId":"share_123456","source":{"kind":"display","sourceId":"1","title":"Fixture"},"edgeCapacity":1,"codec":"vp8","profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced"}}`
|
||||
payload := strings.Replace(start, "1080p", "720p", 1)
|
||||
if kind == "replace-share-source" {
|
||||
if value, err := awaitControlResponse(t, session, []byte(start)); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if _, ok := value.(shareStartedResponse); !ok {
|
||||
t.Fatalf("start: %#v", value)
|
||||
}
|
||||
payload = `{"version":9,"id":"request_replace","type":"replace-share-source","shareId":"share_123456","source":{"kind":"display","sourceId":"2","title":"Fixture"},"audio":false}`
|
||||
}
|
||||
t.Setenv("PIIK_PENDING_CAPTURE_FIXTURE", "true")
|
||||
if value, err := session.Handle(t.Context(), []byte(payload)); value != nil || err != nil {
|
||||
t.Fatalf("capture command blocked instead of deferring: %#v %v", value, err)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(directory, "prepared")); err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("capture fixture did not start")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
// No unbounded parallel initialization while the first is still waiting.
|
||||
assertOperationFailure(t, session, payload)
|
||||
if _, err := session.Handle(t.Context(), []byte(`{"version":9,"id":"request_ice","type":"edge-candidate","shareId":"share_123456","connectionId":"missing_edge","candidate":null}`)); err != nil {
|
||||
t.Fatalf("capture initialization blocked independent control: %v", err)
|
||||
}
|
||||
stopped := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := session.Handle(t.Context(), []byte(`{"version":9,"id":"request_stop","type":"stop-share","shareId":"share_123456"}`))
|
||||
stopped <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-stopped:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("stop could not cancel pending capture")
|
||||
}
|
||||
if session.current("share_123456") != nil {
|
||||
t.Fatal("late startup installed a cancelled Host")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -34,13 +34,19 @@ func (session *Session) handlePublication(envelope requestEnvelope, payload []by
|
||||
return nil, err
|
||||
}
|
||||
case "publication-answer":
|
||||
if err := decodeStrict(payload, &answer); err != nil || len(answer.SDP) == 0 || len(answer.SDP) > maxSDPBytes {
|
||||
if err := decodeStrict(payload, &answer); err != nil {
|
||||
return nil, protocolViolation("native publication answer is invalid")
|
||||
}
|
||||
if !validSDP(answer.SDP) {
|
||||
return nil, errors.New("relayed publication answer is invalid")
|
||||
}
|
||||
case "publication-candidate":
|
||||
if err := decodeStrict(payload, &candidate); err != nil || !validCandidate(candidate.Candidate) {
|
||||
if err := decodeStrict(payload, &candidate); err != nil {
|
||||
return nil, protocolViolation("native publication candidate is invalid")
|
||||
}
|
||||
if !validCandidate(candidate.Candidate) {
|
||||
return nil, errors.New("relayed publication candidate is invalid")
|
||||
}
|
||||
case "publication-layers":
|
||||
if err := decodeStrict(payload, &layers); err != nil || layers.ActiveCount == nil || *layers.ActiveCount < 0 || *layers.ActiveCount > 3 {
|
||||
return nil, protocolViolation("native publication layer demand is invalid")
|
||||
|
||||
@@ -23,12 +23,13 @@ import (
|
||||
"github.com/TNTcraftHIM/Piik/internal/app/nativehost"
|
||||
"github.com/TNTcraftHIM/Piik/internal/app/nativeviewer"
|
||||
"github.com/TNTcraftHIM/Piik/internal/diagnostics"
|
||||
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
maxSDPBytes = 48 * 1024
|
||||
maxCandidateBytes = 4096
|
||||
maxSDPUnits = 48 * 1024
|
||||
maxCandidateUnits = 4096
|
||||
maxSTUNURLBytes = 512
|
||||
maxICEServers = 8
|
||||
maxURLsPerServer = 8
|
||||
@@ -46,11 +47,13 @@ type Session struct {
|
||||
events chan any
|
||||
viewerEvents chan nativeviewer.Event
|
||||
|
||||
mu sync.Mutex
|
||||
host *nativehost.Session
|
||||
viewer *nativeviewer.Session
|
||||
closed bool
|
||||
updateDone chan struct{}
|
||||
mu sync.Mutex
|
||||
host *nativehost.Session
|
||||
viewer *nativeviewer.Session
|
||||
closed bool
|
||||
updateDone chan struct{}
|
||||
startID string
|
||||
startCancel context.CancelFunc
|
||||
}
|
||||
|
||||
type outboundMediaSession interface {
|
||||
@@ -182,7 +185,9 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
|
||||
slog.Debug("piik-client", "event", "native-profile-requested", "requestId", envelope.ID,
|
||||
"share", diagnostics.ID(request.ShareID), "profile", nativeQualityProfile(request.Profile), "codec", request.Codec, "audio", request.Audio,
|
||||
"sourceKind", request.Source.Kind, "adapterIndex", request.AdapterIndex, "encoderIndex", request.EncoderIndex)
|
||||
return session.startShare(ctx, envelope, request)
|
||||
result = session.beginShare(ctx, envelope, request, complete)
|
||||
asynchronous = result == nil
|
||||
return result, nil
|
||||
case "set-microphone":
|
||||
var request microphoneRequest
|
||||
if err := decodeStrict(payload, &request); err != nil || !validIdentities(request.ShareID) ||
|
||||
@@ -244,33 +249,30 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
|
||||
if host == nil {
|
||||
return nil, errors.New("native share does not exist")
|
||||
}
|
||||
session.mu.Lock()
|
||||
updating := session.updateDone != nil
|
||||
session.mu.Unlock()
|
||||
if updating {
|
||||
return operationFailure(envelope, errors.New("native quality update is still active")), nil
|
||||
}
|
||||
audio := request.Audio && session.capabilities.Summary().AudioFor(
|
||||
request.Source.Kind,
|
||||
)
|
||||
slog.Debug("piik-client", "event", "native-source-requested", "requestId", envelope.ID,
|
||||
"share", diagnostics.ID(request.ShareID), "sourceKind", request.Source.Kind, "audio", audio,
|
||||
"adapterIndex", request.AdapterIndex, "encoderIndex", request.EncoderIndex)
|
||||
err := host.ReplaceSource(ctx, nativecapture.VideoOptions{
|
||||
Target: request.Source,
|
||||
ShowCaptureBorder: request.ShowCaptureBorder && session.capabilities.CaptureBorderControl,
|
||||
AdapterIndex: request.AdapterIndex,
|
||||
EncoderIndex: request.EncoderIndex,
|
||||
}, audio)
|
||||
if err != nil {
|
||||
return operationFailure(envelope, err), nil
|
||||
}
|
||||
slog.Debug("piik-client", "event", "native-source-applied", "requestId", envelope.ID,
|
||||
"share", diagnostics.ID(request.ShareID), "sourceKind", request.Source.Kind, "audio", audio)
|
||||
return shareSourceReplacedResponse{
|
||||
responseEnvelope: response(envelope, "share-source-replaced"),
|
||||
ShareID: request.ShareID,
|
||||
}, nil
|
||||
result = session.runHostOperation(host, envelope, func() (any, error) {
|
||||
err := host.ReplaceSource(nativecapture.VideoOptions{
|
||||
Target: request.Source,
|
||||
ShowCaptureBorder: request.ShowCaptureBorder && session.capabilities.CaptureBorderControl,
|
||||
AdapterIndex: request.AdapterIndex,
|
||||
EncoderIndex: request.EncoderIndex,
|
||||
}, audio)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.Debug("piik-client", "event", "native-source-applied", "requestId", envelope.ID,
|
||||
"share", diagnostics.ID(request.ShareID), "sourceKind", request.Source.Kind, "audio", audio)
|
||||
return shareSourceReplacedResponse{
|
||||
responseEnvelope: response(envelope, "share-source-replaced"), ShareID: request.ShareID,
|
||||
}, nil
|
||||
}, complete)
|
||||
asynchronous = result == nil
|
||||
return result, nil
|
||||
case "prepare-edge":
|
||||
var request prepareEdgeRequest
|
||||
if err := decodeStrict(payload, &request); err != nil ||
|
||||
@@ -292,19 +294,23 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
|
||||
if err := decodeStrict(payload, &request); err != nil ||
|
||||
request.Type != envelope.Type ||
|
||||
!validIdentities(request.ShareID, request.ConnectionID) ||
|
||||
request.EdgeCapacity < 1 || request.EdgeCapacity > maxEdgeCapacity ||
|
||||
len(request.SDP) == 0 || len(request.SDP) > maxSDPBytes {
|
||||
request.EdgeCapacity < 1 || request.EdgeCapacity > maxEdgeCapacity {
|
||||
return nil, protocolViolation("native receive-offer request is invalid")
|
||||
}
|
||||
if !validSDP(request.SDP) {
|
||||
return nil, errors.New("relayed offer is invalid")
|
||||
}
|
||||
return session.receiveOffer(envelope, request)
|
||||
case "receive-candidate":
|
||||
var request receiveCandidateRequest
|
||||
if err := decodeStrict(payload, &request); err != nil ||
|
||||
request.Type != envelope.Type ||
|
||||
!validIdentities(request.ShareID, request.ConnectionID) ||
|
||||
!validCandidate(request.Candidate) {
|
||||
!validIdentities(request.ShareID, request.ConnectionID) {
|
||||
return nil, protocolViolation("native receive-candidate request is invalid")
|
||||
}
|
||||
if !validCandidate(request.Candidate) {
|
||||
return nil, errors.New("relayed candidate is invalid")
|
||||
}
|
||||
viewer := session.currentViewer(request.ShareID)
|
||||
if viewer == nil {
|
||||
return nil, errors.New("native Viewer session does not exist")
|
||||
@@ -336,10 +342,12 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
|
||||
var request edgeAnswerRequest
|
||||
if err := decodeStrict(payload, &request); err != nil ||
|
||||
request.Type != envelope.Type ||
|
||||
!validIdentities(request.ShareID, request.ConnectionID) ||
|
||||
len(request.SDP) == 0 || len(request.SDP) > maxSDPBytes {
|
||||
!validIdentities(request.ShareID, request.ConnectionID) {
|
||||
return nil, protocolViolation("native edge-answer request is invalid")
|
||||
}
|
||||
if !validSDP(request.SDP) {
|
||||
return nil, errors.New("relayed answer is invalid")
|
||||
}
|
||||
media := session.currentMedia(request.ShareID)
|
||||
if media == nil {
|
||||
return nil, errors.New("native media session does not exist")
|
||||
@@ -355,10 +363,12 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
|
||||
var request edgeCandidateRequest
|
||||
if err := decodeStrict(payload, &request); err != nil ||
|
||||
request.Type != envelope.Type ||
|
||||
!validIdentities(request.ShareID, request.ConnectionID) ||
|
||||
!validCandidate(request.Candidate) {
|
||||
!validIdentities(request.ShareID, request.ConnectionID) {
|
||||
return nil, protocolViolation("native edge-candidate request is invalid")
|
||||
}
|
||||
if !validCandidate(request.Candidate) {
|
||||
return nil, errors.New("relayed candidate is invalid")
|
||||
}
|
||||
media := session.currentMedia(request.ShareID)
|
||||
if media == nil {
|
||||
return nil, errors.New("native media session does not exist")
|
||||
@@ -417,6 +427,9 @@ func (session *Session) Close() error {
|
||||
return nil
|
||||
}
|
||||
session.closed = true
|
||||
if session.startCancel != nil {
|
||||
session.startCancel()
|
||||
}
|
||||
updateDone := session.updateDone
|
||||
host := session.host
|
||||
viewer := session.viewer
|
||||
@@ -443,11 +456,11 @@ func (session *Session) updateHost(host *nativehost.Session, request requestEnve
|
||||
}, complete)
|
||||
}
|
||||
|
||||
// Quality and microphone setup share one bounded operation slot. Stop remains
|
||||
// Capture mutations share one bounded operation slot. Stop remains
|
||||
// synchronous and cancels the owning share before joining this completion.
|
||||
func (session *Session) runHostOperation(host *nativehost.Session, request requestEnvelope, operation func() (any, error), complete func(any, error)) any {
|
||||
session.mu.Lock()
|
||||
if session.closed || session.host != host || session.updateDone != nil {
|
||||
if session.closed || session.host != host || session.updateDone != nil || session.startCancel != nil {
|
||||
session.mu.Unlock()
|
||||
return operationFailure(request, errors.New("native share operation is unavailable or already active"))
|
||||
}
|
||||
@@ -465,32 +478,62 @@ func (session *Session) runHostOperation(host *nativehost.Session, request reque
|
||||
result = operationFailure(request, err)
|
||||
}
|
||||
complete(result, err)
|
||||
close(done)
|
||||
if session.closed {
|
||||
session.updateDone = nil
|
||||
session.mu.Unlock()
|
||||
return
|
||||
}
|
||||
// Release admission before an enqueued ACK can trigger the next update.
|
||||
select {
|
||||
case session.events <- result:
|
||||
session.updateDone = nil
|
||||
session.mu.Unlock()
|
||||
return
|
||||
default:
|
||||
session.mu.Unlock()
|
||||
}
|
||||
// Backpressure retains one completion but never holds the stop mutex.
|
||||
session.emit(result)
|
||||
session.mu.Lock()
|
||||
if session.updateDone == done {
|
||||
session.updateDone = nil
|
||||
}
|
||||
session.mu.Unlock()
|
||||
session.finishHostOperation(done, result)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Startup has no Host yet to cancel. Reserve that same mutation slot, and
|
||||
// transfer its context to watchHost only after the new Host has been installed.
|
||||
func (session *Session) beginShare(ctx context.Context, envelope requestEnvelope, request startShareRequest, complete func(any, error)) any {
|
||||
session.mu.Lock()
|
||||
if session.closed || session.host != nil || session.viewer != nil || session.updateDone != nil || session.startCancel != nil {
|
||||
session.mu.Unlock()
|
||||
return operationFailure(envelope, errors.New("native share is unavailable or already active"))
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan struct{})
|
||||
session.updateDone, session.startID, session.startCancel = done, request.ShareID, cancel
|
||||
session.mu.Unlock()
|
||||
go func() {
|
||||
result, err := session.startShare(ctx, envelope, request, cancel)
|
||||
if err != nil {
|
||||
cancel()
|
||||
result = operationFailure(envelope, err)
|
||||
}
|
||||
complete(result, err)
|
||||
session.mu.Lock()
|
||||
session.startID, session.startCancel = "", nil
|
||||
session.finishHostOperation(done, result)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Caller holds mu. Release admission before an ACK can trigger the next
|
||||
// mutation; a backed-up event writer must never hold the stop mutex.
|
||||
func (session *Session) finishHostOperation(done chan struct{}, result any) {
|
||||
close(done)
|
||||
if session.closed {
|
||||
session.updateDone = nil
|
||||
session.mu.Unlock()
|
||||
return
|
||||
}
|
||||
select {
|
||||
case session.events <- result:
|
||||
session.updateDone = nil
|
||||
session.mu.Unlock()
|
||||
return
|
||||
default:
|
||||
session.mu.Unlock()
|
||||
}
|
||||
session.emit(result)
|
||||
session.mu.Lock()
|
||||
if session.updateDone == done {
|
||||
session.updateDone = nil
|
||||
}
|
||||
session.mu.Unlock()
|
||||
}
|
||||
|
||||
func (session *Session) shareUpdateResult(request requestEnvelope, shareID string, profile nativehost.QualityProfile, err error) any {
|
||||
if err != nil {
|
||||
return operationFailure(request, err)
|
||||
@@ -504,17 +547,14 @@ func (session *Session) startShare(
|
||||
ctx context.Context,
|
||||
envelope requestEnvelope,
|
||||
request startShareRequest,
|
||||
cancel context.CancelFunc,
|
||||
) (any, error) {
|
||||
profile := nativeQualityProfile(request.Profile)
|
||||
if request.Codec == "vp8" && !session.capabilities.SoftwareVP8 {
|
||||
return nil, errors.New("native VP8 encoding is unavailable")
|
||||
}
|
||||
session.mu.Lock()
|
||||
if session.updateDone != nil {
|
||||
session.mu.Unlock()
|
||||
return operationFailure(envelope, errors.New("native quality update is still active")), nil
|
||||
}
|
||||
if session.closed || session.host != nil || session.viewer != nil {
|
||||
if session.closed || ctx.Err() != nil || session.host != nil || session.viewer != nil {
|
||||
session.mu.Unlock()
|
||||
return nil, errors.New("native share is already active")
|
||||
}
|
||||
@@ -553,7 +593,7 @@ func (session *Session) startShare(
|
||||
return nil, err
|
||||
}
|
||||
session.mu.Lock()
|
||||
if session.closed || session.host != nil || session.viewer != nil {
|
||||
if session.closed || ctx.Err() != nil || session.host != nil || session.viewer != nil {
|
||||
session.mu.Unlock()
|
||||
_ = host.Close()
|
||||
return nil, errors.New("native share is unavailable")
|
||||
@@ -563,7 +603,7 @@ func (session *Session) startShare(
|
||||
codec := host.Codec()
|
||||
slog.Debug("piik-client", "event", "native-profile-applied", "requestId", envelope.ID,
|
||||
"share", diagnostics.ID(request.ShareID), "profile", profile, "codec", codec, "audio", host.HasAudio())
|
||||
go session.watchHost(host)
|
||||
go func() { defer cancel(); session.watchHost(host) }()
|
||||
return shareStartedResponse{
|
||||
responseEnvelope: response(envelope, "share-started"),
|
||||
ShareID: request.ShareID,
|
||||
@@ -707,6 +747,13 @@ func (session *Session) receiveOffer(
|
||||
|
||||
func (session *Session) stopShare(shareID string) error {
|
||||
session.mu.Lock()
|
||||
if session.startID == shareID && session.startCancel != nil {
|
||||
session.startCancel()
|
||||
done := session.updateDone
|
||||
session.mu.Unlock()
|
||||
<-done
|
||||
return session.stopShare(shareID)
|
||||
}
|
||||
host := session.host
|
||||
if host == nil || host.ShareID() != shareID {
|
||||
session.mu.Unlock()
|
||||
@@ -736,7 +783,7 @@ func (session *Session) ensureViewer(
|
||||
edgeCapacity int,
|
||||
) (*nativeviewer.Session, error) {
|
||||
session.mu.Lock()
|
||||
if session.closed || session.host != nil {
|
||||
if session.closed || session.host != nil || session.startCancel != nil {
|
||||
session.mu.Unlock()
|
||||
return nil, errors.New("native media role is unavailable")
|
||||
}
|
||||
@@ -758,7 +805,7 @@ func (session *Session) ensureViewer(
|
||||
return nil, err
|
||||
}
|
||||
session.mu.Lock()
|
||||
if session.closed || session.host != nil || session.viewer != nil {
|
||||
if session.closed || session.host != nil || session.viewer != nil || session.startCancel != nil {
|
||||
session.mu.Unlock()
|
||||
_ = viewer.Close()
|
||||
return nil, errors.New("native media role is unavailable")
|
||||
@@ -972,13 +1019,20 @@ func validSTUNURL(value string) bool {
|
||||
return err == nil && number > 0 && number <= 65_535
|
||||
}
|
||||
|
||||
// Relayed media text keeps the Browser/server's UTF-16 limits. Its rejection
|
||||
// fails one request, not the authenticated Browser's entire native session.
|
||||
func validSDP(sdp string) bool {
|
||||
return sdp != "" && protocol.UTF16Length(sdp) <= maxSDPUnits
|
||||
}
|
||||
|
||||
func validCandidate(candidate *webrtc.ICECandidateInit) bool {
|
||||
if candidate == nil {
|
||||
return true
|
||||
}
|
||||
return len(candidate.Candidate) <= maxCandidateBytes &&
|
||||
(candidate.SDPMid == nil || len(*candidate.SDPMid) <= 128) &&
|
||||
(candidate.UsernameFragment == nil || len(*candidate.UsernameFragment) <= 256)
|
||||
return protocol.UTF16Length(candidate.Candidate) <= maxCandidateUnits &&
|
||||
(candidate.SDPMid == nil || protocol.UTF16Length(*candidate.SDPMid) <= 128) &&
|
||||
(candidate.SDPMLineIndex == nil || *candidate.SDPMLineIndex <= 255) &&
|
||||
(candidate.UsernameFragment == nil || protocol.UTF16Length(*candidate.UsernameFragment) <= 256)
|
||||
}
|
||||
|
||||
func validIdentities(values ...string) bool {
|
||||
|
||||
@@ -21,6 +21,35 @@ import (
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
// Production responses may arrive through Events while the command loop keeps
|
||||
// processing ICE, ping and stop. Tests wait only for this exact request ID.
|
||||
func awaitControlResponse(t *testing.T, session *Session, payload []byte) (any, error) {
|
||||
t.Helper()
|
||||
value, err := session.Handle(t.Context(), payload)
|
||||
if value != nil || err != nil {
|
||||
return value, err
|
||||
}
|
||||
var request requestEnvelope
|
||||
if err := json.Unmarshal(payload, &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.NewTimer(5 * time.Second)
|
||||
defer deadline.Stop()
|
||||
for {
|
||||
select {
|
||||
case value := <-session.Events():
|
||||
encoded, _ := json.Marshal(value)
|
||||
var envelope responseEnvelope
|
||||
_ = json.Unmarshal(encoded, &envelope)
|
||||
if envelope.ID == request.ID {
|
||||
return value, nil
|
||||
}
|
||||
case <-deadline.C:
|
||||
t.Fatalf("request %s lost its response", request.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiveOfferReusePreservesLegacyResponseShape(t *testing.T) {
|
||||
peer, err := webrtc.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
@@ -79,6 +108,11 @@ func runQuietCaptureFixture(directory string) {
|
||||
if err := os.WriteFile(filepath.Join(directory, "arguments.json"), arguments, 0600); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
if os.Getenv("PIIK_PENDING_CAPTURE_FIXTURE") == "true" {
|
||||
_ = os.WriteFile(filepath.Join(directory, "prepared"), nil, 0600)
|
||||
_, _ = io.Copy(io.Discard, os.Stdin)
|
||||
return
|
||||
}
|
||||
var state nativehost.CaptureState
|
||||
state.State, state.Codec = "starting", "vp8"
|
||||
var adapter uint32
|
||||
@@ -164,7 +198,7 @@ func TestMicrophoneOptInPreservesLegacyAndOrdersStateBeforeCompletion(t *testing
|
||||
MicrophoneMixing: optIn, EdgeCapacity: 1, Codec: "vp8",
|
||||
Profile: qualitySettings{Resolution: "1080p", MaxFramerate: 30, MaxBitrate: 5_000_000, DegradationPreference: "balanced"}}
|
||||
payload, _ := json.Marshal(request)
|
||||
result, err := session.Handle(t.Context(), payload)
|
||||
result, err := awaitControlResponse(t, session, payload)
|
||||
started, ok := result.(shareStartedResponse)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("start failed: %v %#v", err, result)
|
||||
@@ -225,7 +259,7 @@ func TestCaptureBorderPreferenceFollowsSourceAndProfile(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := session.Handle(t.Context(), payload)
|
||||
value, err := awaitControlResponse(t, session, payload)
|
||||
if _, failed := value.(requestFailedResponse); err != nil || failed {
|
||||
t.Fatalf("capture request failed: %v, %#v", err, value)
|
||||
}
|
||||
@@ -290,7 +324,13 @@ func TestQuietHostUpdatePreservesControlAndCancelsCleanly(t *testing.T) {
|
||||
t.Cleanup(func() { _ = session.Close() })
|
||||
handle := func(payload string) any {
|
||||
t.Helper()
|
||||
value, err := session.Handle(t.Context(), []byte(payload))
|
||||
var value any
|
||||
var err error
|
||||
if strings.Contains(payload, `"type":"start-share"`) {
|
||||
value, err = awaitControlResponse(t, session, []byte(payload))
|
||||
} else {
|
||||
value, err = session.Handle(t.Context(), []byte(payload))
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -361,9 +401,6 @@ func TestQuietHostUpdatePreservesControlAndCancelsCleanly(t *testing.T) {
|
||||
}
|
||||
case "stop":
|
||||
handle(`{"version":9,"id":"request_stop","type":"stop-share","shareId":"share_123456"}`)
|
||||
if _, ok := handle(strings.ReplaceAll(start, "share_123456", "share_restart")).(shareStartedResponse); !ok {
|
||||
t.Fatal("stopped update blocked a new share")
|
||||
}
|
||||
case "disconnect":
|
||||
if err = session.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -418,6 +455,11 @@ func TestQuietHostUpdatePreservesControlAndCancelsCleanly(t *testing.T) {
|
||||
if success != (outcome == "complete") {
|
||||
t.Fatal("deferred response did not match capture outcome")
|
||||
}
|
||||
if outcome == "stop" {
|
||||
if _, ok := handle(strings.ReplaceAll(start, "share_123456", "share_restart")).(shareStartedResponse); !ok {
|
||||
t.Fatal("stopped update blocked a new share")
|
||||
}
|
||||
}
|
||||
if outcome == "complete" {
|
||||
if value := handle(strings.Replace(update, "request_update", "request_followup", 1)); value != nil {
|
||||
t.Fatal("acknowledged update still blocked its successor")
|
||||
|
||||
@@ -130,7 +130,7 @@ func TestNativeMicrophonePreservesMediaOwnersAcrossInputChanges(t *testing.T) {
|
||||
}
|
||||
microphone = replaced
|
||||
options.Target.SourceID = "2"
|
||||
check(session.ReplaceSource(t.Context(), options, true))
|
||||
check(session.ReplaceSource(options, true))
|
||||
session.mixer.mu.Lock()
|
||||
sameMicrophone := session.mixer.microphone == microphone
|
||||
session.mixer.mu.Unlock()
|
||||
|
||||
@@ -179,7 +179,7 @@ func TestAudioEOFWaitsForExplicitSourceReplacement(t *testing.T) {
|
||||
waitPackets(&videoPackets, videoPackets.Load()+10, "video after audio EOF")
|
||||
before := audioPackets.Load()
|
||||
options.Target.SourceID = "2"
|
||||
check(session.ReplaceSource(ctx, options, true))
|
||||
check(session.ReplaceSource(options, true))
|
||||
if session.currentStream() == previousVideo || session.currentAudioStream() == previousAudio ||
|
||||
session.edge("audio-edge") != edge || session.audioSource != audioSource {
|
||||
t.Fatal("explicit replacement did not preserve the media owners")
|
||||
|
||||
@@ -399,7 +399,6 @@ func (session *Session) UpdateProfile(profile QualityProfile) error {
|
||||
}
|
||||
|
||||
func (session *Session) ReplaceSource(
|
||||
ctx context.Context,
|
||||
options nativecapture.VideoOptions,
|
||||
audioEnabled bool,
|
||||
) error {
|
||||
@@ -423,14 +422,14 @@ func (session *Session) ReplaceSource(
|
||||
}
|
||||
options.Profile = profile.Video
|
||||
options.RestoreToken = ""
|
||||
replacement, state, err := session.prepareVideo(ctx, options, options.Target.Kind == "picker", false)
|
||||
replacement, state, err := session.prepareVideo(session.ctx, options, options.Target.Kind == "picker", false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var replacementAudio *nativecapture.Stream
|
||||
if audioEnabled {
|
||||
replacementAudio, err = startAudioCapture(
|
||||
ctx,
|
||||
session.ctx,
|
||||
session.captureProcess,
|
||||
options.Target,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
@@ -42,6 +43,7 @@ type siteAccessOptions struct {
|
||||
// siteAccessGate issues and verifies the stateless site-access cookie.
|
||||
type siteAccessGate struct {
|
||||
password string
|
||||
signingKey [32]byte
|
||||
secure bool
|
||||
now func() int64
|
||||
ttlSeconds int
|
||||
@@ -79,12 +81,18 @@ func newSiteAccess(options siteAccessOptions) (*siteAccessGate, error) {
|
||||
if now == nil {
|
||||
now = func() int64 { return time.Now().UnixMilli() }
|
||||
}
|
||||
return &siteAccessGate{
|
||||
access := &siteAccessGate{
|
||||
password: options.Password,
|
||||
secure: options.Secure,
|
||||
now: now,
|
||||
ttlSeconds: ttlSeconds,
|
||||
}, nil
|
||||
}
|
||||
// A user-chosen password is not a signing key: a cookie must not permit
|
||||
// offline password guessing. Site authentication is scoped to this process.
|
||||
if _, err := rand.Read(access.signingKey[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return access, nil
|
||||
}
|
||||
|
||||
// required reports whether a password is configured.
|
||||
@@ -157,7 +165,7 @@ func (a *siteAccessGate) cookieName() string {
|
||||
}
|
||||
|
||||
func (a *siteAccessGate) sign(payload string) string {
|
||||
mac := hmac.New(sha256.New, []byte(a.password))
|
||||
mac := hmac.New(sha256.New, a.signingKey[:])
|
||||
mac.Write([]byte(payload))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
@@ -78,12 +78,8 @@ func TestSiteAccessCookieAttributeOrder(t *testing.T) {
|
||||
Now: func() int64 { return 1_000 },
|
||||
TTLSeconds: 12,
|
||||
})
|
||||
secure := newTestAccess(t, siteAccessOptions{
|
||||
Password: testAccessPassword,
|
||||
Secure: true,
|
||||
Now: func() int64 { return 1_000 },
|
||||
TTLSeconds: 12,
|
||||
})
|
||||
secure := *insecure
|
||||
secure.secure = true
|
||||
|
||||
signature := strings.Split(cookiePair(t, insecure.createCookie()), ".")[2]
|
||||
if len(signature) != 43 {
|
||||
@@ -104,6 +100,20 @@ func TestSiteAccessCookieAttributeOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteCookieUsesAnIndependentProcessKey(t *testing.T) {
|
||||
first := newTestAccess(t, siteAccessOptions{Password: "1"})
|
||||
second := newTestAccess(t, siteAccessOptions{Password: "1"})
|
||||
cookie := cookiePair(t, first.createCookie())
|
||||
if !first.isAuthenticated(cookie) || second.isAuthenticated(cookie) {
|
||||
t.Fatal("site cookies must authenticate only their issuing process")
|
||||
}
|
||||
// LAN/HTTPS adapters copy the same gate; they do not allocate another key.
|
||||
copy := *first
|
||||
if !copy.isAuthenticated(cookie) {
|
||||
t.Fatal("a request adapter lost the process key")
|
||||
}
|
||||
}
|
||||
|
||||
// Ports "uses a secure __Host- cookie for production HTTPS".
|
||||
func TestSiteAccessUsesHostPrefixedCookieWhenSecure(t *testing.T) {
|
||||
access := newTestAccess(t, siteAccessOptions{
|
||||
|
||||
@@ -83,6 +83,20 @@ func newServer(t *testing.T, options Options) *Server {
|
||||
return server
|
||||
}
|
||||
|
||||
func TestApplicationPagesCannotBeEmbeddedWithoutAProxy(t *testing.T) {
|
||||
server := newServer(t, Options{})
|
||||
for _, accepting := range []bool{false, true} {
|
||||
server.acceptingTraffic.Store(accepting)
|
||||
for _, path := range []string{"/", "/api/missing", "/healthz"} {
|
||||
response := httptest.NewRecorder()
|
||||
server.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if response.Header().Get("Content-Security-Policy") != "frame-ancestors 'none'" || response.Header().Get("X-Frame-Options") != "DENY" {
|
||||
t.Fatalf("unprotected response: accepting=%v path=%s", accepting, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type harness struct {
|
||||
*Server
|
||||
t *testing.T
|
||||
|
||||
@@ -24,6 +24,8 @@ var (
|
||||
|
||||
// ServeHTTP dispatches requests and contains handler panics at the HTTP boundary.
|
||||
func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Security-Policy", "frame-ancestors 'none'")
|
||||
writer.Header().Set("X-Frame-Options", "DENY")
|
||||
recorder := &responseRecorder{ResponseWriter: writer}
|
||||
defer func() {
|
||||
recovered := recover()
|
||||
|
||||
@@ -2060,8 +2060,7 @@ func TestKeepsActiveCandidateWhenUnrelatedSessionIsReplaced(t *testing.T) {
|
||||
eq(t, current.ConnectionID, "a_active_candidate")
|
||||
}
|
||||
|
||||
// TS 3920: uses a freed Host slot for new demand without moving healthy descendants
|
||||
func TestUsesFreedHostSlotForNewDemandWithoutMovingHealthyDescendants(t *testing.T) {
|
||||
func TestUsesDepartedRelaysFreedHostSlotWithoutMovingHealthyDescendants(t *testing.T) {
|
||||
routes := newController(2, Options{})
|
||||
addViewer(routes, A, 2, nil)
|
||||
addViewer(routes, B, 2, nil)
|
||||
@@ -2075,26 +2074,26 @@ func TestUsesFreedHostSlotForNewDemandWithoutMovingHealthyDescendants(t *testing
|
||||
eq(t, routes.ConfirmDeparture(B, nil), true)
|
||||
repair := must(t, routes.Reconcile(0).Operation)
|
||||
eq(t, repair.ChildPeerID, D)
|
||||
eq(t, repair.Candidates[0].Tuple, peerTuple(A))
|
||||
commitCurrent(t, routes, 1, "d_from_a")
|
||||
eq(t, repair.Candidates[0].Tuple, peerTuple(HOST))
|
||||
commitCurrent(t, routes, 1, "d_from_host")
|
||||
noOperation(t, routes.Reconcile(2).Operation)
|
||||
eq(t, hasEdge(routes, B), false)
|
||||
|
||||
addViewer(routes, E, 0, nil)
|
||||
join := must(t, routes.Reconcile(3).Operation)
|
||||
eq(t, join.ChildPeerID, E)
|
||||
eq(t, join.Candidates[0].Tuple, peerTuple(HOST))
|
||||
commitCurrent(t, routes, 4, "e_from_host")
|
||||
eq(t, join.Candidates[0].Tuple, peerTuple(A))
|
||||
commitCurrent(t, routes, 4, "e_from_a")
|
||||
|
||||
cEdge := edgeOf(t, routes, C)
|
||||
eq(t, cEdge.ParentPeerID, A)
|
||||
eq(t, cEdge.ConnectionID, "c_from_a")
|
||||
dEdge := edgeOf(t, routes, D)
|
||||
eq(t, dEdge.ParentPeerID, A)
|
||||
eq(t, dEdge.ConnectionID, "d_from_a")
|
||||
eq(t, dEdge.ParentPeerID, HOST)
|
||||
eq(t, dEdge.ConnectionID, "d_from_host")
|
||||
eEdge := edgeOf(t, routes, E)
|
||||
eq(t, eEdge.ParentPeerID, HOST)
|
||||
eq(t, eEdge.ConnectionID, "e_from_host")
|
||||
eq(t, eEdge.ParentPeerID, A)
|
||||
eq(t, eEdge.ConnectionID, "e_from_a")
|
||||
}
|
||||
|
||||
// TS 3967: keeps grace media, then reparents a confirmed relay departure
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package route
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestConfirmedDepartureRetiresMediaWhilePaused(t *testing.T) {
|
||||
for _, transport := range []string{"peer", "sfu"} {
|
||||
for _, relay := range []bool{false, true} {
|
||||
name := transport + "/leaf"
|
||||
if relay {
|
||||
name = transport + "/relay"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
routes := newController(2, Options{})
|
||||
addViewer(routes, A, 1, nil)
|
||||
if transport == "sfu" {
|
||||
routes.hydrateHostPublication("publication", res("publication"), "publisher")
|
||||
routes.hydrateEdge(A, sfuEdge("publication", "a_sfu", "subscription"))
|
||||
} else {
|
||||
routes.hydrateEdge(A, peerEdge(HOST, "a_peer"))
|
||||
}
|
||||
if relay {
|
||||
addViewer(routes, B, 0, nil)
|
||||
routes.hydrateEdge(B, peerEdge(A, "b_peer"))
|
||||
}
|
||||
routes.SetPaused(true, ms(1))
|
||||
routes.DisconnectSession(A, sessionOf(A))
|
||||
// Transient signaling loss retains media during membership grace.
|
||||
eqLabels(t, routes.Reconcile(2).Released)
|
||||
eq(t, edgeOf(t, routes, A).PhysicalActive, true)
|
||||
|
||||
routes.ConfirmDeparture(A, ms(3))
|
||||
before := routes.Revision()
|
||||
result := routes.Reconcile(4)
|
||||
noOperation(t, result.Operation)
|
||||
if routes.Revision() <= before {
|
||||
t.Fatal("departure must publish a new active assignment")
|
||||
}
|
||||
if transport == "sfu" {
|
||||
eqLabels(t, result.Released, "subscription", "publication")
|
||||
} else {
|
||||
eqLabels(t, result.Released)
|
||||
}
|
||||
if relay {
|
||||
edge := edgeOf(t, routes, A)
|
||||
eq(t, edge.PhysicalActive, false)
|
||||
eq(t, edge.Usable, false)
|
||||
eq(t, edgeOf(t, routes, B).ConnectionID, "b_peer")
|
||||
} else {
|
||||
eq(t, hasEdge(routes, A), false)
|
||||
}
|
||||
routes.assertGraph()
|
||||
eqLabels(t, routes.Reconcile(5).Released)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepartureRetiresMediaBeforePendingCandidateCanCommit(t *testing.T) {
|
||||
routes := newController(2, Options{})
|
||||
addViewer(routes, A, 0, nil)
|
||||
routes.hydrateEdge(A, peerEdge(HOST, "a_peer"))
|
||||
addViewer(routes, B, 0, nil)
|
||||
routes.Reconcile(0)
|
||||
pending := beginOperation(t, routes, BeginInput{NowMs: 1, ConnectionID: "b_old", Reservation: directOverlap("overlap")})
|
||||
stale := guardFor(B, pending.Current.Revision, "b_old")
|
||||
|
||||
routes.ConfirmDeparture(A, ms(2))
|
||||
result := routes.Reconcile(3)
|
||||
eqLabels(t, result.Released, "overlap")
|
||||
eq(t, hasEdge(routes, A), false)
|
||||
if routes.Revision() <= stale.Revision {
|
||||
t.Fatal("retirement must supersede the pending candidate revision")
|
||||
}
|
||||
eq(t, routes.CandidateReady(stale, 4, nil, CandidateProof{}).Accepted, false)
|
||||
revision := routes.Revision()
|
||||
commitCurrent(t, routes, 5, "b_new")
|
||||
if routes.Revision() <= revision {
|
||||
t.Fatal("replacement commit must not roll back the retirement revision")
|
||||
}
|
||||
eq(t, edgeOf(t, routes, B).ConnectionID, "b_new")
|
||||
}
|
||||
|
||||
func TestDeparturePreservesSharedPublicationAndHealthySibling(t *testing.T) {
|
||||
routes := newController(1, Options{SfuEnabled: true})
|
||||
routes.hydrateHostPublication("publication", res("publication"), "publisher")
|
||||
for _, peerID := range []string{A, B} {
|
||||
addViewer(routes, peerID, 0, nil)
|
||||
routes.hydrateEdge(peerID, sfuEdge("publication", peerID+"_sfu", peerID))
|
||||
}
|
||||
addViewer(routes, C, 0, nil)
|
||||
routes.Reconcile(0)
|
||||
beginOperation(t, routes, BeginInput{NowMs: 1, ConnectionID: "c_pending", Reservation: sfuReuse("pending")})
|
||||
routes.ConfirmDeparture(A, ms(2))
|
||||
result := routes.Reconcile(3)
|
||||
eqLabels(t, result.Released, "pending", A)
|
||||
eq(t, routes.Snapshot().HostPublication.ConnectionID, "publisher")
|
||||
eq(t, edgeOf(t, routes, B).ConnectionID, B+"_sfu")
|
||||
eq(t, edgeOf(t, routes, B).PhysicalActive, true)
|
||||
routes.assertGraph()
|
||||
}
|
||||
@@ -6,6 +6,45 @@ import (
|
||||
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
|
||||
)
|
||||
|
||||
// A confirmed departure ends media authority even while acquisition is paused
|
||||
// or descendants still need the old graph anchor to find a replacement.
|
||||
func (c *Controller) retireDepartedEdges(nowMs int64) []*Resource {
|
||||
released := []*Resource{}
|
||||
changed := false
|
||||
for peerID, edge := range c.upstreamByViewer.All() {
|
||||
current, _ := c.participants.Get(peerID)
|
||||
if current == nil || !current.departureConfirmed || !edge.PhysicalActive {
|
||||
continue
|
||||
}
|
||||
if !changed {
|
||||
// Active and prepared assignments share one revision sequence. Retire
|
||||
// the candidate before publishing a newer active graph; its late ACK
|
||||
// must not commit an older revision or restore the departed route.
|
||||
released = append(released, c.abortOperation(&nowMs, RejectionAborted)...)
|
||||
}
|
||||
changed = true
|
||||
if edge.Kind == UpstreamSfu {
|
||||
c.retireSfuEdge(peerID, edge, &released, c.retainsAnchor(peerID))
|
||||
} else {
|
||||
edge.PhysicalActive = false
|
||||
edge.Usable = false
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
if !c.hasSfuSubscribers() && c.hostPublication != nil {
|
||||
c.clearSfuQuality()
|
||||
if c.hostPublication.PhysicalActive {
|
||||
released = append(released, c.hostPublication.Resource)
|
||||
}
|
||||
c.hostPublication = nil
|
||||
}
|
||||
c.pruneRetiringSfuAnchors()
|
||||
c.revision = c.allocateRevision()
|
||||
c.touchFacts()
|
||||
}
|
||||
return released
|
||||
}
|
||||
|
||||
// pruneDepartedLeaves removes departed viewers without
|
||||
// children to a fixpoint, in participants order, and drops an
|
||||
// orphaned publication.
|
||||
|
||||
@@ -54,7 +54,7 @@ func (c *Controller) directHeadStartMs() int64 {
|
||||
// (sfu-bootstrap carrier, next child, direct continuation, quality child,
|
||||
// root convergence).
|
||||
func (c *Controller) Reconcile(nowMs int64) ReconcileResult {
|
||||
released := []*Resource{}
|
||||
released := c.retireDepartedEdges(nowMs)
|
||||
validation := c.validateOrAdvance(nowMs, nil)
|
||||
released = append(released, validation.released...)
|
||||
failedPeerIDs := failedPeerIDsFrom(validation)
|
||||
@@ -64,15 +64,18 @@ func (c *Controller) Reconcile(nowMs int64) ReconcileResult {
|
||||
c.debug(string(c.operation.reason)+"-preempted", "child", c.debugPeer(c.operation.childPeerID))
|
||||
released = append(released, c.abortOperation(&nowMs, RejectionAborted)...)
|
||||
}
|
||||
removedPeerIDs := []string{}
|
||||
if c.operation == nil {
|
||||
removedPeerIDs = c.pruneDepartedLeaves(&released)
|
||||
}
|
||||
if c.paused || c.operation != nil {
|
||||
return ReconcileResult{
|
||||
Operation: c.operationSnapshot(),
|
||||
RemovedPeerIDs: []string{},
|
||||
RemovedPeerIDs: removedPeerIDs,
|
||||
FailedPeerIDs: failedPeerIDs,
|
||||
Released: released,
|
||||
}
|
||||
}
|
||||
removedPeerIDs := c.pruneDepartedLeaves(&released)
|
||||
|
||||
// The loop bound is a count, not an order.
|
||||
for remaining := c.participants.Len(); remaining > 0; remaining-- {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package signal
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
|
||||
"github.com/TNTcraftHIM/Piik/internal/server/room"
|
||||
"github.com/TNTcraftHIM/Piik/internal/server/sfu"
|
||||
"github.com/TNTcraftHIM/Piik/internal/server/sfu/sfutest"
|
||||
)
|
||||
|
||||
func TestPausedGrantRevocationRetiresCommittedMedia(t *testing.T) {
|
||||
for _, transport := range []string{"peer", "sfu"} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
media := sfutest.New()
|
||||
admission := sfu.NewAdmission(sfu.AdmissionOptions{IngressCapacity: 1, EgressCapacity: 2})
|
||||
h := startHarness(t, harnessOptions{sfu: &SfuFallback{Media: media, Admission: admission}})
|
||||
host, viewer := openClient(t, h), openClient(t, h)
|
||||
hostAuth := authenticate(t, host, h.room, protocol.RoleHost, "departure_host_12345678", 1, "", presenceOptions{})
|
||||
authenticate(t, viewer, h.room, protocol.RoleViewer, "departure_viewer_12345678", 1, "", presenceOptions{})
|
||||
if transport == "sfu" {
|
||||
direct := nextPreparedRoute(t, viewer)
|
||||
viewer.sendJSON(map[string]any{"type": "route-failed", "revision": direct.Revision, "phase": "prepare", "connectionId": direct.Candidate.ConnectionID})
|
||||
}
|
||||
active := commitPreparedRoute(t, viewer)
|
||||
setSharingPaused(host, *hostAuth.ShareGeneration, true)
|
||||
if !waitFor(t, defaultWait, func() bool {
|
||||
paused := false
|
||||
h.locked(func() {
|
||||
rm, _ := h.server.router.rooms.Get(h.room.RoomID)
|
||||
paused = rm.controller.Snapshot().Paused
|
||||
})
|
||||
return paused
|
||||
}) {
|
||||
t.Fatal("sharing did not pause")
|
||||
}
|
||||
|
||||
h.updateRoomAccess(protocol.RevokeViewerGrantRequest{Action: "revoke-viewer-grant"})
|
||||
viewer.next("viewer-grant-revoked")
|
||||
expectCloseCode(t, viewer, 4004)
|
||||
if !waitFor(t, defaultWait, func() bool {
|
||||
retired := false
|
||||
h.locked(func() {
|
||||
rm, _ := h.server.router.rooms.Get(h.room.RoomID)
|
||||
snapshot := rm.controller.Snapshot()
|
||||
retired = snapshot.UpstreamByViewer.Len() == 0 && snapshot.Revision > int64(active.Revision) && admission.Usage() == (sfu.Usage{})
|
||||
})
|
||||
return retired
|
||||
}) {
|
||||
t.Fatal("revoked viewer retained media authority while sharing was paused")
|
||||
}
|
||||
if transport == "sfu" && len(media.Deleted()) != 1 {
|
||||
t.Fatal("orphaned publication and its subscriptions were not physically closed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokedRelayRetiresMediaBeforeChildRecovery(t *testing.T) {
|
||||
for _, mode := range []string{"active", "paused", "host-offline"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
h := newRouterHarness(t, routerHarnessOptions{capacity: 1, withSfu: true})
|
||||
created := h.createRoom()
|
||||
host, first, _ := h.establishSfuRoom(created)
|
||||
h.settle()
|
||||
var child authenticatedRouteParticipant
|
||||
var fence sfu.SubscriptionFence
|
||||
h.locked(func() {
|
||||
admitted, err := h.store.ConnectParticipant(room.ConnectParticipantInput{
|
||||
RoomID: created.RoomID, Role: protocol.RoleViewer,
|
||||
ClientID: "code_child_12345678", SessionID: "code_session_12345678",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child = authenticatedRouteParticipant{roomID: created.RoomID, role: protocol.RoleViewer, peerID: admitted.PeerID, sessionID: "code_session_12345678"}
|
||||
h.doRelay(first, 1)
|
||||
h.doComplete(child)
|
||||
rm, _ := h.router.rooms.Get(created.RoomID)
|
||||
edge, _ := rm.controller.Snapshot().UpstreamByViewer.Get(first.peerID)
|
||||
fence = subscriptionFence(edge.Resource)
|
||||
})
|
||||
prepared := h.waitPreparedTransport(child.sessionID, "direct")
|
||||
h.routeReady(child, int64(prepared.Revision))
|
||||
h.settle()
|
||||
h.locked(func() {
|
||||
if h.doMustEdge(created.RoomID, child.peerID).upstream.PeerID != first.peerID {
|
||||
t.Fatal("child must initially attach to the grant-admitted relay")
|
||||
}
|
||||
if mode == "paused" {
|
||||
h.router.setPaused(created.RoomID, true)
|
||||
}
|
||||
if mode == "host-offline" {
|
||||
h.doDisconnect(host)
|
||||
}
|
||||
update, err := h.store.SetViewerGrant(created.RoomID, "revoke", created.HostToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, viewer := range update.RevokedViewers {
|
||||
h.router.removeViewer(created.RoomID, viewer.PeerID)
|
||||
}
|
||||
})
|
||||
h.settle()
|
||||
h.locked(func() {
|
||||
if h.admission.HasSubscription(fence) {
|
||||
t.Fatal("revoked relay still holds an admitted subscription")
|
||||
}
|
||||
if _, ok := h.store.GetConnectedViewer(created.RoomID, child.peerID); !ok {
|
||||
t.Fatal("grant revocation must preserve the code-admitted child")
|
||||
}
|
||||
})
|
||||
if !slices.Contains(h.media.DrainedSubscriptions(), fence) && !slices.Contains(h.media.Deleted(), fence.ResourceFence) {
|
||||
t.Fatal("neither the exact subscription nor its publication was physically closed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,9 @@ const (
|
||||
serviceRestartCloseGrace = 1_000 * time.Millisecond
|
||||
|
||||
defaultAuthenticationTimeoutMs = 5_000
|
||||
defaultViewerDisconnectGraceMs = 5_000
|
||||
// Covers the Browser's nominal first five reconnect backoffs (about
|
||||
// 0.5-15.5 s); connection/authentication time can extend those attempts.
|
||||
defaultViewerDisconnectGraceMs = 20_000
|
||||
defaultHeartbeatIntervalMs = 30_000
|
||||
)
|
||||
|
||||
|
||||
@@ -266,7 +266,7 @@ private func listSources() async throws {
|
||||
let windows = shareableWindows(content).map { $0.1 }.sorted {
|
||||
$0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending
|
||||
}
|
||||
try writeJSON(displays + windows)
|
||||
try writeJSON(Array((displays + windows).prefix(1024)))
|
||||
}
|
||||
|
||||
private final class StopSignal {
|
||||
@@ -1746,8 +1746,7 @@ private func microphoneDevices() throws -> [(id: AudioDeviceID, uid: String, nam
|
||||
}
|
||||
|
||||
private func listMicrophones() throws {
|
||||
let devices = try microphoneDevices().map { ["id": $0.uid, "label": $0.name] }
|
||||
guard devices.count <= 64 else { throw CaptureFailure(description: "too many microphones") }
|
||||
let devices = try microphoneDevices().prefix(64).map { ["id": $0.uid, "label": $0.name] }
|
||||
FileHandle.standardOutput.write(try JSONSerialization.data(withJSONObject: devices))
|
||||
}
|
||||
|
||||
|
||||
@@ -140,9 +140,10 @@ class Readback final {
|
||||
class NativeVideoFrameBuffer : public webrtc::VideoFrameBuffer {
|
||||
public:
|
||||
NativeVideoFrameBuffer(ComPtr<ID3D11Texture2D> texture, UINT32 width, UINT32 height,
|
||||
UINT64 timestamp100ns, Readback& readback)
|
||||
UINT64 timestamp100ns, Readback& readback,
|
||||
EncoderClock::time_point deadline)
|
||||
: texture_(std::move(texture)), width_(width), height_(height),
|
||||
timestamp100ns_(timestamp100ns), readback_(readback) {}
|
||||
timestamp100ns_(timestamp100ns), readback_(readback), deadline_(deadline) {}
|
||||
Type type() const override { return Type::kNative; }
|
||||
int width() const override { return static_cast<int>(width_); }
|
||||
int height() const override { return static_cast<int>(height_); }
|
||||
@@ -151,12 +152,14 @@ class NativeVideoFrameBuffer : public webrtc::VideoFrameBuffer {
|
||||
}
|
||||
ID3D11Texture2D* texture() const { return texture_.Get(); }
|
||||
UINT64 timestamp100ns() const { return timestamp100ns_; }
|
||||
EncoderClock::time_point deadline() const { return deadline_; }
|
||||
|
||||
private:
|
||||
ComPtr<ID3D11Texture2D> texture_;
|
||||
const UINT32 width_, height_;
|
||||
const UINT64 timestamp100ns_;
|
||||
Readback& readback_;
|
||||
const EncoderClock::time_point deadline_;
|
||||
};
|
||||
|
||||
class HardwareEncoder final : public webrtc::VideoEncoder {
|
||||
@@ -269,7 +272,7 @@ class HardwareEncoder final : public webrtc::VideoEncoder {
|
||||
Fail("adaptive-h264-input", "H264 requires the selected native NV12 surface");
|
||||
const auto began = env_.clock().TimeInMilliseconds();
|
||||
const auto access_unit = encoder_->Encode(native->texture(), native->timestamp100ns(),
|
||||
key_frame_pending_);
|
||||
key_frame_pending_, native->deadline());
|
||||
key_frame_pending_ = false;
|
||||
webrtc::EncodedImage image;
|
||||
image.SetEncodedData(webrtc::EncodedImageBuffer::Create(
|
||||
@@ -426,7 +429,8 @@ class AdaptiveEncoder::Impl final : public webrtc::VideoSourceInterface<webrtc::
|
||||
}
|
||||
|
||||
std::optional<AdaptiveAccessUnit> Encode(FrameProducer produce, UINT32 source_width,
|
||||
UINT32 source_height, UINT64 timestamp100ns, bool key_frame, UINT32 bitrate) {
|
||||
UINT32 source_height, UINT64 timestamp100ns, bool key_frame, UINT32 bitrate,
|
||||
EncoderClock::time_point deadline) {
|
||||
if (!produce || source_width == 0 || source_height == 0 ||
|
||||
source_width > ceiling_.width || source_height > ceiling_.height ||
|
||||
timestamp100ns > static_cast<UINT64>(std::numeric_limits<INT64>::max()))
|
||||
@@ -476,7 +480,7 @@ class AdaptiveEncoder::Impl final : public webrtc::VideoSourceInterface<webrtc::
|
||||
}
|
||||
auto buffer = webrtc::make_ref_counted<NativeVideoFrameBuffer>(
|
||||
std::move(texture), static_cast<UINT32>(width), static_cast<UINT32>(height),
|
||||
timestamp100ns, readback_);
|
||||
timestamp100ns, readback_, deadline);
|
||||
source_sink_->OnFrame(webrtc::VideoFrame::Builder().set_video_frame_buffer(buffer)
|
||||
.set_timestamp_us(capture_us).set_ntp_time_ms(ntp_ms).build());
|
||||
});
|
||||
@@ -643,9 +647,9 @@ AdaptiveEncoder::~AdaptiveEncoder() = default;
|
||||
|
||||
std::optional<AdaptiveAccessUnit> AdaptiveEncoder::Encode(FrameProducer produce,
|
||||
UINT32 source_width, UINT32 source_height, UINT64 timestamp100ns,
|
||||
bool key_frame, UINT32 bitrate) {
|
||||
bool key_frame, UINT32 bitrate, EncoderClock::time_point deadline) {
|
||||
return impl_->Encode(std::move(produce), source_width, source_height, timestamp100ns,
|
||||
key_frame, bitrate);
|
||||
key_frame, bitrate, deadline);
|
||||
}
|
||||
|
||||
} // namespace piik::capture::windows
|
||||
@@ -29,7 +29,8 @@ class AdaptiveEncoder final {
|
||||
|
||||
std::optional<AdaptiveAccessUnit> Encode(
|
||||
FrameProducer produce, UINT32 source_width, UINT32 source_height,
|
||||
UINT64 timestamp100ns, bool key_frame, UINT32 bitrate);
|
||||
UINT64 timestamp100ns, bool key_frame, UINT32 bitrate,
|
||||
EncoderClock::time_point deadline = EncoderClock::time_point::max());
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
|
||||
@@ -56,7 +56,7 @@ Invoke-CaptureBuild ('{0} /out:"{1}" {2} "{3}" {4}' -f $linkCommand,$executableP
|
||||
if ($Check) {
|
||||
$vp8Object = Join-Path $outputPath 'vp8_encoder.obj'
|
||||
Invoke-CaptureBuild ('cl.exe {0} /DNDEBUG "{1}" /Fo:"{2}"' -f $compileFlags,(Join-Path $helperDirectory 'vp8_encoder.cpp'),$vp8Object)
|
||||
foreach ($name in @('capture_geometry', 'capture_control', 'capture_target', 'output_worker')) {
|
||||
foreach ($name in @('capture_geometry', 'capture_control', 'capture_target', 'output_worker', 'mft_event_reader')) {
|
||||
$source = Join-Path $helperDirectory ($name + '.test.cpp')
|
||||
$object = Join-Path $outputPath ($name + '.test.obj')
|
||||
$executable = Join-Path $outputPath ($name + '.test.exe')
|
||||
|
||||
@@ -166,7 +166,8 @@ std::string JsonString(const std::string& value) {
|
||||
|
||||
BOOL CALLBACK CollectWindow(HWND window, LPARAM parameter) {
|
||||
auto* targets = reinterpret_cast<std::vector<SourceTarget>*>(parameter);
|
||||
if (targets->size() >= kMaxSources) return FALSE;
|
||||
// FALSE makes EnumWindows report failure; a full list is still usable.
|
||||
if (targets->size() >= kMaxSources) return TRUE;
|
||||
if (!IsWindowVisible(window) || GetWindow(window, GW_OWNER) != nullptr) {
|
||||
return TRUE;
|
||||
}
|
||||
@@ -209,7 +210,7 @@ BOOL CALLBACK CollectWindow(HWND window, LPARAM parameter) {
|
||||
|
||||
BOOL CALLBACK CollectDisplay(HMONITOR monitor, HDC, LPRECT, LPARAM parameter) {
|
||||
auto* targets = reinterpret_cast<std::vector<SourceTarget>*>(parameter);
|
||||
if (targets->size() >= kMaxSources) return FALSE;
|
||||
if (targets->size() >= kMaxSources) return TRUE;
|
||||
MONITORINFOEXW info{};
|
||||
info.cbSize = sizeof(info);
|
||||
if (!GetMonitorInfoW(monitor, &info)) return TRUE;
|
||||
@@ -931,10 +932,10 @@ int WriteMicrophoneList() {
|
||||
if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, IID_PPV_ARGS(&enumerator))) ||
|
||||
FAILED(enumerator->EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE, &devices))) return 2;
|
||||
UINT count = 0;
|
||||
if (FAILED(devices->GetCount(&count)) || count > 64) return 2;
|
||||
if (FAILED(devices->GetCount(&count))) return 2;
|
||||
std::cout << '[';
|
||||
bool first = true;
|
||||
for (UINT index = 0; index < count; ++index) {
|
||||
for (UINT index = 0; index < count && index < 64; ++index) {
|
||||
ComPtr<IMMDevice> device;
|
||||
ComPtr<IPropertyStore> properties;
|
||||
if (FAILED(devices->Item(index, &device)) || FAILED(device->OpenPropertyStore(STGM_READ, &properties))) continue;
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
#include <propvarutil.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <thread>
|
||||
|
||||
namespace piik::capture::windows {
|
||||
|
||||
@@ -608,7 +607,7 @@ LiveEncoder::LiveEncoder(
|
||||
SelectedTransform selected,
|
||||
VideoProfile profile)
|
||||
: VideoEncoder(OutputKind::h264, selected.name, selected.clsid),
|
||||
selected_(std::move(selected)), profile_(profile) {
|
||||
selected_(std::move(selected)), events_(selected_.events.Get()), profile_(profile) {
|
||||
ConfigureCodec(selected_.codec.Get(), profile_);
|
||||
ComPtr<IMFMediaType> output_type = CreateOutputType(profile_);
|
||||
Check(selected_.transform->SetOutputType(0, output_type.Get(), 0),
|
||||
@@ -701,24 +700,14 @@ EncodedAccessUnit LiveEncoder::Encode(
|
||||
|
||||
MediaEventType LiveEncoder::NextEvent(
|
||||
EncoderClock::time_point deadline, const char* timeout_stage) {
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
ComPtr<IMFMediaEvent> event;
|
||||
HRESULT result = selected_.events->GetEvent(MF_EVENT_FLAG_NO_WAIT,
|
||||
&event);
|
||||
if (result == MF_E_NO_EVENTS_AVAILABLE) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
continue;
|
||||
}
|
||||
Check(result, "mft-get-event");
|
||||
HRESULT status = S_OK;
|
||||
Check(event->GetStatus(&status), "mft-event-status-read");
|
||||
Check(status, "mft-event-status");
|
||||
MediaEventType type = MEUnknown;
|
||||
Check(event->GetType(&type), "mft-event-type");
|
||||
if (type == MEError) Fail("mft-error-event", "hardware MFT emitted MEError");
|
||||
return type;
|
||||
}
|
||||
Fail(timeout_stage, "hardware MFT event wait timed out");
|
||||
ComPtr<IMFMediaEvent> event = events_.Read(deadline, timeout_stage);
|
||||
HRESULT status = S_OK;
|
||||
Check(event->GetStatus(&status), "mft-event-status-read");
|
||||
Check(status, "mft-event-status");
|
||||
MediaEventType type = MEUnknown;
|
||||
Check(event->GetType(&type), "mft-event-type");
|
||||
if (type == MEError) Fail("mft-error-event", "hardware MFT emitted MEError");
|
||||
return type;
|
||||
}
|
||||
|
||||
void LiveEncoder::WaitForInput(EncoderClock::time_point probe_deadline) {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include "capture_error.h"
|
||||
#include "mft_event_reader.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <iomanip>
|
||||
@@ -229,6 +230,7 @@ class LiveEncoder final : public VideoEncoder {
|
||||
void WaitForInput(EncoderClock::time_point deadline);
|
||||
|
||||
SelectedTransform selected_;
|
||||
MftEventReader events_;
|
||||
VideoProfile profile_;
|
||||
MFT_OUTPUT_STREAM_INFO output_info_ = {};
|
||||
UINT32 input_requests_ = 0;
|
||||
|
||||
@@ -1212,52 +1212,53 @@ void WriteCapabilityProbe() {
|
||||
}
|
||||
}
|
||||
|
||||
double MeasureEncoderWork(VideoEncoder* hardware, ID3D11Device* device,
|
||||
const VideoProfile& profile,
|
||||
EncoderClock::time_point deadline) {
|
||||
// Some drivers emit valid H264 at a fraction of the source rate, while the
|
||||
// encoder reports no limitation (realtime-quality-adaptation.md). Pace synthetic
|
||||
// frames through the same WebRTC pipeline as a live output, including its rate
|
||||
// control and frame dropper, with the Browser probe's warmup, one-second sample
|
||||
// and 100 ms lag allowance (video-codec-preflight.ts).
|
||||
UINT32 CadenceLagFrames(UINT32 frame_rate) {
|
||||
return std::max<UINT32>(1, (frame_rate + 9) / 10);
|
||||
}
|
||||
|
||||
bool SustainsEncodedCadence(const AdaptiveEncoder::Factory& create, ID3D11Device* device,
|
||||
const VideoProfile& profile, EncoderClock::time_point deadline) {
|
||||
RequireEncoderTime(deadline);
|
||||
const UINT32 warmup_frames = (profile.frame_rate + 1) / 2;
|
||||
const UINT32 sample_frames = profile.frame_rate;
|
||||
std::unique_ptr<AdaptiveEncoder> adaptive;
|
||||
if (!hardware) {
|
||||
adaptive = std::make_unique<AdaptiveEncoder>(OutputKind::vp8, profile, device,
|
||||
AdaptiveEncoder::Factory{});
|
||||
const UINT32 lag = CadenceLagFrames(profile.frame_rate);
|
||||
// Synthesis is CPU work of the probe, not the encoder; prepare it before pacing.
|
||||
std::array<ComPtr<ID3D11Texture2D>, 8> textures;
|
||||
for (UINT32 index = 0; index < textures.size(); ++index) {
|
||||
textures[index] = CreateSyntheticTexture(device, index * 7, profile);
|
||||
}
|
||||
const auto cadence_start = EncoderClock::now();
|
||||
std::chrono::duration<double> measured{};
|
||||
AdaptiveEncoder encoder(OutputKind::h264, profile, device, create);
|
||||
const auto interval = std::chrono::nanoseconds(
|
||||
static_cast<INT64>(profile.frame_duration_100ns()) * 100);
|
||||
auto start = EncoderClock::now();
|
||||
UINT32 delivered = 0;
|
||||
for (UINT32 frame = 0; frame < warmup_frames + sample_frames; ++frame) {
|
||||
// Cold activation and the first IDR belong to warmup, not steady cadence.
|
||||
// Start the measured schedule afresh; do not carry warmup lag into it.
|
||||
if (frame == warmup_frames) start = EncoderClock::now() - interval * frame;
|
||||
const auto due = start + interval * frame;
|
||||
std::this_thread::sleep_until(std::min(due, deadline));
|
||||
RequireEncoderTime(deadline);
|
||||
auto texture = CreateSyntheticTexture(device, frame, profile);
|
||||
if (adaptive) {
|
||||
// VSE observes real input cadence; pacing is not encoder work.
|
||||
std::this_thread::sleep_until(cadence_start + std::chrono::nanoseconds(
|
||||
static_cast<INT64>(frame) * profile.frame_duration_100ns() * 100));
|
||||
RequireEncoderTime(deadline);
|
||||
}
|
||||
const auto start = EncoderClock::now();
|
||||
const auto timestamp = (static_cast<UINT64>(frame) + 1) * profile.frame_duration_100ns();
|
||||
EncodedAccessUnit encoded;
|
||||
if (adaptive) {
|
||||
auto output = adaptive->Encode([&](UINT32 width, UINT32 height) {
|
||||
if (width != profile.width || height != profile.height)
|
||||
Fail("codec-probe-output", "encoder reduced the probe resolution");
|
||||
return texture;
|
||||
}, profile.width, profile.height, timestamp, frame == 0, profile.bit_rate);
|
||||
RequireEncoderTime(deadline);
|
||||
if (!output || output->width != profile.width || output->height != profile.height ||
|
||||
output->duration100ns != static_cast<UINT64>(profile.frame_duration_100ns())) {
|
||||
Fail("codec-probe-output", "encoder reduced the probe profile or dropped a frame");
|
||||
}
|
||||
encoded = std::move(output->access_unit);
|
||||
} else {
|
||||
encoded = hardware->Encode(texture.Get(), timestamp, frame == 0, deadline);
|
||||
}
|
||||
// A dropped input cannot count as a throughput-probe output frame.
|
||||
if (encoded.bytes.empty() || encoded.bytes.size() > kMaxProductAccessUnitBytes) {
|
||||
Fail("codec-probe-output", "encoder produced an invalid probe frame");
|
||||
}
|
||||
if (frame >= warmup_frames) measured += EncoderClock::now() - start;
|
||||
// One encode is in flight, so a slow encoder delays every later source frame.
|
||||
if (frame >= warmup_frames && EncoderClock::now() > due + interval * lag) return false;
|
||||
const auto& texture = textures[frame % textures.size()];
|
||||
auto output = encoder.Encode([&](UINT32 width, UINT32 height) {
|
||||
if (width != profile.width || height != profile.height)
|
||||
Fail("codec-probe-output", "encoder reduced the probe resolution");
|
||||
return texture;
|
||||
}, profile.width, profile.height,
|
||||
(static_cast<UINT64>(frame) + 1) * profile.frame_duration_100ns(), frame == 0,
|
||||
profile.bit_rate, deadline);
|
||||
RequireEncoderTime(deadline);
|
||||
if (frame >= warmup_frames && EncoderClock::now() > due + interval * lag) return false;
|
||||
if (frame >= warmup_frames && output && !output->access_unit.bytes.empty()) ++delivered;
|
||||
}
|
||||
return measured.count() / sample_frames;
|
||||
return delivered + lag >= sample_frames;
|
||||
}
|
||||
|
||||
struct VideoEncoderSelection final {
|
||||
@@ -1265,20 +1266,6 @@ struct VideoEncoderSelection final {
|
||||
std::unique_ptr<VideoEncoder> initial;
|
||||
};
|
||||
|
||||
template <typename Measure>
|
||||
VideoEncoderSelection CompareSoftwareEncoder(std::unique_ptr<VideoEncoder> hardware,
|
||||
std::optional<double> hardware_work, EncoderClock::time_point deadline, Measure measure) {
|
||||
// No comparison is needed when only the ordinary VP8 path remains.
|
||||
if (!hardware_work) return {};
|
||||
try {
|
||||
RequireEncoderTime(deadline);
|
||||
const double software_work = measure();
|
||||
if (software_work <= *hardware_work) return {};
|
||||
} catch (const std::exception&) {}
|
||||
// The deadline bounds new probing, not reuse of an already-proved encoder.
|
||||
return {OutputKind::h264, std::move(hardware)};
|
||||
}
|
||||
|
||||
using EncoderCandidate = std::pair<UINT, UINT>;
|
||||
|
||||
void LogEncoderRejection(EncoderCandidate candidate, const GateFailure& error) {
|
||||
@@ -1319,16 +1306,12 @@ VideoEncoderSelection SelectVideoEncoder(
|
||||
device = CreateDevice(SelectAdapter(adapters, arguments.adapter_index));
|
||||
return {};
|
||||
}
|
||||
const auto began = EncoderClock::now();
|
||||
const auto budget = std::chrono::seconds(4);
|
||||
const auto deadline = began + budget;
|
||||
const auto hardware_deadline = arguments.codec == "auto" ? began + budget / 2 : deadline;
|
||||
std::optional<double> hardware_work;
|
||||
const auto deadline = EncoderClock::now() + std::chrono::seconds(4);
|
||||
std::unique_ptr<VideoEncoder> hardware;
|
||||
try {
|
||||
std::vector<EncoderCandidate> candidates;
|
||||
for (const auto& adapter : adapters) {
|
||||
RequireEncoderTime(hardware_deadline);
|
||||
RequireEncoderTime(deadline);
|
||||
try {
|
||||
const auto encoders = EnumerateHardwareEncoders(adapter, false);
|
||||
for (UINT index = 0; index < encoders.count; ++index)
|
||||
@@ -1338,41 +1321,42 @@ VideoEncoderSelection SelectVideoEncoder(
|
||||
}
|
||||
}
|
||||
const auto chosen = SelectEncoderCandidate(candidates,
|
||||
{arguments.adapter_index, arguments.mft_index}, hardware_deadline,
|
||||
{arguments.adapter_index, arguments.mft_index}, deadline,
|
||||
[&](EncoderCandidate candidate) {
|
||||
const auto& adapter = SelectAdapter(adapters, candidate.first);
|
||||
auto next_device = CreateDevice(adapter);
|
||||
const auto encoders = EnumerateHardwareEncoders(adapter);
|
||||
// Retire the Auto probe before preparing the live encoder, so selection
|
||||
// does not require two simultaneous hardware sessions on this device.
|
||||
if (arguments.codec == "auto" && !SustainsEncodedCadence(
|
||||
[&](const VideoProfile& profile) -> std::unique_ptr<VideoEncoder> {
|
||||
auto activations = EnumerateHardwareEncoders(adapter);
|
||||
return std::make_unique<LiveEncoder>(ActivateTransform(
|
||||
activations, candidate.second, next_device.manager.Get()), profile);
|
||||
},
|
||||
next_device.device.Get(), arguments.profile, deadline)) {
|
||||
Fail("codec-probe-cadence", "hardware H264 did not sustain the target frame rate");
|
||||
}
|
||||
RequireEncoderTime(deadline);
|
||||
auto selected = ActivateTransform(encoders, candidate.second, next_device.manager.Get());
|
||||
auto next = std::make_unique<LiveEncoder>(std::move(selected), arguments.profile);
|
||||
if (arguments.codec == "auto") {
|
||||
hardware_work = MeasureEncoderWork(next.get(), next_device.device.Get(),
|
||||
arguments.profile, hardware_deadline);
|
||||
} else {
|
||||
// Enumeration and activation alone do not prove usable H264 output.
|
||||
const auto texture = CreateSyntheticTexture(next_device.device.Get(), 0, arguments.profile);
|
||||
next->Encode(texture.Get(), 0, true, hardware_deadline);
|
||||
}
|
||||
RequireEncoderTime(hardware_deadline);
|
||||
// Enumeration and activation alone do not prove usable H264 output.
|
||||
const auto texture = CreateSyntheticTexture(next_device.device.Get(), 0, arguments.profile);
|
||||
next->Encode(texture.Get(), 0, true, deadline);
|
||||
RequireEncoderTime(deadline);
|
||||
device = std::move(next_device);
|
||||
hardware = std::move(next);
|
||||
});
|
||||
arguments.adapter_index = chosen.first;
|
||||
arguments.mft_index = chosen.second;
|
||||
if (arguments.codec == "h264" || *hardware_work <= 1.0 / arguments.profile.frame_rate)
|
||||
return {OutputKind::h264, std::move(hardware)};
|
||||
return {OutputKind::h264, std::move(hardware)};
|
||||
} catch (const GateFailure&) {
|
||||
if (arguments.codec == "h264") throw;
|
||||
hardware_work.reset();
|
||||
hardware.reset();
|
||||
device = DeviceContext{};
|
||||
}
|
||||
if (!device.device) {
|
||||
device = CreateDevice(SelectAdapter(adapters, arguments.adapter_index));
|
||||
}
|
||||
return CompareSoftwareEncoder(std::move(hardware), hardware_work, deadline, [&] {
|
||||
return MeasureEncoderWork(nullptr, device.device.Get(), arguments.profile, deadline);
|
||||
});
|
||||
// Without a sustaining hardware candidate, Auto starts the ordinary VP8 path.
|
||||
device = CreateDevice(SelectAdapter(adapters, arguments.adapter_index));
|
||||
return {};
|
||||
}
|
||||
|
||||
struct CaptureInput final {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
#pragma once
|
||||
|
||||
#include "capture_error.h"
|
||||
|
||||
#include <mfobjects.h>
|
||||
#include <wrl.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <mutex>
|
||||
|
||||
namespace piik::capture::windows {
|
||||
|
||||
// One serialized reader, one outstanding request. The callback owns its state
|
||||
// even when an MFT completes after the encoder's deadline or destruction.
|
||||
// A failed Read retires the reader; Read and destruction are never concurrent.
|
||||
class MftEventReader final {
|
||||
class Completion final : public Microsoft::WRL::RuntimeClass<
|
||||
Microsoft::WRL::RuntimeClassFlags<Microsoft::WRL::ClassicCom>, IMFAsyncCallback> {
|
||||
public:
|
||||
explicit Completion(IMFMediaEventGenerator* source) : source_(source) {}
|
||||
|
||||
HRESULT STDMETHODCALLTYPE GetParameters(DWORD*, DWORD*) override { return E_NOTIMPL; }
|
||||
|
||||
HRESULT STDMETHODCALLTYPE Invoke(IMFAsyncResult* result) override {
|
||||
Microsoft::WRL::ComPtr<IMFMediaEventGenerator> source;
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
source = source_;
|
||||
}
|
||||
if (!source) return S_OK;
|
||||
Microsoft::WRL::ComPtr<IMFMediaEvent> event;
|
||||
const HRESULT status = source->EndGetEvent(result, &event);
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
if (!source_) return S_OK;
|
||||
status_ = status;
|
||||
event_ = std::move(event);
|
||||
ready_ = true;
|
||||
}
|
||||
ready_event_.notify_one();
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaEvent> Read(
|
||||
std::chrono::steady_clock::time_point deadline, const char* timeout_stage) {
|
||||
if (std::chrono::steady_clock::now() >= deadline) Timeout(timeout_stage);
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
ready_ = false;
|
||||
}
|
||||
Check(source_->BeginGetEvent(this, nullptr), "mft-begin-get-event");
|
||||
std::unique_lock lock(mutex_);
|
||||
if (!ready_event_.wait_until(lock, deadline, [this] { return ready_; }) ||
|
||||
std::chrono::steady_clock::now() >= deadline) {
|
||||
Timeout(timeout_stage);
|
||||
}
|
||||
Check(status_, "mft-get-event");
|
||||
return std::move(event_);
|
||||
}
|
||||
|
||||
void Close() {
|
||||
// Break the generator/callback reference cycle before its existing owner
|
||||
// shuts down the MFT. Shutdown need not deliver a final callback.
|
||||
Microsoft::WRL::ComPtr<IMFMediaEventGenerator> source;
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
source.Swap(source_);
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
[[noreturn]] static void Timeout(const char* stage) {
|
||||
Fail(stage, "hardware MFT event wait timed out");
|
||||
}
|
||||
std::mutex mutex_;
|
||||
std::condition_variable ready_event_;
|
||||
Microsoft::WRL::ComPtr<IMFMediaEventGenerator> source_;
|
||||
Microsoft::WRL::ComPtr<IMFMediaEvent> event_;
|
||||
HRESULT status_ = S_OK;
|
||||
bool ready_ = false;
|
||||
};
|
||||
|
||||
public:
|
||||
explicit MftEventReader(IMFMediaEventGenerator* source)
|
||||
: completion_(Microsoft::WRL::Make<Completion>(source)) {
|
||||
Check(completion_ ? S_OK : E_OUTOFMEMORY, "mft-event-reader");
|
||||
}
|
||||
~MftEventReader() { completion_->Close(); }
|
||||
MftEventReader(const MftEventReader&) = delete;
|
||||
MftEventReader& operator=(const MftEventReader&) = delete;
|
||||
|
||||
Microsoft::WRL::ComPtr<IMFMediaEvent> Read(
|
||||
std::chrono::steady_clock::time_point deadline, const char* timeout_stage) {
|
||||
return completion_->Read(deadline, timeout_stage);
|
||||
}
|
||||
|
||||
private:
|
||||
Microsoft::WRL::ComPtr<Completion> completion_;
|
||||
};
|
||||
|
||||
} // namespace piik::capture::windows
|
||||
@@ -0,0 +1,133 @@
|
||||
#include "mft_event_reader.h"
|
||||
|
||||
#include <mfapi.h>
|
||||
#include <mferror.h>
|
||||
#include <cassert>
|
||||
#include <future>
|
||||
#include <thread>
|
||||
|
||||
using namespace piik::capture::windows;
|
||||
using Microsoft::WRL::ComPtr;
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
class Generator final : public Microsoft::WRL::RuntimeClass<
|
||||
Microsoft::WRL::RuntimeClassFlags<Microsoft::WRL::ClassicCom>, IMFMediaEventGenerator> {
|
||||
public:
|
||||
Generator() { Check(MFCreateEventQueue(&queue), "fixture-queue"); }
|
||||
~Generator() { if (destroyed) destroyed->set_value(); }
|
||||
HRESULT STDMETHODCALLTYPE GetEvent(DWORD flags, IMFMediaEvent** event) override {
|
||||
return queue->GetEvent(flags, event);
|
||||
}
|
||||
HRESULT STDMETHODCALLTYPE BeginGetEvent(IMFAsyncCallback* callback, IUnknown* state) override {
|
||||
++begins;
|
||||
if (FAILED(begin_error)) return begin_error;
|
||||
if (hold) { held = callback; return S_OK; }
|
||||
return queue->BeginGetEvent(callback, state);
|
||||
}
|
||||
HRESULT STDMETHODCALLTYPE EndGetEvent(IMFAsyncResult* result, IMFMediaEvent** event) override {
|
||||
++ends;
|
||||
const HRESULT status = queue->EndGetEvent(result, event);
|
||||
if (end_entered) { end_entered->set_value(); release_end.wait(); }
|
||||
return FAILED(end_error) ? end_error : status;
|
||||
}
|
||||
HRESULT STDMETHODCALLTYPE QueueEvent(MediaEventType type, REFGUID extended,
|
||||
HRESULT status, const PROPVARIANT* payload) override {
|
||||
return queue->QueueEventParamVar(type, extended, status, payload);
|
||||
}
|
||||
void Push(MediaEventType type) { Check(QueueEvent(type, GUID_NULL, S_OK, nullptr), "fixture-push"); }
|
||||
ComPtr<IMFMediaEventQueue> queue;
|
||||
ComPtr<IMFAsyncCallback> held;
|
||||
HRESULT begin_error = S_OK;
|
||||
HRESULT end_error = S_OK;
|
||||
bool hold = false;
|
||||
unsigned begins = 0;
|
||||
unsigned ends = 0;
|
||||
std::promise<void>* end_entered = nullptr;
|
||||
std::promise<void>* destroyed = nullptr;
|
||||
std::shared_future<void> release_end;
|
||||
};
|
||||
|
||||
template <typename F>
|
||||
void ExpectFailure(F action, const char* stage) {
|
||||
try { action(); assert(false); }
|
||||
catch (const GateFailure& failure) { assert(failure.stage() == stage); }
|
||||
}
|
||||
|
||||
int main() {
|
||||
Check(CoInitializeEx(nullptr, COINIT_MULTITHREADED), "fixture-com");
|
||||
Check(MFStartup(MF_VERSION), "fixture-mf");
|
||||
{
|
||||
auto source = Microsoft::WRL::Make<Generator>();
|
||||
MftEventReader reader(source.Get());
|
||||
source->Push(METransformNeedInput);
|
||||
auto event = reader.Read(std::chrono::steady_clock::now() + 1s, "fixture-timeout");
|
||||
MediaEventType type = MEUnknown;
|
||||
Check(event->GetType(&type), "fixture-type");
|
||||
assert(type == METransformNeedInput);
|
||||
std::thread delayed([&] { std::this_thread::sleep_for(20ms); source->Push(METransformHaveOutput); });
|
||||
event = reader.Read(std::chrono::steady_clock::now() + 1s, "fixture-timeout");
|
||||
delayed.join();
|
||||
Check(event->GetType(&type), "fixture-type");
|
||||
assert(type == METransformHaveOutput);
|
||||
assert(source->begins == 2 && source->ends == 2);
|
||||
source->queue->Shutdown();
|
||||
}
|
||||
for (bool begin : {false, true}) {
|
||||
auto source = Microsoft::WRL::Make<Generator>();
|
||||
if (begin) source->begin_error = E_FAIL;
|
||||
else { source->end_error = E_FAIL; source->Push(MEError); }
|
||||
MftEventReader reader(source.Get());
|
||||
ExpectFailure([&] { reader.Read(std::chrono::steady_clock::now() + 1s, "fixture-timeout"); },
|
||||
begin ? "mft-begin-get-event" : "mft-get-event");
|
||||
source->queue->Shutdown();
|
||||
}
|
||||
{
|
||||
auto source = Microsoft::WRL::Make<Generator>();
|
||||
source->hold = true;
|
||||
{
|
||||
MftEventReader reader(source.Get());
|
||||
ExpectFailure([&] { reader.Read(std::chrono::steady_clock::now() - 1ms, "fixture-timeout"); }, "fixture-timeout");
|
||||
assert(source->begins == 0);
|
||||
ExpectFailure([&] { reader.Read(std::chrono::steady_clock::now() + 20ms, "fixture-timeout"); }, "fixture-timeout");
|
||||
assert(source->begins == 1);
|
||||
}
|
||||
// Late callback must neither access the dead reader nor retain its source.
|
||||
ComPtr<IMFAsyncCallback> late = source->held;
|
||||
source->queue->Shutdown();
|
||||
assert(source.Reset() == 0);
|
||||
Check(late->Invoke(nullptr), "fixture-late-callback");
|
||||
}
|
||||
{
|
||||
auto source = Microsoft::WRL::Make<Generator>();
|
||||
{
|
||||
MftEventReader reader(source.Get());
|
||||
ExpectFailure([&] { reader.Read(std::chrono::steady_clock::now() + 20ms, "fixture-timeout"); }, "fixture-timeout");
|
||||
}
|
||||
source->queue->Shutdown();
|
||||
assert(source.Reset() == 0);
|
||||
}
|
||||
{
|
||||
std::promise<void> entered, release, destroyed;
|
||||
auto began = entered.get_future();
|
||||
auto retired = destroyed.get_future();
|
||||
auto source = Microsoft::WRL::Make<Generator>();
|
||||
source->end_entered = &entered;
|
||||
source->destroyed = &destroyed;
|
||||
source->release_end = release.get_future().share();
|
||||
source->Push(METransformHaveOutput);
|
||||
{
|
||||
MftEventReader reader(source.Get());
|
||||
ExpectFailure([&] { reader.Read(std::chrono::steady_clock::now() + 500ms, "fixture-timeout"); }, "fixture-timeout");
|
||||
assert(began.wait_for(1s) == std::future_status::ready);
|
||||
}
|
||||
source->queue->Shutdown();
|
||||
source.Reset();
|
||||
// EndGetEvent was already running when Close detached the source. Its
|
||||
// local reference keeps the generator alive, then releases it without join.
|
||||
assert(retired.wait_for(0ms) == std::future_status::timeout);
|
||||
release.set_value();
|
||||
assert(retired.wait_for(1s) == std::future_status::ready);
|
||||
}
|
||||
MFShutdown();
|
||||
CoUninitialize();
|
||||
}
|
||||
@@ -20,9 +20,10 @@ AdaptiveEncoder::AdaptiveEncoder(OutputKind, VideoProfile profile, ID3D11Device*
|
||||
AdaptiveEncoder::~AdaptiveEncoder() = default;
|
||||
|
||||
std::optional<AdaptiveAccessUnit> AdaptiveEncoder::Encode(FrameProducer,
|
||||
UINT32 width, UINT32 height, UINT64 timestamp, bool key, UINT32 bitrate) {
|
||||
UINT32 width, UINT32 height, UINT64 timestamp, bool key, UINT32 bitrate,
|
||||
EncoderClock::time_point deadline) {
|
||||
impl_->encoder->SetBitrate(bitrate);
|
||||
return AdaptiveAccessUnit{impl_->encoder->Encode(nullptr, timestamp, key),
|
||||
return AdaptiveAccessUnit{impl_->encoder->Encode(nullptr, timestamp, key, deadline),
|
||||
width, height, 333'333};
|
||||
}
|
||||
} // namespace piik::capture::windows
|
||||
@@ -149,46 +150,65 @@ void CheckEncoderCandidates() {
|
||||
assert(tried.empty());
|
||||
}
|
||||
|
||||
void CheckAutoEncoderFallback() {
|
||||
const auto deadline = EncoderClock::time_point::max();
|
||||
const auto expired = EncoderClock::time_point::min();
|
||||
auto hardware = [] { return std::make_unique<FixtureEncoder>(nullptr, std::shared_future<void>{}); };
|
||||
for (const auto probe_deadline : {deadline, expired}) {
|
||||
auto initial = hardware();
|
||||
const auto* proven = initial.get();
|
||||
bool probed = false;
|
||||
const auto selected = CompareSoftwareEncoder(std::move(initial), .04, probe_deadline, [&]() -> double {
|
||||
probed = true;
|
||||
// A slow software comparison exhausts its budget after H264 succeeded.
|
||||
RequireEncoderTime(expired);
|
||||
return 0;
|
||||
});
|
||||
assert(selected.kind == OutputKind::h264 && selected.initial.get() == proven);
|
||||
assert(probed == (probe_deadline == deadline));
|
||||
class CadenceFixtureEncoder final : public VideoEncoder {
|
||||
public:
|
||||
CadenceFixtureEncoder(std::chrono::milliseconds latency, bool dropped, bool cold_only)
|
||||
: VideoEncoder(OutputKind::h264, "cadence-fixture", "cadence-fixture"),
|
||||
latency_(latency), dropped_(dropped), cold_only_(cold_only) {}
|
||||
|
||||
EncodedAccessUnit Encode(ID3D11Texture2D*, UINT64 timestamp, bool key,
|
||||
EncoderClock::time_point deadline) override {
|
||||
if (!cold_only_ || calls_ == 0) std::this_thread::sleep_for(latency_);
|
||||
++calls_;
|
||||
RequireEncoderTime(deadline);
|
||||
// An empty unit stands for an input the pipeline did not encode.
|
||||
return dropped_ ? EncodedAccessUnit{timestamp, key, {}} : EncodedAccessUnit{timestamp, key, {1, 2, 3}};
|
||||
}
|
||||
for (const double software_work : {.02, .04, .06}) {
|
||||
const auto selected = CompareSoftwareEncoder(hardware(), .04, deadline, [&] { return software_work; });
|
||||
assert((selected.kind == OutputKind::vp8) == (software_work <= .04));
|
||||
assert(static_cast<bool>(selected.initial) == (software_work > .04));
|
||||
}
|
||||
for (const auto probe_deadline : {deadline, expired}) {
|
||||
bool probed = false;
|
||||
const auto software_only = CompareSoftwareEncoder(nullptr, std::nullopt, probe_deadline, [&]() -> double {
|
||||
probed = true;
|
||||
throw GateFailure("codec-probe-timeout", "Optional comparison is unavailable");
|
||||
});
|
||||
// With no hardware candidate to compare, use the same VP8 path as manual selection.
|
||||
assert(software_only.kind == OutputKind::vp8 && !software_only.initial && !probed);
|
||||
void SetBitrate(UINT32) override {}
|
||||
|
||||
private:
|
||||
std::chrono::milliseconds latency_;
|
||||
bool dropped_;
|
||||
bool cold_only_;
|
||||
unsigned calls_ = 0;
|
||||
};
|
||||
|
||||
void CheckAutoCadenceProbe(ID3D11Device* device) {
|
||||
// The Browser probe allows one 100 ms poll of lag at each target rate.
|
||||
assert(CadenceLagFrames(5) == 1 && CadenceLagFrames(15) == 2 &&
|
||||
CadenceLagFrames(30) == 3 && CadenceLagFrames(60) == 6);
|
||||
VideoProfile profile;
|
||||
profile.width = 64;
|
||||
profile.height = 64;
|
||||
auto probe = [&](std::chrono::milliseconds latency, bool dropped,
|
||||
EncoderClock::time_point deadline, bool cold_only = false) {
|
||||
return SustainsEncodedCadence([=](const VideoProfile&) -> std::unique_ptr<VideoEncoder> {
|
||||
return std::make_unique<CadenceFixtureEncoder>(latency, dropped, cold_only);
|
||||
}, device, profile, deadline);
|
||||
};
|
||||
const auto deadline = EncoderClock::now() + std::chrono::seconds(30);
|
||||
// Valid output at a fraction of the source rate is the driver defect Auto rejects.
|
||||
assert(!probe(std::chrono::milliseconds(0), true, deadline));
|
||||
// Cold startup must not reject an encoder that sustains the measured cadence.
|
||||
assert(probe(std::chrono::milliseconds(220), false, deadline, true));
|
||||
// Sustained slow encoding still fails after warmup.
|
||||
assert(!probe(std::chrono::milliseconds(300), false, deadline));
|
||||
bool expired = false;
|
||||
try {
|
||||
probe(std::chrono::milliseconds(0), false, EncoderClock::time_point::min());
|
||||
} catch (const GateFailure& error) {
|
||||
expired = error.stage() == "codec-probe-timeout";
|
||||
}
|
||||
assert(expired);
|
||||
}
|
||||
|
||||
int main() {
|
||||
CheckEncoderCandidates();
|
||||
CheckAutoEncoderFallback();
|
||||
CheckPrimaryFailureDetails();
|
||||
ComPtr<ID3D11Device> device;
|
||||
Check(D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_WARP, nullptr, 0, nullptr, 0,
|
||||
D3D11_SDK_VERSION, &device, nullptr, nullptr), "fixture-warp");
|
||||
CheckAutoCadenceProbe(device.Get());
|
||||
CheckWorkerFailureGeneration(device.Get(), false);
|
||||
CheckWorkerFailureGeneration(device.Get(), true);
|
||||
}
|
||||
@@ -89,11 +89,11 @@ async function main(): Promise<void> {
|
||||
startedAt: new Date().toISOString(), finishedAt: "", processes: [] as Array<{ role: string; pid: number | null }>,
|
||||
high: null as { frames: number; width: number; height: number } | null,
|
||||
low: null as { frames: number; width: number; height: number } | null,
|
||||
audioEnergy: { high: 0, low: 0 },
|
||||
audioEnergy: { high: 0, low: 0, recovered: 0 },
|
||||
audioDiagnostics: null as unknown,
|
||||
secondRoom: twoRooms ? { connected: false, framesAfterFirstStopped: 0, audioBefore: 0, audioAfter: 0, stopped: false } : null,
|
||||
host: null as Snapshot | null, viewer: null as Snapshot | null,
|
||||
publicationRetired: false, nativeShareStopped: !nativeArm, udpReleased: false, cleanup: false, error: null as string | null };
|
||||
publicationRetired: false, subscriptionRecovered: false, nativeShareStopped: !nativeArm, udpReleased: false, cleanup: false, error: null as string | null };
|
||||
let server: ChildProcessWithoutNullStreams | null = null;
|
||||
let chrome: ChildProcessWithoutNullStreams | null = null;
|
||||
let vite: ViteDevServer | null = null;
|
||||
@@ -132,13 +132,14 @@ async function main(): Promise<void> {
|
||||
try {
|
||||
server = spawn(binary, [], { cwd: ROOT, stdio: "pipe", windowsHide: true,
|
||||
env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot,
|
||||
PIIK_ENV: "development", LISTEN_HOST: "127.0.0.1", PORT: String(serverPort),
|
||||
PIIK_ENV: "development", PIIK_DEBUG: "route", LISTEN_HOST: "127.0.0.1", PORT: String(serverPort),
|
||||
PIIK_LOG_DIR: join(BUILD_ROOT, "logs"),
|
||||
PUBLIC_BASE_URL: origin, ALLOWED_ORIGINS: origin,
|
||||
SFU_LISTEN_HOST: "127.0.0.1", SFU_UDP_PORT: String(mediaPort), SFU_PUBLIC_IP: "127.0.0.1" },
|
||||
});
|
||||
processStarted("server", server);
|
||||
server.stdout.resume();
|
||||
server.stderr.on("data", (data: Buffer) => { serverError = (serverError + data.toString()).slice(-2000); });
|
||||
server.stderr.on("data", (data: Buffer) => { serverError = (serverError + data.toString()).slice(-16000); });
|
||||
await waitForSample((deadline) => fetchJsonBefore<{ status: string }>(`${backend}/healthz`, deadline),
|
||||
() => true, 15_000);
|
||||
vite = await createViteServer({ root: ROOT, configFile: false, appType: "custom", logLevel: "silent",
|
||||
@@ -241,6 +242,18 @@ async function main(): Promise<void> {
|
||||
result.low = await call(viewer, "gate.waitForFrames(854, 480)");
|
||||
await waitForSample(() => call<number>(viewer!, "gate.audioEnergy()", 3_000),
|
||||
(energy) => { result.audioEnergy.low = energy; return energy > result.audioEnergy.high; }, 5_000);
|
||||
result.stage = "committed-subscription-recovery";
|
||||
const previousSubscription = await call<string>(viewer, "gate.failSubscription()");
|
||||
// Terminal failure consumes this exact opportunity. A new authenticated
|
||||
// session reopens acquisition; configuration refresh alone cannot do so.
|
||||
await until(viewer, value => value.routeStatus === "failed");
|
||||
await call(viewer, `gate.reconnectViewer(${JSON.stringify(room)})`);
|
||||
await until(viewer, value => value.committed && value.connectionId !== previousSubscription && value.audioKbps > 0);
|
||||
await call(viewer, "gate.waitForFrames(854, 480)");
|
||||
await waitForSample(() => call<number>(viewer!, "gate.audioEnergy()", 3_000),
|
||||
energy => { result.audioEnergy.recovered = energy; return energy > 0; }, 5_000);
|
||||
result.viewer = await call(viewer, "gate.snapshot()");
|
||||
result.subscriptionRecovered = true;
|
||||
result.stage = "publication-retirement";
|
||||
await call(host, "gate.stopSharing()");
|
||||
await until(host, (value) => value.publicationRetired);
|
||||
@@ -265,9 +278,10 @@ async function main(): Promise<void> {
|
||||
}
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.message : String(error);
|
||||
if (viewer && cdp) result.viewer = await call<Snapshot>(viewer, "gate.snapshot()", 3_000).catch(() => null);
|
||||
if (viewer && cdp && result.stage === "decoded-audio-energy") result.audioDiagnostics = await evaluate(
|
||||
cdp, viewer, "window.__piikGateAudioStats ?? null", Date.now() + 3_000).catch(() => null);
|
||||
if (server?.exitCode !== null && serverError) result.error += `; server: ${serverError}`;
|
||||
if (serverError) result.error += `; server: ${serverError}`;
|
||||
} finally {
|
||||
const pagesStopped = await Promise.all([host, viewer, secondHost, secondViewer].map((page) =>
|
||||
page && cdp ? call<boolean>(page, "gate.stop()", 5_000).catch(() => false) : true));
|
||||
@@ -291,8 +305,8 @@ async function main(): Promise<void> {
|
||||
result.passed = !result.error && result.host?.published === true && result.host.simulcast &&
|
||||
result.viewer?.decoded === true && result.viewer.committed && result.viewer.peerFailures > 0 &&
|
||||
result.viewer.audioKbps > 0 && result.audioEnergy.high > 0 && result.audioEnergy.low > result.audioEnergy.high &&
|
||||
result.high !== null && result.low !== null &&
|
||||
result.publicationRetired && result.cleanup && result.nativeShareStopped &&
|
||||
result.high !== null && result.low !== null && result.audioEnergy.recovered > 0 &&
|
||||
result.publicationRetired && result.subscriptionRecovered && result.cleanup && result.nativeShareStopped &&
|
||||
(!result.secondRoom || result.secondRoom.connected && result.secondRoom.framesAfterFirstStopped >= 30 &&
|
||||
result.secondRoom.audioBefore > 0 && result.secondRoom.audioAfter > result.secondRoom.audioBefore && result.secondRoom.stopped) &&
|
||||
(!nativeArm || result.host.nativeShareStarted && result.host.nativeFramesPerSecond > 0 && result.host.nativeBitrateKbps > 0 && result.host.nativeEncodingCount === 2);
|
||||
|
||||
+111
-77
@@ -14,7 +14,8 @@ import type { SfuConnectionConfig } from "../src/client/sfu/peer";
|
||||
import { NativeClient } from "../src/client/native/client";
|
||||
import { NativeSfuPublisher } from "../src/client/native/native-sfu-publisher";
|
||||
import { defaultNativeCapturePath } from "../src/client/native/capture-selection";
|
||||
import type { HostPublisherTransport } from "../src/client/media/host-sfu-route";
|
||||
import { HostSfuRoute, type HostPublisherTransport } from "../src/client/media/host-sfu-route";
|
||||
import { ViewerSfuRoute } from "../src/client/media/viewer-sfu-route";
|
||||
|
||||
const high: QualitySettings = {
|
||||
resolution: "720p", maxFramerate: 15, maxBitrate: 2_000_000,
|
||||
@@ -22,6 +23,7 @@ const high: QualitySettings = {
|
||||
};
|
||||
const low: QualitySettings = { ...high, resolution: "480p" };
|
||||
const shareGeneration = crypto.randomUUID();
|
||||
const clientId = crypto.randomUUID();
|
||||
let socket: WebSocket | null = null;
|
||||
let publisher: HostPublisherTransport | null = null;
|
||||
let native: NativeClient | null = null;
|
||||
@@ -30,14 +32,16 @@ let nativeShareStopped = false;
|
||||
let nativeFramesPerSecond = 0;
|
||||
let nativeBitrateKbps = 0;
|
||||
let nativeEncodingCount = 0;
|
||||
let subscriber: SfuSubscriber | null = null;
|
||||
let viewerRoute: ViewerSfuRoute | null = null;
|
||||
let hostRoute: HostSfuRoute | null = null;
|
||||
let profile = high;
|
||||
let failSubscriber: (() => void) | null = null;
|
||||
let configuration: SfuConnectionConfig | null = null;
|
||||
let source: MediaStream | null = null;
|
||||
let audio: AudioContext | null = null;
|
||||
let canvas: HTMLCanvasElement | null = null;
|
||||
let video: HTMLVideoElement | null = null;
|
||||
let drawTimer: number | null = null;
|
||||
let preparedRevision = 0;
|
||||
let decoded = false;
|
||||
let authenticated = false;
|
||||
let committed = false;
|
||||
@@ -52,10 +56,19 @@ let audioKbps = 0;
|
||||
let audioCodec: string | null = null;
|
||||
let error: string | null = null;
|
||||
let messageTail = Promise.resolve();
|
||||
let routeStatus: string | null = null;
|
||||
const routeEvents: Array<{ type: string; revision?: number; phase?: string; connectionId?: string | null }> = [];
|
||||
|
||||
function traceRoute(message: { type: string; revision?: number; phase?: string; connectionId?: string | null }): void {
|
||||
routeEvents.push({ type: message.type, revision: message.revision, phase: message.phase, connectionId: message.connectionId });
|
||||
if (routeEvents.length > 20) routeEvents.shift();
|
||||
}
|
||||
|
||||
function send(message: ClientMessage): boolean {
|
||||
clientMessageSchema.parse(message);
|
||||
if (socket?.readyState !== WebSocket.OPEN) return false;
|
||||
if (message.type === "route-failed" || message.type === "route-ready") traceRoute(message);
|
||||
if (message.type === "sfu-signal" && message.kind !== "candidate") traceRoute({ ...message, type: `send-${message.kind}` });
|
||||
if (message.type === "sfu-signal" && message.description?.type === "offer") {
|
||||
simulcast = message.description.sdp.includes("a=simulcast:send q;h");
|
||||
}
|
||||
@@ -68,106 +81,105 @@ function failed(reason: unknown): void {
|
||||
}
|
||||
|
||||
async function accept(message: ServerMessage, host: boolean): Promise<void> {
|
||||
if (message.type === "route-update" || message.type === "sfu-config" || message.type === "route-status") traceRoute(message);
|
||||
if (message.type === "route-status") routeStatus = message.state;
|
||||
if (message.type === "sfu-signal" && message.kind !== "candidate") traceRoute({ ...message, type: `receive-${message.kind}` });
|
||||
if (message.type === "error") throw new Error(`${message.code}: ${message.message}`);
|
||||
if (message.type === "authenticated") authenticated = true;
|
||||
if (message.type === "authenticated") {
|
||||
authenticated = true;
|
||||
if (host && !hostRoute) hostRoute = new HostSfuRoute({
|
||||
send, getStream: () => source, getProfile: () => profile,
|
||||
getVideoCodec: () => codec, reconcileChildren: () => undefined,
|
||||
createPublisher: (onDisconnected, onStats) => {
|
||||
publisher = native
|
||||
? new NativeSfuPublisher(native, shareGeneration, { iceServers: [] }, {
|
||||
send, onDisconnected, onStats: metrics => {
|
||||
onStats(metrics);
|
||||
nativeFramesPerSecond = metrics?.framesPerSecond ?? 0;
|
||||
nativeBitrateKbps = metrics?.bitrateKbps ?? 0;
|
||||
nativeEncodingCount = metrics?.videoEncodingCount ?? 0;
|
||||
},
|
||||
})
|
||||
: new SfuPublisher({ send, onDisconnected, onStats });
|
||||
return publisher;
|
||||
},
|
||||
});
|
||||
await hostRoute?.resyncAuthoritative({ revision: message.routeRevision,
|
||||
phase: "active", assignment: message.routeAssignment });
|
||||
if (!host && !viewerRoute) {
|
||||
video = document.createElement("video");
|
||||
video.volume = 0.1;
|
||||
video.autoplay = true;
|
||||
video.playsInline = true;
|
||||
video.style.cssText = "max-width:100%;width:640px";
|
||||
document.body.append(video);
|
||||
viewerRoute = new ViewerSfuRoute(message.peerId, {
|
||||
send, activatePeer: () => true,
|
||||
onSfuStream: stream => {
|
||||
decoded = committed = true;
|
||||
if (video && video.srcObject !== stream) {
|
||||
video.srcObject = stream;
|
||||
void video.play().catch(failed);
|
||||
}
|
||||
},
|
||||
onSfuUpdate: metrics => {
|
||||
audioKbps = metrics?.audioBitrateKbps ?? 0;
|
||||
audioCodec = metrics?.audioCodec ?? null;
|
||||
},
|
||||
createSubscriber: events => {
|
||||
const transport = new SfuSubscriber({ ...events, send,
|
||||
onState: state => { traceRoute({ type: `media-${state}` }); events.onState(state); },
|
||||
onFirstDecodedFrame: () => { traceRoute({ type: "decoded-frame" }); return events.onFirstDecodedFrame(); },
|
||||
});
|
||||
failSubscriber = () => { void transport.disconnect(); events.onDisconnected(); };
|
||||
return transport;
|
||||
},
|
||||
});
|
||||
}
|
||||
await viewerRoute?.resyncAuthoritative({ revision: message.routeRevision,
|
||||
phase: "active", assignment: message.routeAssignment }, message.peerId);
|
||||
}
|
||||
if (message.type === "sharing-stopped") {
|
||||
sharingStopped = true;
|
||||
await subscriber?.disconnect();
|
||||
await viewerRoute?.disconnect();
|
||||
}
|
||||
if (message.type === "route-update") {
|
||||
if (message.phase === "prepare") routeStatus = null;
|
||||
viewerRoute?.accept(message);
|
||||
await hostRoute?.acceptAndWait(message);
|
||||
if (message.phase === "prepare" && !host) {
|
||||
if (message.candidate.transport === "direct") {
|
||||
// Exercise the ordinary fallback operation without changing room policy.
|
||||
peerFailures += 1;
|
||||
send({ type: "route-failed", revision: message.revision,
|
||||
phase: "prepare", connectionId: message.candidate.connectionId });
|
||||
} else preparedRevision = message.revision;
|
||||
}
|
||||
if (message.phase === "active") {
|
||||
if (!host && message.assignment.upstream.kind === "sfu") {
|
||||
if (!decoded) throw new Error("SFU committed before decoded-frame proof");
|
||||
committed = true;
|
||||
}
|
||||
if (configuration && message.assignment.sfuPublicationGeneration === configuration.publicationGeneration) {
|
||||
configuration = { ...configuration, revision: message.revision };
|
||||
publisher?.updateConfig(configuration);
|
||||
subscriber?.updateConfig(configuration);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (message.type === "sfu-config") {
|
||||
if (configuration) {
|
||||
if (configuration.connectionId !== message.connectionId ||
|
||||
configuration.publicationGeneration !== message.publicationGeneration) {
|
||||
throw new Error("Unexpected SFU replacement during bounded acceptance");
|
||||
}
|
||||
configuration = message;
|
||||
publisher?.updateConfig(message);
|
||||
subscriber?.updateConfig(message);
|
||||
return;
|
||||
}
|
||||
configuration = message;
|
||||
if (host) {
|
||||
const events = { send, onDisconnected: () => {
|
||||
if (!stopping) failed("Publisher disconnected");
|
||||
} };
|
||||
publisher = native
|
||||
? new NativeSfuPublisher(native, shareGeneration, { iceServers: [] }, { ...events,
|
||||
onStats: (metrics) => {
|
||||
nativeFramesPerSecond = metrics?.framesPerSecond ?? 0;
|
||||
nativeBitrateKbps = metrics?.bitrateKbps ?? 0;
|
||||
nativeEncodingCount = metrics?.videoEncodingCount ?? 0;
|
||||
},
|
||||
})
|
||||
: new SfuPublisher(events);
|
||||
await publisher.connect(message);
|
||||
published = await publisher.activate(source!, high, codec);
|
||||
if (!published) throw new Error("Publisher did not activate");
|
||||
} else {
|
||||
video = document.createElement("video");
|
||||
video.muted = false;
|
||||
video.volume = 0.1;
|
||||
video.autoplay = true;
|
||||
video.playsInline = true;
|
||||
video.style.cssText = "max-width:100%;width:640px";
|
||||
document.body.append(video);
|
||||
subscriber = new SfuSubscriber({
|
||||
send,
|
||||
onStream: (stream) => {
|
||||
if (!video || video.srcObject === stream) return;
|
||||
video.srcObject = stream;
|
||||
if (stream) void video.play().catch(failed);
|
||||
},
|
||||
onStats: (metrics) => {
|
||||
audioKbps = metrics.audioBitrateKbps ?? 0;
|
||||
audioCodec = metrics.audioCodec;
|
||||
},
|
||||
onFirstDecodedFrame: () => {
|
||||
decoded = true;
|
||||
return send({ type: "route-ready", revision: preparedRevision, phase: "prepare" });
|
||||
},
|
||||
onDisconnected: () => { if (!sharingStopped) failed("Subscriber disconnected"); },
|
||||
});
|
||||
await subscriber.connect(message);
|
||||
subscriber.armDecodedFrameProof();
|
||||
subscriber.activate();
|
||||
}
|
||||
await hostRoute?.acceptConfig(message);
|
||||
published = publisher !== null;
|
||||
} else await viewerRoute?.acceptConfig(message);
|
||||
}
|
||||
if (message.type === "sfu-signal") {
|
||||
await publisher?.acceptSignal(message);
|
||||
await subscriber?.acceptSignal(message);
|
||||
await hostRoute?.acceptSignal(message);
|
||||
await viewerRoute?.acceptSignal(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(room: CreateRoomResponse, host: boolean): Promise<void> {
|
||||
socket = new WebSocket(new URL("/signal", location.href).href.replace(/^http/, "ws"));
|
||||
const connection = socket;
|
||||
socket.addEventListener("message", ({ data }) => {
|
||||
messageTail = messageTail.then(() => accept(decodeServerMessage(String(data)), host)).catch(failed);
|
||||
});
|
||||
socket.addEventListener("error", () => failed("Room WebSocket failed"));
|
||||
socket.addEventListener("close", (event) => {
|
||||
if (socket !== connection) return;
|
||||
if (host && stopping && event.code === 1000 && event.reason === "Sharing stopped") {
|
||||
void publisher?.disconnect().then(() => { publicationRetired = true; });
|
||||
void hostRoute?.disconnect().then(() => { publicationRetired = true; });
|
||||
} else if (!stopping && !sharingStopped) failed(`Unexpected room socket close: ${event.code}`);
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -175,7 +187,7 @@ async function connect(room: CreateRoomResponse, host: boolean): Promise<void> {
|
||||
socket!.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true });
|
||||
});
|
||||
const common = { type: "authenticate", protocol: SIGNALING_PROTOCOL,
|
||||
roomId: room.roomId, clientId: crypto.randomUUID() } as const;
|
||||
roomId: room.roomId, clientId } as const;
|
||||
send(host
|
||||
? { ...common, role: "host", token: room.hostToken, shareGeneration,
|
||||
qualitySettings: high, routePolicy: { peerOnly: false, topologyOptimization: false, natPrediction: false } }
|
||||
@@ -202,6 +214,7 @@ export async function startHost(nativeSource?: { title: string; port: number },
|
||||
const started = await native.startShare({ shareId: shareGeneration, source: target,
|
||||
codec, audio: true, profile: high, edgeCapacity: 2, ...path });
|
||||
nativeShareStarted = true;
|
||||
source = new MediaStream();
|
||||
if (started.codec !== codec || !started.audio) throw new Error("Requested Native codec and Opus capture did not start");
|
||||
await connect(room, true);
|
||||
return room;
|
||||
@@ -240,14 +253,34 @@ export async function startViewer(room: CreateRoomResponse): Promise<void> {
|
||||
await connect(room, false);
|
||||
}
|
||||
|
||||
export async function reconnectViewer(room: CreateRoomResponse): Promise<void> {
|
||||
const previous = socket;
|
||||
socket = null;
|
||||
previous?.close();
|
||||
authenticated = false;
|
||||
await connect(room, false);
|
||||
}
|
||||
|
||||
export function snapshot() {
|
||||
return { authenticated, published, simulcast, decoded, committed, peerFailures,
|
||||
routeEvents, routeStatus,
|
||||
connectionId: configuration?.connectionId,
|
||||
audioKbps, audioCodec, publicationRetired, sharingStopped, nativeShareStarted, nativeShareStopped,
|
||||
nativeFramesPerSecond, nativeBitrateKbps, nativeEncodingCount,
|
||||
warning: publisher?.getQualityWarning?.() ?? null,
|
||||
failureStage: publisher?.getFailureStage?.() ?? null, error };
|
||||
}
|
||||
|
||||
// Fault injection retires real media; the production route owner must negotiate
|
||||
// and decode on a new server-owned edge before the gate counts recovery.
|
||||
export function failSubscription(): string {
|
||||
if (!failSubscriber || !configuration || !committed) throw new Error("No active subscription");
|
||||
const previous = configuration.connectionId;
|
||||
decoded = committed = false;
|
||||
failSubscriber();
|
||||
return previous;
|
||||
}
|
||||
|
||||
export async function audioEnergy(): Promise<number> {
|
||||
const probe = (window as unknown as { __piikGateAudioEnergy: () => Promise<number> }).__piikGateAudioEnergy;
|
||||
return await probe();
|
||||
@@ -283,8 +316,9 @@ export async function lowerProfile(): Promise<boolean> {
|
||||
canvas.width = 854;
|
||||
canvas.height = 480;
|
||||
}
|
||||
profile = low;
|
||||
send({ type: "set-quality-settings", qualitySettings: low });
|
||||
return await publisher.updateProfile(low);
|
||||
return await hostRoute!.updateProfile(low);
|
||||
}
|
||||
|
||||
export function stopSharing(): void {
|
||||
@@ -294,8 +328,8 @@ export function stopSharing(): void {
|
||||
|
||||
export async function stop(): Promise<boolean> {
|
||||
stopping = true;
|
||||
await publisher?.disconnect();
|
||||
await subscriber?.disconnect();
|
||||
await hostRoute?.disconnect();
|
||||
await viewerRoute?.disconnect();
|
||||
if (native) {
|
||||
if (nativeShareStarted) {
|
||||
await native.stopShare(shareGeneration);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { openAsBlob } from "node:fs";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { readReleaseArtifacts } from "./release-artifacts.mjs";
|
||||
|
||||
const [directory, version, revision, option] = process.argv.slice(2);
|
||||
@@ -14,14 +15,30 @@ const api = `https://gitee.com/api/v5/repos/${repository}`;
|
||||
const marker = `<!-- piik-source: ${revision} -->`;
|
||||
const token = process.env.GITEE_TOKEN?.trim();
|
||||
|
||||
async function fetchResponse(url, options, timeout) {
|
||||
const attempts = (options.method ?? "GET") === "GET" ? 3 : 1;
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, { ...options, signal: AbortSignal.timeout(timeout) });
|
||||
if (attempt === attempts || ![408, 429, 500, 502, 503, 504].includes(response.status)) return response;
|
||||
await response.body?.cancel();
|
||||
} catch (error) {
|
||||
if (attempt === attempts || !(error instanceof TypeError || error?.name === "TimeoutError")) throw error;
|
||||
}
|
||||
await delay(attempt * 1_000);
|
||||
}
|
||||
}
|
||||
|
||||
async function request(path, method = "GET", body) {
|
||||
const multipart = body instanceof FormData;
|
||||
const response = await fetch(api + path, {
|
||||
method, redirect: "error", signal: AbortSignal.timeout(multipart ? 180_000 : 20_000),
|
||||
// A timed-out upload may already exist. Retry only reads; a script rerun
|
||||
// reconciles attachment identities before any subsequent write.
|
||||
const response = await fetchResponse(api + path, {
|
||||
method, redirect: "error",
|
||||
headers: { Accept: "application/json", Authorization: `Bearer ${token}`,
|
||||
...(!body || multipart ? {} : { "Content-Type": "application/json" }) },
|
||||
body: body ? multipart ? body : JSON.stringify(body) : undefined,
|
||||
});
|
||||
}, multipart ? 180_000 : 20_000);
|
||||
if (response.status === 404 && method === "GET") return null;
|
||||
if (!response.ok) throw new Error(`Gitee ${method} ${path}: HTTP ${response.status}`);
|
||||
return response.json();
|
||||
@@ -33,7 +50,7 @@ async function verifyDownload(uploaded, file) {
|
||||
if (uploaded?.size !== file.size || uploaded.browser_download_url !== expectedURL) {
|
||||
throw new Error(`Mirror attachment metadata mismatch: ${file.name}`);
|
||||
}
|
||||
const response = await fetch(expectedURL, { signal: AbortSignal.timeout(180_000) });
|
||||
const response = await fetchResponse(expectedURL, {}, 180_000);
|
||||
if (!response.ok || !response.body) throw new Error(`Mirror attachment is not anonymously downloadable: ${file.name}`);
|
||||
const hash = createHash("sha256");
|
||||
let size = 0;
|
||||
|
||||
+41
-21
@@ -1,7 +1,29 @@
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { appendFileSync, readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import semver from "semver";
|
||||
import { isReleaseVersion, releaseCommits, stableTags } from "./release-version.mjs";
|
||||
import { hasProductChanges, isReleaseVersion, releaseCommits, stableTags } from "./release-version.mjs";
|
||||
|
||||
export function releaseNotesSection(message, source) {
|
||||
const sections = message.replace(/\r\n/g, "\n").split(/^## /m).slice(1)
|
||||
.filter((section) => section.split("\n", 1)[0].trim() === "Release notes");
|
||||
const body = sections.length === 1
|
||||
? sections[0].split("\n").slice(1).join("\n").replace(/<!--[\s\S]*?-->/g, "").trim()
|
||||
: "";
|
||||
if (!body || !body.split("\n").some((line) => line.trim() && !/^#{1,6}(\s|$)/.test(line))) {
|
||||
throw new Error(`${source} needs one nonempty ## Release notes section`);
|
||||
}
|
||||
if (/\uFFFD|\?{3,}/u.test(body)) {
|
||||
throw new Error(`${source} has damaged Release notes text; restore the UTF-8 source`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export function pullRequestNotes(root, event) {
|
||||
const pr = event.pull_request;
|
||||
if (!pr) throw new Error("A pull_request event is required");
|
||||
return hasProductChanges(root, pr.base.sha, pr.head.sha)
|
||||
? releaseNotesSection(pr.body ?? "", `PR #${pr.number}`) : "";
|
||||
}
|
||||
|
||||
// The reviewed PR body survives the squash commit. Only its public section is
|
||||
// release copy; implementation details and verification logs stay in the PR.
|
||||
@@ -14,15 +36,7 @@ export function releaseNotes(root, version, revision, repository, { allowEmpty =
|
||||
const previous = stableTags(root, "--merged", revision).find((tag) => semver.lt(tag, version));
|
||||
const bodies = [];
|
||||
for (const commit of releaseCommits(root, revision, previous)) {
|
||||
const sections = commit.message.replace(/\r\n/g, "\n").split(/^## /m).slice(1)
|
||||
.filter((section) => section.split("\n", 1)[0].trim() === "Release notes");
|
||||
const body = sections.length === 1
|
||||
? sections[0].split("\n").slice(1).join("\n").replace(/<!--[\s\S]*?-->/g, "").trim()
|
||||
: "";
|
||||
if (!body || !body.split("\n").some((line) => line.trim() && !/^#{1,6}(\s|$)/.test(line))) {
|
||||
throw new Error(`Commit ${commit.revision} needs one nonempty ## Release notes section`);
|
||||
}
|
||||
bodies.push(body);
|
||||
bodies.push(releaseNotesSection(commit.message, `Commit ${commit.revision}`));
|
||||
}
|
||||
if (!bodies.length) {
|
||||
if (allowEmpty) return "";
|
||||
@@ -37,15 +51,21 @@ export function releaseNotes(root, version, revision, repository, { allowEmpty =
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const [version, revision, option] = process.argv.slice(2);
|
||||
if (option && option !== "--github-summary") {
|
||||
throw new Error("Use release-notes.mjs <version> <full-SHA> [--github-summary]");
|
||||
if (version === "--pull-request") {
|
||||
if (!process.env.GITHUB_EVENT_PATH) throw new Error("GITHUB_EVENT_PATH is missing");
|
||||
pullRequestNotes(process.cwd(), JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")));
|
||||
process.stdout.write("PR release notes validated.\n");
|
||||
} else {
|
||||
if (option && option !== "--github-summary") {
|
||||
throw new Error("Use release-notes.mjs <version> <full-SHA> [--github-summary]");
|
||||
}
|
||||
const body = releaseNotes(process.cwd(), version, revision,
|
||||
process.env.GITHUB_REPOSITORY || "TNTcraftHIM/Piik", { allowEmpty: Boolean(option) });
|
||||
if (option) {
|
||||
if (!process.env.GITHUB_STEP_SUMMARY) throw new Error("GITHUB_STEP_SUMMARY is missing");
|
||||
appendFileSync(process.env.GITHUB_STEP_SUMMARY,
|
||||
`## Release notes preview: Piik ${version}\n\n${body || "No product changes; this candidate does not publish a release.\n"}`);
|
||||
}
|
||||
process.stdout.write(body);
|
||||
}
|
||||
const body = releaseNotes(process.cwd(), version, revision,
|
||||
process.env.GITHUB_REPOSITORY || "TNTcraftHIM/Piik", { allowEmpty: Boolean(option) });
|
||||
if (option) {
|
||||
if (!process.env.GITHUB_STEP_SUMMARY) throw new Error("GITHUB_STEP_SUMMARY is missing");
|
||||
appendFileSync(process.env.GITHUB_STEP_SUMMARY,
|
||||
`## Release notes preview: Piik ${version}\n\n${body || "No product changes; this candidate does not publish a release.\n"}`);
|
||||
}
|
||||
process.stdout.write(body);
|
||||
}
|
||||
@@ -28,6 +28,13 @@ const nonProductPaths = [
|
||||
"cmd/piik-peer-gate/**",
|
||||
].map((path) => `:(top,glob,exclude)${path}`);
|
||||
|
||||
export function hasProductChanges(root, base, head) {
|
||||
if (![base, head].every(value => /^[a-f0-9]{40}$/.test(value))) {
|
||||
throw new Error("Product comparison requires full commit SHAs");
|
||||
}
|
||||
return git(root, "diff", "--name-only", "--no-renames", `${base}...${head}`, "--", ".", ...nonProductPaths) !== "";
|
||||
}
|
||||
|
||||
// Both version selection and public notes use this same set of product commits.
|
||||
// Git handles deletions and moves across the boundary; no workflow path filter
|
||||
// can hide an earlier product change whose publication needs to be retried.
|
||||
|
||||
@@ -371,6 +371,7 @@ export function Tooltip({
|
||||
ref={wrapRef}
|
||||
className={`lr-comic-tip-wrap${className ? ` ${className}` : ""}${toggleOnClick ? " is-help-only" : ""}${disabledTrigger ? " is-disabled-trigger" : ""}${hoverOpen ? " is-hover-open" : ""}${focusOpen ? " is-focus-open" : ""}${pressOpen ? " is-tip-open" : ""}`}
|
||||
tabIndex={focusableWrap ? 0 : undefined}
|
||||
role={focusableWrap ? "group" : undefined}
|
||||
aria-label={disabledTriggerLabel ?? (focusableWrap ? trigger?.props["aria-label"] ?? overflow?.text : undefined)}
|
||||
aria-describedby={disabledTrigger && caption && interactionOpen ? tooltipId : undefined}
|
||||
onPointerEnter={(event) => {
|
||||
|
||||
@@ -236,9 +236,9 @@ export const en: Record<CopyKey, string> = {
|
||||
"host.viewerOverview": "All viewer connections",
|
||||
|
||||
"host.quality": "Video preset",
|
||||
"host.quality.720p30": "720p · 30 fps",
|
||||
"host.quality.1080p30": "1080p · 30 fps",
|
||||
"host.quality.1080p60": "1080p · 60 fps",
|
||||
"host.quality.720p30": "720p\u00a0· 30\u00a0fps",
|
||||
"host.quality.1080p30": "1080p\u00a0· 30\u00a0fps",
|
||||
"host.quality.1080p60": "1080p\u00a0· 60\u00a0fps",
|
||||
"host.advanced": "Sharing settings",
|
||||
"host.settings.button": "Settings",
|
||||
"host.settings.picture": "Picture",
|
||||
|
||||
@@ -236,9 +236,9 @@ export const zh = {
|
||||
"host.viewerOverview": "全员连接数据",
|
||||
|
||||
"host.quality": "视频预设",
|
||||
"host.quality.720p30": "720p · 30 帧",
|
||||
"host.quality.1080p30": "1080p · 30 帧",
|
||||
"host.quality.1080p60": "1080p · 60 帧",
|
||||
"host.quality.720p30": "720p\u00a0· 30\u00a0帧",
|
||||
"host.quality.1080p30": "1080p\u00a0· 30\u00a0帧",
|
||||
"host.quality.1080p60": "1080p\u00a0· 60\u00a0帧",
|
||||
"host.advanced": "分享设置",
|
||||
"host.settings.button": "设置",
|
||||
"host.settings.picture": "画面",
|
||||
|
||||
@@ -26,6 +26,7 @@ interface ViewerSubscriberTransport {
|
||||
updateConfig(config: SfuConnectionConfig): void;
|
||||
acceptSignal(message: SfuSignalMessage): Promise<void>;
|
||||
reconnect(): boolean;
|
||||
resync(): void;
|
||||
activate(): boolean;
|
||||
deactivate(): boolean;
|
||||
armDecodedFrameProof(requireProgress?: boolean): void;
|
||||
@@ -36,7 +37,6 @@ interface ViewerSubscriberTransport {
|
||||
interface ViewerSubscriberSlot {
|
||||
connectionId: string;
|
||||
revision: number;
|
||||
phase: MediaRoutePhase;
|
||||
publicationGeneration: string;
|
||||
subscriber: ViewerSubscriberTransport;
|
||||
connected: boolean;
|
||||
@@ -102,8 +102,7 @@ export class ViewerSfuRoute {
|
||||
private readonly route = new MediaRouteTransition();
|
||||
private pending: ViewerSubscriberSlot | null = null;
|
||||
private active: ViewerSubscriberSlot | null = null;
|
||||
private recovery: { revision: number; refreshed: boolean } | null = null;
|
||||
private pendingPeerRevision: number | null = null;
|
||||
private pendingPeer: { revision: number; connectionId: string } | null = null;
|
||||
private transitionTail: Promise<void> = Promise.resolve();
|
||||
private resyncGeneration = 0;
|
||||
private resyncing = false;
|
||||
@@ -125,8 +124,6 @@ export class ViewerSfuRoute {
|
||||
previousMediaAssignment,
|
||||
update.assignment,
|
||||
);
|
||||
const continuingRecovery = previousRevision !== update.revision &&
|
||||
sameCommittedMedia && this.recovery !== null;
|
||||
const result = this.route.accept(update);
|
||||
if (result === "stale") {
|
||||
return result;
|
||||
@@ -134,20 +131,6 @@ export class ViewerSfuRoute {
|
||||
if (this.active) {
|
||||
this.active.subscriber.updateConfig({ ...this.active, revision: update.revision });
|
||||
}
|
||||
if (continuingRecovery) {
|
||||
if (this.active) {
|
||||
this.active.revision = update.revision;
|
||||
}
|
||||
if (this.pending?.phase === "active") {
|
||||
this.pending.revision = update.revision;
|
||||
}
|
||||
if (this.recovery) {
|
||||
this.recovery.revision = update.revision;
|
||||
if (this.pending === null) {
|
||||
this.recovery.refreshed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
const pausedActiveReconciliation =
|
||||
this.paused && update.phase === "active" && result !== "duplicate";
|
||||
const retiredActive = pausedActiveReconciliation
|
||||
@@ -169,13 +152,12 @@ export class ViewerSfuRoute {
|
||||
);
|
||||
}
|
||||
if (previousRevision !== update.revision && !sameCommittedMedia) {
|
||||
this.recovery = null;
|
||||
if (
|
||||
update.phase !== "prepare" &&
|
||||
this.pendingPeerRevision !== null
|
||||
this.pendingPeer !== null
|
||||
) {
|
||||
this.events.preparePeer?.(null);
|
||||
this.pendingPeerRevision = null;
|
||||
this.pendingPeer = null;
|
||||
}
|
||||
}
|
||||
if (update.phase === "prepare") {
|
||||
@@ -194,13 +176,16 @@ export class ViewerSfuRoute {
|
||||
candidate.transport !== "sfu" &&
|
||||
update.assignment.upstream.kind === "peer" &&
|
||||
candidate.connectionId.length > 0;
|
||||
const replacedPeerCandidate = this.pendingPeerRevision !== null;
|
||||
const replacedPeerCandidate = this.pendingPeer !== null;
|
||||
if (replacedPeerCandidate) {
|
||||
this.pendingPeerRevision = null;
|
||||
this.pendingPeer = null;
|
||||
this.events.preparePeer?.(null);
|
||||
}
|
||||
if (needsPeerCandidate && update.assignment.upstream.kind === "peer") {
|
||||
this.pendingPeerRevision = update.revision;
|
||||
this.pendingPeer = {
|
||||
revision: update.revision,
|
||||
connectionId: candidate.connectionId,
|
||||
};
|
||||
this.events.preparePeer?.(update.assignment, update.revision, candidate);
|
||||
} else if (
|
||||
update.assignment.upstream.kind !== "peer" &&
|
||||
@@ -225,7 +210,7 @@ export class ViewerSfuRoute {
|
||||
if (!token) {
|
||||
return result;
|
||||
}
|
||||
if (this.pendingPeerRevision !== update.revision) {
|
||||
if (this.pendingPeer?.revision !== update.revision) {
|
||||
this.preparePeer(update.assignment);
|
||||
}
|
||||
if (this.resyncing) {
|
||||
@@ -286,7 +271,7 @@ export class ViewerSfuRoute {
|
||||
}
|
||||
|
||||
private discardPending(): void {
|
||||
this.pendingPeerRevision = null;
|
||||
this.pendingPeer = null;
|
||||
this.events.preparePeer?.(null);
|
||||
this.events.prepareChild?.(null);
|
||||
this.clearPending();
|
||||
@@ -303,7 +288,7 @@ export class ViewerSfuRoute {
|
||||
if (identityChanged) {
|
||||
this.viewerPeerId = viewerPeerId;
|
||||
}
|
||||
this.pendingPeerRevision = null;
|
||||
this.pendingPeer = null;
|
||||
const currentMediaAssignment = this.route.getMediaAssignment();
|
||||
const preservesActiveMedia = Boolean(
|
||||
!identityChanged &&
|
||||
@@ -311,7 +296,6 @@ export class ViewerSfuRoute {
|
||||
sameUpstream(currentMediaAssignment, update.assignment) &&
|
||||
(update.assignment.upstream.kind !== "sfu" ||
|
||||
(this.active?.activated &&
|
||||
!this.active.failed &&
|
||||
this.active.publicationGeneration ===
|
||||
update.assignment.sfuPublicationGeneration)),
|
||||
);
|
||||
@@ -321,7 +305,17 @@ export class ViewerSfuRoute {
|
||||
) {
|
||||
const result = this.accept(update, false);
|
||||
if (result !== "stale") {
|
||||
const active = this.active;
|
||||
const token = this.route.token();
|
||||
await this.transitionTail;
|
||||
if (!this.closed && token && this.route.owns(token) &&
|
||||
preservesActiveMedia && active && this.active === active) {
|
||||
if (active.failed) {
|
||||
this.routeFailed(update.revision, "active", active.connectionId);
|
||||
} else {
|
||||
active.subscriber.resync();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -329,7 +323,6 @@ export class ViewerSfuRoute {
|
||||
const resyncGeneration = ++this.resyncGeneration;
|
||||
this.resyncing = true;
|
||||
this.route.reset();
|
||||
this.recovery = null;
|
||||
this.events.prepareChild?.(null);
|
||||
const pending = this.pending;
|
||||
const active = this.active;
|
||||
@@ -405,10 +398,12 @@ export class ViewerSfuRoute {
|
||||
this.active.activated &&
|
||||
this.active.connectionId === message.connectionId
|
||||
) {
|
||||
this.active.subscriber.updateConfig(message);
|
||||
if (phase === "active") {
|
||||
await this.queueActiveRoute(token, true, this.paused);
|
||||
if (this.active.failed) {
|
||||
this.routeFailed(message.revision, "active", message.connectionId);
|
||||
return;
|
||||
}
|
||||
this.active.subscriber.updateConfig(message);
|
||||
await this.queueActiveRoute(token, true, this.paused);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
@@ -421,6 +416,13 @@ export class ViewerSfuRoute {
|
||||
return;
|
||||
}
|
||||
|
||||
// Active config only describes an existing server-side connection. A new
|
||||
// local peer would have no offer; ask the graph owner to replace that edge.
|
||||
if (phase === "active") {
|
||||
this.routeFailed(message.revision, "active", message.connectionId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearPending();
|
||||
let slot: ViewerSubscriberSlot;
|
||||
const subscriberEvents = {
|
||||
@@ -475,7 +477,6 @@ export class ViewerSfuRoute {
|
||||
slot = {
|
||||
connectionId: message.connectionId,
|
||||
revision: message.revision,
|
||||
phase,
|
||||
publicationGeneration,
|
||||
subscriber,
|
||||
connected: false,
|
||||
@@ -496,7 +497,7 @@ export class ViewerSfuRoute {
|
||||
try {
|
||||
const connected = await subscriber.connect(message);
|
||||
if (!connected) {
|
||||
const currentToken = this.currentPendingToken(slot, token);
|
||||
const currentToken = (this.route.owns(token) ? token : null);
|
||||
if (this.pending === slot && currentToken) {
|
||||
slot.revision = currentToken.revision;
|
||||
this.handleFailure(slot);
|
||||
@@ -509,7 +510,7 @@ export class ViewerSfuRoute {
|
||||
await disconnectSubscriber(subscriber);
|
||||
return;
|
||||
}
|
||||
const currentToken = this.currentPendingToken(slot, token);
|
||||
const currentToken = (this.route.owns(token) ? token : null);
|
||||
if (this.pending !== slot || !currentToken) {
|
||||
if (this.pending === slot) {
|
||||
this.pending = null;
|
||||
@@ -529,9 +530,6 @@ export class ViewerSfuRoute {
|
||||
if (!this.paused) {
|
||||
subscriber.armDecodedFrameProof();
|
||||
}
|
||||
if (slot.phase === "active" && !this.resyncing) {
|
||||
await this.queueActiveRoute(currentToken, true);
|
||||
}
|
||||
} catch {
|
||||
this.failSubscriberSlot(slot);
|
||||
await disconnectSubscriber(subscriber);
|
||||
@@ -564,8 +562,7 @@ export class ViewerSfuRoute {
|
||||
this.resyncGeneration += 1;
|
||||
this.resyncing = false;
|
||||
this.route.reset();
|
||||
this.recovery = null;
|
||||
this.pendingPeerRevision = null;
|
||||
this.pendingPeer = null;
|
||||
this.events.preparePeer?.(null);
|
||||
this.events.prepareChild?.(null);
|
||||
await this.queueTransition(async () => {
|
||||
@@ -639,6 +636,7 @@ export class ViewerSfuRoute {
|
||||
this.active?.publicationGeneration ===
|
||||
assignment.sfuPublicationGeneration &&
|
||||
this.active?.activated &&
|
||||
!this.active.failed &&
|
||||
this.pending?.publicationGeneration !== assignment.sfuPublicationGeneration
|
||||
) {
|
||||
this.clearPending();
|
||||
@@ -651,15 +649,21 @@ export class ViewerSfuRoute {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingPeerRevision = this.pendingPeerRevision;
|
||||
const pendingPeer = this.pendingPeer;
|
||||
const promoted =
|
||||
pendingPeerRevision === token.revision
|
||||
pendingPeer?.revision === token.revision
|
||||
? await this.events.activatePeer(assignment, token.revision)
|
||||
: false;
|
||||
if (pendingPeerRevision === token.revision && promoted !== true) {
|
||||
if (!this.route.owns(token, "active")) {
|
||||
return;
|
||||
}
|
||||
this.pendingPeerRevision = null;
|
||||
if (pendingPeer?.revision === token.revision && promoted !== true) {
|
||||
// Readiness may reach the server before a candidate's local failure.
|
||||
// Once committed, report against the exact active connection for recovery.
|
||||
this.routeFailed(token.revision, "active", pendingPeer.connectionId);
|
||||
return;
|
||||
}
|
||||
this.pendingPeer = null;
|
||||
this.clearPending();
|
||||
if (this.active) {
|
||||
await this.retireActive();
|
||||
@@ -698,6 +702,10 @@ export class ViewerSfuRoute {
|
||||
): Promise<void> {
|
||||
const assignment = this.route.getActiveAssignment();
|
||||
const publicationGeneration = assignment?.sfuPublicationGeneration;
|
||||
if (this.active?.failed && this.active.publicationGeneration === publicationGeneration && !this.pending) {
|
||||
this.routeFailed(token.revision, "active", this.active.connectionId);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.active?.revision === token.revision &&
|
||||
this.active.publicationGeneration === publicationGeneration &&
|
||||
@@ -717,7 +725,7 @@ export class ViewerSfuRoute {
|
||||
this.route.owns(token, "active") &&
|
||||
pending === null
|
||||
) {
|
||||
this.requestRecovery(token.revision);
|
||||
this.events.send({ type: "refresh-sfu", revision: token.revision });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -810,7 +818,6 @@ export class ViewerSfuRoute {
|
||||
await disconnectSubscriber(slot.subscriber);
|
||||
return;
|
||||
}
|
||||
this.recovery = null;
|
||||
this.events.onSfuStream(slot.stream, assignment, true, slot.revision);
|
||||
if (previous && previous !== slot) {
|
||||
previous.failed = true;
|
||||
@@ -843,7 +850,6 @@ export class ViewerSfuRoute {
|
||||
if (!this.route.markMediaActive(token)) {
|
||||
return;
|
||||
}
|
||||
this.recovery = null;
|
||||
}
|
||||
|
||||
private clearPending(): void {
|
||||
@@ -916,7 +922,6 @@ export class ViewerSfuRoute {
|
||||
this.pending = null;
|
||||
}
|
||||
if (wasActive) {
|
||||
this.active = null;
|
||||
this.events.onSfuUpdate?.(null, slot.revision);
|
||||
}
|
||||
|
||||
@@ -930,33 +935,19 @@ export class ViewerSfuRoute {
|
||||
if (revision === null || phase === null) {
|
||||
return;
|
||||
}
|
||||
if (phase === "prepare") {
|
||||
if (wasActive) {
|
||||
const activeRevision = this.route.getActiveRevision();
|
||||
const activeAssignment = this.route.getActiveAssignment();
|
||||
if (wasActive) {
|
||||
if (
|
||||
activeRevision !== null &&
|
||||
activeAssignment?.upstream.kind === "sfu" &&
|
||||
activeAssignment.sfuPublicationGeneration === slot.publicationGeneration
|
||||
) {
|
||||
this.routeFailed(activeRevision, "active", slot.connectionId);
|
||||
}
|
||||
} else if (matchesPlannedRoute) {
|
||||
this.routeFailed(revision, "prepare");
|
||||
if (
|
||||
activeRevision !== null &&
|
||||
activeAssignment?.upstream.kind === "sfu" &&
|
||||
activeAssignment.sfuPublicationGeneration === slot.publicationGeneration
|
||||
) {
|
||||
this.routeFailed(activeRevision, "active", slot.connectionId);
|
||||
}
|
||||
return;
|
||||
} else if (matchesPlannedRoute) {
|
||||
this.routeFailed(revision, phase, phase === "active" ? slot.connectionId : null);
|
||||
}
|
||||
if (!matchesPlannedRoute) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.recovery?.revision === revision &&
|
||||
this.recovery.refreshed
|
||||
) {
|
||||
this.routeFailed(revision, "active", slot.connectionId);
|
||||
return;
|
||||
}
|
||||
this.requestRecovery(revision);
|
||||
}
|
||||
|
||||
private failSubscriberSlot(slot: ViewerSubscriberSlot): void {
|
||||
@@ -967,38 +958,6 @@ export class ViewerSfuRoute {
|
||||
this.handleFailure(slot);
|
||||
}
|
||||
|
||||
private requestRecovery(revision: number): void {
|
||||
if (!this.recovery || this.recovery.revision !== revision) {
|
||||
this.recovery = { revision, refreshed: false };
|
||||
}
|
||||
if (!this.recovery.refreshed) {
|
||||
if (this.events.send({ type: "refresh-sfu", revision })) {
|
||||
this.recovery.refreshed = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private currentPendingToken(
|
||||
slot: ViewerSubscriberSlot,
|
||||
original: RouteOperationToken,
|
||||
): RouteOperationToken | null {
|
||||
if (this.route.owns(original)) {
|
||||
return original;
|
||||
}
|
||||
if (slot.phase !== "active") {
|
||||
return null;
|
||||
}
|
||||
const current = this.route.token();
|
||||
const assignment = this.route.getActiveAssignment();
|
||||
return current &&
|
||||
this.route.owns(current, "active") &&
|
||||
assignment?.upstream.kind === "sfu" &&
|
||||
assignment.sfuPublicationGeneration === slot.publicationGeneration
|
||||
? current
|
||||
: null;
|
||||
}
|
||||
|
||||
private routeFailed(
|
||||
revision: number,
|
||||
phase: MediaRoutePhase,
|
||||
|
||||
@@ -194,8 +194,7 @@ export class NativeSfuPublisher implements HostPublisherTransport {
|
||||
this.flush();
|
||||
} else if (
|
||||
event.type === "publication-state" &&
|
||||
(event.state === "disconnected" ||
|
||||
event.state === "failed" ||
|
||||
(event.state === "failed" ||
|
||||
event.state === "closed")
|
||||
) {
|
||||
void this.fail("transport");
|
||||
|
||||
@@ -198,7 +198,7 @@ export function ControlsPreview() {
|
||||
<section id="playback-preview" className="cp-section">
|
||||
<header className="cp-section-head"><span className="cp-number">07</span><div><h2>{en ? "The playback bar" : "播放时,顺手就能找到。"}</h2>
|
||||
<p>{en ? "This bar shows its waiting state. The playback test page includes a moving picture, volume and picture in picture." : "这里展示等待画面时的播放栏;动态画面、音量和小窗可进入完整播放预览试用。"}</p></div></header>
|
||||
<div className="cp-stage"><StageTv label={t("playback.controls")}><video ref={video} poster={POSTER} playsInline />
|
||||
<div className="cp-stage"><StageTv label={t("playback.controls")}><video ref={video} poster={POSTER} playsInline inert />
|
||||
<PlaybackControls videoRef={video} stream={null} canPlay={false} theaterMode={theater} onPlay={notify}
|
||||
onToggleTheater={() => setTheater(!theater)} onReconnect={notify} reconnectAvailable />
|
||||
</StageTv></div>
|
||||
|
||||
@@ -1896,6 +1896,17 @@ export function HostPage({
|
||||
}
|
||||
}
|
||||
|
||||
function acceptHostPeerUpdate(peer: HostMediaPeer, snapshot: PeerSnapshot, generation: number): void {
|
||||
if (!isCurrentGeneration(generation) || peersRef.current.get(peer.peerId) !== peer) return;
|
||||
if (snapshot.connectionState === "failed" || snapshot.connectionState === "closed") {
|
||||
reportHostChildFailure(peer.peerId, peer.connectionId, generation);
|
||||
removePeer(peer.peerId);
|
||||
return;
|
||||
}
|
||||
updatePeerSnapshot(snapshot);
|
||||
reportSenderQuality(snapshot, activeRouteRevisionRef.current);
|
||||
}
|
||||
|
||||
function prepareHostChild(
|
||||
revision: number,
|
||||
assignment: HostRouteAssignment,
|
||||
@@ -1928,17 +1939,12 @@ export function HostPage({
|
||||
isCurrentGeneration(generation) &&
|
||||
peersRef.current.get(peer.peerId) === peer
|
||||
) {
|
||||
reportHostChildFailure(peer.peerId, peer.connectionId, generation);
|
||||
removePeer(peer.peerId);
|
||||
}
|
||||
},
|
||||
onPromotedUpdate: (peer, snapshot) => {
|
||||
if (
|
||||
isCurrentGeneration(generation) &&
|
||||
peersRef.current.get(peer.peerId) === peer
|
||||
) {
|
||||
updatePeerSnapshot(snapshot);
|
||||
reportSenderQuality(snapshot, activeRouteRevisionRef.current);
|
||||
}
|
||||
acceptHostPeerUpdate(peer, snapshot, generation);
|
||||
},
|
||||
onPreparedUpdate: (_peer, snapshot, revision) => {
|
||||
if (isCurrentGeneration(generation)) {
|
||||
@@ -2064,13 +2070,7 @@ export function HostPage({
|
||||
? signal.send({ type: "signal", targetPeerId, payload })
|
||||
: false,
|
||||
onUpdate: (snapshot) => {
|
||||
if (
|
||||
isCurrentGeneration(generation) &&
|
||||
peersRef.current.get(peerId) === peer
|
||||
) {
|
||||
updatePeerSnapshot(snapshot);
|
||||
reportSenderQuality(snapshot, activeRouteRevisionRef.current);
|
||||
}
|
||||
acceptHostPeerUpdate(peer, snapshot, generation);
|
||||
},
|
||||
};
|
||||
peer = useNative
|
||||
|
||||
@@ -72,7 +72,7 @@ export function PeoplePreview() {
|
||||
<SwitchItem checked={longNames} onChange={setLongNames} label={en ? "Long names" : "换成长名字"} />
|
||||
</div>
|
||||
<div className="cp-people-room">
|
||||
<div className="cp-sync" aria-label={en ? "The same Host in two places" : "同一位房主,两个位置"}>
|
||||
<div className="cp-sync" role="group" aria-label={en ? "The same Host in two places" : "同一位房主,两个位置"}>
|
||||
<span><PawnSvg color={participantColor(HOST_ID)} identity={HOST_ID} host /><small>{en ? "Host view" : "房主这边"}</small></span>
|
||||
<span className="cp-sync-caption">{en ? "One person, the same gesture" : "同一个人,同一个小动作"}</span>
|
||||
<span><PawnSvg color={participantColor(HOST_ID)} identity={HOST_ID} host /><small>{en ? "Viewer view" : "朋友那边"}</small></span>
|
||||
|
||||
@@ -116,7 +116,8 @@ export function PlaybackPreviewPage() {
|
||||
</header> : null}
|
||||
<div className="lr-scene">
|
||||
<StageTv live={Boolean(stream)} label={english ? "Local test picture" : "本机测试画面"}>
|
||||
<video ref={videoRef} playsInline tabIndex={0} aria-label={english ? "Local test video" : "本机测试视频"} />
|
||||
<video ref={videoRef} playsInline tabIndex={0} inert={!stream}
|
||||
aria-label={english ? "Local test video" : "本机测试视频"} />
|
||||
<PlaybackControls videoRef={videoRef} stream={stream}
|
||||
audioTrackKey={stream?.getAudioTracks().map((track) => track.id).join(",")}
|
||||
canPlay={Boolean(stream)} theaterMode={theaterMode} onPlay={play}
|
||||
|
||||
@@ -2206,6 +2206,7 @@ export function ViewerPage({
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
tabIndex={0}
|
||||
inert={!remoteMedia}
|
||||
aria-label={t("viewer.stageAria")}
|
||||
playsInline
|
||||
onPlay={() => {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
.cp-number { font: 600 11px var(--mono); color: var(--ink); }
|
||||
.cp-card p, .cp-section-head p { margin: 12px 0 20px; color: var(--muted); font-size: 13px; line-height: 1.7; }
|
||||
.cp-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 16px 0; }
|
||||
.cp-tools > [role="group"]:not(.lr-lcd) { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
.cp-tools > [role="group"]:not(.lr-lcd, .lr-toggle) { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
.cp-tools .lr-input { max-width: 100%; }
|
||||
.cp-tools .lr-input input { width: 160px; }
|
||||
.cp-tools .lr-pill { max-width: 100%; }
|
||||
|
||||
@@ -65,6 +65,12 @@ export class SfuSubscriber {
|
||||
this.peer?.updateConfig(config);
|
||||
}
|
||||
|
||||
// Reauthentication cannot establish whether a lost offer/answer was applied.
|
||||
// Retire that exact subscription through the route owner; do not replay SDP.
|
||||
resync(): void {
|
||||
if (this.restartPending) this.fail();
|
||||
}
|
||||
|
||||
async acceptSignal(message: SfuSignalMessage): Promise<void> {
|
||||
const peer = this.peer;
|
||||
if (!peer) return;
|
||||
@@ -77,7 +83,7 @@ export class SfuSubscriber {
|
||||
await peer.sendDescription(
|
||||
preferScreenAudioStereo(await peer.waitForOperation(() => peer.pc.createAnswer())),
|
||||
);
|
||||
this.restartPending = false;
|
||||
if (peer.pc.connectionState === "connected") this.restartPending = false;
|
||||
});
|
||||
} catch {
|
||||
if (this.peer === peer) this.fail();
|
||||
@@ -102,13 +108,12 @@ export class SfuSubscriber {
|
||||
reconnect(): boolean {
|
||||
if (
|
||||
!this.active ||
|
||||
this.restartPending ||
|
||||
!this.peer?.send({ kind: "subscribe" })
|
||||
this.restartPending || !this.peer
|
||||
)
|
||||
return false;
|
||||
this.restartPending = true;
|
||||
this.events.onState?.("reconnecting");
|
||||
return true;
|
||||
return this.peer.send({ kind: "subscribe" });
|
||||
}
|
||||
|
||||
armDecodedFrameProof(requireProgress = false): void {
|
||||
|
||||
+12
-1
@@ -319,6 +319,16 @@ body.lr-theater-open .lr-top { visibility: hidden; }
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
/* Keep playback controls reachable on a short landscape viewport. Entry and
|
||||
source-picker panels retain their content-driven size; theater is explicit. */
|
||||
@media (orientation: landscape) and (max-height: 500px) {
|
||||
.lr-room { --tv-viewport-reserve: 128px; }
|
||||
.lr-room:has(.lr-host-share-controls) { --tv-viewport-reserve: 200px; }
|
||||
.lr-room:not(.is-theater) .lr-tv:has(.lr-playback):not(:has(.has-entry)) {
|
||||
width: min(960px, 100%, calc(max(168px, 100svh - var(--tv-viewport-reserve)) * 16 / 9 + 30px));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lr-crt {
|
||||
0% { filter: brightness(3); transform: scaleY(.012); }
|
||||
55% { filter: brightness(1.6); transform: scaleY(1.02); }
|
||||
@@ -900,6 +910,7 @@ html[data-theme="dark"] .lr-viewer-host-slot svg { color: #9bb0c5; }
|
||||
max-width: 340px;
|
||||
flex: 1 1 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 7px 10px;
|
||||
border: 1.5px dashed var(--wall-2);
|
||||
border-radius: 8px;
|
||||
@@ -1810,7 +1821,7 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
|
||||
.lr-sharing-section .lr-door-glyph .lr-cap { white-space: normal; }
|
||||
.lr-sharing-limits { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.lr-sharing-panel .lr-slider { width: 100%; }
|
||||
.lr-sharing-panel .lr-slider output { min-width: 5ch; }
|
||||
.lr-sharing-panel .lr-slider output { min-width: 9ch; }
|
||||
.lr-sharing-section .lr-tiles { gap: 8px; }
|
||||
.lr-sharing-section .lr-tiles > .lr-comic-tip-wrap { flex: 1; min-width: 0; }
|
||||
.lr-sharing-section .lr-tile { width: 100%; min-width: 0; padding: 8px 5px; gap: 2px; border-radius: 14px; }
|
||||
|
||||
@@ -41,6 +41,9 @@ import {
|
||||
} from "./nat-prediction";
|
||||
|
||||
const MAX_PENDING_CANDIDATES = 64;
|
||||
// An audio-direction change must not wait forever for a lost SDP answer.
|
||||
// Initial acquisition and ICE restart keep their existing route/recovery owners.
|
||||
export const RENEGOTIATION_ANSWER_TIMEOUT_MS = 15_000;
|
||||
type PeerIceConfig = Pick<RTCConfiguration, "iceServers"> & {
|
||||
natPredictionStunUrls?: readonly string[];
|
||||
};
|
||||
@@ -97,6 +100,7 @@ export class HostPeer {
|
||||
private profileRevision = 0;
|
||||
private negotiationEpoch = 0;
|
||||
private ordinaryAnswerEpoch: number | null = null;
|
||||
private answerTimer: number | null = null;
|
||||
private pendingNegotiation = false;
|
||||
private senderMutationTail: Promise<void> = Promise.resolve();
|
||||
private negotiationTail: Promise<void> = Promise.resolve();
|
||||
@@ -209,7 +213,7 @@ export class HostPeer {
|
||||
audio: true,
|
||||
});
|
||||
});
|
||||
if (!(await this.createOffer(false)) || this.disposed) {
|
||||
if (!(await this.createOffer()) || this.disposed) {
|
||||
return false;
|
||||
}
|
||||
this.statsTimer = window.setInterval(() => {
|
||||
@@ -306,7 +310,7 @@ export class HostPeer {
|
||||
if (this.disposed) return false;
|
||||
this.snapshot = { ...this.snapshot, error: null };
|
||||
this.emit();
|
||||
return !audioDirectionChanged || (await this.createOffer(false));
|
||||
return !audioDirectionChanged || (await this.createOffer());
|
||||
} finally {
|
||||
if (videoChanged && !retainedNextVideoTrack) {
|
||||
nextVideoTrack.stop();
|
||||
@@ -641,7 +645,7 @@ export class HostPeer {
|
||||
return result;
|
||||
}
|
||||
|
||||
private createOffer(restart: boolean): Promise<boolean> {
|
||||
private createOffer(): Promise<boolean> {
|
||||
return this.enqueueNegotiation(async () => {
|
||||
if (this.disposed) {
|
||||
return false;
|
||||
@@ -653,7 +657,7 @@ export class HostPeer {
|
||||
this.pendingNegotiation = true;
|
||||
return true;
|
||||
}
|
||||
return this.createOwnedOffer(restart, this.nextNegotiationEpoch());
|
||||
return this.createOwnedOffer(false, this.nextNegotiationEpoch());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -664,6 +668,11 @@ export class HostPeer {
|
||||
if (!this.ownsLocalOffer(epoch)) {
|
||||
return false;
|
||||
}
|
||||
if (!restart && this.connection.remoteDescription) {
|
||||
this.answerTimer = window.setTimeout(() => {
|
||||
if (this.ownsLocalOffer(epoch)) this.fail(new Error("SDP renegotiation answer timed out"));
|
||||
}, RENEGOTIATION_ANSWER_TIMEOUT_MS);
|
||||
}
|
||||
try {
|
||||
if (restart) {
|
||||
this.connection.restartIce();
|
||||
@@ -702,6 +711,7 @@ export class HostPeer {
|
||||
if (this.ordinaryAnswerEpoch === epoch) {
|
||||
this.ordinaryAnswerEpoch = null;
|
||||
}
|
||||
this.clearAnswerTimer();
|
||||
this.setError(error, "host.err.createConnection");
|
||||
return false;
|
||||
}
|
||||
@@ -728,6 +738,7 @@ export class HostPeer {
|
||||
}
|
||||
await this.flushCandidates();
|
||||
if (this.ownsAnswer(epoch)) {
|
||||
this.clearAnswerTimer();
|
||||
this.ordinaryAnswerEpoch = null;
|
||||
if (this.pendingNegotiation) {
|
||||
this.pendingNegotiation = false;
|
||||
@@ -741,9 +752,7 @@ export class HostPeer {
|
||||
if (!this.ownsAnswer(epoch)) {
|
||||
return;
|
||||
}
|
||||
this.ordinaryAnswerEpoch = null;
|
||||
this.pendingNegotiation = false;
|
||||
throw error;
|
||||
this.fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -760,11 +769,17 @@ export class HostPeer {
|
||||
}
|
||||
|
||||
private nextNegotiationEpoch(): number {
|
||||
this.clearAnswerTimer();
|
||||
this.ordinaryAnswerEpoch = null;
|
||||
this.negotiationEpoch += 1;
|
||||
return this.negotiationEpoch;
|
||||
}
|
||||
|
||||
private clearAnswerTimer(): void {
|
||||
if (this.answerTimer !== null) window.clearTimeout(this.answerTimer);
|
||||
this.answerTimer = null;
|
||||
}
|
||||
|
||||
private async updateStats(): Promise<void> {
|
||||
if (
|
||||
this.disposed ||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "../src/client/media/quality.ts";
|
||||
import { HostProvisionalChild } from "../src/client/media/host-provisional-child.ts";
|
||||
import { EMPTY_METRICS, type PeerSnapshot } from "../src/client/types.ts";
|
||||
import { HostPeer, type HostMediaPeer } from "../src/client/webrtc/host-peer.ts";
|
||||
import { HostPeer, RENEGOTIATION_ANSWER_TIMEOUT_MS, type HostMediaPeer } from "../src/client/webrtc/host-peer.ts";
|
||||
import {
|
||||
automaticVideoCodecPreference,
|
||||
manualVideoCodecPreference,
|
||||
@@ -36,6 +36,7 @@ vi.mock("../src/client/webrtc/video-codec-preflight.ts", () => ({
|
||||
}));
|
||||
|
||||
const statsCallbacks: Array<() => void> = [];
|
||||
const timeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
|
||||
class FakeSender {
|
||||
failNextReplace = false;
|
||||
@@ -519,14 +520,19 @@ beforeEach(() => {
|
||||
return statsCallbacks.length;
|
||||
}),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout: (callback: () => void, delay: number) =>
|
||||
globalThis.setTimeout(callback, delay),
|
||||
setTimeout: (callback: () => void, delay: number) => {
|
||||
const timer = globalThis.setTimeout(callback, delay);
|
||||
timeouts.push(timer);
|
||||
return timer;
|
||||
},
|
||||
clearTimeout: (timer: ReturnType<typeof setTimeout>) =>
|
||||
globalThis.clearTimeout(timer),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const timer of timeouts.splice(0)) globalThis.clearTimeout(timer);
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -1293,6 +1299,109 @@ describe("HostPeer source replacement", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("retires a stalled audio transaction, not the initial candidate", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
const onUpdate = vi.fn();
|
||||
const video = createTrack("video", "video");
|
||||
const peer = createPeer(createStream(video, null), onUpdate);
|
||||
try {
|
||||
await peer.start();
|
||||
const connection = FakePeerConnection.latest!;
|
||||
await vi.advanceTimersByTimeAsync(RENEGOTIATION_ANSWER_TIMEOUT_MS * 2);
|
||||
expect(connection.connectionState).not.toBe("closed");
|
||||
await acceptPeerAnswer(peer);
|
||||
await peer.replaceStream(createStream(video, createTrack("audio", "microphone")));
|
||||
await vi.advanceTimersByTimeAsync(RENEGOTIATION_ANSWER_TIMEOUT_MS - 1);
|
||||
expect(peer.isConnected()).toBe(true);
|
||||
// Another microphone change coalesces without extending the first wait.
|
||||
await peer.replaceStream(createStream(video, null));
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(connection.connectionState).toBe("closed");
|
||||
expect(onUpdate.mock.calls.at(-1)?.[0]).toMatchObject({ connectionState: "failed" });
|
||||
const updates = onUpdate.mock.calls.length;
|
||||
await peer.acceptSignal({ kind: "description", connectionId: peer.connectionId,
|
||||
description: { type: "answer", sdp: "late-answer" } });
|
||||
expect(onUpdate).toHaveBeenCalledTimes(updates);
|
||||
} finally { peer.dispose(); }
|
||||
});
|
||||
|
||||
it.each(["answer", "dispose"])("cancels the renegotiation deadline on %s", async (completion) => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
const onUpdate = vi.fn();
|
||||
const video = createTrack("video", "video");
|
||||
const peer = createPeer(createStream(video, null), onUpdate);
|
||||
try {
|
||||
await peer.start();
|
||||
await acceptPeerAnswer(peer);
|
||||
await peer.replaceStream(createStream(video, createTrack("audio", "microphone")));
|
||||
if (completion === "answer") await acceptPeerAnswer(peer);
|
||||
else peer.dispose();
|
||||
const updates = onUpdate.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(RENEGOTIATION_ANSWER_TIMEOUT_MS * 2);
|
||||
expect(onUpdate).toHaveBeenCalledTimes(updates);
|
||||
if (completion === "answer") expect(peer.isConnected()).toBe(true);
|
||||
} finally { peer.dispose(); }
|
||||
});
|
||||
|
||||
it("releases a hung local-description operation at the renegotiation deadline", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
const video = createTrack("video", "video");
|
||||
const peer = createPeer(createStream(video, null));
|
||||
let finish!: () => void;
|
||||
try {
|
||||
await peer.start();
|
||||
await acceptPeerAnswer(peer);
|
||||
const connection = FakePeerConnection.latest!;
|
||||
const local = vi.spyOn(connection, "setLocalDescription").mockImplementationOnce(() =>
|
||||
new Promise<void>((resolve) => { finish = resolve; }));
|
||||
const replaced = peer.replaceStream(createStream(video, createTrack("audio", "microphone")));
|
||||
await vi.waitFor(() => expect(local).toHaveBeenCalledOnce());
|
||||
await vi.advanceTimersByTimeAsync(RENEGOTIATION_ANSWER_TIMEOUT_MS);
|
||||
await expect(replaced).resolves.toBe(false);
|
||||
expect(connection.connectionState).toBe("closed");
|
||||
finish();
|
||||
await Promise.resolve();
|
||||
expect(connection.createOfferCallCount).toBe(2);
|
||||
} finally { peer.dispose(); }
|
||||
});
|
||||
|
||||
it("retires a connected sender whose renegotiation answer cannot be applied", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const video = createTrack("video", "video");
|
||||
const peer = createPeer(createStream(video, null), onUpdate);
|
||||
try {
|
||||
await peer.start();
|
||||
await acceptPeerAnswer(peer);
|
||||
await peer.replaceStream(createStream(video, createTrack("audio", "microphone")));
|
||||
const connection = FakePeerConnection.latest!;
|
||||
vi.spyOn(connection, "setRemoteDescription").mockRejectedValueOnce(new DOMException("Rejected SDP", "OperationError"));
|
||||
await peer.acceptSignal({ kind: "description", connectionId: peer.connectionId,
|
||||
description: { type: "answer", sdp: "invalid-answer" } });
|
||||
expect(connection.connectionState).toBe("closed");
|
||||
expect(onUpdate.mock.calls.at(-1)?.[0]).toMatchObject({ connectionState: "failed" });
|
||||
} finally { peer.dispose(); }
|
||||
});
|
||||
|
||||
it("gives a coalesced transaction its own deadline after the previous answer", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
const video = createTrack("video", "video");
|
||||
const peer = createPeer(createStream(video, null));
|
||||
try {
|
||||
await peer.start();
|
||||
await acceptPeerAnswer(peer);
|
||||
await peer.replaceStream(createStream(video, createTrack("audio", "microphone")));
|
||||
await peer.replaceStream(createStream(video, null));
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await peer.acceptSignal({ kind: "description", connectionId: peer.connectionId,
|
||||
description: { type: "answer", sdp: "audio-answer" } });
|
||||
expect(FakePeerConnection.latest!.createOfferCallCount).toBe(3);
|
||||
await vi.advanceTimersByTimeAsync(RENEGOTIATION_ANSWER_TIMEOUT_MS - 1);
|
||||
expect(peer.isConnected()).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(FakePeerConnection.latest!.connectionState).toBe("closed");
|
||||
} finally { peer.dispose(); }
|
||||
});
|
||||
|
||||
it.each([false, true])("defers audio renegotiation until the current answer (restart=%s)", async (restart) => {
|
||||
const video = createTrack("video", "shared-video");
|
||||
const peer = createPeer(createStream(video, null));
|
||||
@@ -2045,6 +2154,25 @@ function hostProvisionalInput(
|
||||
}
|
||||
|
||||
describe("Host provisional child runtime ownership", () => {
|
||||
it("reports a lost prepared audio answer to its existing route owner", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
const failed = vi.fn();
|
||||
const sendSignal = vi.fn(() => true);
|
||||
const video = createTrack("video", "video");
|
||||
const owner = new HostProvisionalChild({ sendSignal, onPreparedChildFailed: failed });
|
||||
const input = hostProvisionalInput(7, ["child"], createStream(video, null));
|
||||
try {
|
||||
expect(owner.prepare(input)).toBe(true);
|
||||
await vi.waitFor(() => expect(sendSignal).toHaveBeenCalledOnce());
|
||||
await owner.acceptSignal("child", { kind: "description", connectionId: input.candidate.connectionId,
|
||||
description: { type: "answer", sdp: "initial-answer" } });
|
||||
await owner.replaceStream(createStream(video, createTrack("audio", "microphone")));
|
||||
await vi.advanceTimersByTimeAsync(RENEGOTIATION_ANSWER_TIMEOUT_MS);
|
||||
expect(failed).toHaveBeenCalledExactlyOnceWith(7, input.candidate.connectionId);
|
||||
expect(FakePeerConnection.latest!.connectionState).toBe("closed");
|
||||
} finally { owner.discard(); }
|
||||
});
|
||||
|
||||
it("rejects failed construction, releases the clone and permits the next preparation", async () => {
|
||||
const source = createTrack("video", "construction-source");
|
||||
const sendSignal = vi.fn(() => true);
|
||||
|
||||
@@ -170,6 +170,10 @@ describe("Native SFU publication", () => {
|
||||
frameHeight: 360,
|
||||
}),
|
||||
);
|
||||
emit({ version: 9, type: "publication-state", shareId: "native_share", ...config, state: "disconnected" });
|
||||
expect(control.closePublication).not.toHaveBeenCalled();
|
||||
expect(onDisconnected).not.toHaveBeenCalled();
|
||||
emit({ version: 9, type: "publication-state", shareId: "native_share", ...config, state: "connected" });
|
||||
emit({
|
||||
version: 9,
|
||||
type: "publication-state",
|
||||
|
||||
@@ -9,6 +9,7 @@ const { command, notes } = vi.hoisted(() => ({
|
||||
}));
|
||||
vi.mock("node:child_process", () => ({ execFileSync: command }));
|
||||
vi.mock("../scripts/release-notes.mjs", () => ({ releaseNotes: notes }));
|
||||
vi.mock("node:timers/promises", () => ({ setTimeout: async () => undefined }));
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), "piik-publisher-"));
|
||||
const version = "v1.0.1", revision = "a".repeat(40);
|
||||
@@ -42,7 +43,8 @@ describe("Gitee mirror publication", () => {
|
||||
return { name, size: bytes.length, digest: `sha256:${hash(bytes)}`, state: "uploaded" };
|
||||
});
|
||||
async function mirror(options: { existing?: "pending" | "published" | "unknown"; corrupt?: boolean;
|
||||
wrongSource?: boolean; wrongDigest?: boolean; missing?: boolean } = {}) {
|
||||
wrongSource?: boolean; wrongDigest?: boolean; missing?: boolean;
|
||||
fault?: { method: string; remaining: number; status: number | "timeout" } } = {}) {
|
||||
const mutations: string[] = [];
|
||||
const attachments = sourceAssets.map(({ name, size }) => ({ name, size, browser_download_url: download + name }));
|
||||
const present = options.existing === "pending" ? attachments.slice(0, 1) : options.missing ? [] : attachments;
|
||||
@@ -64,6 +66,11 @@ describe("Gitee mirror publication", () => {
|
||||
const path = url.slice(api.length);
|
||||
const method = init?.method ?? "GET";
|
||||
if (method !== "GET") mutations.push(`${method} ${path}`);
|
||||
if (options.fault?.method === method && options.fault.remaining > 0) {
|
||||
options.fault.remaining--;
|
||||
if (options.fault.status === "timeout") throw new DOMException("Timed out", "TimeoutError");
|
||||
return new Response(null, { status: options.fault.status });
|
||||
}
|
||||
if (path.startsWith("/releases/tags/")) return options.existing
|
||||
? Response.json({ id: 42, tag_name: version, body: options.existing === "unknown" ? "unknown" : marker,
|
||||
prerelease: options.existing !== "published" }) : new Response(null, { status: 404 });
|
||||
@@ -121,6 +128,26 @@ describe("Gitee mirror publication", () => {
|
||||
expect(await mirror({ existing: "published" })).toEqual({ mutations: [], failure: undefined });
|
||||
});
|
||||
|
||||
it.each([503, "timeout"] as const)("retries transient read failures (%s) within a fixed bound", async (status) => {
|
||||
const fault = { method: "GET", remaining: 2, status };
|
||||
expect((await mirror({ existing: "published", fault })).failure).toBeUndefined();
|
||||
expect(fault.remaining).toBe(0);
|
||||
fault.remaining = 4;
|
||||
expect((await mirror({ fault })).failure).toBeDefined();
|
||||
expect(fault.remaining).toBe(1);
|
||||
});
|
||||
|
||||
it("does not repeat forbidden reads or ambiguous writes", async () => {
|
||||
const denied = { method: "GET", remaining: 2, status: 403 };
|
||||
expect((await mirror({ fault: denied })).failure).toContain("HTTP 403");
|
||||
expect(denied.remaining).toBe(1);
|
||||
const write = { method: "POST", remaining: 2, status: "timeout" as const };
|
||||
const result = await mirror({ fault: write });
|
||||
expect(result.failure).toBeDefined();
|
||||
expect(result.mutations).toEqual(["POST /releases"]);
|
||||
expect(write.remaining).toBe(1);
|
||||
});
|
||||
|
||||
it.each([{ wrongSource: true }, { wrongDigest: true }, { existing: "unknown" as const }])
|
||||
("rejects mismatched provenance before mirror writes: %j", async (options) => {
|
||||
const result = await mirror(options);
|
||||
|
||||
@@ -5,13 +5,57 @@ import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { releaseNotes } from "../scripts/release-notes.mjs";
|
||||
import { pullRequestNotes, releaseNotes } from "../scripts/release-notes.mjs";
|
||||
|
||||
const planner = fileURLToPath(new URL("../scripts/release-version.mjs", import.meta.url));
|
||||
const publisher = fileURLToPath(new URL("../scripts/publish-release.mjs", import.meta.url));
|
||||
const sha = (value: string) => createHash("sha256").update(value).digest("hex");
|
||||
|
||||
describe("release automation", () => {
|
||||
it("validates the PR body against its net product diff before the squash", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "piik-pr-notes-"));
|
||||
const git = (...args: string[]) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
|
||||
const commit = () => { git("add", "."); git("commit", "-m", "checkpoint without release notes"); return git("rev-parse", "HEAD"); };
|
||||
try {
|
||||
git("init", "--quiet", "--initial-branch=main");
|
||||
git("config", "user.name", "Piik fixture");
|
||||
git("config", "user.email", "fixture@example.invalid");
|
||||
git("config", "commit.gpgSign", "false");
|
||||
mkdirSync(join(root, "public"));
|
||||
mkdirSync(join(root, "site"));
|
||||
writeFileSync(join(root, "public/asset.svg"), "art");
|
||||
const base = commit();
|
||||
writeFileSync(join(root, "README.md"), "guide");
|
||||
const docs = commit();
|
||||
const event = { pull_request: { number: 1, base: { sha: base }, head: { sha: docs }, body: "" } };
|
||||
expect(pullRequestNotes(root, event)).toBe("");
|
||||
// Moving a product file out is still a deletion from the shipped surface.
|
||||
git("mv", "public/asset.svg", "site/asset.svg");
|
||||
event.pull_request.head.sha = commit();
|
||||
expect(() => pullRequestNotes(root, event)).toThrow("one nonempty");
|
||||
for (const body of ["## Release notes\n???", "## Release notes\n\uFFFD"]) {
|
||||
event.pull_request.body = body;
|
||||
expect(() => pullRequestNotes(root, event)).toThrow("damaged");
|
||||
}
|
||||
event.pull_request.body = "## Release notes\nOne\n## Release notes\nTwo";
|
||||
expect(() => pullRequestNotes(root, event)).toThrow("one nonempty");
|
||||
event.pull_request.body = "## Implementation\nprivate detail\n## Release notes\n修复分享 / Sharing fixes.\nLiteral `code` and $(text).";
|
||||
expect(pullRequestNotes(root, event)).toBe("修复分享 / Sharing fixes.\nLiteral `code` and $(text).");
|
||||
const eventFile = join(root, "event.json");
|
||||
writeFileSync(join(root, ".git/info/exclude"), "event.json\n");
|
||||
writeFileSync(eventFile, JSON.stringify(event));
|
||||
const script = fileURLToPath(new URL("../scripts/release-notes.mjs", import.meta.url));
|
||||
expect(execFileSync(process.execPath, [script, "--pull-request"], {
|
||||
cwd: root, encoding: "utf8", env: { ...process.env, GITHUB_EVENT_PATH: eventFile },
|
||||
})).toContain("validated");
|
||||
// Reverting the product change leaves a documentation-only PR.
|
||||
git("mv", "site/asset.svg", "public/asset.svg");
|
||||
event.pull_request.head.sha = commit();
|
||||
event.pull_request.body = "";
|
||||
expect(pullRequestNotes(root, event)).toBe("");
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
}, 15_000);
|
||||
|
||||
it("publishes reviewed notes, excludes private history and retains every unreleased phase on retries", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "piik-release-notes-"));
|
||||
const git = (...args: string[]) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
|
||||
@@ -60,6 +104,11 @@ describe("release automation", () => {
|
||||
const invalid = commit(`chore: maintenance\n\n${section}`);
|
||||
expect(() => releaseNotes(root, "v1.1.1", invalid, "fixture/Piik")).toThrow("one nonempty");
|
||||
}
|
||||
for (const damaged of ["????????????", "Updated \uFFFD capture"]) {
|
||||
git("checkout", "--quiet", "--detach", next);
|
||||
const invalid = commit(`fix: capture\n\n## Release notes\n${damaged}`);
|
||||
expect(() => releaseNotes(root, "v1.1.1", invalid, "fixture/Piik")).toThrow("damaged Release notes");
|
||||
}
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
}, 15_000); // Real Git subprocesses share the runner with the rest of the suite.
|
||||
|
||||
|
||||
+149
-61
@@ -106,6 +106,7 @@ const candidate = (
|
||||
|
||||
function createFakeSubscriber(events: SubscriberEvents, log: string[], label: string) {
|
||||
return {
|
||||
resync: vi.fn(),
|
||||
updateConfig: vi.fn(),
|
||||
acceptSignal: vi.fn(async () => undefined),
|
||||
reconnect: vi.fn(() => true),
|
||||
@@ -134,6 +135,18 @@ function createFakeSubscriber(events: SubscriberEvents, log: string[], label: st
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareSfu(
|
||||
route: ViewerSfuRoute,
|
||||
revision: number,
|
||||
assignment = viewerSfuAssignment(),
|
||||
config = sfuConfig(revision, assignment.sfuPublicationGeneration ?? undefined),
|
||||
) {
|
||||
route.accept({ revision, phase: "prepare", assignment,
|
||||
candidate: candidate(revision, "viewer_12345678", "sfu", config.connectionId) });
|
||||
await route.acceptConfig(config);
|
||||
route.accept({ revision, phase: "active", assignment });
|
||||
}
|
||||
|
||||
function receiveMetrics(
|
||||
timestampMs: number,
|
||||
overrides: Partial<ConnectionMetrics> = {},
|
||||
@@ -156,6 +169,79 @@ function receiveMetrics(
|
||||
}
|
||||
|
||||
describe("minimal route transition contracts", () => {
|
||||
it("reports active failure when a prepared Peer disappears before commit delivery", async () => {
|
||||
const messages: ClientMessage[] = [];
|
||||
const onSfuStream = vi.fn();
|
||||
let subscriber!: ReturnType<typeof createFakeSubscriber>;
|
||||
const route = new ViewerSfuRoute("viewer_12345678", {
|
||||
activatePeer: () => false,
|
||||
onSfuStream,
|
||||
send: (message) => { messages.push(message); return true; },
|
||||
createSubscriber: (events) => {
|
||||
subscriber = createFakeSubscriber(events, [], "current");
|
||||
return subscriber;
|
||||
},
|
||||
});
|
||||
await prepareSfu(route, 1, viewerSfuAssignment(), sfuConfig(1));
|
||||
subscriber.events.onStream({} as MediaStream);
|
||||
subscriber.events.onFirstDecodedFrame();
|
||||
await vi.waitFor(() => expect(onSfuStream).toHaveBeenCalledOnce());
|
||||
messages.length = 0;
|
||||
|
||||
const assignment = peerAssignment("host_12345678");
|
||||
route.accept({ revision: 2, phase: "prepare", assignment, candidate: candidate(2) });
|
||||
// First-frame readiness reached the server, then the local candidate failed.
|
||||
// Its prepare failure can arrive after the server committed this revision.
|
||||
route.accept({ revision: 2, phase: "active", assignment });
|
||||
await vi.waitFor(() => expect(messages).toContainEqual({
|
||||
type: "route-failed", revision: 2, phase: "active", connectionId: candidate(2).connectionId,
|
||||
}));
|
||||
expect(messages).not.toContainEqual({ type: "route-ready", revision: 2, phase: "active" });
|
||||
expect(subscriber.disconnect).not.toHaveBeenCalled();
|
||||
await route.disconnect();
|
||||
});
|
||||
|
||||
it.each([false, true])("ignores a superseded Peer promotion resolving to %s", async (promoted) => {
|
||||
const messages: ClientMessage[] = [];
|
||||
const onSfuStream = vi.fn();
|
||||
let finishPromotion!: (value: boolean) => void;
|
||||
const promotion = new Promise<boolean>((resolve) => { finishPromotion = resolve; });
|
||||
const activatePeer = vi.fn((_assignment: ParticipantRouteAssignment, revision?: number) =>
|
||||
revision === 2 ? promotion : true,
|
||||
);
|
||||
let subscriber!: ReturnType<typeof createFakeSubscriber>;
|
||||
const route = new ViewerSfuRoute("viewer_12345678", {
|
||||
activatePeer,
|
||||
onSfuStream,
|
||||
send: (message) => { messages.push(message); return true; },
|
||||
createSubscriber: (events) => {
|
||||
subscriber = createFakeSubscriber(events, [], "current");
|
||||
return subscriber;
|
||||
},
|
||||
});
|
||||
await prepareSfu(route, 1, viewerSfuAssignment(), sfuConfig(1));
|
||||
subscriber.events.onStream({} as MediaStream);
|
||||
subscriber.events.onFirstDecodedFrame();
|
||||
await vi.waitFor(() => expect(onSfuStream).toHaveBeenCalledOnce());
|
||||
messages.length = 0;
|
||||
|
||||
const first = peerAssignment("first_parent");
|
||||
route.accept({ revision: 2, phase: "prepare", assignment: first, candidate: candidate(2) });
|
||||
route.accept({ revision: 2, phase: "active", assignment: first });
|
||||
await vi.waitFor(() => expect(activatePeer).toHaveBeenCalledWith(first, 2));
|
||||
const next = peerAssignment("next_parent");
|
||||
route.accept({ revision: 3, phase: "prepare", assignment: next, candidate: candidate(3) });
|
||||
finishPromotion(promoted);
|
||||
await promotion;
|
||||
await Promise.resolve();
|
||||
expect(messages).toEqual([]);
|
||||
expect(subscriber.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
route.accept({ revision: 3, phase: "active", assignment: next });
|
||||
await vi.waitFor(() => expect(activatePeer).toHaveBeenCalledWith(next, 3));
|
||||
await route.disconnect();
|
||||
});
|
||||
|
||||
it("rebinds Viewer route ownership before a post-restart prepare", async () => {
|
||||
const prepared: Array<{ parentPeerId: string | null; revision?: number }> = [];
|
||||
const route = new ViewerSfuRoute("viewer_old_12345678", {
|
||||
@@ -435,8 +521,7 @@ describe("minimal route transition contracts", () => {
|
||||
},
|
||||
});
|
||||
const config = sfuConfig(7);
|
||||
route.accept({ revision: 7, phase: "active", assignment: viewerSfuAssignment() });
|
||||
await route.acceptConfig(config);
|
||||
await prepareSfu(route, 7, viewerSfuAssignment(), config);
|
||||
events.onStream({} as MediaStream);
|
||||
events.onFirstDecodedFrame();
|
||||
await vi.waitFor(() => expect(stream).toHaveBeenCalledOnce());
|
||||
@@ -1064,12 +1149,7 @@ describe("minimal route transition contracts", () => {
|
||||
},
|
||||
});
|
||||
|
||||
route.accept({
|
||||
revision: 1,
|
||||
phase: "active",
|
||||
assignment: viewerSfuAssignment([], "publication-old"),
|
||||
});
|
||||
await route.acceptConfig(sfuConfig(1, "publication-old"));
|
||||
await prepareSfu(route, 1, viewerSfuAssignment([], "publication-old"), sfuConfig(1, "publication-old"));
|
||||
route.accept({
|
||||
revision: 2,
|
||||
phase: "prepare",
|
||||
@@ -1129,12 +1209,7 @@ describe("minimal route transition contracts", () => {
|
||||
},
|
||||
});
|
||||
|
||||
route.accept({
|
||||
revision: 1,
|
||||
phase: "active",
|
||||
assignment: viewerSfuAssignment(),
|
||||
});
|
||||
await route.acceptConfig(sfuConfig(1));
|
||||
await prepareSfu(route, 1, viewerSfuAssignment(), sfuConfig(1));
|
||||
const firstStream = {} as MediaStream;
|
||||
subscribers[0]?.events.onStream(firstStream);
|
||||
subscribers[0]?.events.onFirstDecodedFrame();
|
||||
@@ -1330,12 +1405,7 @@ describe("minimal route transition contracts", () => {
|
||||
},
|
||||
});
|
||||
|
||||
route.accept({
|
||||
revision: 7,
|
||||
phase: "active",
|
||||
assignment: viewerSfuAssignment(),
|
||||
});
|
||||
await route.acceptConfig(sfuConfig(7));
|
||||
await prepareSfu(route, 7, viewerSfuAssignment(), sfuConfig(7));
|
||||
const firstStream = {} as MediaStream;
|
||||
subscribers[0]?.events.onStream(firstStream);
|
||||
subscribers[0]?.events.onFirstDecodedFrame();
|
||||
@@ -1371,8 +1441,7 @@ describe("minimal route transition contracts", () => {
|
||||
},
|
||||
});
|
||||
const assignment = viewerSfuAssignment();
|
||||
route.accept({ revision: 7, phase: "active", assignment });
|
||||
await route.acceptConfig(sfuConfig(7));
|
||||
await prepareSfu(route, 7, assignment, sfuConfig(7));
|
||||
const initialStream = {} as MediaStream;
|
||||
subscribers[0]?.events.onStream(initialStream);
|
||||
subscribers[0]?.events.onFirstDecodedFrame();
|
||||
@@ -1410,8 +1479,7 @@ describe("minimal route transition contracts", () => {
|
||||
},
|
||||
});
|
||||
const assignment = viewerSfuAssignment();
|
||||
route.accept({ revision: 7, phase: "active", assignment });
|
||||
await route.acceptConfig(sfuConfig(7));
|
||||
await prepareSfu(route, 7, assignment, sfuConfig(7));
|
||||
const initialStream = {} as MediaStream;
|
||||
subscribers[0]?.events.onStream(initialStream);
|
||||
subscribers[0]?.events.onFirstDecodedFrame();
|
||||
@@ -1447,8 +1515,7 @@ describe("minimal route transition contracts", () => {
|
||||
},
|
||||
});
|
||||
const assignment = viewerSfuAssignment(["relay-child"]);
|
||||
route.accept({ revision: 7, phase: "active", assignment });
|
||||
await route.acceptConfig(sfuConfig(7));
|
||||
await prepareSfu(route, 7, assignment, sfuConfig(7));
|
||||
subscribers[0]!.events.onStream({} as MediaStream);
|
||||
subscribers[0]!.events.onFirstDecodedFrame();
|
||||
await vi.waitFor(() => expect(streams).toHaveLength(1));
|
||||
@@ -1470,10 +1537,10 @@ describe("minimal route transition contracts", () => {
|
||||
expect(messages.filter((message) => message.type === "refresh-sfu").every((message) => message.revision === 7)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not spend SFU recovery until refresh signaling is sent", async () => {
|
||||
it("reports a failed subscription again after offline reauthentication without creating an empty peer", async () => {
|
||||
const messages: ClientMessage[] = [];
|
||||
const streams: MediaStream[] = [];
|
||||
let acceptsRefresh = false;
|
||||
let acceptsFailure = false;
|
||||
const subscribers: ReturnType<typeof createFakeSubscriber>[] = [];
|
||||
const route = new ViewerSfuRoute("viewer_12345678", {
|
||||
activatePeer: () => true,
|
||||
@@ -1481,7 +1548,7 @@ describe("minimal route transition contracts", () => {
|
||||
onSfuStream: (stream) => streams.push(stream),
|
||||
send: (message) => {
|
||||
messages.push(message);
|
||||
return message.type !== "refresh-sfu" || acceptsRefresh;
|
||||
return message.type !== "route-failed" || acceptsFailure;
|
||||
},
|
||||
createSubscriber: (events) => {
|
||||
const subscriber = createFakeSubscriber(events, [], "active");
|
||||
@@ -1489,17 +1556,20 @@ describe("minimal route transition contracts", () => {
|
||||
return subscriber;
|
||||
},
|
||||
});
|
||||
route.accept({
|
||||
revision: 7,
|
||||
phase: "active",
|
||||
assignment: viewerSfuAssignment(),
|
||||
});
|
||||
await route.acceptConfig(sfuConfig(7));
|
||||
await prepareSfu(route, 7, viewerSfuAssignment(), sfuConfig(7));
|
||||
subscribers[0]!.events.onStream({} as MediaStream);
|
||||
subscribers[0]!.events.onFirstDecodedFrame();
|
||||
await vi.waitFor(() => expect(streams).toHaveLength(1));
|
||||
subscribers[0]!.events.onDisconnected();
|
||||
acceptsRefresh = true;
|
||||
acceptsFailure = true;
|
||||
|
||||
await route.resyncAuthoritative({ revision: 7, phase: "active", assignment: viewerSfuAssignment() }, "viewer_12345678");
|
||||
await route.acceptConfig(sfuConfig(7));
|
||||
expect(subscribers).toHaveLength(1);
|
||||
expect(messages.filter(message => message.type === "route-failed")).toContainEqual({
|
||||
type: "route-failed", revision: 7, phase: "active", connectionId: sfuConfig(7).connectionId,
|
||||
});
|
||||
messages.length = 0;
|
||||
|
||||
route.accept({
|
||||
revision: 8,
|
||||
@@ -1508,15 +1578,41 @@ describe("minimal route transition contracts", () => {
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
messages.filter((message) => message.type === "refresh-sfu"),
|
||||
messages.filter((message) => message.type === "route-failed"),
|
||||
).toEqual([
|
||||
{ type: "refresh-sfu", revision: 7 },
|
||||
{ type: "refresh-sfu", revision: 8 },
|
||||
{ type: "route-failed", revision: 8, phase: "active", connectionId: sfuConfig(7).connectionId },
|
||||
]),
|
||||
);
|
||||
await route.disconnect();
|
||||
});
|
||||
|
||||
it("keeps an in-flight SFU recovery across an unrelated room revision", async () => {
|
||||
it("does not resync a retained subscriber after a newer preparation supersedes authentication", async () => {
|
||||
const subscribers: ReturnType<typeof createFakeSubscriber>[] = [];
|
||||
const streams: MediaStream[] = [];
|
||||
const route = new ViewerSfuRoute("viewer_12345678", {
|
||||
activatePeer: () => true, send: () => true,
|
||||
onSfuStream: stream => streams.push(stream),
|
||||
createSubscriber: events => {
|
||||
const subscriber = createFakeSubscriber(events, [], "retained");
|
||||
subscribers.push(subscriber);
|
||||
return subscriber;
|
||||
},
|
||||
});
|
||||
await prepareSfu(route, 7, viewerSfuAssignment(), sfuConfig(7));
|
||||
subscribers[0]!.events.onStream({} as MediaStream);
|
||||
subscribers[0]!.events.onFirstDecodedFrame();
|
||||
await vi.waitFor(() => expect(streams).toHaveLength(1));
|
||||
const resync = route.resyncAuthoritative({ revision: 7, phase: "active",
|
||||
assignment: viewerSfuAssignment() }, "viewer_12345678");
|
||||
route.accept({ revision: 8, phase: "prepare", assignment: peerAssignment("parent_12345678"),
|
||||
candidate: candidate(8) });
|
||||
await resync;
|
||||
expect(subscribers[0]!.resync).not.toHaveBeenCalled();
|
||||
expect(subscribers[0]!.disconnect).not.toHaveBeenCalled();
|
||||
await route.disconnect();
|
||||
});
|
||||
|
||||
it("recovers a failed subscription through a new server-owned preparation", async () => {
|
||||
let releaseRecovery!: (connected: boolean) => void;
|
||||
const recoveryConnected = new Promise<boolean>((resolve) => {
|
||||
releaseRecovery = resolve;
|
||||
@@ -1538,20 +1634,20 @@ describe("minimal route transition contracts", () => {
|
||||
},
|
||||
});
|
||||
const assignment = viewerSfuAssignment();
|
||||
route.accept({ revision: 7, phase: "active", assignment });
|
||||
await route.acceptConfig(sfuConfig(7));
|
||||
await prepareSfu(route, 7, assignment, sfuConfig(7));
|
||||
const initialStream = {} as MediaStream;
|
||||
subscribers[0]?.events.onStream(initialStream);
|
||||
subscribers[0]?.events.onFirstDecodedFrame();
|
||||
await vi.waitFor(() => expect(streams).toEqual([initialStream]));
|
||||
|
||||
expect(route.reconnectActive()).toBe(true);
|
||||
subscribers[0]!.events.onDisconnected();
|
||||
route.accept({ revision: 8, phase: "prepare", assignment,
|
||||
candidate: candidate(8, "viewer_12345678", "sfu", "recovery_connection") });
|
||||
const recovery = route.acceptConfig({
|
||||
...sfuConfig(7),
|
||||
...sfuConfig(8),
|
||||
connectionId: "recovery_connection",
|
||||
});
|
||||
await vi.waitFor(() => expect(subscribers).toHaveLength(2));
|
||||
route.accept({ revision: 8, phase: "active", assignment });
|
||||
expect(subscribers).toHaveLength(2);
|
||||
expect(subscribers[1]?.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1560,6 +1656,7 @@ describe("minimal route transition contracts", () => {
|
||||
const recoveredStream = {} as MediaStream;
|
||||
subscribers[1]?.events.onStream(recoveredStream);
|
||||
subscribers[1]?.events.onFirstDecodedFrame();
|
||||
route.accept({ revision: 8, phase: "active", assignment });
|
||||
await vi.waitFor(() =>
|
||||
expect(streams).toEqual([initialStream, recoveredStream]),
|
||||
);
|
||||
@@ -1599,7 +1696,7 @@ describe("minimal route transition contracts", () => {
|
||||
expect(messages.filter((message) => message.type === "route-ready")).toEqual([]);
|
||||
});
|
||||
|
||||
it("rebuilds a failed Viewer SFU subscriber while sharing is paused", async () => {
|
||||
it("reports paused subscription failure and waits for the server to prepare its replacement", async () => {
|
||||
const messages: ClientMessage[] = [];
|
||||
const streams: MediaStream[] = [];
|
||||
const subscribers: ReturnType<typeof createFakeSubscriber>[] = [];
|
||||
@@ -1622,26 +1719,22 @@ describe("minimal route transition contracts", () => {
|
||||
},
|
||||
});
|
||||
|
||||
route.accept({
|
||||
revision: 7,
|
||||
phase: "active",
|
||||
assignment: viewerSfuAssignment(),
|
||||
});
|
||||
await route.acceptConfig(sfuConfig(7));
|
||||
await prepareSfu(route, 7, viewerSfuAssignment(), sfuConfig(7));
|
||||
subscribers[0]!.events.onStream({} as MediaStream);
|
||||
subscribers[0]!.events.onFirstDecodedFrame();
|
||||
await vi.waitFor(() => expect(streams).toHaveLength(1));
|
||||
|
||||
route.setPaused(true);
|
||||
subscribers[0]!.events.onDisconnected();
|
||||
expect(messages).toContainEqual({ type: "refresh-sfu", revision: 7 });
|
||||
expect(messages).toContainEqual({ type: "route-failed", revision: 7, phase: "active", connectionId: sfuConfig(7).connectionId });
|
||||
|
||||
await route.acceptConfig({ ...sfuConfig(7), connectionId: "recovery_connection" });
|
||||
expect(subscribers).toHaveLength(2);
|
||||
expect(subscribers[1]!.armDecodedFrameProof).not.toHaveBeenCalled();
|
||||
expect(subscribers).toHaveLength(1);
|
||||
|
||||
route.setPaused(false);
|
||||
await prepareSfu(route, 8);
|
||||
expect(subscribers[1]!.armDecodedFrameProof).toHaveBeenCalledOnce();
|
||||
await route.disconnect();
|
||||
});
|
||||
|
||||
|
||||
@@ -1673,12 +1766,7 @@ describe("minimal route transition contracts", () => {
|
||||
},
|
||||
});
|
||||
|
||||
route.accept({
|
||||
revision: 1,
|
||||
phase: "active",
|
||||
assignment: viewerSfuAssignment([], "publication-old"),
|
||||
});
|
||||
await route.acceptConfig(sfuConfig(1, "publication-old"));
|
||||
await prepareSfu(route, 1, viewerSfuAssignment([], "publication-old"), sfuConfig(1, "publication-old"));
|
||||
const oldStream = {} as MediaStream;
|
||||
subscribers[0]?.events.onStream(oldStream);
|
||||
subscribers[0]?.events.onFirstDecodedFrame();
|
||||
|
||||
@@ -577,6 +577,32 @@ describe("embedded SFU browser transport", () => {
|
||||
expect(onStream).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[false, false], [true, false], [false, true], [true, true],
|
||||
])("retires an unfinished ICE restart on reauthentication (send=%s, answer=%s)", async (sent, answered) => {
|
||||
const send = vi.fn(() => sent);
|
||||
const onDisconnected = vi.fn();
|
||||
const subscriber = new SfuSubscriber({ send, onStream: vi.fn(), onDisconnected });
|
||||
cleanups.push(() => subscriber.disconnect());
|
||||
await subscriber.connect(config);
|
||||
subscriber.activate();
|
||||
const pc = FakePc.instances[0]!;
|
||||
pc.state("connected");
|
||||
subscriber.resync();
|
||||
expect(pc.close).not.toHaveBeenCalled();
|
||||
pc.state("disconnected");
|
||||
subscriber.updateConfig({ ...config, revision: 9 });
|
||||
expect(send).toHaveBeenCalledOnce();
|
||||
if (answered) await subscriber.acceptSignal(
|
||||
signal({ description: { type: "offer", sdp: "restart offer" } }),
|
||||
);
|
||||
expect(pc.close).not.toHaveBeenCalled();
|
||||
subscriber.resync();
|
||||
subscriber.resync();
|
||||
expect(pc.close).toHaveBeenCalledOnce();
|
||||
expect(onDisconnected).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("bounds pending ICE and reports one terminal failure", async () => {
|
||||
const { publisher: host, pc, onDisconnected } = await publisher();
|
||||
for (let index = 0; index < 65; index++) {
|
||||
|
||||
Reference in new issue
Block a user