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:
AAA authored and GitHub committed 2026-09-29 08:44:17 +08:00
1 parent 28827ec6a2
commit 89a03e6470
73 files changed
+2001 -676

No files matched your search

+20
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+6 -4
View File
@@ -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
+3 -3
View File
@@ -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
+19 -1
View File
@@ -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 |
+2 -1
View File
@@ -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
+5
View File
@@ -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.
+13 -9
View File
@@ -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.
+4
View File
@@ -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
+20 -1
View File
@@ -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
+11 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+17 -3
View File
@@ -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
}
+85
View File
@@ -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",
-3
View File
@@ -383,9 +383,6 @@ func (server *Server) handleControl(response http.ResponseWriter, request *http.
case reads <- payload:
case <-controlContext.Done():
return
default:
cancelControl()
return
}
}
}()
+1 -1
View File
@@ -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")
}
})
}
}
+8 -2
View File
@@ -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")
+127 -73
View File
@@ -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 {
+48 -6
View File
@@ -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")
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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")
+2 -3
View File
@@ -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,
)
+11 -3
View File
@@ -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))
}
+16 -6
View File
@@ -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{
+14
View File
@@ -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
+2
View File
@@ -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()
+9 -10
View File
@@ -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
+100
View File
@@ -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()
}
+39
View File
@@ -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.
+6 -3
View File
@@ -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-- {
+119
View File
@@ -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")
}
})
}
}
+3 -1
View File
@@ -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
)
+2 -3
View File
@@ -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))
}
+11 -7
View File
@@ -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
+2 -1
View File
@@ -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;
+1 -1
View File
@@ -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')
+5 -4
View File
@@ -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;
+9 -20
View File
@@ -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) {
+2
View File
@@ -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;
+64 -80
View File
@@ -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 {
+102
View File
@@ -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();
}
+52 -32
View File
@@ -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);
}
+21 -7
View File
@@ -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
View File
@@ -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);
+21 -4
View File
@@ -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
View File
@@ -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);
}
+7
View File
@@ -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.
+1
View File
@@ -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) => {
+3 -3
View File
@@ -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",
+3 -3
View File
@@ -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": "画面",
+63 -104
View File
@@ -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,
+1 -2
View File
@@ -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");
+1 -1
View File
@@ -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>
+14 -14
View File
@@ -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
+1 -1
View File
@@ -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>
+2 -1
View File
@@ -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}
+1
View File
@@ -2206,6 +2206,7 @@ export function ViewerPage({
ref={videoRef}
autoPlay
tabIndex={0}
inert={!remoteMedia}
aria-label={t("viewer.stageAria")}
playsInline
onPlay={() => {
+1 -1
View File
@@ -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%; }
+9 -4
View File
@@ -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
View File
@@ -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; }
+22 -7
View File
@@ -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 ||
+131 -3
View File
@@ -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);
+4
View File
@@ -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",
+28 -1
View File
@@ -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);
+50 -1
View File
@@ -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
View File
@@ -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();
+26
View File
@@ -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++) {